Repository: ludwig-ai/ludwig Branch: main Commit: e083d20cd4f0 Files: 893 Total size: 20.6 MB Directory structure: gitextract_l1ifb68j/ ├── .actrc ├── .deepsource.toml ├── .devcontainer/ │ ├── Dockerfile │ └── devcontainer.json ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── feature_request.md │ ├── pull_request_template.md │ └── workflows/ │ ├── docker.yml │ ├── pytest.yml │ ├── pytest_slow.yml │ ├── schema.yml │ ├── test-results.yml │ └── upload-pypi.yml ├── .gitignore ├── .nojekyll ├── .pre-commit-config.yaml ├── .protolint.yaml ├── .vscode/ │ └── settings.json ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── MANIFEST.in ├── NOTICE ├── README.md ├── README_KR.md ├── RELEASES.md ├── docker/ │ ├── README.md │ ├── ludwig/ │ │ └── Dockerfile │ ├── ludwig-gpu/ │ │ └── Dockerfile │ ├── ludwig-ray/ │ │ └── Dockerfile │ └── ludwig-ray-gpu/ │ └── Dockerfile ├── examples/ │ ├── README.md │ ├── calibration/ │ │ ├── README.md │ │ ├── train_forest_cover_calibrated.py │ │ └── train_mushroom_edibility_calibrated.py │ ├── class_imbalance/ │ │ ├── README.md │ │ ├── balanced_model_config.yaml │ │ ├── model_training.py │ │ ├── model_training_results.ipynb │ │ └── standard_model_config.yaml │ ├── forecasting/ │ │ ├── README.md │ │ └── config.yaml │ ├── getting_started/ │ │ ├── rotten_tomatoes.yaml │ │ └── run.sh │ ├── hyperopt/ │ │ ├── README.md │ │ └── model_hyperopt_example.ipynb │ ├── insurance_lite/ │ │ ├── config.yaml │ │ └── train.py │ ├── kfold_cv/ │ │ ├── README.md │ │ ├── display_kfold_cv_results.py │ │ ├── k-fold_cv_classification.sh │ │ ├── prepare_classification_data_set.py │ │ └── regression_example.ipynb │ ├── lbfgs/ │ │ ├── config.yaml │ │ └── model.py │ ├── llama2_7b_finetuning_4bit/ │ │ ├── README.md │ │ ├── llama2_7b_4bit.yaml │ │ ├── run_train.sh │ │ └── train_alpaca.py │ ├── llm_base_model_dequantization/ │ │ ├── README.md │ │ └── phi_2_dequantization.py │ ├── llm_few_shot_learning/ │ │ └── simple_model_training.py │ ├── llm_finetuning/ │ │ ├── README.md │ │ ├── imdb_deepspeed_zero3.yaml │ │ ├── imdb_deepspeed_zero3_ray.yaml │ │ ├── run_train_dsz3.sh │ │ ├── run_train_dsz3_ray.sh │ │ └── train_imdb_ray.py │ ├── llm_instruction_tuning/ │ │ └── train_alpaca_ray.py │ ├── llm_text_generation/ │ │ └── simple_model_training.py │ ├── llm_zero_shot_learning/ │ │ └── simple_model_training.py │ ├── mnist/ │ │ ├── README.md │ │ ├── advanced_model_training.py │ │ ├── assess_model_performance.py │ │ ├── config.yaml │ │ ├── simple_model_training.py │ │ └── visualize_model_test_results.ipynb │ ├── ray/ │ │ └── kubernetes/ │ │ ├── README.md │ │ ├── clusters/ │ │ │ ├── ludwig-ray-cpu-cluster.yaml │ │ │ └── ludwig-ray-gpu-cluster.yaml │ │ └── utils/ │ │ ├── attach.sh │ │ ├── dashboard.sh │ │ ├── krsync.sh │ │ ├── ray_down.sh │ │ ├── ray_up.sh │ │ ├── rsync_up.sh │ │ ├── submit.sh │ │ └── upload.sh │ ├── regex_freezing/ │ │ ├── ecd_freezing_with_regex_training.py │ │ └── llm_freezing_with_regex_training.py │ ├── semantic_segmentation/ │ │ ├── camseq.py │ │ └── config_camseq.yaml │ ├── serve/ │ │ ├── README.md │ │ └── client_program.py │ ├── synthetic/ │ │ └── train.py │ ├── tabnet/ │ │ └── higgs/ │ │ ├── medium_config.yaml │ │ ├── small_config.yaml │ │ ├── train_higgs_medium.py │ │ └── train_higgs_small.py │ ├── titanic/ │ │ ├── README.md │ │ ├── model1_config.yaml │ │ ├── model2_config.yaml │ │ ├── model_training_results.ipynb │ │ ├── multiple_model_training.py │ │ └── simple_model_training.py │ ├── twitter_bots/ │ │ ├── README.md │ │ ├── train_twitter_bots.py │ │ └── train_twitter_bots_text_only.py │ ├── wine_quality/ │ │ ├── README.md │ │ └── model_defaults_example.ipynb │ └── wmt15/ │ ├── config_large.yaml │ ├── config_small.yaml │ └── train_nmt.py ├── ludwig/ │ ├── __init__.py │ ├── accounting/ │ │ ├── __init__.py │ │ └── used_tokens.py │ ├── api.py │ ├── api_annotations.py │ ├── automl/ │ │ ├── __init__.py │ │ ├── auto_tune_config.py │ │ ├── automl.py │ │ ├── base_config.py │ │ └── defaults/ │ │ ├── base_automl_config.yaml │ │ ├── combiner/ │ │ │ ├── concat_config.yaml │ │ │ ├── tabnet_config.yaml │ │ │ └── transformer_config.yaml │ │ ├── reference_configs.yaml │ │ └── text/ │ │ └── bert_config.yaml │ ├── backend/ │ │ ├── __init__.py │ │ ├── base.py │ │ ├── datasource.py │ │ ├── deepspeed.py │ │ ├── ray.py │ │ └── utils/ │ │ ├── __init__.py │ │ └── storage.py │ ├── benchmarking/ │ │ ├── README.md │ │ ├── __init__.py │ │ ├── artifacts.py │ │ ├── benchmark.py │ │ ├── examples/ │ │ │ ├── benchmarking_config.yaml │ │ │ └── process_config.py │ │ ├── profiler.py │ │ ├── profiler_callbacks.py │ │ ├── profiler_dataclasses.py │ │ ├── reporting.py │ │ ├── summarize.py │ │ ├── summary_dataclasses.py │ │ └── utils.py │ ├── callbacks.py │ ├── check.py │ ├── cli.py │ ├── collect.py │ ├── combiners/ │ │ ├── __init__.py │ │ └── combiners.py │ ├── config_sampling/ │ │ ├── __init__.py │ │ ├── explore_schema.py │ │ └── parameter_sampling.py │ ├── config_validation/ │ │ ├── __init__.py │ │ ├── checks.py │ │ ├── preprocessing.py │ │ └── validation.py │ ├── constants.py │ ├── contrib.py │ ├── contribs/ │ │ ├── __init__.py │ │ ├── aim.py │ │ ├── comet.py │ │ ├── mlflow/ │ │ │ ├── __init__.py │ │ │ └── model.py │ │ └── wandb.py │ ├── data/ │ │ ├── __init__.py │ │ ├── batcher/ │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── bucketed.py │ │ │ ├── iterable.py │ │ │ ├── random_access.py │ │ │ └── test_batcher.py │ │ ├── cache/ │ │ │ ├── __init__.py │ │ │ ├── manager.py │ │ │ ├── types.py │ │ │ └── util.py │ │ ├── concatenate_datasets.py │ │ ├── dataframe/ │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── dask.py │ │ │ ├── modin.py │ │ │ └── pandas.py │ │ ├── dataset/ │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── pandas.py │ │ │ └── ray.py │ │ ├── dataset_synthesizer.py │ │ ├── negative_sampling.py │ │ ├── postprocessing.py │ │ ├── preprocessing.py │ │ ├── prompt.py │ │ ├── sampler.py │ │ ├── split.py │ │ ├── split_dataset.py │ │ └── utils.py │ ├── datasets/ │ │ ├── README.md │ │ ├── __init__.py │ │ ├── archives.py │ │ ├── configs/ │ │ │ ├── __init__.py │ │ │ ├── adult_census_income.yaml │ │ │ ├── ae_price_prediction.yaml │ │ │ ├── agnews.yaml │ │ │ ├── allstate_claims_severity.yaml │ │ │ ├── alpaca.yaml │ │ │ ├── amazon_employee_access_challenge.yaml │ │ │ ├── amazon_review_polarity.yaml │ │ │ ├── amazon_reviews.yaml │ │ │ ├── ames_housing.yaml │ │ │ ├── bbcnews.yaml │ │ │ ├── bnp_claims_management.yaml │ │ │ ├── bookprice_prediction.yaml │ │ │ ├── california_house_price.yaml │ │ │ ├── camseq.yaml │ │ │ ├── code_alpaca.yaml │ │ │ ├── connect4.yaml │ │ │ ├── consumer_complaints.yaml │ │ │ ├── consumer_complaints_generation.yaml │ │ │ ├── creditcard_fraud.yaml │ │ │ ├── customer_churn_prediction.yaml │ │ │ ├── data_scientist_salary.yaml │ │ │ ├── dbpedia.yaml │ │ │ ├── electricity.yaml │ │ │ ├── ethos_binary.yaml │ │ │ ├── fake_job_postings2.yaml │ │ │ ├── fever.yaml │ │ │ ├── flickr8k.yaml │ │ │ ├── forest_cover.yaml │ │ │ ├── goemotions.yaml │ │ │ ├── goodbooks_books.yaml │ │ │ ├── google_qa_answer_type_reason_explanation.yaml │ │ │ ├── google_qa_question_type_reason_explanation.yaml │ │ │ ├── google_quest_qa.yaml │ │ │ ├── higgs.yaml │ │ │ ├── hugging_face.yaml │ │ │ ├── ieee_fraud.yaml │ │ │ ├── imbalanced_insurance.yaml │ │ │ ├── imdb.yaml │ │ │ ├── imdb_genre_prediction.yaml │ │ │ ├── insurance_lite.yaml │ │ │ ├── iris.yaml │ │ │ ├── irony.yaml │ │ │ ├── jc_penney_products.yaml │ │ │ ├── jigsaw_unintended_bias.yaml │ │ │ ├── jigsaw_unintended_bias100k.yaml │ │ │ ├── kdd_appetency.yaml │ │ │ ├── kdd_churn.yaml │ │ │ ├── kdd_upselling.yaml │ │ │ ├── kick_starter_funding.yaml │ │ │ ├── melbourne_airbnb.yaml │ │ │ ├── mercari_price_suggestion.yaml │ │ │ ├── mercari_price_suggestion100K.yaml │ │ │ ├── mercedes_benz_greener.yaml │ │ │ ├── mnist.yaml │ │ │ ├── mushroom_edibility.yaml │ │ │ ├── naval.yaml │ │ │ ├── news_channel.yaml │ │ │ ├── news_popularity2.yaml │ │ │ ├── noshow_appointments.yaml │ │ │ ├── numerai28pt6.yaml │ │ │ ├── ohsumed_7400.yaml │ │ │ ├── ohsumed_cmu.yaml │ │ │ ├── otto_group_product.yaml │ │ │ ├── poker_hand.yaml │ │ │ ├── porto_seguro_safe_driver.yaml │ │ │ ├── product_sentiment_machine_hack.yaml │ │ │ ├── protein.yaml │ │ │ ├── reuters_cmu.yaml │ │ │ ├── reuters_r8.yaml │ │ │ ├── rossman_store_sales.yaml │ │ │ ├── santander_customer_satisfaction.yaml │ │ │ ├── santander_customer_transaction.yaml │ │ │ ├── santander_value_prediction.yaml │ │ │ ├── sarcastic_headlines.yaml │ │ │ ├── sarcos.yaml │ │ │ ├── sst2.yaml │ │ │ ├── sst3.yaml │ │ │ ├── sst5.yaml │ │ │ ├── synthetic_fraud.yaml │ │ │ ├── talkingdata_adtrack_fraud.yaml │ │ │ ├── telco_customer_churn.yaml │ │ │ ├── temperature.yaml │ │ │ ├── titanic.yaml │ │ │ ├── twitter_bots.yaml │ │ │ ├── walmart_recruiting.yaml │ │ │ ├── wine_reviews.yaml │ │ │ ├── wmt15.yaml │ │ │ ├── women_clothing_review.yaml │ │ │ ├── yahoo_answers.yaml │ │ │ ├── yelp_review_polarity.yaml │ │ │ ├── yelp_reviews.yaml │ │ │ └── yosemite.yaml │ │ ├── dataset_config.py │ │ ├── kaggle.py │ │ ├── loaders/ │ │ │ ├── __init__.py │ │ │ ├── adult_census_income.py │ │ │ ├── agnews.py │ │ │ ├── allstate_claims_severity.py │ │ │ ├── camseq.py │ │ │ ├── code_alpaca_loader.py │ │ │ ├── consumer_complaints_loader.py │ │ │ ├── creditcard_fraud.py │ │ │ ├── dataset_loader.py │ │ │ ├── ethos_binary.py │ │ │ ├── flickr8k.py │ │ │ ├── forest_cover.py │ │ │ ├── goemotions.py │ │ │ ├── higgs.py │ │ │ ├── hugging_face.py │ │ │ ├── ieee_fraud.py │ │ │ ├── insurance_lite.py │ │ │ ├── kdd_loader.py │ │ │ ├── mnist.py │ │ │ ├── naval.py │ │ │ ├── rossman_store_sales.py │ │ │ ├── santander_value_prediction.py │ │ │ ├── sarcastic_headlines.py │ │ │ ├── sarcos.py │ │ │ ├── split_loaders.py │ │ │ └── sst.py │ │ ├── model_configs/ │ │ │ ├── __init__.py │ │ │ ├── adult_census_income_default.yaml │ │ │ ├── allstate_claims_severity_default.yaml │ │ │ ├── ames_housing_default.yaml │ │ │ ├── bnp_claims_management_default.yaml │ │ │ ├── forest_cover_default.yaml │ │ │ ├── higgs_best.yaml │ │ │ ├── higgs_default.yaml │ │ │ ├── ieee_fraud_default.yaml │ │ │ ├── mercedes_benz_greener_default.yaml │ │ │ ├── mnist_default.yaml │ │ │ ├── mushroom_edibility_default.yaml │ │ │ ├── otto_group_product_default.yaml │ │ │ ├── poker_hand_default.yaml │ │ │ ├── porto_seguro_safe_driver_default.yaml │ │ │ ├── synthetic_fraud_default.yaml │ │ │ └── titanic_default.yaml │ │ └── utils.py │ ├── decoders/ │ │ ├── __init__.py │ │ ├── base.py │ │ ├── generic_decoders.py │ │ ├── image_decoders.py │ │ ├── llm_decoders.py │ │ ├── registry.py │ │ ├── sequence_decoder_utils.py │ │ ├── sequence_decoders.py │ │ ├── sequence_tagger.py │ │ └── utils.py │ ├── distributed/ │ │ ├── __init__.py │ │ ├── base.py │ │ ├── ddp.py │ │ ├── deepspeed.py │ │ └── fsdp.py │ ├── encoders/ │ │ ├── __init__.py │ │ ├── bag_encoders.py │ │ ├── base.py │ │ ├── category_encoders.py │ │ ├── date_encoders.py │ │ ├── generic_encoders.py │ │ ├── h3_encoders.py │ │ ├── image/ │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── timm.py │ │ │ └── torchvision.py │ │ ├── registry.py │ │ ├── sequence_encoders.py │ │ ├── set_encoders.py │ │ ├── text_encoders.py │ │ └── types.py │ ├── error.py │ ├── evaluate.py │ ├── experiment.py │ ├── explain/ │ │ ├── __init__.py │ │ ├── captum.py │ │ ├── captum_ray.py │ │ ├── explainer.py │ │ ├── explanation.py │ │ └── util.py │ ├── export.py │ ├── features/ │ │ ├── __init__.py │ │ ├── audio_feature.py │ │ ├── bag_feature.py │ │ ├── base_feature.py │ │ ├── binary_feature.py │ │ ├── category_feature.py │ │ ├── date_feature.py │ │ ├── feature_registries.py │ │ ├── feature_utils.py │ │ ├── h3_feature.py │ │ ├── image_feature.py │ │ ├── number_feature.py │ │ ├── sequence_feature.py │ │ ├── set_feature.py │ │ ├── text_feature.py │ │ ├── timeseries_feature.py │ │ └── vector_feature.py │ ├── forecast.py │ ├── globals.py │ ├── hyperopt/ │ │ ├── __init__.py │ │ ├── execution.py │ │ ├── results.py │ │ ├── run.py │ │ ├── search_algos.py │ │ └── utils.py │ ├── hyperopt_cli.py │ ├── model_export/ │ │ ├── base_model_exporter.py │ │ └── onnx_exporter.py │ ├── models/ │ │ ├── __init__.py │ │ ├── base.py │ │ ├── calibrator.py │ │ ├── ecd.py │ │ ├── embedder.py │ │ ├── inference.py │ │ ├── llm.py │ │ ├── predictor.py │ │ ├── registry.py │ │ └── retrieval.py │ ├── modules/ │ │ ├── __init__.py │ │ ├── attention_modules.py │ │ ├── convolutional_modules.py │ │ ├── embedding_modules.py │ │ ├── fully_connected_modules.py │ │ ├── initializer_modules.py │ │ ├── loss_implementations/ │ │ │ ├── __init__.py │ │ │ └── corn.py │ │ ├── loss_modules.py │ │ ├── lr_scheduler.py │ │ ├── metric_modules.py │ │ ├── metric_registry.py │ │ ├── mlp_mixer_modules.py │ │ ├── normalization_modules.py │ │ ├── optimization_modules.py │ │ ├── recurrent_modules.py │ │ ├── reduction_modules.py │ │ ├── tabnet_modules.py │ │ └── training_hooks.py │ ├── predict.py │ ├── preprocess.py │ ├── progress_bar.py │ ├── schema/ │ │ ├── __init__.py │ │ ├── combiners/ │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── common_transformer_options.py │ │ │ ├── comparator.py │ │ │ ├── concat.py │ │ │ ├── project_aggregate.py │ │ │ ├── sequence.py │ │ │ ├── sequence_concat.py │ │ │ ├── tab_transformer.py │ │ │ ├── tabnet.py │ │ │ ├── transformer.py │ │ │ └── utils.py │ │ ├── common_fields.py │ │ ├── decoders/ │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── image_decoders.py │ │ │ ├── llm_decoders.py │ │ │ ├── sequence_decoders.py │ │ │ └── utils.py │ │ ├── defaults/ │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── ecd.py │ │ │ ├── llm.py │ │ │ └── utils.py │ │ ├── encoders/ │ │ │ ├── __init__.py │ │ │ ├── bag_encoders.py │ │ │ ├── base.py │ │ │ ├── category_encoders.py │ │ │ ├── date_encoders.py │ │ │ ├── h3_encoders.py │ │ │ ├── image/ │ │ │ │ ├── __init__.py │ │ │ │ ├── base.py │ │ │ │ ├── timm.py │ │ │ │ └── torchvision.py │ │ │ ├── sequence_encoders.py │ │ │ ├── set_encoders.py │ │ │ ├── text/ │ │ │ │ ├── __init__.py │ │ │ │ ├── encoders.py │ │ │ │ └── hf_model_params.py │ │ │ ├── text_encoders.py │ │ │ └── utils.py │ │ ├── export_schema.py │ │ ├── features/ │ │ │ ├── __init__.py │ │ │ ├── audio_feature.py │ │ │ ├── augmentation/ │ │ │ │ ├── __init__.py │ │ │ │ ├── base.py │ │ │ │ ├── image.py │ │ │ │ └── utils.py │ │ │ ├── bag_feature.py │ │ │ ├── base.py │ │ │ ├── binary_feature.py │ │ │ ├── category_feature.py │ │ │ ├── date_feature.py │ │ │ ├── h3_feature.py │ │ │ ├── image_feature.py │ │ │ ├── loss/ │ │ │ │ ├── __init__.py │ │ │ │ ├── loss.py │ │ │ │ └── utils.py │ │ │ ├── number_feature.py │ │ │ ├── preprocessing/ │ │ │ │ ├── __init__.py │ │ │ │ ├── audio.py │ │ │ │ ├── bag.py │ │ │ │ ├── base.py │ │ │ │ ├── binary.py │ │ │ │ ├── category.py │ │ │ │ ├── date.py │ │ │ │ ├── h3.py │ │ │ │ ├── image.py │ │ │ │ ├── number.py │ │ │ │ ├── sequence.py │ │ │ │ ├── set.py │ │ │ │ ├── text.py │ │ │ │ ├── timeseries.py │ │ │ │ ├── utils.py │ │ │ │ └── vector.py │ │ │ ├── sequence_feature.py │ │ │ ├── set_feature.py │ │ │ ├── text_feature.py │ │ │ ├── timeseries_feature.py │ │ │ ├── utils.py │ │ │ └── vector_feature.py │ │ ├── hyperopt/ │ │ │ ├── __init__.py │ │ │ ├── executor.py │ │ │ ├── parameter.py │ │ │ ├── scheduler.py │ │ │ ├── search_algorithm.py │ │ │ └── utils.py │ │ ├── jsonschema.py │ │ ├── llms/ │ │ │ ├── __init__.py │ │ │ ├── base_model.py │ │ │ ├── generation.py │ │ │ ├── model_parameters.py │ │ │ ├── peft.py │ │ │ ├── prompt.py │ │ │ └── quantization.py │ │ ├── lr_scheduler.py │ │ ├── metadata/ │ │ │ ├── __init__.py │ │ │ ├── configs/ │ │ │ │ ├── combiners.yaml │ │ │ │ ├── common.yaml │ │ │ │ ├── decoders.yaml │ │ │ │ ├── encoders.yaml │ │ │ │ ├── features.yaml │ │ │ │ ├── llm.yaml │ │ │ │ ├── loss.yaml │ │ │ │ ├── optimizers.yaml │ │ │ │ ├── preprocessing.yaml │ │ │ │ └── trainer.yaml │ │ │ ├── feature_metadata.py │ │ │ └── parameter_metadata.py │ │ ├── model_config.py │ │ ├── model_types/ │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── ecd.py │ │ │ ├── llm.py │ │ │ └── utils.py │ │ ├── optimizers.py │ │ ├── preprocessing.py │ │ ├── profiler.py │ │ ├── split.py │ │ ├── trainer.py │ │ └── utils.py │ ├── serve.py │ ├── train.py │ ├── trainers/ │ │ ├── __init__.py │ │ ├── base.py │ │ ├── registry.py │ │ ├── trainer.py │ │ └── trainer_llm.py │ ├── types.py │ ├── upload.py │ ├── utils/ │ │ ├── __init__.py │ │ ├── algorithms_utils.py │ │ ├── audio_utils.py │ │ ├── augmentation_utils.py │ │ ├── automl/ │ │ │ ├── __init__.py │ │ │ ├── data_source.py │ │ │ ├── field_info.py │ │ │ ├── ray_utils.py │ │ │ ├── type_inference.py │ │ │ └── utils.py │ │ ├── backward_compatibility.py │ │ ├── batch_size_tuner.py │ │ ├── calibration.py │ │ ├── carton_utils.py │ │ ├── checkpoint_utils.py │ │ ├── config_utils.py │ │ ├── data_utils.py │ │ ├── dataframe_utils.py │ │ ├── dataset_utils.py │ │ ├── date_utils.py │ │ ├── defaults.py │ │ ├── entmax/ │ │ │ ├── LICENSE │ │ │ ├── README.md │ │ │ ├── __init__.py │ │ │ ├── activations.py │ │ │ ├── losses.py │ │ │ └── root_finding.py │ │ ├── error_handling_utils.py │ │ ├── eval_utils.py │ │ ├── fs_utils.py │ │ ├── h3_util.py │ │ ├── heuristics.py │ │ ├── hf_utils.py │ │ ├── html_utils.py │ │ ├── image_utils.py │ │ ├── inference_utils.py │ │ ├── llm_quantization_utils.py │ │ ├── llm_utils.py │ │ ├── logging_utils.py │ │ ├── loss_utils.py │ │ ├── math_utils.py │ │ ├── metric_utils.py │ │ ├── metrics_printed_table.py │ │ ├── misc_utils.py │ │ ├── model_utils.py │ │ ├── nlp_utils.py │ │ ├── numerical_test_utils.py │ │ ├── output_feature_utils.py │ │ ├── package_utils.py │ │ ├── print_utils.py │ │ ├── registry.py │ │ ├── server_utils.py │ │ ├── state_dict_backward_compatibility.py │ │ ├── strings_utils.py │ │ ├── structural_warning.py │ │ ├── system_utils.py │ │ ├── time_utils.py │ │ ├── tokenizers.py │ │ ├── torch_utils.py │ │ ├── trainer_utils.py │ │ ├── triton_utils.py │ │ ├── types.py │ │ ├── upload_utils.py │ │ ├── version_transformation.py │ │ └── visualization_utils.py │ ├── vector_index/ │ │ ├── __init__.py │ │ ├── base.py │ │ └── faiss.py │ └── visualize.py ├── pyproject.toml ├── pytest.ini ├── requirements.txt ├── requirements_benchmarking.txt ├── requirements_distributed.txt ├── requirements_explain.txt ├── requirements_extra.txt ├── requirements_hyperopt.txt ├── requirements_llm.txt ├── requirements_serve.txt ├── requirements_test.txt ├── requirements_viz.txt ├── schemastore/ │ ├── README.md │ ├── catalog-entry.json │ └── test/ │ ├── ludwig.yaml │ └── ludwig_config.yaml ├── setup.cfg ├── setup.py └── tests/ ├── README.md ├── __init__.py ├── conftest.py ├── docker-compose.yml ├── integration_tests/ │ ├── __init__.py │ ├── parameter_update_utils.py │ ├── scripts/ │ │ ├── run_train_aim.py │ │ ├── run_train_comet.py │ │ └── run_train_wandb.py │ ├── synthetic_test_data.py │ ├── test_api.py │ ├── test_audio_feature.py │ ├── test_automl.py │ ├── test_cache_manager.py │ ├── test_cached_preprocessing.py │ ├── test_carton.py │ ├── test_class_imbalance_feature.py │ ├── test_cli.py │ ├── test_collect.py │ ├── test_config_global_defaults.py │ ├── test_contrib_aim.py │ ├── test_contrib_comet.py │ ├── test_contrib_wandb.py │ ├── test_custom_components.py │ ├── test_date_feature.py │ ├── test_dependencies.py │ ├── test_experiment.py │ ├── test_explain.py │ ├── test_graph_execution.py │ ├── test_hyperopt.py │ ├── test_hyperopt_ray.py │ ├── test_input_feature_tied.py │ ├── test_kfold_cv.py │ ├── test_llm.py │ ├── test_missing_value_strategy.py │ ├── test_mlflow.py │ ├── test_model_save_and_load.py │ ├── test_model_training_options.py │ ├── test_number_feature.py │ ├── test_peft.py │ ├── test_postprocessing.py │ ├── test_preprocessing.py │ ├── test_ray.py │ ├── test_reducers.py │ ├── test_regularizers.py │ ├── test_remote.py │ ├── test_reproducibility.py │ ├── test_sequence_decoders.py │ ├── test_sequence_encoders.py │ ├── test_sequence_features.py │ ├── test_server.py │ ├── test_simple_features.py │ ├── test_timeseries_feature.py │ ├── test_torchscript.py │ ├── test_trainer.py │ ├── test_triton.py │ ├── test_triton_configs/ │ │ └── transformer_combiner_with_attention_reduce.yaml │ ├── test_visualization.py │ ├── test_visualization_api.py │ └── utils.py ├── ludwig/ │ ├── __init__.py │ ├── accounting/ │ │ └── test_used_tokens.py │ ├── augmentation/ │ │ ├── test_augmentation_pipeline.py │ │ ├── test_auto_augmentation.py │ │ └── test_image_augmentation.py │ ├── automl/ │ │ ├── __init__.py │ │ ├── test_base_config.py │ │ ├── test_data_source.py │ │ ├── test_tune_config.py │ │ └── test_utils.py │ ├── backend/ │ │ └── test_ray.py │ ├── benchmarking/ │ │ ├── example_files/ │ │ │ ├── invalid/ │ │ │ │ ├── benchmarking_config_1.yaml │ │ │ │ ├── benchmarking_config_2.yaml │ │ │ │ └── benchmarking_config_3.yaml │ │ │ ├── process_config.py │ │ │ └── valid/ │ │ │ ├── benchmarking_config_1.yaml │ │ │ ├── benchmarking_config_2.yaml │ │ │ └── benchmarking_config_3.yaml │ │ ├── test_benchmarking.py │ │ └── test_profiler.py │ ├── combiners/ │ │ └── test_combiners.py │ ├── config_sampling/ │ │ ├── static_schema.json │ │ └── test_config_sampling.py │ ├── config_validation/ │ │ ├── test_checks.py │ │ ├── test_validate_config_combiner.py │ │ ├── test_validate_config_encoder.py │ │ ├── test_validate_config_features.py │ │ ├── test_validate_config_hyperopt.py │ │ ├── test_validate_config_misc.py │ │ ├── test_validate_config_preprocessing.py │ │ └── test_validate_config_trainer.py │ ├── contrib/ │ │ └── test_contrib.py │ ├── data/ │ │ ├── dataframe/ │ │ │ └── test_dask.py │ │ ├── test_cache_util.py │ │ ├── test_dataset_synthesizer.py │ │ ├── test_negative_sampling.py │ │ ├── test_postprocessing.py │ │ ├── test_preprocessing.py │ │ ├── test_ray_data.py │ │ └── test_split.py │ ├── datasets/ │ │ ├── __init__.py │ │ ├── download_all_datasets.py │ │ ├── mnist/ │ │ │ └── test_mnist_workflow.py │ │ ├── model_configs/ │ │ │ └── train_all_model_configs.py │ │ ├── test_dataset_configs.py │ │ ├── test_dataset_links.py │ │ ├── test_datasets.py │ │ ├── test_model_configs.py │ │ └── titanic/ │ │ └── test_titanic_workflow.py │ ├── decoders/ │ │ ├── test_image_decoder.py │ │ ├── test_llm_decoders.py │ │ ├── test_sequence_decoder.py │ │ ├── test_sequence_decoder_utils.py │ │ └── test_sequence_tagger.py │ ├── encoders/ │ │ ├── __init__.py │ │ ├── test_bag_encoders.py │ │ ├── test_category_encoders.py │ │ ├── test_date_encoders.py │ │ ├── test_generic_encoders.py │ │ ├── test_h3_encoders.py │ │ ├── test_image_encoders.py │ │ ├── test_llm_encoders.py │ │ ├── test_sequence_encoders.py │ │ ├── test_set_encoders.py │ │ ├── test_text_encoders.py │ │ └── test_timm_encoder.py │ ├── evaluation/ │ │ └── test_evaluation.py │ ├── explain/ │ │ ├── test_captum.py │ │ └── test_util.py │ ├── features/ │ │ ├── __init__.py │ │ ├── test_audio_feature.py │ │ ├── test_bag_feature.py │ │ ├── test_binary_feature.py │ │ ├── test_category_feature.py │ │ ├── test_date_feature.py │ │ ├── test_feature_utils.py │ │ ├── test_h3_feature.py │ │ ├── test_image_feature.py │ │ ├── test_number_feature.py │ │ ├── test_sequence_features.py │ │ ├── test_set_feature.py │ │ ├── test_text_feature.py │ │ └── test_timeseries_feature.py │ ├── hyperopt/ │ │ └── test_hyperopt.py │ ├── model_export/ │ │ └── test_onnx_exporter.py │ ├── models/ │ │ ├── __init__.py │ │ ├── test_trainable_image_layers.py │ │ ├── test_training_determinism.py │ │ └── test_training_success.py │ ├── modules/ │ │ ├── __init__.py │ │ ├── test_attention.py │ │ ├── test_convolutional_modules.py │ │ ├── test_embedding_modules.py │ │ ├── test_encoder.py │ │ ├── test_fully_connected_modules.py │ │ ├── test_initializer_modules.py │ │ ├── test_loss_modules.py │ │ ├── test_lr_scheduler.py │ │ ├── test_metric_modules.py │ │ ├── test_mlp_mixer_modules.py │ │ ├── test_normalization_modules.py │ │ ├── test_recurrent_modules.py │ │ ├── test_reduction_modules.py │ │ ├── test_regex_freezing.py │ │ ├── test_tabnet_modules.py │ │ └── test_utils.py │ ├── schema/ │ │ ├── hyperopt/ │ │ │ ├── test_scheduler.py │ │ │ └── test_search_algorithm.py │ │ ├── test_model_config.py │ │ └── test_schema_utils.py │ ├── schema_fields/ │ │ ├── test_fields_misc.py │ │ ├── test_fields_optimization.py │ │ ├── test_fields_preprocessing.py │ │ └── test_marshmallow_misc.py │ └── utils/ │ ├── __init__.py │ ├── automl/ │ │ ├── test_type_inference.py │ │ └── test_utils.py │ ├── entmax/ │ │ ├── test_losses.py │ │ ├── test_mask.py │ │ ├── test_root_finding.py │ │ └── test_topk.py │ ├── test_algorithm_utils.py │ ├── test_audio_utils.py │ ├── test_backward_compatibility.py │ ├── test_calibration.py │ ├── test_class_balancing.py │ ├── test_config_utils.py │ ├── test_data_utils.py │ ├── test_dataframe_utils.py │ ├── test_dataset_utils.py │ ├── test_date_utils.py │ ├── test_defaults.py │ ├── test_error_handling_utils.py │ ├── test_errors.py │ ├── test_fs_utils.py │ ├── test_heuristics.py │ ├── test_hf_utils.py │ ├── test_hyperopt_ray_utils.py │ ├── test_image_utils.py │ ├── test_llm_utils.py │ ├── test_metric_utils.py │ ├── test_model_utils.py │ ├── test_normalization.py │ ├── test_numerical_test_utils.py │ ├── test_output_feature_utils.py │ ├── test_server_utils.py │ ├── test_state_dict_backward_compatibility.py │ ├── test_strings_utils.py │ ├── test_tokenizers.py │ ├── test_torch_utils.py │ ├── test_trainer_utils.py │ ├── test_upload_utils.py │ └── test_version_transformation.py ├── regression_tests/ │ ├── automl/ │ │ ├── golden/ │ │ │ ├── adult_census_income.types.json │ │ │ └── mnist.types.json │ │ ├── scripts/ │ │ │ └── update_golden_types.py │ │ ├── test_auto_type_inference.py │ │ └── utils.py │ ├── benchmark/ │ │ ├── configs/ │ │ │ ├── adult_census_income.ecd.yaml │ │ │ ├── ames_housing.ecd.yaml │ │ │ ├── mercedes_benz_greener.ecd.yaml │ │ │ └── sarcos.ecd.yaml │ │ ├── expected_metric.py │ │ ├── expected_metrics/ │ │ │ ├── adult_census_income.ecd.yaml │ │ │ ├── ames_housing.ecd.yaml │ │ │ ├── mercedes_benz_greener.ecd.yaml │ │ │ └── sarcos.ecd.yaml │ │ └── test_model_performance.py │ └── model/ │ └── test_old_models.py └── training_success/ ├── __init__.py ├── configs.py └── test_training_success.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .actrc ================================================ -P ubuntu-latest=ludwigai/ludwig-ray ================================================ FILE: .deepsource.toml ================================================ version = 1 test_patterns = [ "tests/**" ] [[analyzers]] name = "python" enabled = true runtime_version = "3.x.x" ================================================ FILE: .devcontainer/Dockerfile ================================================ FROM python:3.12-slim RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ && apt-get install -y --no-install-recommends \ git \ build-essential \ curl \ libsndfile1 \ ffmpeg \ sox \ && apt-get clean && rm -rf /var/lib/apt/lists/* # Create non-root user ARG USERNAME=vscode ARG USER_UID=1000 ARG USER_GID=$USER_UID RUN groupadd --gid $USER_GID $USERNAME \ && useradd --uid $USER_UID --gid $USER_GID -m $USERNAME USER $USERNAME ENV PATH="/home/$USERNAME/.local/bin:$PATH" # Pre-install pip tools so editable install is fast RUN pip install --user --no-cache-dir --upgrade pip setuptools wheel ================================================ FILE: .devcontainer/devcontainer.json ================================================ { "name": "Ludwig Dev", "build": { "dockerfile": "Dockerfile", "context": ".." }, "customizations": { "vscode": { "settings": { "python.defaultInterpreterPath": "/usr/local/bin/python" }, "extensions": [ "ms-python.python", "ms-python.vscode-pylance", "charliermarsh.ruff" ] } }, "postCreateCommand": "pip install --user -e '.[test]'", "remoteUser": "vscode", "features": { "ghcr.io/devcontainers/features/git:1": {} } } ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: '' labels: '' assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 1. Click on '....' 1. Scroll down to '....' 1. See error Please provide code, yaml config file and a sample of data in order to entirely reproduce the issue. Issues that are not reproducible will be ignored. **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **Environment (please complete the following information):** - OS: [e.g. iOS] - Version [e.g. 22] - Python version - Ludwig version **Additional context** Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: '' labels: '' assignees: '' --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the use case** A clear and concise description of what the use case for this feature is. **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .github/pull_request_template.md ================================================ # Code Pull Requests Please provide the following: - a clear explanation of what your code does - if applicable, a reference to an issue - a reproducible test for your PR (code, config and data sample) # Documentation Pull Requests Note that the documentation HTML files are in `docs/` while the Markdown sources are in `mkdocs/docs`. If you are proposing a modification to the documentation you should change only the Markdown files. `api.md` is automatically generated from the docstrings in the code, so if you want to change something in that file, first modify `ludwig/api.py` docstring, then run `mkdocs/code_docs_autogen.py`, which will create `mkdocs/docs/api.md` . ================================================ FILE: .github/workflows/docker.yml ================================================ name: docker on: schedule: - cron: "0 10 * * *" # everyday at 10am push: branches: ["main", "release-*"] tags: ["v*.*.*"] jobs: start-runner: name: Start self-hosted EC2 runner if: > github.event_name == 'schedule' && github.repository == 'ludwig-ai/ludwig' || github.event_name == 'push' && github.repository == 'ludwig-ai/ludwig' || github.event_name == 'pull_request' && github.event.pull_request.base.repo.full_name == 'ludwig-ai/ludwig' && !github.event.pull_request.head.repo.fork runs-on: ubuntu-latest outputs: label: ${{ steps.start-ec2-runner.outputs.label }} ec2-instance-id: ${{ steps.start-ec2-runner.outputs.ec2-instance-id }} steps: - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v4 with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: ${{ secrets.AWS_REGION }} - name: Start EC2 runner id: start-ec2-runner uses: machulav/ec2-github-runner@v2.3.2 with: mode: start github-token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} ec2-image-id: ami-0759580dedc953d1f ec2-instance-type: r5.large subnet-id: subnet-0983be43 security-group-id: sg-4cba0d08 aws-resource-tags: > [ {"Key": "Name", "Value": "ludwig-github-${{ github.head_ref || github.sha }}"}, {"Key": "GitHubRepository", "Value": "${{ github.repository }}"}, {"Key": "GitHubHeadRef", "Value": "${{ github.head_ref }}"}, {"Key": "GitHubSHA", "Value": "${{ github.sha }}"} ] docker: name: Build docker image ${{ matrix.docker-image }} (push=${{ github.event_name != 'pull_request' }}) if: needs.start-runner.result != 'skipped' needs: start-runner # required to start the main job when the runner is ready runs-on: ${{ needs.start-runner.outputs.label }} # run the job on the newly created runners # we want an ongoing run of this workflow to be canceled by a later commit # so that there is only one concurrent run of this workflow for each branch concurrency: group: docker-${{ matrix.docker-image }}-${{ github.head_ref || github.sha }} cancel-in-progress: true strategy: fail-fast: false matrix: docker-image: - ludwig - ludwig-gpu - ludwig-ray - ludwig-ray-gpu steps: - name: Checkout uses: actions/checkout@v4 - name: Docker meta id: meta uses: docker/metadata-action@v5 with: # list of Docker images to use as base name for tags images: | ludwigai/${{ matrix.docker-image }} # generate Docker tags based on the following events/attributes tags: | type=schedule type=ref,event=branch type=ref,event=pr type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} type=sha - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to DockerHub if: github.event_name != 'pull_request' uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push uses: docker/build-push-action@v6 with: context: . file: ./docker/${{ matrix.docker-image }}/Dockerfile push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} stop-runner: name: Stop self-hosted EC2 runner # required to stop the runner even if the error happened in the previous job if: always() && needs.start-runner.result != 'skipped' needs: - start-runner # required to get output from the start-runner job - docker # required to wait when the main job is done runs-on: ubuntu-latest steps: - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v4 with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: ${{ secrets.AWS_REGION }} - name: Stop EC2 runner uses: machulav/ec2-github-runner@v2.3.2 with: mode: stop github-token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} label: ${{ needs.start-runner.outputs.label }} ec2-instance-id: ${{ needs.start-runner.outputs.ec2-instance-id }} ================================================ FILE: .github/workflows/pytest.yml ================================================ name: pytest on: push: branches: ["main", "release-*"] pull_request: branches: ["main", "release-*"] concurrency: group: pytest-${{ github.head_ref || github.sha }} cancel-in-progress: true jobs: unit-tests: runs-on: ubuntu-latest env: AWS_ACCESS_KEY_ID: ${{ secrets.LUDWIG_TESTS_AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.LUDWIG_TESTS_AWS_SECRET_ACCESS_KEY }} KAGGLE_USERNAME: ${{ secrets.KAGGLE_USERNAME }} KAGGLE_KEY: ${{ secrets.KAGGLE_KEY }} IS_NOT_FORK: ${{ !(github.event.pull_request.base.repo.full_name == 'ludwig-ai/ludwig' && github.event.pull_request.head.repo.fork) }} name: Unit Tests timeout-minutes: 60 steps: - uses: actions/checkout@v4 - name: Set up Python 3.12 uses: actions/setup-python@v5 with: python-version: "3.12" - name: Setup Linux run: | sudo apt-get update && sudo apt-get install -y cmake libsndfile1 libsox-dev - name: pip cache uses: actions/cache@v4 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-unit-${{ hashFiles('requirements*.txt', '.github/workflows/pytest.yml') }} - name: Install dependencies run: | python -m pip install -U pip setuptools pip install torch==2.6.0 torchvision torchaudio --extra-index-url https://download.pytorch.org/whl/cpu pip install '.[test]' --extra-index-url https://download.pytorch.org/whl/cpu pip list - name: Unit Tests run: | RUN_PRIVATE=$IS_NOT_FORK LUDWIG_TEST_SUITE_TIMEOUT_S=5400 pytest -v --timeout 300 --durations 100 -m "not distributed and not slow and not combinatorial and not llm" --junitxml pytest.xml tests/ludwig - name: Regression Tests run: | RUN_PRIVATE=$IS_NOT_FORK LUDWIG_TEST_SUITE_TIMEOUT_S=5400 pytest -v --timeout 300 --durations 100 -m "not distributed and not slow and not combinatorial and not llm" --junitxml pytest-regression.xml tests/regression_tests - name: Upload Test Results if: always() uses: actions/upload-artifact@v4 with: name: Unit Test Results path: pytest*.xml integration-tests: runs-on: ubuntu-latest strategy: fail-fast: false matrix: test-markers: - "integration_tests_a" - "integration_tests_b" - "integration_tests_c" - "integration_tests_d" - "integration_tests_e" - "integration_tests_f" env: AWS_ACCESS_KEY_ID: ${{ secrets.LUDWIG_TESTS_AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.LUDWIG_TESTS_AWS_SECRET_ACCESS_KEY }} KAGGLE_USERNAME: ${{ secrets.KAGGLE_USERNAME }} KAGGLE_KEY: ${{ secrets.KAGGLE_KEY }} IS_NOT_FORK: ${{ !(github.event.pull_request.base.repo.full_name == 'ludwig-ai/ludwig' && github.event.pull_request.head.repo.fork) }} MARKERS: ${{ matrix.test-markers }} name: Integration (${{ matrix.test-markers }}) services: minio: image: fclairamb/minio-github-actions env: MINIO_ACCESS_KEY: minio MINIO_SECRET_KEY: minio123 ports: - 9000:9000 timeout-minutes: 90 steps: - uses: actions/checkout@v4 - name: Set up Python 3.12 uses: actions/setup-python@v5 with: python-version: "3.12" - name: Setup Linux run: | sudo apt-get update && sudo apt-get install -y cmake libsndfile1 libsox-dev - name: pip cache uses: actions/cache@v4 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-integration-${{ hashFiles('requirements*.txt', '.github/workflows/pytest.yml') }} - name: Install dependencies run: | python -m pip install -U pip setuptools pip install torch==2.6.0 torchvision torchaudio --extra-index-url https://download.pytorch.org/whl/cpu pip install '.[test]' --extra-index-url https://download.pytorch.org/whl/cpu pip list - name: Free Disk Space uses: jlumbroso/free-disk-space@main with: tool-cache: false android: true dotnet: true haskell: true large-packages: false docker-images: true swap-storage: true - name: Integration Tests run: | RUN_PRIVATE=$IS_NOT_FORK LUDWIG_TEST_SUITE_TIMEOUT_S=7200 pytest -v --timeout 300 --durations 100 -m "not slow and not combinatorial and not llm and $MARKERS" --junitxml pytest.xml tests/integration_tests - name: Upload Test Results if: always() uses: actions/upload-artifact@v4 with: name: Integration Test Results (${{ matrix.test-markers }}) path: pytest.xml distributed-tests: runs-on: ubuntu-latest env: AWS_ACCESS_KEY_ID: ${{ secrets.LUDWIG_TESTS_AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.LUDWIG_TESTS_AWS_SECRET_ACCESS_KEY }} KAGGLE_USERNAME: ${{ secrets.KAGGLE_USERNAME }} KAGGLE_KEY: ${{ secrets.KAGGLE_KEY }} IS_NOT_FORK: ${{ !(github.event.pull_request.base.repo.full_name == 'ludwig-ai/ludwig' && github.event.pull_request.head.repo.fork) }} name: Distributed Tests services: minio: image: fclairamb/minio-github-actions env: MINIO_ACCESS_KEY: minio MINIO_SECRET_KEY: minio123 ports: - 9000:9000 timeout-minutes: 120 steps: - uses: actions/checkout@v4 - name: Set up Python 3.12 uses: actions/setup-python@v5 with: python-version: "3.12" - name: Setup Linux run: | sudo apt-get update && sudo apt-get install -y cmake libsndfile1 libsox-dev - name: pip cache uses: actions/cache@v4 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-distributed-${{ hashFiles('requirements*.txt', '.github/workflows/pytest.yml') }} - name: Install dependencies run: | python -m pip install -U pip setuptools pip install torch==2.6.0 torchvision torchaudio --extra-index-url https://download.pytorch.org/whl/cpu pip install '.[test]' --extra-index-url https://download.pytorch.org/whl/cpu pip list - name: Free Disk Space uses: jlumbroso/free-disk-space@main with: tool-cache: false android: true dotnet: true haskell: true large-packages: false docker-images: true swap-storage: true - name: Distributed Unit Tests run: | RUN_PRIVATE=$IS_NOT_FORK LUDWIG_TEST_SUITE_TIMEOUT_S=5400 pytest -v --timeout 300 --durations 100 -m "distributed and not slow and not combinatorial and not llm" --junitxml pytest-unit.xml tests/ludwig - name: Distributed Integration Tests run: | RUN_PRIVATE=$IS_NOT_FORK LUDWIG_TEST_SUITE_TIMEOUT_S=7200 pytest -v --timeout 300 --durations 100 -m "distributed and not slow and not combinatorial and not llm" --junitxml pytest-integration.xml tests/integration_tests - name: Upload Test Results if: always() uses: actions/upload-artifact@v4 with: name: Distributed Test Results path: pytest*.xml test-minimal-install: name: Minimal Install runs-on: ubuntu-latest timeout-minutes: 15 steps: - uses: actions/checkout@v4 - name: Set up Python 3.12 uses: actions/setup-python@v5 with: python-version: "3.12" - name: Setup Linux run: | sudo apt-get update && sudo apt-get install -y cmake libsndfile1 - name: Install dependencies run: | python -m pip install -U pip setuptools pip install torch==2.6.0 torchvision torchaudio --extra-index-url https://download.pytorch.org/whl/cpu pip install '.' --extra-index-url https://download.pytorch.org/whl/cpu pip list - name: Check Install run: | ludwig check_install event_file: name: "Event File" runs-on: ubuntu-latest steps: - name: Upload uses: actions/upload-artifact@v4 with: name: Event File path: ${{ github.event_path }} ================================================ FILE: .github/workflows/pytest_slow.yml ================================================ # This workflow will install Python dependencies and run all tests marked as `slow` on a single Python version. name: pytest (slow) on: push: branches: ["main", "release-*"] jobs: slow-pytest: name: py-slow runs-on: ubuntu-latest env: # Use Minio credentials for all S3 operations in tests. # PyArrow/Ray S3 clients use these env vars directly, so they must point to Minio. AWS_ACCESS_KEY_ID: minio AWS_SECRET_ACCESS_KEY: minio123 AWS_ENDPOINT_URL: http://localhost:9000 KAGGLE_USERNAME: ${{ secrets.KAGGLE_USERNAME }} KAGGLE_KEY: ${{ secrets.KAGGLE_KEY }} IS_NOT_FORK: ${{ !(github.event.pull_request.base.repo.full_name == 'ludwig-ai/ludwig' && github.event.pull_request.head.repo.fork) }} services: minio: image: fclairamb/minio-github-actions env: MINIO_ACCESS_KEY: minio MINIO_SECRET_KEY: minio123 ports: - 9000:9000 timeout-minutes: 150 steps: - uses: actions/checkout@v4 - name: Set up Python 3.12 uses: actions/setup-python@v5 with: python-version: "3.12" - name: Setup Linux if: runner.os == 'linux' run: | sudo apt-get update && sudo apt-get install -y cmake libsndfile1 libsox-dev - name: Install dependencies run: | python --version pip --version python -m pip install -U pip pip install torch==2.6.0 torchvision torchaudio pip install '.[test]' pip list shell: bash - name: Create Minio test bucket run: | python -c " import boto3 s3 = boto3.client('s3', endpoint_url='http://localhost:9000', aws_access_key_id='minio', aws_secret_access_key='minio123') try: s3.create_bucket(Bucket='ludwig-tests') except s3.exceptions.BucketAlreadyOwnedByYou: pass " shell: bash - name: Tests run: | RUN_PRIVATE=$IS_NOT_FORK LUDWIG_TEST_SUITE_TIMEOUT_S=7200 pytest -v --timeout 600 --durations 100 -m "slow" --junitxml pytest.xml tests/integration_tests/ ================================================ FILE: .github/workflows/schema.yml ================================================ name: Publish JSON Schema on: push: branches: [main] paths: - "ludwig/schema/**" - "ludwig/config_validation/**" - "ludwig/constants.py" release: types: [published] workflow_dispatch: permissions: contents: read jobs: publish-schema: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.12" - name: Install Ludwig run: pip install -e ".[test]" - name: Export schemas run: | mkdir -p schema-out ludwig export_schema --model-type combined -o schema-out/ludwig-config.json ludwig export_schema --model-type ecd -o schema-out/ludwig-config-ecd.json ludwig export_schema --model-type llm -o schema-out/ludwig-config-llm.json - name: Generate index.html run: | cat > schema-out/index.html << 'HTMLEOF' Ludwig JSON Schema

Ludwig JSON Schema

JSON Schema files for Ludwig config validation and IDE auto-complete.

Usage

Add to your Ludwig YAML config:

# yaml-language-server: $schema=https://ludwig-ai.github.io/schema/ludwig-config.json

Or see SchemaStore for automatic IDE integration.

HTMLEOF - name: Publish to ludwig-ai/schema uses: cpina/github-action-push-to-another-repository@v1.7.2 env: SSH_DEPLOY_KEY: ${{ secrets.SCHEMA_REPO_DEPLOY_KEY }} with: source-directory: schema-out destination-github-username: ludwig-ai destination-repository-name: schema target-branch: main commit-message: "Update Ludwig JSON schema from ${{ github.sha }}" ================================================ FILE: .github/workflows/test-results.yml ================================================ name: test results on: workflow_run: workflows: ["pytest"] types: - completed jobs: test-results: name: Test Results runs-on: ubuntu-latest if: github.event.workflow_run.conclusion != 'skipped' steps: - name: Download and Extract Artifacts env: GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} run: | mkdir -p artifacts && cd artifacts artifacts_url=${{ github.event.workflow_run.artifacts_url }} gh api "$artifacts_url" -q '.artifacts[] | [.name, .archive_download_url] | @tsv' | while read artifact do IFS=$'\t' read name url <<< "$artifact" gh api $url > "$name.zip" unzip -d "$name" "$name.zip" done - name: Publish Unit Test Results uses: EnricoMi/publish-unit-test-result-action@v2 with: commit: ${{ github.event.workflow_run.head_sha }} event_file: artifacts/Event File/event.json event_name: ${{ github.event.workflow_run.event }} files: "artifacts/**/*.xml" ================================================ FILE: .github/workflows/upload-pypi.yml ================================================ name: Upload to PyPI on: # Triggers the workflow when a release or draft of a release is published, # or a pre-release is changed to a release release: types: [released] # Allows you to run this workflow manually from the Actions tab workflow_dispatch: jobs: pypi-publish: name: upload release to PyPI runs-on: ubuntu-latest # Specifying a GitHub environment is optional, but strongly encouraged environment: release permissions: # IMPORTANT: this permission is mandatory for trusted publishing id-token: write steps: - name: Checkout uses: actions/checkout@v4 with: submodules: "recursive" - uses: actions/setup-python@v5 with: python-version: "3.12" - name: Build source distribution run: | pip install setuptools python setup.py sdist - name: Publish package distributions to PyPI uses: pypa/gh-action-pypi-publish@release/v1 ================================================ FILE: .gitignore ================================================ ################### # ludwig specific # ################### *.lock_preprocessing results/ ludwig/results/ results_*/ ludwig_arm64/ # ailabs-utils ailabs_util docker_assets # data mnist_data/ profile_images/ ./profile_images/ ########### # General # ########### # Mac stuff .DS_Store # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python env/ env* build/ develop-eggs/ dist/ downloads/ ./downloads/ ./dataset/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *,cover .hypothesis/ # Data *.csv *.hdf5 *.meta.json *.parquet # Translations *.mo *.pot # Django stuff: *.log local_settings.py # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # dotenv .env # virtualenv .venv venv* ENV/ # Spyder project settings .spyderproject # Rope project settings .ropeproject # PyCharm .idea # ctags tags # examples examples/*/data/ examples/*/visualizations/ # benchmarking configs ludwig/benchmarking/configs/ # Aim tracking .aim/ # Comet .comet.config # Test-generated artifacts (image/audio features) *.png *.wav generated_audio/ generated_images/ ================================================ FILE: .nojekyll ================================================ ================================================ FILE: .pre-commit-config.yaml ================================================ # Apply to all files without committing: # pre-commit run --all-files # Apply to changed files: # pre-commit run # Update this file: # pre-commit autoupdate # Run a specific hook: # pre-commit run ci: autofix_prs: true autoupdate_commit_msg: "[pre-commit.ci] pre-commit suggestions" autoupdate_schedule: weekly repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - id: check-ast - id: fix-byte-order-marker - id: check-case-conflict - id: check-executables-have-shebangs - id: check-json - id: check-toml - id: check-yaml - id: debug-statements - id: detect-private-key - id: end-of-file-fixer - id: trailing-whitespace - id: mixed-line-ending - repo: https://github.com/asottile/pyupgrade rev: v3.21.2 hooks: - id: pyupgrade args: [--py312-plus] - repo: https://github.com/PyCQA/docformatter rev: v1.7.7 hooks: - id: docformatter args: [--in-place, --wrap-summaries=115, --wrap-descriptions=120] - repo: https://github.com/PyCQA/isort rev: 8.0.0 hooks: - id: isort name: Format imports - repo: https://github.com/pycqa/flake8 rev: 7.3.0 hooks: - id: flake8 - repo: https://github.com/psf/black rev: 26.1.0 hooks: - id: black name: Format code - repo: https://github.com/asottile/blacken-docs rev: 1.20.0 hooks: - id: blacken-docs args: [--line-length=120] - repo: https://github.com/hukkin/mdformat rev: 1.0.0 hooks: - id: mdformat additional_dependencies: - mdformat-gfm==1.0.0 - mdformat_frontmatter==2.0.10 exclude: CHANGELOG.md - repo: https://github.com/yoheimuta/protolint rev: v0.56.4 hooks: - id: protolint ================================================ FILE: .protolint.yaml ================================================ # Adapted from # https://github.com/yoheimuta/protolint/blob/master/_example/config/.protolint.yaml --- # Lint directives. lint: # Linter files to walk. files: # The specific files to exclude. exclude: # NOTE: UNIX paths will be properly accepted by both UNIX and Windows. - ../proto/invalidFileName.proto # Linter rules. # Run `protolint list` to see all available rules. rules: # Set the default to all linters. This option works the other way around as no_default does. # If you want to enable this option, delete the comment out below and no_default. # all_default: true # The specific linters to add. add: - FIELD_NAMES_LOWER_SNAKE_CASE - MESSAGE_NAMES_UPPER_CAMEL_CASE - MAX_LINE_LENGTH - INDENT - FIELD_NAMES_EXCLUDE_PREPOSITIONS - FILE_NAMES_LOWER_SNAKE_CASE - IMPORTS_SORTED - PACKAGE_NAME_LOWER_CASE - ORDER - PROTO3_FIELDS_AVOID_REQUIRED - PROTO3_GROUPS_AVOID - REPEATED_FIELD_NAMES_PLURALIZED - QUOTE_CONSISTENT # Linter rules option. rules_option: # MAX_LINE_LENGTH rule option. max_line_length: # Enforces a maximum line length max_chars: 120 # Specifies the character count for tab characters tab_chars: 2 # FILE_NAMES_LOWER_SNAKE_CASE rule option. file_names_lower_snake_case: excludes: - ../proto/invalidFileName.proto # QUOTE_CONSISTENT rule option. quote_consistent: # Available quote are "double" or "single". quote: double ================================================ FILE: .vscode/settings.json ================================================ { "editor.rulers": [ 120 ], "editor.formatOnSave": true, "[python]": { "editor.defaultFormatter": "ms-python.black-formatter", "editor.formatOnSave": true }, "black-formatter.args": [ "--line-length", "120" ], "flake8.args": [ "--config=setup.cfg" ], "python.testing.unittestEnabled": false, "python.testing.pytestEnabled": false, "python.envFile": "${workspaceFolder}/.env" } ================================================ FILE: CODEOWNERS ================================================ # Default code owners for the entire repository * @w4nderlust @tgaddair @justinxzhao @arnavgarg1 @geoffreyangus @jeffkinnison @Infernaught @alexsherstinsky ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Code of conduct Ludwig adopts the [Linux Foundation code of conduct](https://lfprojects.org/policies/code-of-conduct/). ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing Everyone is welcome to contribute, and we value everybody’s contribution. Code is thus not the only way to help the community. Answering questions, helping others, reaching out and improving the documentation are immensely valuable contributions as well. It also helps us if you spread the word: reference the library from blog posts on the awesome projects it made possible, shout out on X every time it has helped you, or simply star the repo to say "thank you". Check out the official [ludwig docs](https://ludwig-ai.github.io/ludwig-docs/) to get oriented around the codebase, and join the community! ## Open Issues Issues are listed at: If you would like to work on any of them, make sure it is not already assigned to someone else. You can self-assign it by commenting on the Issue page with one of the keywords: `#take` or `#self-assign`. Work on your self-assigned issue and eventually create a Pull Request. ## Creating Pull Requests 1. Fork the [repository](https://github.com/ludwig-ai/ludwig) by clicking on the "Fork" button on the repository's page. This creates a copy of the code under your GitHub user account. 1. Clone your fork to your local disk, and add the base repository as a remote: ```bash git clone git@github.com:/ludwig.git cd ludwig git remote add upstream https://github.com/ludwig-ai/ludwig.git ``` 1. Create a new branch to hold your development changes: ```bash git checkout -b a-descriptive-name-for-my-changes ``` *Do not*\* work on the `master` branch. 1. Set up a development environment by running the following command in a virtual environment: ```bash pip install -e . ``` The above command will install only the packages in "requirements.txt" in the developer mode. If you would like to be able to potentially make changes to the overall Ludwig codebase, then use the following command: ```bash pip install -e .[full] ``` Please note that in certain Shell environments (e.g., the `Z shell`), the dependencies in brackets have to be quoted: ```bash pip install -e ."[full]" ``` If you do not need access to the entire Ludwig codebase, but just want to be able to run `pytest` on the essential functionality, then you would replace the above command with: ```bash pip install -e .[test] ``` (Please use `pip install -e ."[test]"` where your Shell environment requires quotes around the square brackets.) For the full list of the optional dependencies available in Ludwig, please see [Installation Guide](https://ludwig.ai/latest/getting_started/installation/) and "setup.py" in the root of the Ludwig repository. 1. On MacOS with Apple Silicon, if this installation approach runs into errors, you may need to install the following prerequisites: ```bash brew install cmake libomp ``` This step requires `homebrew` to be installed on your development machine. 1. Install and run `pre-commit`: ```bash pip install pre-commit pre-commit install ``` 1. Develop features on your branch. 1. Format your code by running pre-commits so that your newly added files look nice: ```bash pre-commit run ``` Pre-commits also run automatically when committing. 1. Once you're happy with your changes, make a commit to record your changes locally: ```bash git add . git commit ``` It is a good idea to sync your copy of the code with the original repository regularly. This way you can quickly account for changes: ```bash git fetch upstream git rebase upstream/master ``` Push the changes to your account using: ```bash git push -u origin a-descriptive-name-for-my-changes ``` 1. Once you are satisfied, go the webpage of your fork on GitHub. Click on "Pull request" to send your contribution to the project maintainers for review. ## Other tips - Add unit tests for any new code you write. - Make sure tests pass. See the [Developer Guide](https://ludwig-ai.github.io/ludwig-docs/latest/developer_guide/style_guidelines_and_tests/) for more details. ## Attribution This contributing guideline is adapted from `huggingface`, available at . ## Code of Conduct Please be mindful of and adhere to the Linux Foundation's [Code of Conduct](https://lfprojects.org/policies/code-of-conduct) when contributing to Ludwig. ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] 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. -------------------------------------------------------------------------- Code in ludwig/api_annotations.py adapted from https://github.com/ray-project/ray (Apache-2.0 License) 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 https://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. -------------------------------------------------------------------------- Code in ludwig/utils/structural_warnings.py adapted from https://github.com/ray-project/ray (Apache-2.0 License) 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 https://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. -------------------------------------------------------------------------- Code in ludwig/utils/logging_utils.py adapted from https://github.com/ray-project/ray (Apache-2.0 License) 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 https://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. ================================================ FILE: MANIFEST.in ================================================ include *.txt recursive-include ludwig/datasets *.yaml recursive-include ludwig/automl/defaults *.yaml recursive-include ludwig/schema/metadata/configs *.yaml ================================================ FILE: NOTICE ================================================ Ludwig includes derived work from TensorFlow(https://github.com/tensorflow/tensorflow) under the Apache License 2.0: Copyright 2016 The prometheus-operator Authors 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. The derived work can be found in the files: ludwig/models/modules/convolutional_modules.py ------ Ludwig includes derived work from Keras(https://github.com/keras-team/keras) under the MIT License: COPYRIGHT All contributions by François Chollet: Copyright (c) 2015 - 2018, François Chollet. All rights reserved. All contributions by Google: Copyright (c) 2015 - 2018, Google, Inc. All rights reserved. All contributions by Microsoft: Copyright (c) 2017 - 2018, Microsoft, Inc. All rights reserved. All other contributions: Copyright (c) 2015 - 2018, the respective contributors. All rights reserved. Each contributor holds copyright over their respective contributions. The project versioning (Git) records all such contribution source information. LICENSE The MIT License (MIT) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. The derived work can be found in the files: mkdocs/code_docs_autogen.py ================================================ FILE: README.md ================================================

_Declarative deep learning framework built for scale and efficiency._ [![PyPI version](https://badge.fury.io/py/ludwig.svg)](https://badge.fury.io/py/ludwig) [![Discord](https://img.shields.io/badge/Discord-Join%20Chat-5865F2?logo=discord&logoColor=white)](https://discord.gg/CBgdrGnZjy) [![DockerHub](https://img.shields.io/docker/pulls/ludwigai/ludwig.svg)](https://hub.docker.com/r/ludwigai) [![Downloads](https://pepy.tech/badge/ludwig)](https://pepy.tech/project/ludwig) [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://github.com/ludwig-ai/ludwig/blob/main/LICENSE) [![X](https://img.shields.io/twitter/follow/ludwig_ai.svg?style=social&logo=twitter)](https://twitter.com/ludwig_ai)
# 📖 What is Ludwig? Ludwig is a **low-code** framework for building **custom** AI models like **LLMs** and other deep neural networks. Key features: - 🛠 **Build custom models with ease:** a declarative YAML configuration file is all you need to train a state-of-the-art LLM on your data. Support for multi-task and multi-modality learning. Comprehensive config validation detects invalid parameter combinations and prevents runtime failures. - ⚡ **Optimized for scale and efficiency:** automatic batch size selection, distributed training ([DDP](https://pytorch.org/tutorials/beginner/ddp_series_theory.html), [DeepSpeed](https://github.com/microsoft/DeepSpeed)), parameter efficient fine-tuning ([PEFT](https://github.com/huggingface/peft)), 4-bit quantization (QLoRA), paged and 8-bit optimizers, and larger-than-memory datasets. - 📐 **Expert level control:** retain full control of your models down to the activation functions. Support for hyperparameter optimization, explainability, and rich metric visualizations. - 🧱 **Modular and extensible:** experiment with different model architectures, tasks, features, and modalities with just a few parameter changes in the config. Think building blocks for deep learning. - 🚢 **Engineered for production:** prebuilt [Docker](https://hub.docker.com/u/ludwigai) containers, native support for running with [Ray](https://www.ray.io/) on [Kubernetes](https://github.com/ray-project/kuberay), export models to [Torchscript](https://pytorch.org/docs/stable/jit.html) and [Triton](https://developer.nvidia.com/triton-inference-server), upload to [HuggingFace](https://huggingface.co/models) with one command. Ludwig is hosted by the [Linux Foundation AI & Data](https://lfaidata.foundation/). **Tech stack:** Python 3.12 | PyTorch 2.6 | Pydantic 2 | Transformers 5 | Ray 2.54 ![img](https://raw.githubusercontent.com/ludwig-ai/ludwig-docs/main/docs/images/ludwig_legos_unanimated.gif) # 💾 Installation Install from PyPI. Be aware that Ludwig requires Python 3.12+. ```shell pip install ludwig ``` Or install with all optional dependencies: ```shell pip install ludwig[full] ``` Please see [contributing](https://github.com/ludwig-ai/ludwig/blob/main/CONTRIBUTING.md) for more detailed installation instructions. # 🚂 Getting Started Want to take a quick peek at some of Ludwig's features? Check out this Colab Notebook 🚀 [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/drive/1lB4ALmEyvcMycE3Mlnsd7I3bc0zxvk39) Looking to fine-tune LLMs? Check out these notebooks: 1. Fine-Tune Llama-2-7b: [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/drive/1r4oSEwRJpYKBPM0M0RSh0pBEYK_gBKbe) 1. Fine-Tune Llama-2-13b: [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/drive/1zmSEzqZ7v4twBrXagj1TE_C--RNyVAyu) 1. Fine-Tune Mistral-7b: [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/drive/1i_8A1n__b7ljRWHzIsAdhO7u7r49vUm4) For a full tutorial, check out the official [getting started guide](https://ludwig.ai/latest/getting_started/), or take a look at end-to-end [Examples](https://ludwig.ai/latest/examples). ## Large Language Model Fine-Tuning [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/drive/1c3AO8l_H6V_x37RwQ8V7M6A-RmcBf2tG?usp=sharing) Let's fine-tune a pretrained LLM to follow instructions like a chatbot ("instruction tuning"). ### Prerequisites - [HuggingFace API Token](https://huggingface.co/docs/hub/security-tokens) - Access approval to your chosen base model (e.g., [Llama-3.1-8B](https://huggingface.co/meta-llama/Llama-3.1-8B)) - GPU with at least 12 GiB of VRAM (in our tests, we used an Nvidia T4) ### Running We'll use the [Stanford Alpaca](https://crfm.stanford.edu/2023/03/13/alpaca.html) dataset, which will be formatted as a table-like file that looks like this: | instruction | input | output | | :-----------------------------------------------: | :--------------: | :-----------------------------------------------: | | Give three tips for staying healthy. | | 1.Eat a balanced diet and make sure to include... | | Arrange the items given below in the order to ... | cake, me, eating | I eating cake. | | Write an introductory paragraph about a famous... | Michelle Obama | Michelle Obama is an inspirational woman who r... | | ... | ... | ... | Create a YAML config file named `model.yaml` with the following: ```yaml model_type: llm base_model: meta-llama/Llama-3.1-8B quantization: bits: 4 adapter: type: lora prompt: template: | Below is an instruction that describes a task, paired with an input that may provide further context. Write a response that appropriately completes the request. ### Instruction: {instruction} ### Input: {input} ### Response: input_features: - name: prompt type: text output_features: - name: output type: text trainer: type: finetune learning_rate: 0.0001 batch_size: 1 gradient_accumulation_steps: 16 epochs: 3 learning_rate_scheduler: decay: cosine warmup_fraction: 0.01 preprocessing: sample_ratio: 0.1 backend: type: local ``` And now let's train the model: ```bash export HUGGING_FACE_HUB_TOKEN = "" ludwig train --config model.yaml --dataset "ludwig://alpaca" ``` ## Supervised ML Let's build a neural network that predicts whether a given movie critic's review on [Rotten Tomatoes](https://www.kaggle.com/stefanoleone992/rotten-tomatoes-movies-and-critic-reviews-dataset) was positive or negative. Our dataset will be a CSV file that looks like this: | movie_title | content_rating | genres | runtime | top_critic | review_content | recommended | | :------------------: | :------------: | :------------------------------: | :-----: | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | | Deliver Us from Evil | R | Action & Adventure, Horror | 117.0 | TRUE | Director Scott Derrickson and his co-writer, Paul Harris Boardman, deliver a routine procedural with unremarkable frights. | 0 | | Barbara | PG-13 | Art House & International, Drama | 105.0 | FALSE | Somehow, in this stirring narrative, Barbara manages to keep hold of her principles, and her humanity and courage, and battles to save a dissident teenage girl whose life the Communists are trying to destroy. | 1 | | Horrible Bosses | R | Comedy | 98.0 | FALSE | These bosses cannot justify either murder or lasting comic memories, fatally compromising a farce that could have been great but ends up merely mediocre. | 0 | | ... | ... | ... | ... | ... | ... | ... | Download a sample of the dataset from [here](https://ludwig.ai/latest/data/rotten_tomatoes.csv). ```bash wget https://ludwig.ai/latest/data/rotten_tomatoes.csv ``` Next create a YAML config file named `model.yaml` with the following: ```yaml input_features: - name: genres type: set preprocessing: tokenizer: comma - name: content_rating type: category - name: top_critic type: binary - name: runtime type: number - name: review_content type: text encoder: type: embed output_features: - name: recommended type: binary ``` That's it! Now let's train the model: ```bash ludwig train --config model.yaml --dataset rotten_tomatoes.csv ``` **Happy modeling** Try applying Ludwig to your data. [Reach out on Discord](https://discord.gg/CBgdrGnZjy) if you have any questions. # ❓ Why you should use Ludwig - **Minimal machine learning boilerplate** Ludwig takes care of the engineering complexity of machine learning out of the box, enabling research scientists to focus on building models at the highest level of abstraction. Data preprocessing, hyperparameter optimization, device management, and distributed training for `torch.nn.Module` models come completely free. - **Easily build your benchmarks** Creating a state-of-the-art baseline and comparing it with a new model is a simple config change. - **Easily apply new architectures to multiple problems and datasets** Apply new models across the extensive set of tasks and datasets that Ludwig supports. Ludwig includes a [full benchmarking toolkit](https://arxiv.org/abs/2111.04260) accessible to any user, for running experiments with multiple models across multiple datasets with just a simple configuration. - **Highly configurable data preprocessing, modeling, and metrics** Any and all aspects of the model architecture, training loop, hyperparameter search, and backend infrastructure can be modified as additional fields in the declarative configuration to customize the pipeline to meet your requirements. For details on what can be configured, check out [Ludwig Configuration](https://ludwig.ai/latest/configuration/) docs. - **Multi-modal, multi-task learning out-of-the-box** Mix and match tabular data, text, images, and even audio into complex model configurations without writing code. - **Rich model exporting and tracking** Automatically track all trials and metrics with tools like Tensorboard, Comet ML, Weights & Biases, MLFlow, and Aim Stack. - **Automatically scale training to multi-GPU, multi-node clusters** Go from training on your local machine to the cloud without code changes. - **Low-code interface for state-of-the-art models, including pre-trained Huggingface Transformers** Ludwig also natively integrates with pre-trained models, such as the ones available in [Huggingface Transformers](https://huggingface.co/docs/transformers/index). Users can choose from a vast collection of state-of-the-art pre-trained PyTorch models to use without needing to write any code at all. For example, training a BERT-based sentiment analysis model with Ludwig is as simple as: ```shell ludwig train --dataset sst5 --config_str "{input_features: [{name: sentence, type: text, encoder: bert}], output_features: [{name: label, type: category}]}" ``` - **Low-code interface for AutoML** [Ludwig AutoML](https://ludwig.ai/latest/user_guide/automl/) allows users to obtain trained models by providing just a dataset, the target column, and a time budget. ```python auto_train_results = ludwig.automl.auto_train(dataset=my_dataset_df, target=target_column_name, time_limit_s=7200) ``` - **Easy productionisation** Ludwig makes it easy to serve deep learning models, including on GPUs. Launch a REST API for your trained Ludwig model. ```shell ludwig serve --model_path=/path/to/model ``` Ludwig supports exporting models to efficient Torchscript bundles. ```shell ludwig export_torchscript -–model_path=/path/to/model ``` # 📚 Tutorials - [Text Classification](https://ludwig.ai/latest/examples/text_classification) - [Tabular Data Classification](https://ludwig.ai/latest/examples/adult_census_income) - [Image Classification](https://ludwig.ai/latest/examples/mnist) - [Multimodal Classification](https://ludwig.ai/latest/examples/multimodal_classification) # 🔬 Example Use Cases - [Named Entity Recognition Tagging](https://ludwig.ai/latest/examples/ner_tagging) - [Natural Language Understanding](https://ludwig.ai/latest/examples/nlu) - [Machine Translation](https://ludwig.ai/latest/examples/machine_translation) - [Chit-Chat Dialogue Modeling through seq2seq](https://ludwig.ai/latest/examples/seq2seq) - [Sentiment Analysis](https://ludwig.ai/latest/examples/sentiment_analysis) - [One-shot Learning with Siamese Networks](https://ludwig.ai/latest/examples/oneshot) - [Visual Question Answering](https://ludwig.ai/latest/examples/visual_qa) - [Spoken Digit Speech Recognition](https://ludwig.ai/latest/examples/speech_recognition) - [Speaker Verification](https://ludwig.ai/latest/examples/speaker_verification) - [Binary Classification (Titanic)](https://ludwig.ai/latest/examples/titanic) - [Timeseries forecasting](https://ludwig.ai/latest/examples/forecasting) - [Timeseries forecasting (Weather)](https://ludwig.ai/latest/examples/weather) - [Movie rating prediction](https://ludwig.ai/latest/examples/movie_ratings) - [Multi-label classification](https://ludwig.ai/latest/examples/multi_label) - [Multi-Task Learning](https://ludwig.ai/latest/examples/multi_task) - [Simple Regression: Fuel Efficiency Prediction](https://ludwig.ai/latest/examples/fuel_efficiency) - [Fraud Detection](https://ludwig.ai/latest/examples/fraud) # 💡 More Information Read our publications on [Ludwig](https://arxiv.org/pdf/1909.07930.pdf), [declarative ML](https://arxiv.org/pdf/2107.08148.pdf), and [Ludwig’s SoTA benchmarks](https://openreview.net/pdf?id=hwjnu6qW7E4). Learn more about [how Ludwig works](https://ludwig.ai/latest/user_guide/how_ludwig_works/), [how to get started](https://ludwig.ai/latest/getting_started/), and work through more [examples](https://ludwig.ai/latest/examples). If you are interested in [contributing](https://github.com/ludwig-ai/ludwig/blob/main/CONTRIBUTING.md), have questions, comments, or thoughts to share, or if you just want to be in the know, please consider [joining our Community Discord](https://discord.gg/CBgdrGnZjy) and follow us on [X](https://twitter.com/ludwig_ai)! # 🤝 Join the community to build Ludwig with us Ludwig is an actively managed open-source project that relies on contributions from folks just like you. Consider joining the active group of Ludwig contributors to make Ludwig an even more accessible and feature rich framework for everyone to use!
## Star History [![Star History Chart](https://api.star-history.com/svg?repos=ludwig-ai/ludwig&type=Date)](https://star-history.com/#ludwig-ai/ludwig&Date) # 👋 Getting Involved - [Discord](https://discord.gg/CBgdrGnZjy) - [X (Twitter)](https://twitter.com/ludwig_ai) - [Medium](https://medium.com/ludwig-ai) - [GitHub Issues](https://github.com/ludwig-ai/ludwig/issues) ================================================ FILE: README_KR.md ================================================

_확장성과 효율성을 위해 설계된 선언적 딥러닝 프레임워크_ [![PyPI version](https://badge.fury.io/py/ludwig.svg)](https://badge.fury.io/py/ludwig) [![Discord](https://img.shields.io/badge/Discord-Join%20Chat-5865F2?logo=discord&logoColor=white)](https://discord.gg/CBgdrGnZjy) [![DockerHub](https://img.shields.io/docker/pulls/ludwigai/ludwig.svg)](https://hub.docker.com/r/ludwigai) [![Downloads](https://pepy.tech/badge/ludwig)](https://pepy.tech/project/ludwig) [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://github.com/ludwig-ai/ludwig/blob/main/LICENSE) [![X](https://img.shields.io/twitter/follow/ludwig_ai.svg?style=social&logo=twitter)](https://twitter.com/ludwig_ai)
# 📖 Ludwig란? Ludwig는 **LLM** 및 기타 심층 신경망과 같은 **맞춤형** AI 모델을 구축하기 위한 **로우코드** 프레임워크입니다. 주요 기능: - 🛠 **손쉬운 맞춤형 모델 구축:** 선언적 YAML 설정 파일만으로 최신 LLM을 데이터에 맞춰 학습시킬 수 있습니다. 멀티태스크 및 멀티모달 학습을 지원합니다. 포괄적인 설정 검증으로 잘못된 매개변수 조합을 감지하고 런타임 오류를 방지합니다. - ⚡ **확장성과 효율성 최적화:** 자동 배치 크기 선택, 분산 학습([DDP](https://pytorch.org/tutorials/beginner/ddp_series_theory.html), [DeepSpeed](https://github.com/microsoft/DeepSpeed)), 매개변수 효율적 미세 조정([PEFT](https://github.com/huggingface/peft)), 4비트 양자화(QLoRA), 페이지 및 8비트 옵티마이저, 메모리 초과 데이터셋 지원. - 📐 **전문가 수준의 제어:** 활성화 함수까지 모델을 완전히 제어할 수 있습니다. 하이퍼파라미터 최적화, 설명 가능성, 풍부한 메트릭 시각화를 지원합니다. - 🧱 **모듈식 및 확장 가능:** 설정에서 몇 가지 매개변수만 변경하여 다양한 모델 아키텍처, 태스크, 피처, 모달리티를 실험할 수 있습니다. 딥러닝을 위한 빌딩 블록이라고 생각하세요. - 🚢 **프로덕션을 위한 설계:** 사전 빌드된 [Docker](https://hub.docker.com/u/ludwigai) 컨테이너, [Kubernetes](https://github.com/ray-project/kuberay)에서 [Ray](https://www.ray.io/) 실행 네이티브 지원, [Torchscript](https://pytorch.org/docs/stable/jit.html) 및 [Triton](https://developer.nvidia.com/triton-inference-server)으로 모델 내보내기, 한 번의 명령으로 [HuggingFace](https://huggingface.co/models)에 업로드. Ludwig는 [Linux Foundation AI & Data](https://lfaidata.foundation/)에서 호스팅합니다. **기술 스택:** Python 3.12 | PyTorch 2.6 | Pydantic 2 | Transformers 5 | Ray 2.54 ![img](https://raw.githubusercontent.com/ludwig-ai/ludwig-docs/master/docs/images/ludwig_legos_unanimated.gif) # 💾 설치 PyPI에서 설치합니다. Ludwig는 Python 3.12 이상을 요구합니다. ```shell pip install ludwig ``` 모든 선택적 의존성을 포함하여 설치: ```shell pip install ludwig[full] ``` 더 자세한 설치 방법은 [기여 가이드](https://github.com/ludwig-ai/ludwig/blob/main/CONTRIBUTING.md)를 참조하세요. # 🚂 시작하기 Ludwig의 기능을 빠르게 살펴보고 싶으시다면 이 Colab 노트북을 확인하세요 🚀 [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/drive/1lB4ALmEyvcMycE3Mlnsd7I3bc0zxvk39) LLM 미세 조정을 원하시나요? 다음 노트북을 확인하세요: 1. Fine-Tune Llama-2-7b: [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/drive/1r4oSEwRJpYKBPM0M0RSh0pBEYK_gBKbe) 1. Fine-Tune Llama-2-13b: [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/drive/1zmSEzqZ7v4twBrXagj1TE_C--RNyVAyu) 1. Fine-Tune Mistral-7b: [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/drive/1i_8A1n__b7ljRWHzIsAdhO7u7r49vUm4) 전체 튜토리얼은 공식 [시작 가이드](https://ludwig.ai/latest/getting_started/)를 확인하시거나, 엔드투엔드 [예제](https://ludwig.ai/latest/examples)를 살펴보세요. ## 대규모 언어 모델 미세 조정 [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/drive/1c3AO8l_H6V_x37RwQ8V7M6A-RmcBf2tG?usp=sharing) 사전 학습된 LLM을 챗봇처럼 지시를 따르도록 미세 조정("인스트럭션 튜닝")해 봅시다. ### 사전 요구 사항 - [HuggingFace API 토큰](https://huggingface.co/docs/hub/security-tokens) - 선택한 베이스 모델에 대한 접근 승인 (예: [Llama-3.1-8B](https://huggingface.co/meta-llama/Llama-3.1-8B)) - 최소 12 GiB VRAM의 GPU (테스트에서는 Nvidia T4를 사용했습니다) ### 실행 [Stanford Alpaca](https://crfm.stanford.edu/2023/03/13/alpaca.html) 데이터셋을 사용합니다. 다음과 같은 테이블 형식의 파일로 구성됩니다: | instruction | input | output | | :-----------------------------------------------: | :--------------: | :-----------------------------------------------: | | Give three tips for staying healthy. | | 1.Eat a balanced diet and make sure to include... | | Arrange the items given below in the order to ... | cake, me, eating | I eating cake. | | Write an introductory paragraph about a famous... | Michelle Obama | Michelle Obama is an inspirational woman who r... | | ... | ... | ... | `model.yaml`이라는 YAML 설정 파일을 다음 내용으로 생성하세요: ```yaml model_type: llm base_model: meta-llama/Llama-3.1-8B quantization: bits: 4 adapter: type: lora prompt: template: | Below is an instruction that describes a task, paired with an input that may provide further context. Write a response that appropriately completes the request. ### Instruction: {instruction} ### Input: {input} ### Response: input_features: - name: prompt type: text output_features: - name: output type: text trainer: type: finetune learning_rate: 0.0001 batch_size: 1 gradient_accumulation_steps: 16 epochs: 3 learning_rate_scheduler: decay: cosine warmup_fraction: 0.01 preprocessing: sample_ratio: 0.1 backend: type: local ``` 이제 모델을 학습시켜 봅시다: ```bash export HUGGING_FACE_HUB_TOKEN = "" ludwig train --config model.yaml --dataset "ludwig://alpaca" ``` ## 지도 학습 ML [Rotten Tomatoes](https://www.kaggle.com/stefanoleone992/rotten-tomatoes-movies-and-critic-reviews-dataset) 영화 평론가의 리뷰가 긍정적인지 부정적인지 예측하는 신경망을 만들어 봅시다. 데이터셋은 다음과 같은 CSV 파일입니다: | movie_title | content_rating | genres | runtime | top_critic | review_content | recommended | | :------------------: | :------------: | :------------------------------: | :-----: | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | | Deliver Us from Evil | R | Action & Adventure, Horror | 117.0 | TRUE | Director Scott Derrickson and his co-writer, Paul Harris Boardman, deliver a routine procedural with unremarkable frights. | 0 | | Barbara | PG-13 | Art House & International, Drama | 105.0 | FALSE | Somehow, in this stirring narrative, Barbara manages to keep hold of her principles, and her humanity and courage, and battles to save a dissident teenage girl whose life the Communists are trying to destroy. | 1 | | Horrible Bosses | R | Comedy | 98.0 | FALSE | These bosses cannot justify either murder or lasting comic memories, fatally compromising a farce that could have been great but ends up merely mediocre. | 0 | | ... | ... | ... | ... | ... | ... | ... | [여기](https://ludwig.ai/latest/data/rotten_tomatoes.csv)에서 데이터셋 샘플을 다운로드하세요. ```bash wget https://ludwig.ai/latest/data/rotten_tomatoes.csv ``` 다음으로 `model.yaml`이라는 YAML 설정 파일을 생성하세요: ```yaml input_features: - name: genres type: set preprocessing: tokenizer: comma - name: content_rating type: category - name: top_critic type: binary - name: runtime type: number - name: review_content type: text encoder: type: embed output_features: - name: recommended type: binary ``` 이게 전부입니다! 이제 모델을 학습시켜 봅시다: ```bash ludwig train --config model.yaml --dataset rotten_tomatoes.csv ``` **즐거운 모델링 되세요** Ludwig를 여러분의 데이터에 적용해 보세요. 질문이 있으시면 [Discord에서 문의](https://discord.gg/CBgdrGnZjy)해 주세요. # ❓ Ludwig를 사용해야 하는 이유 - **최소한의 머신러닝 보일러플레이트** Ludwig는 머신러닝의 엔지니어링 복잡성을 기본으로 처리하여, 연구자들이 가장 높은 수준의 추상화에서 모델 구축에 집중할 수 있게 합니다. `torch.nn.Module` 모델에 대한 데이터 전처리, 하이퍼파라미터 최적화, 디바이스 관리, 분산 학습이 완전히 무료로 제공됩니다. - **손쉬운 벤치마크 구축** 최신 기준 모델을 만들고 새 모델과 비교하는 것이 간단한 설정 변경만으로 가능합니다. - **새로운 아키텍처를 여러 문제와 데이터셋에 쉽게 적용** Ludwig가 지원하는 광범위한 태스크 및 데이터셋 세트에 새 모델을 적용하세요. Ludwig에는 간단한 설정만으로 여러 데이터셋에서 여러 모델 실험을 실행할 수 있는 [전체 벤치마킹 도구](https://arxiv.org/abs/2111.04260)가 모든 사용자에게 제공됩니다. - **데이터 전처리, 모델링, 메트릭의 높은 설정 가능성** 모델 아키텍처, 학습 루프, 하이퍼파라미터 검색, 백엔드 인프라의 모든 측면을 선언적 설정에서 추가 필드로 수정하여 파이프라인을 요구 사항에 맞게 커스터마이즈할 수 있습니다. 설정 가능한 항목에 대한 자세한 내용은 [Ludwig 설정](https://ludwig.ai/latest/configuration/) 문서를 확인하세요. - **멀티모달, 멀티태스크 학습 기본 지원** 코드 작성 없이 테이블 데이터, 텍스트, 이미지, 오디오까지 복잡한 모델 설정으로 혼합하여 사용할 수 있습니다. - **풍부한 모델 내보내기 및 추적** Tensorboard, Comet ML, Weights & Biases, MLFlow, Aim Stack 등의 도구로 모든 시도와 메트릭을 자동으로 추적합니다. - **멀티 GPU, 멀티 노드 클러스터로 학습 자동 확장** 로컬 머신에서 클라우드로 코드 변경 없이 전환할 수 있습니다. - **사전 학습된 Huggingface Transformers를 포함한 최신 모델의 로우코드 인터페이스** Ludwig는 [Huggingface Transformers](https://huggingface.co/docs/transformers/index)에서 제공하는 사전 학습된 모델과 네이티브로 통합됩니다. 사용자는 코드를 전혀 작성하지 않고도 방대한 최신 사전 학습 PyTorch 모델을 사용할 수 있습니다. 예를 들어, Ludwig로 BERT 기반 감성 분석 모델을 학습시키는 것은 다음과 같이 간단합니다: ```shell ludwig train --dataset sst5 --config_str "{input_features: [{name: sentence, type: text, encoder: bert}], output_features: [{name: label, type: category}]}" ``` - **AutoML을 위한 로우코드 인터페이스** [Ludwig AutoML](https://ludwig.ai/latest/user_guide/automl/)을 사용하면 데이터셋, 대상 컬럼, 시간 예산만 제공하여 학습된 모델을 얻을 수 있습니다. ```python auto_train_results = ludwig.automl.auto_train(dataset=my_dataset_df, target=target_column_name, time_limit_s=7200) ``` - **손쉬운 프로덕션화** Ludwig는 GPU를 포함한 딥러닝 모델 서빙을 쉽게 만들어 줍니다. 학습된 Ludwig 모델에 대한 REST API를 실행하세요. ```shell ludwig serve --model_path=/path/to/model ``` Ludwig는 효율적인 Torchscript 번들로 모델 내보내기를 지원합니다. ```shell ludwig export_torchscript --model_path=/path/to/model ``` # 📚 튜토리얼 - [텍스트 분류](https://ludwig.ai/latest/examples/text_classification) - [테이블 데이터 분류](https://ludwig.ai/latest/examples/adult_census_income) - [이미지 분류](https://ludwig.ai/latest/examples/mnist) - [멀티모달 분류](https://ludwig.ai/latest/examples/multimodal_classification) # 🔬 예제 사용 사례 - [개체명 인식 태깅](https://ludwig.ai/latest/examples/ner_tagging) - [자연어 이해](https://ludwig.ai/latest/examples/nlu) - [기계 번역](https://ludwig.ai/latest/examples/machine_translation) - [seq2seq를 통한 대화 모델링](https://ludwig.ai/latest/examples/seq2seq) - [감성 분석](https://ludwig.ai/latest/examples/sentiment_analysis) - [시아미즈 네트워크를 이용한 원샷 학습](https://ludwig.ai/latest/examples/oneshot) - [시각적 질의응답](https://ludwig.ai/latest/examples/visual_qa) - [음성 숫자 인식](https://ludwig.ai/latest/examples/speech_recognition) - [화자 인증](https://ludwig.ai/latest/examples/speaker_verification) - [이진 분류 (타이타닉)](https://ludwig.ai/latest/examples/titanic) - [시계열 예측](https://ludwig.ai/latest/examples/forecasting) - [시계열 예측 (날씨)](https://ludwig.ai/latest/examples/weather) - [영화 평점 예측](https://ludwig.ai/latest/examples/movie_ratings) - [다중 레이블 분류](https://ludwig.ai/latest/examples/multi_label) - [멀티태스크 학습](https://ludwig.ai/latest/examples/multi_task) - [단순 회귀: 연비 예측](https://ludwig.ai/latest/examples/fuel_efficiency) - [사기 탐지](https://ludwig.ai/latest/examples/fraud) # 💡 추가 정보 [Ludwig](https://arxiv.org/pdf/1909.07930.pdf), [선언적 ML](https://arxiv.org/pdf/2107.08148.pdf), [Ludwig의 SoTA 벤치마크](https://openreview.net/pdf?id=hwjnu6qW7E4)에 대한 논문을 읽어보세요. [Ludwig의 작동 방식](https://ludwig.ai/latest/user_guide/how_ludwig_works/), [시작 가이드](https://ludwig.ai/latest/getting_started/), 더 많은 [예제](https://ludwig.ai/latest/examples)를 확인하세요. [기여](https://github.com/ludwig-ai/ludwig/blob/main/CONTRIBUTING.md)에 관심이 있으시거나, 질문, 의견, 공유하고 싶은 생각이 있으시거나, 최신 정보를 받고 싶으시다면 [Discord 커뮤니티에 참여](https://discord.gg/CBgdrGnZjy)하시고 [X](https://twitter.com/ludwig_ai)에서 팔로우해 주세요! # 🤝 함께 Ludwig를 만들어 갈 커뮤니티에 참여하세요 Ludwig는 여러분과 같은 분들의 기여에 의존하는 활발하게 관리되는 오픈소스 프로젝트입니다. Ludwig를 모든 사람이 사용할 수 있는 더 접근 가능하고 기능이 풍부한 프레임워크로 만들기 위해 활발한 Ludwig 기여자 그룹에 참여하는 것을 고려해 주세요!
## Star History [![Star History Chart](https://api.star-history.com/svg?repos=ludwig-ai/ludwig&type=Date)](https://star-history.com/#ludwig-ai/ludwig&Date) # 👋 참여하기 - [Discord](https://discord.gg/CBgdrGnZjy) - [X (Twitter)](https://twitter.com/ludwig_ai) - [Medium](https://medium.com/ludwig-ai) - [GitHub Issues](https://github.com/ludwig-ai/ludwig/issues) ================================================ FILE: RELEASES.md ================================================ # Releasing ## Release procedure 1. Update version number in `ludwig/globals.py` 1. Update version number in `setup.py` 1. Commit 1. Tag the commit with the version number `vX.Y.Z` with a meaningful message 1. Push with `--tags` 1. If a non-patch release, edit the release notes 1. Create a release for Pypi: `python setup.py sdist` 1. Release on Pypi: `twine upaload --repository pypi dist/ludwig-X.Y.Z.tar.gz` ## Release policy Ludwig follows [Semantic Versioning](https://semver.org). In general, for major and minor releases, maintainers should all agree on the release. For patches, in particular time sensitive ones, a single maintainer can release without a full consensus, but this practice should be reserved for critical situations. ================================================ FILE: docker/README.md ================================================ # Ludwig Docker Images These images provide Ludwig, a toolbox to train and evaluate deep learning models without the need to write code. Ludwig Docker images contain the full set of pre-requisite packages to support these capabilities - text features - image features - audio features - visualizations - hyperparameter optimization - distributed training - model serving ## Repositories These four repositories contain a version of Ludwig with full features built from the project's `master` branch. - `ludwigai/ludwig` Ludwig packaged with PyTorch - `ludwigai/ludwig-gpu` Ludwig packaged with gpu-enabled version of PyTorch - `ludwigai/ludwig-ray` Ludwig packaged with PyTorch and Ray 2.3.1 (https://github.com/ray-project/ray) - `ludwigai/ludwig-ray-gpu` Ludwig packaged with gpu-enabled versions of PyTorch and Ray 2.3.1 (https://github.com/ray-project/ray) ## Image Tags - `master` - built from Ludwig's `master` branch - `nightly` - nightly build of Ludwig's software. - `sha-` - version of Ludwig software at designated git sha1 7-character commit point. ## Running Containers Examples of using the `ludwigai/ludwig:master` image to: - run the `ludwig cli` command or - run Python program containing Ludwig api or - view Ludwig results with Tensorboard For purposes of the examples assume this host directory structure ``` /top/level/directory/path/ data/ train.csv src/ config.yaml ludwig_api_program.py ``` ### Run Ludwig CLI ``` # set shell variable to parent directory parent_path=/top/level/directory/path # invoke docker run command to execute the ludwig cli # map host directory ${parent_path}/data to container /data directory # map host directory ${parent_path}/src to container /src directory docker run -v ${parent_path}/data:/data \ -v ${parent_path}/src:/src \ ludwigai/ludwig:master \ experiment --config /src/config.yaml \ --dataset /data/train.csv \ --output_directory /src/results ``` Experiment results can be found in host directory `/top/level/directory/path/src/results` ### Run Python program using Ludwig APIs ``` # set shell variable to parent directory parent_path=/top/level/directory/path # invoke docker run command to execute Python interpreter # map host directory ${parent_path}/data to container /data directory # map host directory ${parent_path}/src to container /src directory # set current working directory to container /src directory # change default entrypoint from ludwig to python docker run -v ${parent_path}/data:/data \ -v ${parent_path}/src:/src \ -w /src \ --entrypoint python \ ludwigai/ludwig:master /src/ludwig_api_program.py ``` Ludwig results can be found in host directory `/top/level/directory/path/src/results` ### View Ludwig Tensorboard results ``` # set shell variable to parent directory parent_path=/top/level/directory/path # invoke docker run command to execute Tensorboard # map host directory ${parent_path}/src to container /src directory # set up mapping from localhost port 6006 to container port 6006 # change default entrypoint from ludwig to tensorboard # --logdir container location of tenorboard logs /src/results/_/model/logs # --bind_all Tensorboard serves on all public container interfaces docker run -v ${parent_path}/src:/src \ -p 6006:6006 \ --entrypoint tensorboard \ ludwigai/ludwig:master \ --logdir /src/results/experiment_run/model/logs \ --bind_all ``` Point browser to `http://localhost:6006` to see Tensorboard dashboard. ### Devcontainer If you want to contribute to Ludwig, you can setup a Docker container with all the dependencies installed as a full featured development environment. This can be done using devcontainers with VS Code: https://code.visualstudio.com/docs/devcontainers/containers You can find the `devcontainer.json` file within the top level `.devcontainer` folder. ================================================ FILE: docker/ludwig/Dockerfile ================================================ # # Ludwig Docker image with full set of pre-requiste packages to support these capabilities # text features # image features # audio features # visualizations # hyperparameter optimization # distributed training # model serving # FROM python:3.12-slim RUN apt-get -y update && apt-get -y install \ git \ libsndfile1 \ build-essential \ g++ \ cmake \ ffmpeg \ sox \ libsox-dev RUN pip install -U pip WORKDIR /ludwig RUN pip install --no-cache-dir torch==2.6.0 torchvision torchaudio --extra-index-url https://download.pytorch.org/whl/cpu COPY . . RUN pip install --no-cache-dir '.[full]' WORKDIR /data ENTRYPOINT ["ludwig"] ================================================ FILE: docker/ludwig-gpu/Dockerfile ================================================ # # Ludwig Docker image with full set of pre-requiste packages to support these capabilities # text features # image features # audio features # visualizations # hyperparameter optimization # distributed training # model serving # FROM pytorch/pytorch:2.6.0-cuda12.4-cudnn9-devel RUN apt-get -y update && DEBIAN_FRONTEND="noninteractive" apt-get -y install \ git \ libsndfile1 \ cmake \ ffmpeg \ sox \ libsox-dev RUN pip install -U pip WORKDIR /ludwig COPY . . RUN pip install --no-cache-dir '.[full]' WORKDIR /data ENTRYPOINT ["ludwig"] ================================================ FILE: docker/ludwig-ray/Dockerfile ================================================ # # Ludwig Docker image with Ray support and full dependencies including: # text features # image features # audio features # visualizations # hyperparameter optimization # distributed training # model serving # FROM rayproject/ray:2.44.1-py312 RUN sudo apt-get update && DEBIAN_FRONTEND="noninteractive" sudo apt-get install -y \ build-essential \ wget \ git \ curl \ libsndfile1 \ cmake \ tzdata \ rsync \ vim \ ffmpeg \ sox \ libsox-dev RUN pip install -U pip WORKDIR /ludwig RUN pip install --no-cache-dir torch==2.6.0 torchvision torchaudio --extra-index-url https://download.pytorch.org/whl/cpu COPY . . RUN pip install --no-cache-dir '.[full]' --extra-index-url https://download.pytorch.org/whl/cpu ================================================ FILE: docker/ludwig-ray-gpu/Dockerfile ================================================ # # Ludwig Docker image with Ray support and full dependencies including: # text features # image features # audio features # visualizations # hyperparameter optimization # distributed training # model serving # FROM rayproject/ray:2.44.1-py312-cu124 RUN sudo apt-get update && \ DEBIAN_FRONTEND="noninteractive" sudo apt-get install -y \ build-essential \ wget \ git \ curl \ libsndfile1 \ cmake \ tzdata \ rsync \ vim \ ffmpeg \ sox \ libsox-dev RUN pip install -U pip WORKDIR /ludwig RUN pip install --no-cache-dir torch==2.6.0 torchvision torchaudio --extra-index-url https://download.pytorch.org/whl/cu124 COPY . . RUN pip install --no-cache-dir '.[full]' ================================================ FILE: examples/README.md ================================================ # Examples This directory contains example programs demonstrating Ludwig's Python APIs. | Directory | Examples Provided | | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | hyperopt | Demonstrates Ludwig's to hyper-parameter optimization capability. | | kfold_cv | Provides two examples for performing a k-fold cross validation analysis. One example uses the `ludwig experiment` cli. The other example uses the `ludwig.experiment.kfold_cross_validate()` api function. | | mnist | Creates a model config data structure from a yaml file and trains a model. Programmatically modify the model config data structure to evaluate several different neural network architectures. Jupyter notebook demonstrates using a hold-out test data set to visualize model performance for alternative model architectures. | | titanic | Trains a simple model with model config contained in a yaml file. Trains multiple models from yaml files and generate visualizations to compare training results. Jupyter notebook demonstrating how to programmatically create visualizations. | | serve | Demonstrates running Ludwig http model server. A sample Python program illustrates how to invoke the REST API to get predictions from input features. | | class_imbalance | Demonstrates using our class balancing feature to over-sample an imbalanced dataset. | ================================================ FILE: examples/calibration/README.md ================================================ # Calibration Examples Drawing on the methods in On Calibration of Modern Neural Networks (Chuan Guo, Geoff Pleiss, Yu Sun, Kilian Q. Weinberger), Ludwig supports temperature scaling for binary and category output features. Temperature scaling brings a model’s output probabilities closer to the true likelihood while preserving the same accuracy and top k predictions. To enable calibration, add calibration: True to any binary or category feature: ``` output_features: - name: Cover_Type type: category calibration: True ``` With calibration enabled, Ludwig will attempt to find a scale factor (temperature) which will bring the probabilities closer to the true likelihoods using the validation set. This calibration phase is run after training is complete. If no validation set is provided, the training set is used for calibration. To visualize the effects of calibration in Ludwig, you can use Calibration Plots, which bin the data based on model probability and plot the model probability (X) versus the observed rate (Y) for each bin. ================================================ FILE: examples/calibration/train_forest_cover_calibrated.py ================================================ #!/usr/bin/env python import copy import logging import shutil import numpy as np import yaml import ludwig.visualize from ludwig.api import LudwigModel from ludwig.datasets import forest_cover # clean out prior results shutil.rmtree("./results_forest_cover", ignore_errors=True) shutil.rmtree("./visualizations_forest_cover", ignore_errors=True) # Download and prepare the dataset dataset = forest_cover.load() config_yaml = """ input_features: - name: Elevation type: number - name: Aspect type: number - name: Slope type: number - name: Horizontal_Distance_To_Hydrology type: number - name: Vertical_Distance_To_Hydrology type: number - name: Horizontal_Distance_To_Roadways type: number - name: Hillshade_9am type: number - name: Hillshade_Noon type: number - name: Hillshade_3pm type: number - name: Horizontal_Distance_To_Fire_Points type: number - name: Wilderness_Area type: category - name: Soil_Type type: category output_features: - name: Cover_Type type: category combiner: type: transformer trainer: batch_size: 256 learning_rate: .001 epochs: 1 """ uncalibrated_config = yaml.safe_load(config_yaml) scaled_config = copy.deepcopy(uncalibrated_config) scaled_config["output_features"][0]["calibration"] = True uncalibrated_model = LudwigModel(config=uncalibrated_config, logging_level=logging.INFO) uncalibrated_model.train( dataset, model_name="uncalibrated", experiment_name="forest_cover_calibration", output_directory="results_forest_cover", ) scaled_model = LudwigModel(config=scaled_config, logging_level=logging.INFO) scaled_model.train( dataset, model_name="scaled", experiment_name="forest_cover_calibration", output_directory="results_forest_cover" ) # Generates predictions and performance statistics for the test set. uncalibrated_test_stats, uncalibrated_test_predictions, _ = uncalibrated_model.evaluate( dataset, collect_predictions=True, collect_overall_stats=True ) scaled_test_stats, scaled_test_predictions, _ = scaled_model.evaluate( dataset, collect_predictions=True, collect_overall_stats=True ) uncalibrated_probs = np.stack(uncalibrated_test_predictions["Cover_Type_probabilities"], axis=0) scaled_probs = np.stack(scaled_test_predictions["Cover_Type_probabilities"], axis=0) ludwig.visualize.calibration_1_vs_all( probabilities_per_model=[uncalibrated_probs, scaled_probs], model_names=["Uncalibrated", "Calibrated"], ground_truth=dataset["Cover_Type"], metadata=uncalibrated_model.training_set_metadata, output_feature_name="Cover_Type", top_n_classes=[7, 7], labels_limit=7, output_directory="visualizations_forest_cover", file_format="png", ) ================================================ FILE: examples/calibration/train_mushroom_edibility_calibrated.py ================================================ #!/usr/bin/env python import copy import logging import shutil import numpy as np import yaml import ludwig.visualize from ludwig.api import LudwigModel from ludwig.datasets import mushroom_edibility # clean out prior results shutil.rmtree("./results_mushroom_edibility", ignore_errors=True) shutil.rmtree("./visualizations_mushroom_edibility", ignore_errors=True) # Download and prepare the dataset dataset = mushroom_edibility.load() # This dataset has no split, so add a split column dataset.split = np.random.choice(3, len(dataset), p=(0.7, 0.1, 0.2)) config_yaml = """ input_features: - name: cap-shape type: category - name: cap-surface type: category - name: cap-color type: category - name: bruises? type: category - name: odor type: category - name: gill-attachment type: category - name: gill-spacing type: category - name: gill-size type: category - name: gill-color type: category - name: stalk-shape type: category - name: stalk-root type: category - name: stalk-surface-above-ring type: category - name: stalk-surface-below-ring type: category - name: stalk-color-above-ring type: category - name: stalk-color-below-ring type: category - name: veil-type type: category - name: veil-color type: category - name: ring-number type: category - name: ring-type type: category - name: spore-print-color type: category - name: population type: category - name: habitat type: category output_features: - name: class type: category combiner: type: concat trainer: batch_size: 256 learning_rate: .0001 epochs: 10 """ uncalibrated_config = yaml.safe_load(config_yaml) scaled_config = copy.deepcopy(uncalibrated_config) scaled_config["output_features"][0]["calibration"] = True uncalibrated_model = LudwigModel(config=uncalibrated_config, logging_level=logging.INFO) uncalibrated_model.train( dataset, model_name="uncalibrated", experiment_name="mushroom_edibility_calibration", output_directory="results_mushroom_edibility", ) scaled_model = LudwigModel(config=scaled_config, logging_level=logging.INFO) scaled_model.train( dataset, model_name="scaled", experiment_name="mushroom_edibility_calibration", output_directory="results_mushroom_edibility", ) # Generates predictions and performance statistics for the test set. uncalibrated_test_stats, uncalibrated_test_predictions, _ = uncalibrated_model.evaluate( dataset, collect_predictions=True, collect_overall_stats=True ) scaled_test_stats, scaled_test_predictions, _ = scaled_model.evaluate( dataset, collect_predictions=True, collect_overall_stats=True ) uncalibrated_probs = np.stack(uncalibrated_test_predictions["class_probabilities"], axis=0) scaled_probs = np.stack(scaled_test_predictions["class_probabilities"], axis=0) ludwig.visualize.calibration_1_vs_all( probabilities_per_model=[uncalibrated_probs, scaled_probs], model_names=["Uncalibrated", "Calibrated"], ground_truth=dataset["class"], metadata=uncalibrated_model.training_set_metadata, output_feature_name="class", top_n_classes=[3, 3], labels_limit=3, output_directory="visualizations_mushroom_edibility", file_format="png", ) ================================================ FILE: examples/class_imbalance/README.md ================================================ # Credit Card Fraud Detection Example This API example is based on Kaggle's [Imbalanced Insurance](https://www.kaggle.com/arashnic/imbalanced-data-practice) dataset for detecting whether customers will buy vehicle insurance. ### Preparatory Steps Create and download your [Kaggle API Credentials](https://github.com/Kaggle/kaggle-api#api-credentials). The Imbalanced Insurance dataset is hosted by Kaggle, and as such Ludwig will need to authenticate you through the Kaggle API to download the dataset. ### Examples | File | Description | | ---------------------------- | -------------------------------------------------------------------------------------------------------------- | | model_training.py | Demonstrates the use of oversampling by training two different models: one with no oversampling, and one with. | | model_training_results.ipynb | Example for extracting training statistics and generating visualizations. | Enter `python model_training.py` will train a standard model with no class balancing and a balanced model with class balancing applied. Results of model training will be stored in this location. ``` ./results/ balance_example_standard_model/ balance_example_balanced_model/ ``` The only difference between these two models is that the balanced model uses a small amount of oversampling in addition to the other configuration parameters. The way this is done is by specifying the ratio that you want the minority class to have in relation to the majority class. For instance, if you specify 0.5 for the `oversample_minority` preprocessing parameter, the minority class will be oversampled until it makes up 50% of the majority class. In this example, we had an imbalance where the minority class was 19% of the majority class in size. We decided that we wanted to increase that to 26%. Though this doesn't seem like much, it is enough to get some small performance improvements without experiencing performance degradation due to over-fitting. Here are the performance differences in the two models followed by some plots showing different metrics over training: | Metric | Standard Model | Balanced Model | | :------: | :------------: | :------------: | | Loss | 0.3649 | 0.2758 | | Accuracy | 0.7732 | 0.8237 | | ROC AUC | 0.8533 | 0.8660 | Here are the learning curve plots from both models: ![](../images/balance_example_accuracy_curves.png) ![](../images/balance_example_roc_auc_curves.png) Here is the comparison of model performances on ROC_AUC and Accuracy: ![](../images/compare_performance_Response.png) The creation of the learning curves is demonstrated in the Jupyter notebook `model_training_results.ipynb`. The comparison plot was generated using the ludwig visualize [compare performance](https://ludwig-ai.github.io/ludwig-docs/0.4/user_guide/visualizations/#compare-performance) command. ================================================ FILE: examples/class_imbalance/balanced_model_config.yaml ================================================ input_features: - name: Gender type: category - name: Age type: number - name: Driving_License type: binary - name: Region_Code type: number - name: Previously_Insured type: binary - name: Vehicle_Age type: category - name: Vehicle_Damage type: category - name: Annual_Premium type: number - name: Policy_Sales_Channel type: number - name: Vintage type: number output_features: - name: Response type: binary preprocessing: oversample_minority: 0.26 trainer: learning_rate: 0.0001 learning_rate_scheduler: decay: exponential decay_rate: 0.9 decay_steps: 30000 staircase: True epochs: 50 ================================================ FILE: examples/class_imbalance/model_training.py ================================================ #!/usr/bin/env python # # Class Imbalance Model Training Example # # This example trains a model utilizing a standard config, and then a config using oversampling import logging import shutil # Import required libraries from ludwig.api import LudwigModel from ludwig.datasets import imbalanced_insurance from ludwig.visualize import compare_performance # clean out old results shutil.rmtree("./results", ignore_errors=True) shutil.rmtree("./visualizations", ignore_errors=True) # list models to train list_of_model_ids = ["standard_model", "balanced_model"] list_of_eval_stats = [] training_set, val_set, test_set = imbalanced_insurance.load() # Train models for model_id in list_of_model_ids: print(">>>> training: ", model_id) # Define Ludwig model object that drive model training model = LudwigModel(config=model_id + "_config.yaml", logging_level=logging.WARN) # initiate model training train_stats, _, _ = model.train( training_set=training_set, validation_set=val_set, test_set=test_set, experiment_name="balance_example", model_name=model_id, skip_save_model=True, ) # evaluate model on test_set eval_stats, _, _ = model.evaluate(test_set) # save eval stats for later use list_of_eval_stats.append(eval_stats) print(">>>>>>> completed: ", model_id, "\n") compare_performance( list_of_eval_stats, "Response", model_names=list_of_model_ids, output_directory="./visualizations", file_format="png", ) ================================================ FILE: examples/class_imbalance/model_training_results.ipynb ================================================ { "cells": [ { "cell_type": "markdown", "id": "8c1e31e4-d8d4-4e83-8f4c-f868365d14d7", "metadata": {}, "source": [ "# Model Analysis\n", "\n", "This notebook will analyze the training results of the standard and balanced model on the [imbalanced insurance](https://www.kaggle.com/arashnic/imbalanced-data-practice) dataset. In order for the cells in this notebook to run, you must first run the following command to train the models:\n", "```\n", "python model_training.py\n", "```" ] }, { "cell_type": "markdown", "id": "a3b3b6c1-5d11-4a03-9dfa-b070e45b2adb", "metadata": {}, "source": [ "## Import required libraries" ] }, { "cell_type": "code", "execution_count": 226, "id": "6a6bbd43-1333-4a0e-b895-c3e393d5ee07", "metadata": {}, "outputs": [], "source": [ "from ludwig.utils.data_utils import load_json\n", "from ludwig.visualize import learning_curves\n", "import pandas as pd\n", "import numpy as np\n", "import os.path\n", "import matplotlib.pyplot as plt\n", "%matplotlib inline" ] }, { "cell_type": "markdown", "id": "b5e4d240-7607-4036-9573-6b452523c18f", "metadata": {}, "source": [ "## Learning Curves" ] }, { "cell_type": "markdown", "id": "725dde7d-f7a5-4836-8cf1-a3f50acafd30", "metadata": {}, "source": [ "### Create Plotting Data Function " ] }, { "cell_type": "code", "execution_count": 227, "id": "d8c35325-cfab-4ead-8699-1e98e55a8c7b", "metadata": {}, "outputs": [], "source": [ "def create_plot_ready_data(list_of_train_stats, model_names, metric, target):\n", " # List of splits to evaluate statistics for\n", " list_of_splits = ['training', 'validation', 'test'] \n", " \n", " # Empty list to fill with dfs for each models' stats\n", " list_of_train_stats_df = []\n", " \n", " # For each models' stats, create a df with columns of stats for each split listed above\n", " for name, stats in zip(model_names, list_of_train_stats):\n", " list_of_dfs = []\n", " for split in list_of_splits:\n", " df = pd.DataFrame(stats[split][target])\n", " df.columns = [split + '_' + c for c in df.columns]\n", " list_of_dfs.append(df)\n", " \n", " combined_df = pd.concat(list_of_dfs, axis=1)\n", " combined_df.name = name\n", " combined_df['epoch'] = combined_df.index + 1\n", " list_of_train_stats_df.append(combined_df)\n", " \n", " # holding ready for plot ready data\n", " plot_ready_list = []\n", " \n", " # consolidate the multiple training statistics dataframes\n", " for df in list_of_train_stats_df:\n", " for col in ['training', 'validation']:\n", " df2 = df[['epoch', col + '_{}'.format(metric)]].copy()\n", " df2.columns = ['epoch', '{}'.format(metric)]\n", " df2['split'] = col\n", " df2['model'] = df.name\n", " plot_ready_list.append(df2)\n", "\n", " return pd.concat(plot_ready_list, axis=0, ignore_index=True)" ] }, { "cell_type": "markdown", "id": "722f5f48-7026-427e-9bb7-388fec15a24f", "metadata": { "tags": [] }, "source": [ "### Create Plotting Data" ] }, { "cell_type": "code", "execution_count": 228, "id": "6b48aef8-11a8-4bba-8d52-114adb9cb2f2", "metadata": {}, "outputs": [], "source": [ "standard_stats = load_json(os.path.join('results/balance_example_standard_model','training_statistics.json'))\n", "balanced_stats = load_json(os.path.join('results/balance_example_balanced_model','training_statistics.json'))\n", "\n", "accuracy_learning_curves = create_plot_ready_data([standard_stats, balanced_stats], ['standard_model', 'balanced_model'], 'accuracy', 'Response')\n", "roc_auc_learning_curves = create_plot_ready_data([standard_stats, balanced_stats], ['standard_model', 'balanced_model'], 'roc_auc', 'Response')\n", "loss_learning_curves = create_plot_ready_data([standard_stats, balanced_stats], ['standard_model', 'balanced_model'], 'loss', 'Response')" ] }, { "cell_type": "code", "execution_count": 229, "id": "a06aaa41-e692-4348-aab5-6bd78711f6f1", "metadata": {}, "outputs": [ { "data": { "image/png": "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\n", "text/plain": [ "
" ] }, "metadata": { "needs_background": "light" }, "output_type": "display_data" } ], "source": [ "fig = plt.figure(figsize=(10,6))\n", "sns.set_style(style='dark')\n", "ax = sns.lineplot(x='epoch', y='accuracy',\n", " style='split',\n", " hue='model',\n", " data=accuracy_learning_curves)\n", "ax.set_title('Accuracy Learning Curves', fontdict={'fontsize': 16})\n", "ax.grid(visible=True, which='major', color='black', linewidth=0.075)\n", "ax.grid(visible=True, which='minor', color='black', linewidth=0.075)\n", "ax.set_xlabel(\"Epoch\", fontsize = 15)\n", "ax.set_ylabel(\"Accuracy\", fontsize = 15);" ] }, { "cell_type": "code", "execution_count": 230, "id": "b6ef7fce-b49e-49a6-a4b0-436d078f72ed", "metadata": {}, "outputs": [ { "data": { "image/png": "iVBORw0KGgoAAAANSUhEUgAAAmMAAAGICAYAAAANo+ehAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjUuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/YYfK9AAAACXBIWXMAAAsTAAALEwEAmpwYAAB3l0lEQVR4nO3dd3gUxRvA8e9eSy69J9RAAoTeBBGkFwsIwk/sYkMRFQuKBbuAiogI9oaKigKioKCIogiKiNI7CIQSIL1ecrm2+/vj4DQmgQDJXcr7eR4ekp0t704W7s3M7IyiaZqGEEIIIYTwCZ2vAxBCCCGEqMskGRNCCCGE8CFJxoQQQgghfEiSMSGEEEIIH5JkTAghhBDChyQZE0IIIYTwIUnGhKhmRo0aRVJSUok/rVu35oILLuDOO+9k//79pY7Jzc1l+vTpXHzxxbRr146ePXsyduxY1q5dW+51VqxYwejRo+nevTudOnVi+PDhzJ07F4fDUaE4n3vuOZKSknj//ffLLO/fvz+TJk0q99pJSUmkpKSU2H748GGeffZZBgwYQPv27RkwYABPPvkkx44dO2UsX331FUlJSWRnZ1codl949NFHueyyy7x2vV27dvHwww/Tt29f2rdvz8UXX8yLL75YretIiLrK4OsAhBClde7cmUceecTzvd1uZ/fu3bzxxhuMHj2a5cuX4+fnB8DBgwe55ZZbcDqd3HLLLbRp04bc3FwWL17MzTffzLhx47jnnntKnP/ZZ59l3rx5DB8+nGuvvZaAgAD+/PNPXnzxRf744w9mzpyJXq8vNz6n08m3335L8+bNWbhwIbfddts53/Pvv//OPffcQ6NGjbjzzjtp2LAhR48e5f3332fkyJF8+umnJCQknPN1fOWuu+6iqKjIK9f6+uuvefzxx+nUqRMPPPAAMTEx7N+/n3fffZeff/6ZuXPnEhUV5ZVYhBAVoAkhqpUbbrhBGzNmTJll8+fP11q0aKH98ssvmqZpmtPp1C677DJt0KBBWlZWVqn9Z86cqbVo0UL76aefPNsWLVqktWjRQps3b16p/b/99lutRYsW2qJFi04Z488//6y1bNlS+/3337UWLVpof/31V6l9+vXrpz377LNlHv/jjz9qLVq00I4cOaJpmqZlZWVpF1xwgXbDDTdoNputxL7Z2dlar169tBtvvLHceL788kutRYsWZdZBXbN//36tXbt22oMPPqipqlqi7NChQ1rHjh21iRMn+ig6IURZpJtSiBokKCioxPcrV65k7969TJgwgYiIiFL7jxs3jsaNG/P22297ts2ePZukpCSuvvrqUvsPHjyYW2+9lfDw8FPGsXjxYtq3b0/37t1JSEjgiy++OMs7+ud82dnZTJw4EZPJVKIsPDycRx55hO7du+N0Os/pOmvWrOHKK6+kffv29O7dm1mzZuFyuTzlDoeDV199lYsvvpi2bdvStWtXxo0bx/Hjxz379O/fn+nTp3PVVVfRpUsXPvroI1577TX+97//sXTpUk9X8RVXXMHGjRs9x/27mzIlJYWkpCR+/vlnRo8eTYcOHejVqxdvvfVWiXhTUlK488476dy5Mz179mT27NncfPPNPProo+Xe49y5c1FVlUcffRRFUUqUNW7cmAkTJtCqVasScXz//fcl9rv88ss911i3bh1JSUnMmzePnj170qdPHx599FE6d+6M3W4vcdy9997L9ddf7/l+6dKlDB06lHbt2jFw4EA++eSTEvtv2bKF66+/nk6dOnH++edz7733cvTo0XLvTYjaSpIxIaohTdNwOp2eP4WFhaxbt45XXnmF+vXr06VLF8CdXOh0Onr27FnmefR6PQMGDGDLli1kZ2eTnp7O3r176dOnT7nXfuSRR05ZXlBQwMqVKxk6dCjg/uD+/vvvKSgoOOv7XbNmDdHR0bRu3brM8iFDhjB27FgMhrMfWbF27Vpuv/12GjZsyOuvv87o0aP58MMPmTJlimefF154gU8//ZTbb7+dDz74gPvvv5+1a9fy/PPPlzjXhx9+SO/evXnppZfo3bs34O4ufvXVVxk3bhyvvfYaNpuN++6775QJ5MSJE+nQoQNvv/02/fr1Y+bMmaxatQoAm83GzTffTHJyMi+88AIPP/wwH3/8MRs2bDjlff7222+0adOm3G7I66+/nlGjRlWozv7tzTffZNKkSYwfP5477riDwsJCfv31V095UVERq1evZsiQIQAsWrSIBx98kK5du/LWW28xfPhwXnjhBc8YQ6vVypgxY4iNjeXNN99k8uTJ7Ny5kwceeOCMYxOippMxY0JUQ6tWraJNmzYltvn7+9O9e3cmTpxIYGAgAEePHiU8PJyAgIByz9WwYUMAjh8/7mkFql+//lnH9t1336GqKoMHDwbcydjMmTNZsmQJ11133VmdMzU19ZxiqoiZM2fSoUMHXnnlFQB69+5NaGgoEydOZPTo0TRs2JDs7GwefvhhRo4cCcD5559PcnIyS5YsKXGupk2bMm7cuBLbCgsL+eijj2jfvj0ALpeLu+66i927d9O2bdsyY7r00ku59957AejWrRvLly9n9erV9OnTh6+//ppjx46xbNky4uPjAUhISOCKK6445X2mpaWVm9Sei5tuuon+/ft7vm/Tpg3ff/89AwYMANyttA6Hg0suuQRVVZkxYwZDhw7lqaeeAqBnz54oisKbb77Jddddx759+8jNzWXUqFF06tQJcLeC/vHHH6iqik4nbQWi7pBkTIhq6LzzzmPixIkA/P3337z44ot0796dadOmlejG0zTtlAPtgRLlJ79WVfWsY1u8eDHdunXDYDCQn59PYGAgnTp1YuHChWecjJ3sRtPpdOcU0+lYrVa2bt3K+PHjS7RU9e7dG1VVWbduHQ0bNmTmzJmAO6E5cOAABw4cYOPGjaW64xITE0tdw2AwlEi64uLiPNcuT8eOHT1f63Q6YmJiPIP8161bR/PmzT2JGEDbtm09yXV5qqoumzVrVuL7oUOH8vrrr2O32zGZTCxbtowePXoQERHB/v37SU9Pp2/fvqXq+9VXX2Xr1q20bduWsLAwxo4dy5AhQ+jTpw/du3fn/PPPr/TYhajuJBkTohoKDg6mXbt2ALRr14569epxyy23YDKZmDZtmme/Bg0asHbtWmw2m+ftyv86OQanXr16nm3/HgP1X+np6URFRZXZMnHkyBHPOKiuXbuWKt+5c6enVcZsNpdKYk46ud1sNnvuY9u2beXGZLFY0DSN4ODgcvc5lfz8fFRV5eWXX+bll18uVZ6RkQHAxo0beeaZZ9izZw/BwcG0atWqzHqNjIwstc1kMpWos5Nfnyox8vf3L/G9TqdD0zTAPV1JWeMAT/cWZIMGDU75883NzcXPz89T9xX131gGDx7MtGnT+O233+jWrRu//vorzz77rOcaAA8++CAPPvhgqXNlZGQQFBTEp59+yhtvvMGiRYuYO3cuISEhjB8//qxbWIWoqSQZE6IG6N69OyNHjuSLL77gkksu8XQX9evXj88//5yVK1dyySWXlDpO0zR+/vln2rVr5/kwbd26Nb/++isTJkwo81q33HILUVFRzJkzp1TZ4sWL8ff35+233y6ReLhcLsaOHcsXX3zB008/DbgTlszMzDKvkZaWhtFoJCQkBIAePXqwcuVKdu3a5Rlc/m/z5s3jlVde4fvvv6dRo0anqqoynezWvfPOOz3dav8WExNDQUEBY8eOpXPnzrz22mueFqlp06axe/fuM77muYqJiWHnzp2ltmdnZ9O0adNyj+vRoweffvop2dnZZSZzs2bN4ptvvmH16tWelsn/JowVmYIjNjaWLl268MMPP3j2HzhwIIAnaX7qqac83bb/drJ1r3nz5sycORO73c6GDRuYM2cOzz77LG3atKFDhw6njUGI2kI65YWoIR544AGCg4OZOnWqp2WpZ8+etGvXjmnTpnlad/7tnXfeYf/+/YwZM8az7aabbmL37t1lvgH59ddfs2/fPs/g/P/65ptvPN1J3bp18/zp0aMHffv2ZenSpRQXFwPulrP169eXOcnoihUr6NSpk2dA/uWXX05YWBgvvvhiqda0zMxM5syZQ8eOHc8qEQP3W6gtW7bkyJEjtGvXzvPHaDQyY8YMUlNTOXDgAHl5edx0002eRExVVX7//XdPa5U3denShb///psjR454tu3du7fE92W57rrrUBSFF198sVTc+/fvZ/HixQwYMIDAwEDP27np6emefdLS0kpNxlueoUOHsnr1apYvX07fvn0950tISCAsLIy0tLQS9Z2bm8usWbOwWCysXr2a7t27k52djclkonv37jz55JMAp53kV4jaRlrGhKghIiIiuOOOO5g+fTqffPIJo0ePRq/XM2PGDEaPHs2IESO47bbbaN26Nfn5+SxdupRly5YxduxYLrroIs95Lr/8cn755Reeeuoptm7dyoABA1AUhd9++43PP/+cSy+9tMxB4uvXr+fw4cOMHz++zPiGDRvG8uXL+f777xk+fDg33HAD8+fPZ9SoUdx22200aNCA9PR0Fi1axNatW/nwww89x4aGhvLcc89x//33c80113DDDTdQv3599u/fz/vvv4/L5WLq1KmnraP58+eX6n5r2LAhAwcO5N577+Xuu+8mKCiIQYMGkZOTw8yZM9HpdLRo0QKn00lgYCBvvvkmqqpSXFzMZ599xu7du1EUBU3TSk0VUZWGDRvG22+/zdixY7n33ntxuVy88sorKIpyyjji4+OZOHEikydPJi0tjSuvvJKIiAh27NjB+++/T2xsLI89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\n", "text/plain": [ "
" ] }, "metadata": {}, "output_type": "display_data" } ], "source": [ "fig = plt.figure(figsize=(10,6))\n", "sns.set_style(style='dark')\n", "ax = sns.lineplot(x='epoch', y='roc_auc',\n", " style='split',\n", " hue='model',\n", " data=roc_auc_learning_curves)\n", "ax.set_title('ROC AUC Learning Curves', fontdict={'fontsize': 16})\n", "ax.grid(visible=True, which='major', color='black', linewidth=0.075)\n", "ax.grid(visible=True, which='minor', color='black', linewidth=0.075)\n", "ax.set_xlabel(\"Epoch\", fontsize = 15)\n", "ax.set_ylabel(\"ROC AUC\", fontsize = 15);" ] }, { "cell_type": "code", "execution_count": 231, "id": "9bfd8dc1-b0e0-451c-8f2b-de9e92e976c0", "metadata": {}, "outputs": [ { "data": { "image/png": "iVBORw0KGgoAAAANSUhEUgAAAmsAAAGICAYAAAAedKdVAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjUuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/YYfK9AAAACXBIWXMAAAsTAAALEwEAmpwYAABW70lEQVR4nO3dd3xUdf798dedml4JhA5BARUQkPJTARULNiyIuMQvFhTLigoqYqEoglhZFUEEWVFEkV1cu7sKsmBFBNEVQaUqJUBInUkymXJ/fwQiSAgDJLmT5Dwfj13IvXfmvuc9Fzh+bvkYpmmaiIiIiEhEslldgIiIiIgcmsKaiIiISARTWBMRERGJYAprIiIiIhFMYU1EREQkgimsiYiIiEQwhTWReq5v375MmDDB6jIOMmTIEG6++Wary6hUu3btmD17do3syzRN/vWvf3H11VfTo0cPunXrxlVXXcW7775bI/sXEes4rC5ARKS2evPNN2nSpEm17ycQCHD77bfz+eef85e//IUbb7wRu93OsmXLGD16NP/73/948MEHq70OEbGGwpqIyFHq3LlzjexnxowZLFmyhFmzZtG7d+/y5X369KFhw4Y8/fTT9OvXj27dutVIPSJSs3QaVEQOKycnhzFjxtCnTx9OPvlkrrnmGv73v/8dsM1LL73EueeeS8eOHTnnnHOYNm0aoVAo7PVHIxAI8Oyzz3LmmWfSsWNHBgwYwFdffXXANhs3buSOO+7g//2//0eHDh3o27cv06ZNY9/kLcuXL6ddu3bMnz+fXr16ccYZZ7B161b69u3LrFmzGD9+PD169KBr166MHj0aj8dT/t77nwadOnUqAwYM4P3336dfv3507NiRK664glWrVh1Qz0cffcTFF19Mp06dGDhwIIsWLaJdu3YsX768ws/o9/uZO3cuZ5111gFBbZ9rrrmGq6++GpvNVl5Hly5dDthm7dq1B+zjvvvu469//St33303Xbt2ZeTIkZx99tmMGzfugNfl5+fToUMH/vnPfwJQVFTEI488wmmnnUanTp0YMmQIP/300wGvqY7vWaS+U1gTkUp5vV4GDx7Ml19+yd13383f/vY3TNPk//7v//j5558B+PDDD3n22We57rrrmD17NldeeSVTp05lwYIFYa0/WmPHjuXll1/mmmuuYdq0aWRkZDBs2LDygOT1ernmmmvIy8vj8ccf58UXX6Rnz54899xzLFmy5ID3mj59OhMmTGDkyJE0a9YMgBdffJGCggKmTJnCiBEj+OCDD3jhhRcOWc/mzZt57rnnGD58OFOnTsXn83HnnXcSCAQAWLZsGSNHjqRjx45MmzaN0047jbvvvrvSz/jjjz+Sl5fHGWecUeH6qKgoxo0bR9euXcPuG8DSpUvx+XxMmzaNq666iosuuoiPP/6YYDBYvs0nn3wCwHnnnYdpmtx666188MEHjBgxgmeffRaXy8WQIUP47bffgOr7nkXqO50GFZFKvfXWW/z222+89957HHfccQD06tWL888/n+eff56pU6eyYsUKmjZtSmZmJoZh0KNHDxwOBw0bNgQ47PqjsWHDBt566y0mTpzIlVdeCZSdFty9ezfPPPMMr776Kps2baJFixY888wzpKSkAHDqqaeyaNEiVqxYQd++fcvf79prrz3gZ4D09HSmTJmCYRj06tWLb775hmXLljFq1KgKa/J6vcyZM4dOnToBEAwG+etf/8q6devo0KED06dPp3v37kyePBmA3r174/V6ee211w75ObOysgCq/Nq4QCDAhAkTyvuSmprKiy++yDfffMOpp54KlI0C9unTh4SEBD777DO+/vprXn75ZU477bTy+i+66CJeeOEFJk+eXC3fs4hoZE1EDmPFihUcd9xx5UENwOVycc455/DNN98A0KVLFzZt2sQVV1zBzJkz+eWXX7jhhhvKw8/h1h+Nffvu06cPgUCg/H9nnHEGq1atorS0lA4dOvD6668THx/P+vXrWbRoEc8//zyBQIDS0tID3m//z7dPx44dMQyj/Of09HSKiooOWZPD4aBDhw4HbA9QXFyMz+fj+++/5+yzzz7gNeeff36ln9NutwOUn7atKikpKeVBDeD444+nbdu2fPTRRwDk5eWxfPlyLr74YqDsdHF0dDTdu3cv7zWUBfevv/4aqJ7vWUQ0siYih1FQUECDBg0OWt6gQQO8Xi8Al1xyCcFgkHnz5jFlyhSefvpp2rdvz5QpU2jTps1h1x+NvLw8oCysVSQ3N5dGjRoxY8YMXnrpJQoLC2natCldunTB4XAcFH72Dy77REdHH/CzYRiVhiaXy1V+7RhQ/vtQKER+fj6hUOig/aSmph76Q/LHiNr27dsPuc3OnTtp1KhRpe/zZxXtt3///rz88suMHz+eTz75BKfTyVlnnQWU9bu4uPiAMLqP0+kEDn8ciMjRUVgTkUolJiaycePGg5bv3r2bpKSk8p8vv/xyLr/8cvbs2cOnn37KtGnTGD58ePlIzeHWH6n4+HgMw+CNN97A4Tj4r7Lk5GTefvttnnnmGcaPH8/FF19MfHw8QPlpvpqUmpqK0+kkJyfngOV//vnPTjzxRFJSUvjss88YPHjwQetLS0vp378/55xzDo8++iiGYRx0Qf++UH04F110EVOmTOHbb7/l3//+N2effXZ5YI2Pjy8/VVqZqv6eRUSnQUXkME455RTWr1/Phg0bypeVlpayaNGi8ovaH3zwQe644w6gLJRceeWVDBw4kB07doS1/mjrMk0Tr9dLx44dy//31VdfMWfOHBwOB9999x3p6ekMHjy4PKitWbOGnJycKj+teDh2u53OnTvz6aefHrB88eLFlb7OZrNx9dVX8+mnn/Lll18etP6ll14iPz+f/v37AxAXF0dJSQkFBQXl26xcuTKsGps2bUrnzp157733+Prrr8vfE8r6nZOTQ0xMzAH9fu+998ofzFsd37OIaGRNRIB169YxZ86cg5ZfdNFFDBgwgFdeeYVhw4YxYsQI4uPjmTNnDtnZ2dxyyy0AdO/endGjRzNlyhROO+00srKyeOONNzj33HPDWn8oW7durbCuM844gxNOOIF+/foxatQohg8fTps2bfjmm2944YUXuPHGG7HZbHTs2JH58+fz/PPP06NHDzZs2MC0adMwDIOSkpJj7tuRuu2227j++usZM2YM559/PqtXry6/uWD/06d/NmzYML7++mtuvvlm/u///o/TTjuN0tJSPvnkE95++22GDh1aPlrYu3dvJk+ezIMPPsjVV1/NunXreP3118OusX///kyaNIn4+PjyGwkAzjrrLDp27MhNN93E8OHDady4MR9//DHz5s3j4YcfBo7+exaRyimsiQgrV66scPSlc+fOdO7cmXnz5vH4448zYcIEgsFg+bITTzwRgMsuuwyPx8O8efOYM2cO8fHx9OvXr/yxFIdbfyjr168vv3Nyf2lpabRu3ZqnnnqKZ599lpkzZ7Jnzx6aNm3K3XffzQ033ADAgAED2LRpE/Pnz+ell16iadOm3HDDDWzYsCHs0aaqdOqpp/LEE08wbdo03n77bU488UTuvvtuJk+eTExMzCFf53a7mT17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7nsm/qGnnSpzSYd0Ljqp0RHV8corf+eKK646otfIoSmsHQNHTHLZb0ryrS1ERETEIr/9toVHH30Yh8OB3W6na9duFBTk89RTj3HrrcN57LGJeDyF5Ofn0b//5Vx++UCGD7+J449vx8aNGygq8vDII4+Tnt6YF1+cxvLlX9GoUSPy8/MA2LVrJ0899RilpT4KCvK57rph9OlzJkOGDKJ585Y4nU5uv/0uJkwYg2mapKSkVlrvqlXf8tprc3A6nezatZNLL72CVau+Zf36X7jyysFcfvlAVqz4mpkzX8DtdhMfn8Do0WOIj4/niScmsWnTRpo2bUZpadlZtZ07s3jiiUcpLfXhcrm5994HqrzHCmvHwIxKAsBVmmdpHSIiIgAXndQo7FGwqppGacWK5bRr157bb7+L77//juTkZBYuXMA999zHzz+v45xzzuOMM/qSnb2b4cNv4vLLBwJwwgknceedd/Pii9P45JP/cPrpvfj+++946aVXKS4u4i9/GQCUndb8y1+upmvXbvzvf98ze/aL9OlzJsXFxVx33Q20bdue559/hnPO6ccll1zO4sUf869//bPSmnft2sWcOa+zbt1axo27jzfffJvdu3fxwAOjuOyyK3jiiUeZPv0l0tIa8uab85g79+907dqd0tJSZs6cQ1ZWFv/972IApk17loEDr+LUU0/n22+/YcaM57nppr8eU0//TGHtGPib/D+uD45lg9HA6lJEREQscfHFlzJv3ivcffftxMbGcfPNt5WvS01NZcGC11m6dAkxMbEEAn88PaFt23YANGrUiD179rBp00batz8Bm81GbGwcGRnH7X2PBrzyymw++OAdwDjgPVq0aAXApk0b6dfvQgA6djz5sGEtI6MNDoeD+Ph4mjRpitPpJD4+gdJSH3l5ecTExJKW1hCATp26MGvWCyQnp3DCCScBkJ6eTsOGZaF448b1zJ37MvPmvQKAw1H10Uo3GBwDM6YB3zs7kRdwW12KiIiIJT7/fCknn9yFZ599gbPOOpt5814pvx7ujTfm0qFDJ8aNe4S+fc8pXw4HXyvXokVL1q5dQygUori4mM2bNwLw0kszOP/8ixg79hG6du12wGv2vUfLli1Zs+YHANau/emwNVd2mV5SUhJFRV6ys7MB+P77VTRv3pyWLVuV7yM7eze7d+/eW3crbr31dp5/fiajRj3AmWeefdj9HymNrB2LUi+32hbygXkiptnrsBdpioiI1DXt25/IhAljsdvt2Gw2br/9Lnbs2M6ECWO5+OJLeeqpyXz88UckJiZit9vLr/X6s+OPb8dZZ53DjTdeQ4MGaSQnpwBw1lln8+yzTzF37ss0bNiIvLy8g1574423Mn78/Sxa9DFNmjQ9ps9jGAb33vsgDz44CpvNIC4unvvuG0dqaio//PA9w4ZdS3p6Y5KSkgC47bY7efrpxygtLcXnK+HOO+85pv1XWJO5f8ytY/z+IHl5RZVuU1RUtj4mJuaI398o9dBgVnsm+TP5v79OJsZ1bOf9I92x9Ko+UZ/Cp16FT70KT33sU1bWFtLTWx7x66rqmrW6rrr6VNH3lpYWX+G2Glk7BqYzlpBhJ8nwkF/ir/NhTUREpLZ4+eVZrFy54qDlDzww/phH32qawtqxMAyKbPEk4SW7sJTGCVFWVyQiIiLA9dcP4/rrh1ldRpXQDQbHqNieQKLhZbsmcxcREZFqoLB2jPyuRBLxsLPQZ3UpIiIiUgfpNOgx+r3FFfxrdRYuhTURERGpBhpZO0Yl7a/irVAfcor8VpciIiIidZDC2jFqbO6kj+178ooV1kREpH768MP3eOGFqYfdbtWqbxk//v4aqKjMJZf0q/Z9jB9/P6tWfXvI9QMH9sfnO7azbzoNeowa//4Or7r+xk1RZ1ldioiI1HPudf8kau388Dbe95TVwzzPveSEv+BrP/CY6pJjo7B2rKKTAbD58i0uRERExDpr1vyPO++8Fa/Xy9ChN+HzlfDWW/8on2Jq4sQnDth+4cI3Wbp0CYFAgLi4OCZNepJPPvk3X331BT5fCdu2beXqq6/lwgv7s2bNjzz77FOYpklaWkPGj3+ErVu38swzT2KaJomJidx//3iio6N54olJbNq0kaZNmx1ytoR9rrrqMjp06MTWrb/TtWs3vF4Pa9euoUWLlowd+wg7dmxn8uQJBAIBbDYbd955D8cf35aFCxfw/vtvk5ragNzcXAACgQBPPvkoW7f+TigUYtiwWw+aHutoKawdI9OdCEBB7m6LKxERkfrO135g2KNgVf1k/qioKJ588lny8nK56abr6N//Mp588lmioqJ44olJfPPNVzRokAZAKBQiPz+fZ56Zjs1m4667hrN27RoAvF4PU6Y8z++//8bo0SO58ML+PPHEJB5++FFatWrNW2/9g82bN/P0049x//3jaN06g/fff5t5816hQ4dOlJaWMnPmHLKysvjvfxdXWnNW1g6efXYGDRo04IIL+jJz5hxGjryXQYMupbCwkGnTnuGKKwbRq9cZbNy4nscee4Rnn32Bf/xjPq++Oh+bzcYNN/wfAO+99zaJiUncf/848vPzuO22m3jttQVV0luFtWNkupMACBbnWluIiIiIhTp16oxhGCQnpxAbG4fD4WDixPHExMSwZctmOnToVL6tzWbD6XTy0EMPEh0dza5duwgEAgAcd1xbABo2bFQ+Mpabm0OrVq0BGDDgSgC2bNnE008/BkAwGKB585Zs2rSBE044CYD09HQaNmxUac0JCYmkp6cDEB0dTevWGQDExsZRWupj8+bNnHxyF6Bs7tJdu3ayZctmWrfOwOVyAZTvb8OG9fzww3f89NOP5TXl5+cdbTsPoLB2jEJRSQDEhjzWFiIiImKhtWt/AmDPnmy8Xg8LFrzBwoXvAzBy5G3sPxX5+vW/smzZf5k16xVKSkrKR6egbCL1P2vQoAG///4bzZu34LXX5tC8eUtatGjJmDETSE9P54cfVrNnTzYOh4NFi/4DDCY7eze7d1d+1quife2vVatW/PDDak4/vQ+//vozKSmpNGnSlM2bN+LzleBwOPnll58577wLaNmyFQ0bNuSaa4bi85Xwyit/Jz4+Idz2VUph7RiFYhqxwnYyBSE3pmke9osXERGpi3w+H3fccQvFxUWMHj2Gd955i6FD/4/o6Gji4+PJzt5N48ZNAGjWrDnR0dHccMMQXC4nqakNyM4+dLAaNeoBJk+egM1mIzU1lUGDMmnUKJ2JE8cRCoUAuO++sbRo0ZIffvieYcOuJT29MUlJScf0mW67bQSPPTaR+fPnEQwGuf/+sSQnJ3Pjjbdwyy1DSUpKJjo6GoBLLx3A449PZPjwm/B6PVx++ZXYbFXz0A3D3D/q1jF+f5C8vKJKtykqKlsfExNz1PsZ+vp3/G9HIUuGn0acu+7m36roVX2gPoVPvQqfehWe+tinrKwtpKe3POLXVfU1a3VVdfWpou8tLS2+wm3rbrKoQclugyh85BaV1umwJiIiUtt8/vlS5s+fd9DyK68czBln1I7HbilZVIFZuwYxz9GHvOKeNE+2uhoRERHZp1evM+jV6wyryzgmmsGgCoTcSSQaHjylAatLERERkTpGYa0KBFyJJOJlR16J1aWIiIhIHaOwVgWKHQkkGV5+zfZaXYqIiIjUMQprVcAWk0wSHnKKNJm7iIjUDcOH38SWLZv58MP3+PzzpUDZFFFS8xTWqoA9NhUDk7xihTUREalbLrywf/kF+q+88neLq6mfdDdoFSg5czJnr/6c40p0g4GIiES2337bwqOPPozdbsdut3PxxZfy4YfvYbPZ2LNnD5dccjlXXDGofPvZs18kNTWV/Px8Cgryeeqpx7jnnvss/AT1T42OrH3//fcMGTIEgC1btjB48GAyMzMZP358+ROIFyxYwIABAxg0aBBLliwBoKSkhNtvv53MzEyGDRtGTk5OTZZ9WA67DZsBXt0NKiIiEW7FiuW0a9eeKVOeZ8iQ6yksLCA7ezePPTaFmTNfZsGC18nNPfjf2WuvvYGEhEQFNQvUWFibNWsWY8aMwefzATB58mRGjBjB66+/jmmaLF68mN27dzN37lzmz5/P7NmzmTJlCqWlpbzxxhu0bduW119/ncsuu4zp06fXVNlhcW1exH/c95Fm7rG6FBERkUpdfPGlJCYmMWrUnbz11j+w2+106NAJl8uF2x1FRkYbtm3banWZsp8aOw3aokULpk6dyr333gvAmjVr6NGjBwB9+vThiy++wGaz0aVLF1wuFy6XixYtWrBu3TpWrlzJjTfeWL5tuGEtFAqVTz1yKMXFla8Ph1lUyPH8RjKFh91fbVYVvaoP1KfwqVfhU6/CUx/7FAqZ5VMihWPZsiV07Hgy11xzPYsXf8ysWTNITEyktLQUv9/Pxo0baNy4KVA21ZJpmuX7MM3QEe2rLjDNsjN/Vf2xQyGzgsxg8XRT/fr1Y+vWP5L6/pOex8bGUlhYiMfjIT7+j0JjY2PxeDwHLN+3bSQJuRMBsPnyrC1ERETkMNq1O4GJE8djt9ux2QwGDBjEf/7zAffeO4KCgnyuuWboISdAb9myNRMnjmfMmIdrtuh6zrIbDPafid7r9ZKQkEBcXBxer/eA5fHx8Qcs37dtuPsIdzLfY5n0156YDoA7UIg7Khq7zTjq96oN6tMEycdCfQqfehU+9So89alPBQXGEU0y3qJFS2bOnFM+Qvb999/x888/8fDDkw/Y7vnnZwKQkdHmoGX1yb4RtaqeyN1mM8I+Ti17dMeJJ57I8uXLAVi2bBndunWjU6dOrFy5Ep/PR2FhIRs2bKBt27Z07dqVpUuXlm97yimnWFV2hUx3EgCJhpdCn24yEBERkapj2cja6NGjGTt2LFOmTCEjI4N+/fpht9sZMmQImZmZmKbJyJEjcbvdDB48mNGjRzN48GCcTidPP/20VWVXaN9p0CQ85Bb5SYp2WlyRiIhIeLp27UbXrt2sLkMqYZimaVpdRHXx+4Pk5VV+sem+i/uOacjcNHn1/Q+Z+ws8POBUTmudcvTvFcGqpFf1gPoUPvUqfOpVeOpjn7KytpCe3vKIX7fvNGhVn96ra6qrTxV9b2lpFd9goBkMqoJhUJJ6EnnEk1Xgs7oaERERqUM0g0EVOTf/HxTbC8gtOvL/uhERERE5FI2sVZGOns+5wPYNMW7lXxEREak6CmtVxIhOJtHwkq/J3EVEpB7x+Xy8997bYW374Yfv8fnnSw+5fu7cOfz0049VVFndoWGgKhJwJZJkeFi9Nd/qUkRERGpMTs4e3nvvbfr3v+yw2154Yf9K1w8Zcl3VFFXHKKxVESM6iUS85BRpZE1ERKyxaNF/+Pjjj8Ladt/DIPbNJnQo5513Aeec0++Q61999e9s3ryJ3r27061bD4qLi7nvvrH8+98fsG7dTxQVFdGqVWseeGA8s2e/SGpqKi1atGLevFdxOh3s2LGdvn3P5dprb2DSpIc4++zzyMnZw1dffYHPV8K2bVu5+uprufDC/vz0049MmfIEMTExJCcn43K5efDBh8LuT22lsFZVopKIN4op8ZVYXYmIiEiNueaaoWzYsJ6ePU+lsLCQESPuwestmybymWemEwqFGDJkELt37zrgdTt37mDOnDfw+/1cdtn5XHvtDQes93o9TJnyPL///hujR4/kwgv789RTkxkzZgIZGW148cVpZGfvrsmPahmFtSpS2vJsHv86n5K9E76KiIjUtHPO6VfpKNj+quP5YS1alD0Rwe2OIjc3l/HjHyAmJobi4mICgQNn+MnIOA6Hw4HD4cDtjjrovY47ri0ADRs2orS0FIDs7Ozy6a9OPrkLixd/XGW1RzKFtSoSaNiJd+0eikoV1kREpP4wDBvm3oEK2965sb/++gt27drJhAmTyc3NZdmyJfz5GfyHOfta4enZhg0bsWnTRlq3zmDNmv9VzQeoBRTWqohRnMOlti/5KNTW6lJERERqTHJyMn5/AJ/vj4fCn3DCScyZM5ubbroOl8tFkyZNq+SU5d13j2by5AlER8fgdDpIS2t4zO9ZG2i6qSqamsSRtYrkhZdwXekoJt9xOw573XsqSn2cxuVoqE/hU6/Cp16Fpz72qT5NN7Vw4QL69j2X5ORkZs6cjtPp5Prrh1XrPiNhuimNrFURMyoJgCS8FPgCpMS4rC1IRESkjklJSeGuu24jOjqGuLi4enEnKCisVZlQVDIASYaHPZ5ShTUREZEqdtZZ53DWWedYXUaNq3vn6ixiuhKAsrC2vUCP7xAREZGqobBWVWx2SuxxJOIlq9B3+O1FREREwqCwVoU2p1/AT2ZLdimsiYiISBVRWKtCv3d7iH8Ez9SUUyIiIlJlFNaqUGN3CY3ZQ25RqdWliIiIRJThw29iy5bNfPjhe3z++dKD1l9ySeUzLyxduoTs7N3s2ZPNU089Vl1lRiSFtSqU8e145rknE+vWTbYiIiIVufDC/vTqdcYRv+4f/3gDr9dLamoD7rnnvmqoLHIpVVQhIzqZZMNDKFRnnzMsIiIR7t57R1S4/IknngFgxozn2bhxffn0T/umdbr55uG0aXMcn3zybz755N8Hve5QHnhgFFde+Re6dDmFtWvXMH36cyQlJePxFJKfn0f//pdz+eUDy7efPftFUlNT6d//cp54YhKbNm2kadNm5fN/bty4nqlT/0YoZOLxlE0MX1hYyPr1vzBx4jjGjn2EiRPHM3PmHFas+JqZM1/A7XaTkJDI/feP49dff2bevFdxOh3s2LGdvn3PPWiS+NpGYa0KhaKSiMfDbzmVz5ogIiJSV/TvfxkfffQ+Xbqcwocfvk/Xrt3IyGjDGWf0JTt7N8OH33RAWNvn66+/pLS0lJkz55CVlcV//7sYgE2bNjJ8+EjatDmOjz/+Nx9++B6jR4/huOPaMmrUAzidTgBM0+SJJx5l+vSXSEtryIIFb/DKK7M57bRe7Ny5gzlz3sDv93PZZecrrMkfTHciDkLkFeRaXYqIiNRThxsJu+WW4cChp1E699zzOffc88PeX8+epzJ9+rMUFOTzww/f8dRTzzFjxvMsXbqEmJhYAoFAha/btGkDJ5xwEgDp6ek0bNgIgAYNGjJnzku43W6KioqIjY2t8PV5eXnExMSWzw/auXMXXnxxOqed1ouMjONwOBw4HA7c7qiwP0uk0jVrVch0JwEQHSy0thAREZEaYrPZOOusc3jqqcfo3ftM5s9/jQ4dOjFu3CP07XsOh5qCvGXLVqxZ8wMA2dm72b27bKL3Z599khtuuJkxYx6mTZvjyl9vs9kIhULlr09KSqKoyEt2djYAq1evonnzFgDsPbNbZ2hkrQqFYtLYbjTCEdJz1kREpP646KJLGDToUubP/xc7dmznqacm8/HHH5GYmIjdbi+/Hm1/vXufyQ8/fM+wYdeSnt6YpKQkAM477wLuu+9uUlJSSEtrSH5+HgAdOnRi4sTx3Hvvg0DZtXb33vsgDz44CpvNID4+gQceeIiNG9fX1MeuMYZ5qMhbB/j9QfLyKr9+rKiobH1MTEyV7POa11axdqeHL+7shctRtwYuq7pXdZX6FD71KnzqVXjqY5+ysraQnt7yiF93qNOgcqDq6lNF31taWnyF29atNBEBEqLKBivzi/WsNRERETl2CmtVyCjew4s51zPQvpSc4oovqBQRERE5EgprVch0xpDk30ka+XhLFdZERETk2CmsVSVHNEGbm0TDo8ncRUSkxtThy8/rpCP9vhTWqpjPEU8iXtbu9FhdioiI1AMOhwuvt0CBrZYwTROvtwCHwxX2a/TojipmRiWRVOxhj1c3GIiISPVLTk4jN3c3Hk/eEb1u39SINlsdeyhZFauOPjkcLpKT08Lfvsr2LGWikknCS26x3+pKRESkHrDbHTRo0PiIX1cfH3NyNCKhTzoNWsUKz3+Bm/0jKSjRDQYiIiJy7BTWqpg9Pp1CYvH4FNZERETk2CmsVTH3r+8x3T2VYEgXeoqIiMixU1irYvb8TVxgfEWSU2FNREREjp3CWhULRSUBYJbkWluIiIiI1AkKa1XMdCcCECjKs7YQERERqRMU1qpYyJ0EQCIeSvxBa4sRERGRWk9hrYqZe0+DJhkePWtNREREjpnCWhULJrbm743G8L9QBjvyS6wuR0RERGo5hbUqZroT+K1RP3aRTFaBJnMXERGRY6PppqrBGZ4PWGs42elpZXUpIiIiUstZGtb8fj/33Xcf27Ztw2az8cgjj+BwOLjvvvswDIPjjz+e8ePHY7PZWLBgAfPnz8fhcHDrrbdy1llnWVl6pXpvncFv9u4UR/W3uhQRERGp5SwNa0uXLiUQCDB//ny++OILnnnmGfx+PyNGjKBnz56MGzeOxYsX07lzZ+bOncvChQvx+XxkZmZy+umn43K5rCz/kMyoRJKKvezU/KAiIiJyjCwNa61btyYYDBIKhfB4PDgcDlavXk2PHj0A6NOnD1988QU2m40uXbrgcrlwuVy0aNGCdevW0alTp0rfPxQKUVRUVOk2xcWVrz8acc4EEvGy+vdcijo2qPL3t0p19KouUp/Cp16FT70Kj/oUPvUqPDXbp/gKl1oa1mJiYti2bRsXXHABubm5zJgxgxUrVmAYBgCxsbEUFhbi8XiIj//jA8TGxuLxeKwq+7BMdyJJxg52eUqtLkVERERqOUvD2pw5c+jVqxd33303O3bs4Nprr8Xv/+PZZF6vl4SEBOLi4vB6vQcs3z+8HYrNZiMmJiasWsLdLhy22FSS+JUif6hK3zdS1MXPVB3Up/CpV+FTr8KjPoVPvQqPlX2y9NEdCQkJ5aErMTGRQCDAiSeeyPLlywFYtmwZ3bp1o1OnTqxcuRKfz0dhYSEbNmygbdu2VpZeqdKWZ/Ou2ZsizWAgIiIix8jSkbXrrruOBx54gMzMTPx+PyNHjqRDhw6MHTuWKVOmkJGRQb9+/bDb7QwZMoTMzExM02TkyJG43W4rS6+Ur90AXlqUTmkgZHUpIiIiUssZpmmaVhdRXfz+IHl5lV8YuO8GhKoc3jRK8rhnzgd8XdyEz0ZG7iNGjlR19KouUp/Cp16FT70Kj/oUPvUqPDXZp7S0ii/x0gwG1cC1+RPmBEeTbmZTh7OwiIiI1ACFtWpguhMBiMer69ZERETkmCisVYOQOwmARMNLTpEe3yEiIiJHT2GtGphRSQAk4WF7Xom1xYiIiEitprBWDfaNrCUZHnYU+qwtRkRERGo1hbVqYEYlkh3bFo8ZzS6FNRERETkGCmvVwe7mqzMX8naoF3u8umZNREREjp7CWjVpkuAGTHKK/IfdVkRERORQFNaqSYdPB/Oi6xncDrVYREREjp6SRDUx7E7S7F4cNsPqUkRERKQWU1irJmZUUtmjO/L16A4RERE5egpr1STkTiI2VMiGPZXPTSoiIiJSGYW1amK6E0k0PPgCIatLERERkVpMYa2ahKKSicKPEdRpUBERETl6CmvVpLjzMPrFvUWJ6SJkmlaXIyIiIrWUwlp1sbuJi3ID4CkJWFyMiIiI1FYKa9XEvmctj3rHcpKxiT1FmsVAREREjo7CWjUxQgHaFa+iibGHIr9uMhAREZGjo7BWTULuRACSDA85mh9UREREjpLCWjUx3UkAJOLlf9sLrC1GREREai2FtWpiuuIJGXaSDA/ZGlkTERGRo6SwVl0Mg5ArgUS85Bb5ra5GREREaimFtWqUc95MZgcvIL9EYU1ERESOjsPqAuoyo8WpbDH9tPTpOWsiIiJydDSyVo1c699nqOM/FOvRHSIiInKUFNaqkXvjvxnm/pgGsS6rSxEREZFaSmGtGplRScSbHl2zJiIiIkdNYa0ahdxJRIc87CootroUERERqaUU1qqRGZWEDZPokJdgyLS6HBEREamFFNaqUWjvLAZJhocCnQoVERGRo6CwVo0CDTvxdtL1eMxoduSXWF2OiIiI1EIKa9UomNKWb5pcxx4S2VHos7ocERERqYUU1qpToIRTgqtpQjY7FdZERETkKCisVSOjtJArfr6TvvbviHXZrS5HREREaiGFtWpkuhMBSMKDtzRocTUiIiJSGymsVSe7i5AzliTDw9qsQqurERERkVpIYa2ame5EEvGyJVcPxhUREZEjp7BWzUx3EkmGF48vYHUpIiIiUguFHdZM0+Sdd94hKysLgNmzZ3PxxRfz4IMPUlRUVG0F1nalzU7nV5pTpGvWRERE5CiEHdaef/55HnroIbKysvj22295+umn6d69O9999x1PPvlkddZYq3l7jWe6MZiSQMjqUkRERKQWCjus/etf/+LJJ5+kc+fOfPTRR3Tu3Jnx48czadIkPvnkk+qssXYzQ6Q6SihVWBMREZGjEHZY2717Nx06dADg888/p3fv3gCkpaXh8Xiqp7o6IParyXwSuhGboYncRURE5Mg5wt2wefPm/Pjjj+Tk5LBlyxb69OkDwJIlS2jevHm1FVjbhaKScOHHCPoIBEM47LqnQ0RERMIXdli78cYbGTlyJDabje7du3PSSScxffp0pk2bxqOPPnrUBbz44ot8+umn+P1+Bg8eTI8ePbjvvvswDIPjjz+e8ePHY7PZWLBgAfPnz8fhcHDrrbdy1llnHfU+a9L+D8bNLfaTFue2uCIRERGpTcIOawMGDODEE09k69at5adAO3fuzJw5c+jevftR7Xz58uV89913vPHGGxQXF/P3v/+dyZMnM2LECHr27Mm4ceNYvHgxnTt3Zu7cuSxcuBCfz0dmZiann346LpfrqPZbk0LuJAASDS/b80sU1kREROSIhB3WANq3b0/7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lv+8Zs0aevToAUCfPn348ssvrSotovy5Tz/++CP//e9/ufrqq3nggQfweDwWVhc5zj//fO68887yn+12u46pQ6ioVzquKnbOOefwyCOPALB9+3YaNGig46oCFfXJ6mOqXoc1j8dDXFxc+c92u51AIGBhRZEpKiqKG264gdmzZ/Pwww9zzz33qE9/0q9fPxyOPwaqTdPEMAwAYmNjKSwstKq0iPLnPnXq1Il7772XefPm0bx5c6ZNm2ZhdZEjNjaWuLg4PB4Pd9xxByNGjNAxdQgV9UrH1aE5HA5Gjx7NI488Qr9+/XRcHcKf+2T1MVWvw1pcXBxer7f851AodMA/JFKmdevWXHLJJRiGQevWrUlKSmL37t1WlxXR9r/mw+v1kpCQYGE1kevcc8+lQ4cO5b//6aefLK4ocuzYsYNrrrmGSy+9lP79++uYqsSfe6XjqnKPP/44//nPfxg7dmz5ZS6g4+rP9u9Tr169LD2m6nVY69q1K8uWLQNg9erVtG3b1uKKItM///lPHnvsMQB27tyJx+MhLS3N4qoi24knnsjy5csBWLZsGd26dbO4osh0ww038MMPPwDw1VdfcdJJJ1lcUWTIzs5m6NChjBo1ioEDBwI6pg6lol7puKrY22+/zYsvvghAdHQ0hmHQoUMHHVd/UlGfhg8fbukxVa8nct93N+gvv/yCaZo8+uijtGnTxuqyIk5paSn3338/27dvxzAM7rnnHrp27Wp1WRFn69at3HXXXSxYsIBNmzYxduxY/H4/GRkZTJw4EbvdbnWJEWH/Pq1Zs4ZHHnkEp9NJgwYNeOSRRw64NKG+mjhxIh999BEZGRnlyx588EEmTpyoY+pPKurViBEjePLJJ3Vc/UlRURH3338/2dnZBAIBhg0bRps2bfR31Z9U1KfGjRtb+ndVvQ5rIiIiIpGuXp8GFREREYl0CmsiIiIiEUxhTURERCSCKayJiIiIRDCFNREREZEIpifAikid17dvX7Zt21bhuuOPP57333+/2mto164dTzzxBJdeemm170tE6haFNRGpF4YNG8a111570HLNWiIikU5/S4lIvRATE6OZN0SkVtI1ayJS723dupV27drx3nvvccEFF3DyySczZMgQfv755/JtAoEAs2bN4rzzzqNjx47079+fDz/88ID3Wbp0KVdeeSUnn3wyffv25aWXXjpg/YYNGxgyZAgdO3akb9++/POf/6yRzycitZvCmojIXo899hgjRozgn//8J/Hx8Vx//fUUFhaWr5s9ezZ33XUX7777LhdddBF33XUX//nPfwD47rvvuOWWWzj99NN5++23uf/++5k2bRoLFiwof/958+YxePBgPvzwQ/r27cvYsWP5/fffLfmsIlJ7aLopEanz+vbty65du3A6nQetu++++zj99NM5++yzGTNmDEOGDAGgsLCQPn36MHr0aC6++GJ69uzJuHHjuOqqq8pfO2LECH7//XcWLlzIXXfdxe7du5k7d275+rfffhu73U7//v1p164dt9xyCyNHjgQgPz+fHj16MHXqVM4777xq7oCI1Ga6Zk1E6oWrr76azMzMg5anpKSQn58PQPfu3cuXx8fH06ZNG3755Rc2btxIIBCga9euB7y2e/fufPrppwD88ssv9OnT54D1l1122QE/t2rVqvz3iYmJAJSUlBz1ZxKR+kFhTUTqhcTERFq2bFnhun1h7c8jb6FQCJvNhsvlqvB1wWCw/G7ScO4qtdkOvvJEJzdE5HB0zZqIyF4//vhj+e/z8/PZtGkTJ5xwAq1atcLpdLJy5coDtl+5ciXHHXccAG3atDng9QB/+9vf+Otf/1r9hYtInaaRNRGpF4qKiti9e3eF6/aNbk2ZMoXU1FQaNmzI008/TXJyMhdccAFRUVFcf/31PPPMMyQlJdG+fXs+/vhjPv74Y6ZMmQLA0KFDGThwINOnT+eiiy5i3bp1vPrqqzz44IM19hlFpG5SWBORemHWrFnMmjWrwnX7HqExaNAgJkyYwK5du+jRowevvPIKMTExANx5553YbDYeffRRcnNzadOmDVOmTOGCCy4A4KSTTmLq1Kk899xzTJ8+nfT0dEaOHMnAgQNr5gOKSJ2lu0FFpN7bunUrZ599NvPmzaNbt25WlyMicgBdsyYiIiISwRTWRERERCKYToOKiIiIRDCNrImIiIhEMIU1ERERkQimsCYiIiISwRTWRERERCKYwpqIiIhIBFNYExEREYlg/x/95ioavjW7OwAAAABJRU5ErkJggg==\n", "text/plain": [ "
" ] }, "metadata": {}, "output_type": "display_data" } ], "source": [ "fig = plt.figure(figsize=(10,6))\n", "sns.set_style(style='dark')\n", "ax = sns.lineplot(x='epoch', y='loss',\n", " style='split',\n", " hue='model',\n", " data=loss_learning_curves)\n", "ax.set_title('Loss Learning Curves', fontdict={'fontsize': 16})\n", "ax.grid(visible=True, which='major', color='black', linewidth=0.075)\n", "ax.grid(visible=True, which='minor', color='black', linewidth=0.075)\n", "ax.set_xlabel(\"Epoch\", fontsize = 15)\n", "ax.set_ylabel(\"Loss\", fontsize = 15);" ] }, { "cell_type": "code", "execution_count": null, "id": "11855d06-552d-4bc2-ba74-668422aace0c", "metadata": {}, "outputs": [], "source": [] } ], "metadata": { "kernelspec": { "display_name": "base39", "language": "python", "name": "base39" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.9.10" } }, "nbformat": 4, "nbformat_minor": 5 } ================================================ FILE: examples/class_imbalance/standard_model_config.yaml ================================================ input_features: - name: Gender type: category - name: Age type: number - name: Driving_License type: binary - name: Region_Code type: number - name: Previously_Insured type: binary - name: Vehicle_Age type: category - name: Vehicle_Damage type: category - name: Annual_Premium type: number - name: Policy_Sales_Channel type: number - name: Vintage type: number output_features: - name: Response type: binary trainer: learning_rate: 0.0001 learning_rate_scheduler: decay: exponential decay_rate: 0.9 decay_steps: 30000 staircase: True epochs: 50 ================================================ FILE: examples/forecasting/README.md ================================================ - Download and unpack hourly weather data from https://www.kaggle.com/selfishgene/historical-hourly-weather-data - `ludwig train --config config.yaml --dataset temperature.csv` - `ludwig forecast -n 10 --model_path results/experiment_run/model --dataset temperature.csv` ================================================ FILE: examples/forecasting/config.yaml ================================================ input_features: - name: Seattle type: timeseries preprocessing: window_size: 10 encoder: type: passthrough output_features: - name: Seattle_next type: timeseries column: Seattle preprocessing: horizon: 2 combiner: type: concat flatten_inputs: true preprocessing: split: type: datetime column: datetime ================================================ FILE: examples/getting_started/rotten_tomatoes.yaml ================================================ input_features: - name: genres type: set preprocessing: tokenizer: comma - name: content_rating type: category - name: top_critic type: binary - name: runtime type: number - name: review_content type: text encoder: type: embed output_features: - name: recommended type: binary trainer: epochs: 3 ================================================ FILE: examples/getting_started/run.sh ================================================ #!/usr/bin/env bash # Fail fast if an error occurs set -e # Get the directory of this script, which contains the config file SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) # Download the data wget https://ludwig.ai/latest/data/rotten_tomatoes.csv wget https://ludwig.ai/latest/data/rotten_tomatoes_test.csv # Check the first 5 rows head -n 5 rotten_tomatoes.csv # Train ludwig train --config ${SCRIPT_DIR}/rotten_tomatoes.yaml --dataset rotten_tomatoes.csv # Predict and Evaluate ludwig predict --model_path results/experiment_run/model --dataset rotten_tomatoes_test.csv ================================================ FILE: examples/hyperopt/README.md ================================================ # Hyperparameter Optimization Demonstrates hyperparameter optimization using Ludwig's in-built capabilities. ### Preparatory Steps - Create `data` directory - Download [Kaggle wine quality data set](https://www.kaggle.com/rajyellow46/wine-quality) into the `data` directory. Directory should appear as follows: ``` hyperopt/ data/ winequalityN.csv ``` ### Description Jupyter notebook `model_hyperopt_example.ipynb` demonstrates several hyperparameter optimization capabilities. Key features demonstrated in the notebook: - Training data is prepared for use - Programmatically create Ludwig config dictionary from the training data dataframe - Setup parameter space for hyperparameter optimization - Perform two hyperparameter runs - Parallel workers using random search strategy - Serial processing using random search strategy - Parallel workers using grid search strategy (Note: takes about 35 minutes) - Demonstrate various Ludwig visualizations for hyperparameter optimization ================================================ FILE: examples/hyperopt/model_hyperopt_example.ipynb ================================================ { "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# Hyperparameter Optimization In Ludwig\n", "\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Demonstrates hyper-parameter tuning capabilities of Ludwig. The following steps occur in this notebook:\n", "* Training data is prepared for use\n", "* Programmatically create Ludwig config dictionary from the training data dataframe\n", "* Setup parameter space for hyperparameter optimization\n", "* Perform two hyperparameter runs\n", " * Parallel workers using random search strategy\n", " * Serial processing using random search strategy\n", " * Parallel workers using grid search strategy\n", "* Demonstrate various Ludwig visualizations for hyperparameter optimization" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Import required libraries" ] }, { "cell_type": "code", "execution_count": 1, "metadata": { "pycharm": { "is_executing": false } }, "outputs": [], "source": [ "import warnings\n", "warnings.simplefilter('ignore')\n", "\n", "import shutil\n", "import datetime\n", "\n", "import pandas as pd\n", "import numpy as np\n", "\n", "from ludwig.hyperopt.run import hyperopt\n", "from ludwig.visualize import hyperopt_results_to_dataframe, hyperopt_hiplot_cli, hyperopt_report_cli\n", "\n", "from sklearn.model_selection import train_test_split" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Retrieve data for training" ] }, { "cell_type": "code", "execution_count": 2, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "(6497, 13)" ] }, "execution_count": 2, "metadata": {}, "output_type": "execute_result" } ], "source": [ "train_df = pd.read_csv('./data/winequalityN.csv')\n", "train_df.shape" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Standardize column names to replace spaces(\" \") with underscore(\"_\")" ] }, { "cell_type": "code", "execution_count": 3, "metadata": {}, "outputs": [], "source": [ "new_col = []\n", "for i in range(len(train_df.columns)):\n", " new_col.append(train_df.columns[i].replace(' ', '_'))\n", " \n", "train_df.columns = new_col" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Data Set Overview" ] }, { "cell_type": "code", "execution_count": 4, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "type object\n", "fixed_acidity float64\n", "volatile_acidity float64\n", "citric_acid float64\n", "residual_sugar float64\n", "chlorides float64\n", "free_sulfur_dioxide float64\n", "total_sulfur_dioxide float64\n", "density float64\n", "pH float64\n", "sulphates float64\n", "alcohol float64\n", "quality int64\n", "dtype: object" ] }, "execution_count": 4, "metadata": {}, "output_type": "execute_result" } ], "source": [ "train_df.dtypes" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Create training and test data sets" ] }, { "cell_type": "code", "execution_count": 5, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "3 30\n", "4 216\n", "5 2138\n", "6 2836\n", "7 1079\n", "8 193\n", "9 5\n", "Name: quality, dtype: int64" ] }, "execution_count": 5, "metadata": {}, "output_type": "execute_result" } ], "source": [ "train_df['quality'].value_counts().sort_index()" ] }, { "cell_type": "code", "execution_count": 6, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "categorical variables: ['type'] \n", "\n", "numerical variables: ['sulphates', 'fixed_acidity', 'total_sulfur_dioxide', 'density', 'pH', 'residual_sugar', 'free_sulfur_dioxide', 'alcohol', 'citric_acid', 'volatile_acidity', 'chlorides'] \n", "\n" ] } ], "source": [ "# isolate the predictor variables only\n", "predictor_vars = list(set(train_df.columns) - set(['quality']))\n", "\n", "#extract categorical variables\n", "categorical_vars = []\n", "for p in predictor_vars:\n", " if train_df[p].dtype == 'object':\n", " categorical_vars.append(p)\n", " \n", "print(\"categorical variables:\", categorical_vars,'\\n')\n", "\n", "# get numerical variables\n", "numerical_vars = list(set(predictor_vars) - set(categorical_vars))\n", "\n", "print(\"numerical variables:\", numerical_vars,\"\\n\")" ] }, { "cell_type": "code", "execution_count": 7, "metadata": {}, "outputs": [ { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
countmeanstdmin25%50%75%max
fixed_acidity6487.07.2165791.2967503.800006.400007.000007.7000015.90000
volatile_acidity6489.00.3396910.1646490.080000.230000.290000.400001.58000
citric_acid6494.00.3187220.1452650.000000.250000.310000.390001.66000
residual_sugar6495.05.4443264.7581250.600001.800003.000008.1000065.80000
chlorides6495.00.0560420.0350360.009000.038000.047000.065000.61100
free_sulfur_dioxide6497.030.52531917.7494001.0000017.0000029.0000041.00000289.00000
total_sulfur_dioxide6497.0115.74457456.5218556.0000077.00000118.00000156.00000440.00000
density6497.00.9946970.0029990.987110.992340.994890.996991.03898
pH6488.03.2183950.1607482.720003.110003.210003.320004.01000
sulphates6493.00.5312150.1488140.220000.430000.510000.600002.00000
alcohol6497.010.4918011.1927128.000009.5000010.3000011.3000014.90000
quality6497.05.8183780.8732553.000005.000006.000006.000009.00000
\n", "
" ], "text/plain": [ " count mean std min 25% \\\n", "fixed_acidity 6487.0 7.216579 1.296750 3.80000 6.40000 \n", "volatile_acidity 6489.0 0.339691 0.164649 0.08000 0.23000 \n", "citric_acid 6494.0 0.318722 0.145265 0.00000 0.25000 \n", "residual_sugar 6495.0 5.444326 4.758125 0.60000 1.80000 \n", "chlorides 6495.0 0.056042 0.035036 0.00900 0.03800 \n", "free_sulfur_dioxide 6497.0 30.525319 17.749400 1.00000 17.00000 \n", "total_sulfur_dioxide 6497.0 115.744574 56.521855 6.00000 77.00000 \n", "density 6497.0 0.994697 0.002999 0.98711 0.99234 \n", "pH 6488.0 3.218395 0.160748 2.72000 3.11000 \n", "sulphates 6493.0 0.531215 0.148814 0.22000 0.43000 \n", "alcohol 6497.0 10.491801 1.192712 8.00000 9.50000 \n", "quality 6497.0 5.818378 0.873255 3.00000 5.00000 \n", "\n", " 50% 75% max \n", "fixed_acidity 7.00000 7.70000 15.90000 \n", "volatile_acidity 0.29000 0.40000 1.58000 \n", "citric_acid 0.31000 0.39000 1.66000 \n", "residual_sugar 3.00000 8.10000 65.80000 \n", "chlorides 0.04700 0.06500 0.61100 \n", "free_sulfur_dioxide 29.00000 41.00000 289.00000 \n", "total_sulfur_dioxide 118.00000 156.00000 440.00000 \n", "density 0.99489 0.99699 1.03898 \n", "pH 3.21000 3.32000 4.01000 \n", "sulphates 0.51000 0.60000 2.00000 \n", "alcohol 10.30000 11.30000 14.90000 \n", "quality 6.00000 6.00000 9.00000 " ] }, "execution_count": 7, "metadata": {}, "output_type": "execute_result" } ], "source": [ "train_df.describe().T" ] }, { "cell_type": "code", "execution_count": 8, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "unique values for type is 2\n" ] } ], "source": [ "for p in categorical_vars:\n", " print(\"unique values for\",p,\"is\",train_df[p].nunique())" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Create config" ] }, { "cell_type": "code", "execution_count": 9, "metadata": {}, "outputs": [], "source": [ "# template for config\n", "config = {'input_features':[], 'output_features': [], 'trainer':{}}\n", "\n", "# setup input features for categorical variables\n", "for p in categorical_vars:\n", " a_feature = {'name': p.replace(' ','_'), 'type': 'category', 'representation': 'sparse'}\n", " config['input_features'].append(a_feature)\n", "\n", "\n", "# setup input features for numerical variables\n", "for p in numerical_vars:\n", " a_feature = {'name': p.replace(' ','_'), 'type': 'number', \n", " 'preprocessing': {'missing_value_strategy': 'fill_with_mean', 'normalization': 'zscore'}}\n", " config['input_features'].append(a_feature)\n", "\n", "# set up output variable\n", "config['output_features'].append({'name': 'quality', 'type':'category'})\n", "\n", "# set up trainer\n", "config['trainer'] = {'epochs': 20}" ] }, { "cell_type": "code", "execution_count": 10, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "config:\n" ] }, { "data": { "text/plain": [ "{'input_features': [{'name': 'type',\n", " 'type': 'category',\n", " 'representation': 'sparse'},\n", " {'name': 'sulphates',\n", " 'type': 'number',\n", " 'preprocessing': {'missing_value_strategy': 'fill_with_mean',\n", " 'normalization': 'zscore'}},\n", " {'name': 'fixed_acidity',\n", " 'type': 'number',\n", " 'preprocessing': {'missing_value_strategy': 'fill_with_mean',\n", " 'normalization': 'zscore'}},\n", " {'name': 'total_sulfur_dioxide',\n", " 'type': 'number',\n", " 'preprocessing': {'missing_value_strategy': 'fill_with_mean',\n", " 'normalization': 'zscore'}},\n", " {'name': 'density',\n", " 'type': 'number',\n", " 'preprocessing': {'missing_value_strategy': 'fill_with_mean',\n", " 'normalization': 'zscore'}},\n", " {'name': 'pH',\n", " 'type': 'number',\n", " 'preprocessing': {'missing_value_strategy': 'fill_with_mean',\n", " 'normalization': 'zscore'}},\n", " {'name': 'residual_sugar',\n", " 'type': 'number',\n", " 'preprocessing': {'missing_value_strategy': 'fill_with_mean',\n", " 'normalization': 'zscore'}},\n", " {'name': 'free_sulfur_dioxide',\n", " 'type': 'number',\n", " 'preprocessing': {'missing_value_strategy': 'fill_with_mean',\n", " 'normalization': 'zscore'}},\n", " {'name': 'alcohol',\n", " 'type': 'number',\n", " 'preprocessing': {'missing_value_strategy': 'fill_with_mean',\n", " 'normalization': 'zscore'}},\n", " {'name': 'citric_acid',\n", " 'type': 'number',\n", " 'preprocessing': {'missing_value_strategy': 'fill_with_mean',\n", " 'normalization': 'zscore'}},\n", " {'name': 'volatile_acidity',\n", " 'type': 'number',\n", " 'preprocessing': {'missing_value_strategy': 'fill_with_mean',\n", " 'normalization': 'zscore'}},\n", " {'name': 'chlorides',\n", " 'type': 'number',\n", " 'preprocessing': {'missing_value_strategy': 'fill_with_mean',\n", " 'normalization': 'zscore'}}],\n", " 'output_features': [{'name': 'quality', 'type': 'category'}],\n", " 'trainer': {'epochs': 20}}" ] }, "execution_count": 10, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# View the config\n", "print(\"config:\")\n", "config" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Define hyperparameter search space" ] }, { "cell_type": "code", "execution_count": 11, "metadata": {}, "outputs": [], "source": [ "SEED=13\n", "\n", "hyperopt_configs = {\n", " \"parameters\": {\n", " \"trainer.learning_rate\": {\n", " \"type\": \"float\",\n", " \"space\": \"loguniform\",\n", " \"lower\": 0.0001,\n", " \"upper\": 0.01,\n", " \"q\": 3,\n", " },\n", " \"trainer.batch_size\": {\n", " \"type\": \"int\",\n", " \"space\": \"qlograndint\",\n", " \"base\" : 2,\n", " \"lower\": 32,\n", " \"upper\": 256,\n", " \"q\": 5,\n", " },\n", " \"quality.fc_size\": {\n", " \"type\": \"int\",\n", " 'space': 'qrandint',\n", " \"lower\": 32,\n", " \"upper\": 256,\n", " \"q\": 5,\n", " },\n", " \"quality.num_fc_layers\": {\n", " 'type': 'int',\n", " 'space': 'qrandint',\n", " 'lower': 1,\n", " 'upper': 5,\n", " 'q': 4,\n", " }\n", " },\n", " \"goal\": \"minimize\",\n", " 'output_feature': \"quality\",\n", " 'validation_metrics': 'loss'\n", "}\n", "\n", "# add hyperopt parameter space to the config\n", "config['hyperopt'] = hyperopt_configs" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Train with optimal hyperparameters on the whole data set" ] }, { "cell_type": "code", "execution_count": 12, "metadata": {}, "outputs": [], "source": [ "# clean out old results\n", "shutil.rmtree('./results_ray', ignore_errors=True)\n", "shutil.rmtree('./results_random_serial', ignore_errors=True)\n", "shutil.rmtree('./visualizations', ignore_errors=True)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "#### Random Search with ray executors\n", "\n", "This executor will use a local run cluster with 3 samples (should take less than 30 seconds)" ] }, { "cell_type": "code", "execution_count": 13, "metadata": { "scrolled": false }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "{ 'executor': {'time_budget_s': 1000, 'type': 'ray'},\n", " 'goal': 'minimize',\n", " 'metric': 'loss',\n", " 'output_feature': 'quality',\n", " 'parameters': { 'quality.fc_size': { 'lower': 32,\n", " 'q': 5,\n", " 'space': 'qrandint',\n", " 'type': 'int',\n", " 'upper': 256},\n", " 'quality.num_fc_layers': { 'lower': 1,\n", " 'q': 4,\n", " 'space': 'qrandint',\n", " 'type': 'int',\n", " 'upper': 5},\n", " 'trainer.batch_size': { 'base': 2,\n", " 'lower': 32,\n", " 'q': 5,\n", " 'space': 'qlograndint',\n", " 'type': 'int',\n", " 'upper': 256},\n", " 'trainer.learning_rate': { 'lower': 0.0001,\n", " 'q': 3,\n", " 'space': 'loguniform',\n", " 'type': 'float',\n", " 'upper': 0.01}},\n", " 'sampler': {'num_samples': 3, 'type': 'ray'},\n", " 'split': 'validation',\n", " 'validation_metrics': 'loss'}\n", "\n", "\n", "Initializing new Ray cluster...\n", "CPU times: user 492 ms, sys: 207 ms, total: 699 ms\n", "Wall time: 12 s\n" ] } ], "source": [ "%%time\n", "%%capture\n", "print(\"starting:\", datetime.datetime.now())\n", "config['hyperopt']['executor'] = {'type': 'ray', 'time_budget_s': 1000}\n", "config['hyperopt']['sampler'] = {'type': 'ray', 'num_samples': 3}\n", "results_ray = hyperopt(\n", " config,\n", " dataset=train_df.sample(4000, random_state=42), # limit number records for demonstration purposes\n", " output_directory='results_ray' # location to place results\n", ")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "#### Random Search with serial executor\n", "\n", "Run the serialize executor with 2 samples (should take less then 3 minutes)" ] }, { "cell_type": "code", "execution_count": 14, "metadata": {}, "outputs": [], "source": [ "hyperopt_configs = {\n", " \"parameters\": {\n", " \"trainer.learning_rate\": {\n", " \"type\": \"float\",\n", " \"low\": 0.0001,\n", " \"high\": 0.01,\n", " \"space\": \"log\",\n", " \"steps\": 3,\n", " },\n", " \"trainer.batch_size\": {\n", " \"type\": \"int\",\n", " \"low\": 32,\n", " \"high\": 256,\n", " \"space\": \"log\",\n", " \"steps\": 5,\n", " \"base\" : 2\n", " },\n", " \"quality.fc_size\": {\n", " \"type\": \"int\",\n", " \"low\": 32,\n", " \"high\": 256,\n", " \"steps\": 5\n", " },\n", " \"quality.num_fc_layers\": {\n", " 'type': 'int',\n", " 'low': 1,\n", " 'high': 5,\n", " 'space': 'linear',\n", " 'steps': 4\n", " }\n", " },\n", " \"goal\": \"minimize\",\n", " 'output_feature': \"quality\",\n", " 'validation_metrics': 'loss'\n", "}\n", "\n", "# add hyperopt parameter space to the config\n", "config['hyperopt'] = hyperopt_configs" ] }, { "cell_type": "code", "execution_count": 15, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "starting: 2022-04-09 15:09:04.768901\n", "Note: steps_per_checkpoint (was 40) is now set to the number of steps per epoch: 40.\n", "\n", "Note: steps_per_checkpoint (was 52) is now set to the number of steps per epoch: 52.\n", "\n", "CPU times: user 17min 20s, sys: 48 s, total: 18min 8s\n", "Wall time: 2min 42s\n" ] } ], "source": [ "%%time\n", "print(\"starting:\", datetime.datetime.now())\n", "config['hyperopt']['executor'] = {'type': 'serial'}\n", "config['hyperopt']['sampler'] = {'type': 'random', 'num_samples': 2}\n", "results_random_serial = hyperopt(\n", " config,\n", " dataset= train_df.sample(4000, random_state=42), # limit number records for demonstration purposes\n", " output_directory='hyperopt_results',\n", " experiment_name='random_serial',\n", ")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Note:\n", "`results_ray`, `results_random_serial` are `HyperoptResults` object with the ordered_trials, so will convert to dictionary to visualize." ] }, { "cell_type": "code", "execution_count": 20, "metadata": {}, "outputs": [], "source": [ "def hyperopt_results_dict(results):\n", " return [{\"metric_score\": t.metric_score, \"parameters\": t.parameters} for t in results.ordered_trials]" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Convert hyperparameter optimization results to dataframe" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "#### Results For Ray executor" ] }, { "cell_type": "code", "execution_count": 21, "metadata": {}, "outputs": [ { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
losstrainer.learning_ratetrainer.batch_sizequality.fc_sizequality.num_fc_layers
01.2911790.001393170604
11.3003320.000203451004
21.9562020.00052775450
\n", "
" ], "text/plain": [ " loss trainer.learning_rate trainer.batch_size quality.fc_size \\\n", "0 1.291179 0.001393 170 60 \n", "1 1.300332 0.000203 45 100 \n", "2 1.956202 0.000527 75 45 \n", "\n", " quality.num_fc_layers \n", "0 4 \n", "1 4 \n", "2 0 " ] }, "execution_count": 21, "metadata": {}, "output_type": "execute_result" } ], "source": [ "df1 = hyperopt_results_to_dataframe(\n", " hyperopt_results_dict(results_ray),\n", " hyperopt_configs['parameters'],\n", " hyperopt_configs['validation_metrics']\n", ")\n", "df1" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "#### Results for Random Search with serial executor" ] }, { "cell_type": "code", "execution_count": 22, "metadata": {}, "outputs": [ { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
lossquality.fc_sizequality.num_fc_layerstrainer.batch_sizetrainer.learning_rate
01.300466754550.005918
11.362126451720.002479
\n", "
" ], "text/plain": [ " loss quality.fc_size quality.num_fc_layers trainer.batch_size \\\n", "0 1.300466 75 4 55 \n", "1 1.362126 45 1 72 \n", "\n", " trainer.learning_rate \n", "0 0.005918 \n", "1 0.002479 " ] }, "execution_count": 22, "metadata": {}, "output_type": "execute_result" } ], "source": [ "df2 = hyperopt_results_to_dataframe(\n", " hyperopt_results_dict(results_random_serial),\n", " hyperopt_configs['parameters'],\n", " hyperopt_configs['validation_metrics']\n", ")\n", "df2" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Example Hyperopt Visualizations" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "#### Report results of the a hyperparameter optimization run" ] }, { "cell_type": "code", "execution_count": 31, "metadata": {}, "outputs": [], "source": [ "%matplotlib inline" ] }, { "cell_type": "code", "execution_count": 34, "metadata": { "scrolled": false }, "outputs": [ { "data": { "image/png": "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", "text/plain": [ "
" ] }, "metadata": {}, "output_type": "display_data" }, { "data": { "image/png": "iVBORw0KGgoAAAANSUhEUgAAAagAAAEYCAYAAAAJeGK1AAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjUuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/YYfK9AAAACXBIWXMAAAsTAAALEwEAmpwYAAAqoUlEQVR4nO3dfViT1/0/8HcACYFIrUJpfWCKFWU6ZbTdtMgUq0Wt4kMREAjt6qyztQ5rO0QwtVCe+jCs+Ct0FPbdfCKID/W7Wf0aZbXV6VpprCjt1AqKWkQt0qCQkJzfH15mUkBACLnB9+u6el3Jfec+9+cc07w5d04SmRBCgIiISGLsbF0AERFRcxhQREQkSQwoIiKSJAYUERFJEgOKiIgkiQFFRESSxICiNhk+fDiuXbvWaNu2bduwaNEiG1XUunXr1kGr1Ta7b/jw4Zg5cyZmzZqF2bNnIygoCM8++yyOHz/eJbWdP38er7zySpec6053/pvFx8fj0KFDAICEhASUlJR0eT3t9cYbb2DSpEnIyMho97FHjhzBjBkzrFAVWYuDrQsgspYjR47g0UcfbXH/X//6V/Tt29dyPzc3F2+99RY0Go3Va7t48SLOnj1r9fPcTXJysuX2oUOHEBYWZsNq2kaj0eCf//wnHn74YVuXQl2AAUUddvPmTfzmN79BQUEBhgwZAgD47W9/i8jISGi1WshkMpw5cwbXrl2Dv78/EhIS0KtXL5w5cwbJycmorq6GyWSCSqVCSEgIjhw5guTkZDg7O+PGjRsoLCzE9u3bsX79etjZ2cHNzQ2rVq3CkCFDsGLFimbbLygoQElJCd5++23Y29tjypQpd+1DQ0MDLl26hAceeMCyLSsrC//3f/8Hs9mMAQMG4I033oCHhwdUKhWGDh2KkpIS/PDDD5g1axaWLl0KANBqtVi3bh1MJhOUSiXi4uIwevRoZGZmQqfT4fLlyxg2bBiOHz+OyspKLFiwALm5uYiPj8eoUaMwf/78RnUZDAYkJyfj0KFD6NevH3x8fHDz5k2kpaVBpVIhMjISU6dOBYBG9wsLC6HRaGA0GnH9+nUsXLgQERERjdq+/fjS0lJcvnwZr732GpKSkrBo0SIcOHAAvXv3hhACU6dOxfvvv48RI0YAuBX8GRkZGDRoEE6dOgWDwQC1Wo2xY8dixYoVGDZsGBYsWAAAje5PmjQJM2bMwD//+U9UV1fjlVdeQXFxMU6cOAEHBwdkZWXBw8OjxX+jiIgICCGwcOFCvPHGG+jXrx/UajWuXbsGOzs7LF68GNOnT2/LUxZnz55FYmIibty4gcuXL2PEiBFYs2YN9uzZg02bNiE/Px/ArT8kQkNDsX//fpw/f75Nz9eNGzciPj4e5eXlsLOzw8iRI5GYmAg7O16wajdB1Abe3t5ixowZIjg42PLfhAkTxIsvviiEEOKtt94S6enpQgghysvLxYQJE0RDQ4OIjY0Vs2fPFnq9XtTX14vIyEixfv16YTQaxfTp00VJSYkQQoiamhoxbdo08dVXX4nDhw+LESNGiIqKCiGEEIcOHRKTJ08WV69eFUIIsXXrVjFt2jRhNptbbF8IIaKiosQnn3xy1/7MnDlT+Pv7i0mTJomkpCRx5coVIYQQ27dvFzExMcJoNAohhMjPzxe/+93vLO0uXLhQGAwGcf36dREUFCT2798vTp8+LZ588klx7tw5S93+/v7ixx9/FGvXrhVBQUGW9g4fPiyeeeaZVsc9Ly9PREdHi/r6eqHX68WsWbNEbGxss/27fV+v14vQ0FBx7do1IYQQX331lfD19bWM3e1/szuPDwwMFF9//bUQQojFixeLDRs2WPoQGhraqKbDhw8LHx8fcfLkSSGEELm5uSIyMlIIIURsbKz46KOPLI+9835gYKBISUkRQgjxj3/8Q4wYMUKUlpYKIYR46aWXRFZWVqvj4e3tbXkezJ4921LnxYsXxVNPPSV+/PHHFo+9c8zT0tLEjh07hBBCGAwGMWPGDLF7925RX18vxo0bJ06dOiWEEGLNmjXi3Xffbdfzdfv27eKFF14QQgjR0NAg4uPjRVlZWat9o6Y4g6I2++klsW3btmHPnj0Abv11GxUVhWXLlkGj0SAkJAT29vYAgDlz5sDFxQUAMGvWLOzbtw9jx47FuXPnsHLlSkt7dXV1OHnyJIYOHYpHHnkEAwYMAAB89tlnmD59uuXcc+fORXJyMioqKlpsPyoqqs39OXnyJBYuXIhf/vKX6NevHwCgqKgIx48fx7PPPgsAMJvNuHnzpuXYsLAw9OrVC7169cLUqVPx+eefw8vLC2PHjsWgQYMAAOPGjUPfvn0t7+34+vrCwaF9/8sdPnwYM2bMgKOjIxwdHTF79mx88803dz3GxcUF2dnZ+PTTT1FWVoZvvvkGN27caPM5IyMj8c477yAyMhIajabJrA4A+vfvDx8fHwDAz3/+c2zfvr1NbT/99NMAgEGDBsHNzc0yK/P09MT169fbXGN1dTW++eYbzJs3DwDwyCOPtPh+Y3Nef/11HDx4EDk5OSgrK8Ply5dx48YNODo6Yt68eSgoKEBsbCy2b9+ODRs2oKysrM3P18ceewwZGRlQqVR48skn8dxzz+FnP/tZm2uj/2JAUacYMmQIhg8fjn379uF///d/sWXLFsu+20EFAEII2NnZwWQywdXVFR9//LFl35UrV9C7d2/odDo4Ozs3OuanhBBoaGhosf32+PnPf464uDgkJCRgzJgxGDhwIMxmM373u99ZLosZDIZGL6B3Bs3tc7ZW5519aiu5XN7ofq9evZq0f5vRaAQAfP/99wgLC0NoaCgee+wxTJ06FUVFRW0+55NPPombN2/iX//6F7788kukp6c3eYyTk5Pltkwms9Rx5+07a7rN0dGxxb60x+3xl8lklm3fffcd+vfv36i2lrz66qswmUyYNm0aJk6ciEuXLlnqDgsLw7x58/CrX/0Kw4YNw8CBA/Htt9+2+fk6aNAg7N27F0eOHMHhw4fx29/+FgkJCZZLsdR2vChKnSYiIgJvv/02xowZ0+i9hE8++QQGgwH19fXYvn07AgMDMWTIEMjlcsv/8JcuXcKMGTOaXUk2fvx47Nq1y7KKcOvWrejTp4/lr9Lm2gduBdftcGjNjBkz4Ovri5SUFMs5CwsLodfrAQDvv/8+/vjHP1oev3PnTpjNZly/fh2ffPIJJk2ahLFjx+LgwYM4f/48AOBf//oXLl26hDFjxjQ5n729fZMX7+ZMnDgR27ZtQ319PQwGA3bt2mXZd+fs7Ny5c/j2228BACUlJejbty9eeuklBAQEWMLJZDK1eJ47x0omkyEiIgLx8fGYMWNGk5C8mwcffNBS07Vr1/Dll1+2+dj2UCqVGDlyJHbs2AHg1vNn/vz5+PHHH9t0/Oeff46XX34Z06dPh0wmw7Fjxyzj079/f8tz4fbssT3P102bNiEuLg7jx4/H66+/jvHjx+PUqVOd0Ov7D2dQ1GkCAwORkJCA8PDwRtudnJwQERGBmpoay3JuOzs7fPDBB0hOTsZHH32EhoYG/OEPf8Bjjz2GI0eONDre398fzz//PJ577jmYzWb07dsXH374oWWm1Fz7t+tJT0+H0WjEnDlzWq1/1apVCA4OxmeffYZ58+ahsrISoaGhkMlkeOSRR5CWlmZ5bF1dHUJCQlBbW4uIiAiMGzcOwK1l0EuWLIHJZIKTkxOys7PRu3fvJucaNmwY7O3tERISgi1btiAhIaHZRRJz5szB+fPnMWfOHDg7Oze6xLp48WKsWLECn376Kby8vPD4449bxquwsBBTp06FQqHA6NGj0bdvX5SXl7fY98mTJ2PZsmV46623MH78eMyZMwfp6emWlX3Hjx9HQkJCoxlEc1QqFV577TUEBQVh4MCB+NWvftXKqN+79957D2+++SbWr18PmUyG5ORkuLu7t+nYZcuW4eWXX8YDDzwAhUKBJ554AufOnbPsnzt3LpKSkjBhwgQAt2Z+bX2+zp49G//+978xffp0KBQK9O/fH9HR0Z3X8fuITDR3XYLoHhQXF2PVqlX4+9//brn08tNVXZ3N2u0356er57pSbm4uTp061SgsreEf//gHtm/fjo8++siq55Eis9mMxMRE9O/fHy+++KKty7mvcQZFnSI2Nhb//ve/kZ6e3uh9Aep+VCoVrly5gszMzC4/d0xMTIufD8vIyICXl5dVj9fr9QgMDMTo0aMbXdIl2+AMioiIJImLJIiISJIYUEREJEl8DwqATqdr11Lan6qvr+/Q8cQx7CiOX8dw/Dqmo+NXX18PX1/fJtsZULj1Ycjbn4q/F6WlpR06njiGHcXx6xiOX8d0dPxKS0ub3c5LfEREJEkMKCIikiQGFBERSRIDioiIJIkBRUREksSAIiIiSWJAdYDZLPBdlR4XjS74rkoPs5nfGkVE1Fn4Oah7ZDYL7D7xPV4t0KHOaIZTLzv8KdQXU0c+DDs7flkqEVFHcQZ1j8qu1lrCCQDqjGa8WqBD2dVaG1dGRNQzMKDuUWVNnSWcbqszmnH5xzobVURE1LMwoO6Rh6sTnHo1Hj6nXnZ4qLeTjSoiIupZGFD3aHA/F/wp1NcSUrffgxrcz8XGlRER9QxcJHGP7OxkmDryYYxYGoCz31/DkIf7YnA/Fy6QICLqJJxBdYCdnQxe7kr0d6iFl7uS4URE1IkYUEREJEkMKCIikiQGFBERSRIDioiIJKlHr+IrLi6GRqMBAMTHx8PV1dXGFRERUVv16BlUQUEBEhMTERISgl27dtm6HCIiaoceHVAmkwlyuRzu7u6oqqqydTlERNQOPTqgFAoFDAYDqqqq4ObmZutyiIioHawaUMeOHYNKpWqy3WAwYPny5QgNDcULL7yAsrKyDrVtNpuhVqsRFhYGlUqF8vJyAEBoaCjUajXy8/MRHBzcob4QEVHXstoiiZycHOzcuRMKhaLJvoKCAjg7O6OgoADfffcdkpKSkJuba9l/4cIFDBgwoMntltrWarUwGAzQaDTQ6XRIS0tDVlYWRo0ahbS0NGt1kYiIrMhqMyhPT09kZmY2u+/06dP4zW9+AwDw8vLCmTNnLPvq6uoQExMDrVaLvLw8pKamttr20aNHERAQAADw9fVFSUlJZ3aFiIhswGoBFRQUBAeH5idoPj4+KCoqghACOp0OlZWVMJlMAAAnJyfk5uYiKSkJu3fvRkZGRqtt6/V6KJVKy317e3s0NDR0co+IiKgr2WSRxLPPPgulUomIiAjs3bsXI0eOhL29PQBACIG1a9fC398fLi4uKCwsbLU9pVKJ2tr//pKt2WxuMRyJiKh7sElAHT9+HOPGjcPmzZsxdepUDBo0yLKvrq4OgwcPRkpKCrKzs2E0Glttz8/PDwcOHAAA6HQ6eHt7W612IiLqGl02zaiurkZCQgLWrVuHn/3sZ3j//feRnZ2N3r17Izk52fI4hUKBqKgoAIBcLkd0dHSrbU+ZMgUHDx5EeHg4hBBISUmxWj+IiKhryIQQwtZF2FppaSl8fHxsdjxxDDuK49cxHL+OsdZraI/+oC4REXVfDCgiIpIkBhQREUkSA4qIiCSJAUVERJLEgCIiIkliQBERkSQxoIiISJIYUEREJEkMKCIikiQGFBERSRIDioiIJIkBRUREksSAIiIiSerRPztbXFwMjUYDAIiPj4erq6uNKyIiorbq0TOogoICJCYmIiQkBLt27bJ1OURE1A49OqBMJhPkcjnc3d1RVVVl63KIiKgdenRAKRQKGAwGVFVVwc3NzdblEBFRO1g1oI4dOwaVStVku9FoxPLlyxEeHo6IiAicOXOmQ22bzWao1WqEhYVBpVKhvLwcABAaGgq1Wo38/HwEBwd3rDNERNSlrLZIIicnBzt37oRCoWiy79NPP0VDQwPy8/Nx8OBBrFmzBpmZmZb9Fy5cwIABA5rcbqltrVYLg8EAjUYDnU6HtLQ0ZGVlYdSoUUhLS7NWF4mIyIqsNoPy9PRsFDp3GjJkCEwmE8xmM/R6PRwc/puTdXV1iImJgVarRV5eHlJTU1tt++jRowgICAAA+Pr6oqSkpJN7Q0REXc1qM6igoCBUVFQ0u8/Z2RkXLlzAtGnT8MMPPyA7O9uyz8nJCbm5uZg5cyY8PDywcePGVtvW6/VQKpWW+/b29mhoaGgUfERE1L3YZJHE//zP/2D8+PHYs2cPPv74Y6xYsQL19fUAACEE1q5dC39/f7i4uKCwsLDV9pRKJWpray33zWYzw4mIqJuzSUC5urqid+/eAIAHHngADQ0NMJlMAG5d4hs8eDBSUlKQnZ0No9HYant+fn44cOAAAECn08Hb29t6xRMRUZfosoCqrq7GkiVLAADPP/88Tpw4gYiICDz33HNYtmwZnJ2dAdxaGh4VFQUAkMvliI6ObrXtKVOmwNHREeHh4UhNTUVcXJz1OkJERF1CJoQQti7C1kpLS+Hj42Oz44lj2FEcv47h+HWMtV5De/QHdYmIqPtiQBERkSQxoIiISJIYUEREJEkMKCIikiQGFBERSRIDioiIJIkBRUREksSAIiIiSWJAERGRJDGgiIhIkhhQREQkSQwoIiKSJAYUERFJEgOKiIgkiQFFRESS5GDrAqypuLgYGo0GABAfHw9XV1cbV0RERG3Vo2dQBQUFSExMREhICHbt2mXrcoiIqB16dECZTCbI5XK4u7ujqqrK1uUQEVE79OiAUigUMBgMqKqqgpubm63LISKidrBqQB07dgwqlarJ9m3btkGlUkGlUiE0NBS/+MUvUFNTc89tm81mqNVqhIWFQaVSoby8HAAQGhoKtVqN/Px8BAcHd7xDRETUZay2SCInJwc7d+6EQqFosm/u3LmYO3cuAODNN9/Es88+22gBw4ULFzBgwIAmt1tqW6vVwmAwQKPRQKfTIS0tDVlZWRg1ahTS0tKs1UUiIrIiq82gPD09kZmZedfHHD9+HKdPn0ZYWJhlW11dHWJiYqDVapGXl4fU1NRW2z569CgCAgIAAL6+vigpKemkXhARka1YbQYVFBSEioqKuz7mww8/xMsvv9xom5OTE3JzczFz5kx4eHhg48aNrbat1+uhVCot9+3t7dHQ0AAHhx69ip6IqEez2SKJmpoanD17FmPHjm20XQiBtWvXwt/fHy4uLigsLGy1LaVSidraWst9s9nMcCIi6uZsFlBffPEFxo0b12R7XV0dBg8ejJSUFGRnZ8NoNLbalp+fHw4cOAAA0Ol08Pb27vR6iYioa3VZQFVXV2PJkiWW+2fPnsXAgQObPE6hUCAqKgoAIJfLER0d3WrbU6ZMgaOjI8LDw5Gamoq4uLjOK5yIiGxCJoQQti7C1kpLS+Hj42Oz44lj2FEcv47h+HWMtV5De/QHdYmIqPtiQBERkSQxoIiISJIYUEREJEkMKCIikiQGFBERSRIDioiIJIkBRUREksSAIiIiSWJAERGRJDGgiIhIkhhQREQkSQwoIiKSJAYUERFJEgOKiIgkiQFFRESSxIAiIiJJYkAREZEkOdi6AGsqLi6GRqMBAMTHx8PV1dXGFRERUVv16BlUQUEBEhMTERISgl27dtm6HCIiaoc2BVRlZSVOnz6Ns2fPYuXKlSgtLbV2XZ3CZDJBLpfD3d0dVVVVti6HiIjaoU0BtXz5cly5cgUZGRnw9/dHSkqKtevqFAqFAgaDAVVVVXBzc7N1OURE1A5tCiiZTIYnnngCNTU1eOaZZ2Bn17Yrg8eOHYNKpWp234cffoiwsDDMnTsXW7ZsaXvFzbRtNpuhVqsRFhYGlUqF8vJyAEBoaCjUajXy8/MRHBzc7nMQEZHttGmRRENDA9555x08/vjjOHz4MIxGY6vH5OTkYOfOnVAoFE32HTlyBF999RU2b96MmzdvIi8vr9H+CxcuYMCAAU1ut9S2VquFwWCARqOBTqdDWloasrKyMGrUKKSlpbWli0REJDFtmgqlpqZi0KBBePHFF3Ht2jWkp6e3eoynpycyMzOb3ff555/D29sbL7/8Mn7/+99j4sSJln11dXWIiYmBVqtFXl4eUlNTW2376NGjCAgIAAD4+vqipKSkLd0iIiIJa9MM6qGHHsJTTz2FmpoanD17FmPGjGn1mKCgIFRUVDS774cffsDFixeRnZ2NiooKLF68GLt374ZMJoOTkxNyc3Mxc+ZMeHh4YOPGja22rdfroVQqLfft7e3R0NAAB4cevYqeiKhHa9MMaunSpThx4gTefvtt9OrVC2q1ukMn7dOnD8aPHw9HR0d4eXlBLpfj2rVrAAAhBNauXQt/f3+4uLigsLCw1faUSiVqa2st981mM8OJiKiba1NA1dXVYdKkSfj+++/x4osvwmQydeikjz32GD777DMIIVBZWYmbN2+iT58+lnMNHjwYKSkpyM7ObtP7XX5+fjhw4AAAQKfTwdvbu0P1ERGR7bVpmmE0GvHXv/4VI0eOxOnTp3Hz5s12n6i6uhoJCQlYt24dAgMD8cUXXyAkJARCCKjVatjb2wO4tTQ8KioKACCXyxEdHd1q21OmTMHBgwcRHh4OIUS3WQZPREQtkwkhRGsPKi4uhlarxeLFi/Hxxx9j9OjRGD16dFfU1yVKS0vh4+Njs+OJY9hRHL+O4fh1jLVeQ9s0g/Lz80NNTQ00Gg0GDx7co8KJiIikqU3vQb333nvYtm0bHBwcsGPHDn62iIiIrK5NM6gvvvgC+fn5AIDnnnsOoaGhVi2KiIioTTOohoYGmM1mALeWcMtkMqsWRURE1KYZ1DPPPIP58+djzJgx+PrrrzF9+nRr10VERPe5uwbUe++9Z5kteXh4oKioCD4+PpYP1RIREVnLXQPKy8vLcnvIkCEIDAy0ekFERERAKwE1Z86crqqDiIiokR79k+9ERNR9MaCIiEiSGFBERCRJDCgiIpIkBhQREUkSA4qIiCSJAUVERJLEgCIiIkliQBERkSQxoIiISJIYUEREJElt+rmN7qq4uBgajQYAEB8fD1dXVxtXREREbdWjZ1AFBQVITExESEgIdu3aZetyiIioHXp0QJlMJsjlcri7u6OqqsrW5RARUTv06IBSKBQwGAyoqqqCm5ubrcshIqJ2sGpAHTt2DCqVqtl9c+bMgUqlgkqlQlxcXIfaNpvNUKvVCAsLg0qlQnl5OQAgNDQUarUa+fn5CA4OvveOEBFRl7PaIomcnBzs3LkTCoWiyb76+noIIbB+/fpmj71w4QIGDBjQ5HZLbWu1WhgMBmg0Guh0OqSlpSErKwujRo1CWlpaJ/eMiIi6gtVmUJ6ensjMzGx23zfffIObN2/ihRdeQHR0NHQ6nWVfXV0dYmJioNVqkZeXh9TU1FbbPnr0KAICAgAAvr6+KCkp6dzOEBFRl7PaDCooKAgVFRXN7nNycsKCBQswb948lJWVYeHChdi9ezccHBzg5OSE3NxczJw5Ex4eHti4cWOrbev1eiiVSst9e3t7NDQ0wMGhR6+iJyLq0WyySGLIkCEIDg6GTCbDkCFD0KdPH8sqOyEE1q5dC39/f7i4uKCwsLDV9pRKJWpray33zWYzw4mIqJuzSUAVFhZa3huqrKyEXq+Hu7s7gFuX+AYPHoyUlBRkZ2fDaDS22p6fnx8OHDgAANDpdPD29rZe8URE1CW6LKCqq6uxZMkSAEBISAh+/PFHzJ8/H8uWLUNKSoplxqNQKBAVFQUAkMvliI6ObrXtKVOmwNHREeHh4UhNTb2nVYFERCQtMiGEsHURtlZaWgofHx+bHU8cw47i+HUMx69jrPUa2qM/qEtERN0XA4qIiCSJAUVERJLEgCIiIkliQBERkSQxoIiISJIYUEREJEkMKCIikiQGFBERSRIDioiIJIkBRUREksSAIiIiSWJAERGRJDGgiIhIkhhQREQkSQwoIiKSJAYUERFJEgOKiIgkiQFFRESSxIAiIiJJYkAREZEkMaCIiEiSHGxdgDUVFxdDo9EAAOLj4+Hq6mrjioiIqK169AyqoKAAiYmJCAkJwa5du2xdDhERtUOPDiiTyQS5XA53d3dUVVXZuhwiImqHHh1QCoUCBoMBVVVVcHNzs3U5RETUDlYNqGPHjkGlUrW4/+rVq5gwYQLOnDnTobbNZjPUajXCwsKgUqlQXl4OAAgNDYVarUZ+fj6Cg4PvrRNERGQTVlskkZOTg507d0KhUDS732g0Qq1Ww8nJqcm+CxcuYMCAAU1ut9S2VquFwWCARqOBTqdDWloasrKyMGrUKKSlpXVyz4iIqCtYbQbl6emJzMzMFvenp6cjPDwcDz30UKPtdXV1iImJgVarRV5eHlJTU1tt++jRowgICAAA+Pr6oqSkpJN6QUREtmK1gAoKCoKDQ/MTtG3btqFv376WULmTk5MTcnNzkZSUhN27dyMjI6PVtvV6PZRKpeW+vb09GhoaOqEXRERkKzZZJLF161YcOnQIKpUKpaWliI2NtayyE0Jg7dq18Pf3h4uLCwoLC1ttT6lUora21nLfbDa3GI5ERNQ92ORVfOPGjZbbKpUKq1evhru7O4Bbl/gGDx6MqKgo1NfXWz5oezd+fn4oKirC9OnTodPp4O3tbbXaiYioa3TZDKq6uhpLlixp9XEKhQJRUVEAALlcjujo6FaPmTJlChwdHREeHo7U1FTExcV1uF4iIrItq86gBg4ciIKCAgBAnz59sG7duiaPWb9+fYfbtrOzQ2Ji4r0XSkREktOjP6hLRETdFwOKiIgkiQFFRESSxIAiIiJJYkAREZEkMaCIiEiSGFBERCRJDCgiIpIkBhQREUkSA4qIiCSJAUVERJLEgCIiIkliQBERkSQxoIiISJIYUEREJEkMKCIikiQGFBERSRIDioiIJIkBRUREksSAIiIiSWJAERGRJDGgiIhIkhhQREQkSQwoIiKSJAdbF2BNxcXF0Gg0AID4+Hi4urrauCIiImqrHj2DKigoQGJiIkJCQrBr1y5bl0NERO3QowPKZDJBLpfD3d0dVVVVti6HiKhHMZsFvqvS46LRBd9V6WE2i05tv0df4lMoFDAYDKiqqoKbm5utyyEi6jHMZoHdJ77HqwU61BnNcOplhz+F+mLqyIdhZyfrlHNYdQZ17NgxqFSqJttNJhPi4uIQHh6O+fPn4z//+U+H2jabzVCr1QgLC4NKpUJ5eTkAIDQ0FGq1Gvn5+QgODu5YZ4iIyKLsaq0lnACgzmjGqwU6lF2t7bRzWG0GlZOTg507d0KhUDTZV1RUBADIz8/HkSNHkJGRgaysLMv+CxcuYMCAAU1ut9S2VquFwWCARqOBTqdDWloasrKyMGrUKKSlpVmri0RE963KmjpLON1WZzTj8o918HJXdso5rDaD8vT0RGZmZrP7Jk+ejKSkJADAxYsXG62uq6urQ0xMDLRaLfLy8pCamtpq20ePHkVAQAAAwNfXFyUlJZ3ZFSIi+gkPVyc49WocIU697PBQb6dOO4fVZlBBQUGoqKho+cQODoiNjcXevXuxdu1ay3YnJyfk5uZi5syZ8PDwwMaNG1ttW6/XQ6n8b2Lb29ujoaEBDg49+i02IiKb8XzQGW/NHoWEHSWW96Demj0Kng86d9o5bLqKLz09HXv27MGqVatw48YNAIAQAmvXroW/vz9cXFxQWFjYajtKpRK1tf+97mk2mxlORERWdO6HG8jcfwoLxnthyaRHsWC8FzL3n8K5H2502jls8iq+Y8cOVFZWYtGiRVAoFJDJZLCzu5WVdXV1GDx4MKKiolBfX2/5oO3d+Pn5oaioCNOnT4dOp4O3t7e1u0BEdF+rrKlD+dWb+H9Fpxtt7xbvQf1UdXU1lixZAgB4+umncfLkSURGRmLBggVYuXIlnJxuXbdUKBSIiooCAMjlckRHR7fa9pQpU+Do6Ijw8HCkpqYiLi7Oeh0hIqIueQ9KJoTo3E9WdUOlpaXw8fGx2fHEMewojl/HcPzarzM/B9XS+PONGiIiajc7OxmmjnwYI5YG4Oz31zDk4b4Y3M+l0z6kC/TwrzoiIiLrsbOTwctdif4OtfByV3ZqOAEMKCIikigGFBERSRIDioiIJIkBRUREksSAIiIiSeLnoADodDrI5XJbl0FEdF+qr6+Hr69vk+0MKCIikiRe4iMiIkliQBERkSQxoIiISJIYUEREJEkMKCIikiQGFBERSRIDqgPMZjPUajXCwsKgUqlQXl5u65K6pWPHjkGlUtm6jG7HaDTi9ddfR0REBEJCQrBv3z5bl9StmEwmxMXFITw8HPPnz8d//vMfW5fULV29ehUTJkzAmTNnOr1tBlQHaLVaGAwGaDQaLF++HGlpabYuqdvJyclBQkIC6uvrbV1Kt7Nz50706dMHmzZtwkcffYSkpCRbl9StFBUVAQDy8/MRExODjIwMG1fU/RiNRqjVassvonc2BlQHHD16FAEBAQAAX19flJSU2Lii7sfT0xOZmZm2LqNbmjp1Kv7whz8AAIQQsLe3t3FF3cvkyZMtoX7x4kW4urrauKLuJz09HeHh4XjooYes0j4DqgP0ej2USqXlvr29PRoaGmxYUfcTFBQEBwf+sPO9cHFxgVKphF6vx9KlSxETE2PrkrodBwcHxMbGIikpCTNnzrR1Od3Ktm3b0LdvX8sf6dbAgOoApVKJ2tpay32z2cwXW+pSly5dQnR0NGbNmsUX2HuUnp6OPXv2YNWqVbhx44aty+k2tm7dikOHDkGlUqG0tBSxsbGoqqrq1HPw1bQD/Pz8UFRUhOnTp0On08Hb29vWJdF95MqVK3jhhRegVqsxbtw4W5fT7ezYsQOVlZVYtGgRFAoFZDIZ7Oz4N3tbbdy40XJbpVJh9erVcHd379RzMKA6YMqUKTh48CDCw8MhhEBKSoqtS6L7SHZ2NmpqavDBBx/ggw8+AHBr0Ym13rDuaZ5++mnExcUhMjISDQ0NWLlyJcdOYvht5kREJEmczxIRkSQxoIiISJIYUEREJEkMKCIikiQGFBERSRIDiqiDMjMzsXnzZpSWlmLdunUAgL1796KystLGld2yd+9ePP300/jb3/7W5mO2bduGd99914pVEbWOAUXUSXx8fLBkyRIAwN/+9jfo9XobV3TL/v37sWLFCkRHR9u6FKJ24Qd16b5XW1uL5cuXo6amBo8++ii++uor9OnTB6tXr8bQoUOxefNmXLlyBa+88gree+89lJSUoLq6GiNGjEBqaqqlnSNHjiA/Px+zZs2yfPXLvHnzUFZWhtjYWJhMJsyePRuFhYWQy+WoqKjA8uXL8fDDD+P8+fP4xS9+gTfffBOZmZlwc3PD/PnzcebMGaxevRrr16/HzJkz8fjjj+Pbb7+Fl5cX+vXrhy+//BKOjo7485//jF69ejXp2759+3DgwAGUlJTgwQcfxOnTp7F582aYzWZMmjQJS5cubXV8mutzeHg4kpKSMGzYMHz66acoKirC8uXLER8fjx9++AEAkJCQgOHDhyMwMBBeXl4YOnQoHn/8ceTk5MDBwQEPPfQQMjIy+O0N1CI+M+i+t2nTJgwfPhybNm3C7NmzG32/4p30ej1cXV3xl7/8BVu3boVOp2v2Mt7EiRPh4+OD9PR0PPPMM9i3bx9MJhM+++wz/PrXv4ZcLrc8tqysDMnJydiyZQsOHDhw1+8yq62txYwZM7Bp0yZ8+eWX8PPzw8aNG2E0GnH69Olmj3nqqacQEBCA119/HZ6ensjJycGmTZuwfft2GAyGFvvaWp/nzZuH7du3A7j1nWzz5s1DdnY2xo4di/Xr1yMpKQmrV68GcOv7At99912sXLkSf//737FgwQJs3rwZgYGBkpllkjRxBkX3vYqKCss3Mvv5+cHR0bHR/ttftiKXy3Ht2jW8+uqrcHZ2xo0bN2A0Gu/atlKpxBNPPIHPP/8c27Ztw0svvdRov6enp+Ub8d3d3Vv9XayRI0cCAFxdXTF06FDL7bb8ntb58+cxbNgwy9f5vPbaa60e01Kfp02bhrlz52LBggWorKzEyJEjsWbNGhw+fBiffPIJAOD69esAgAcffBAPPvggACAuLg4ffvghNmzYAC8vL0yePLnVGuj+xRkU3feGDx+Oo0ePAgC+/fZbGAwGODo6WmYzJ0+eBAAcOHAAly5dwp/+9Ce8+uqrqKurQ0vfFCaTySz7QkNDsWXLFly9ehUjRoxo8rifksvllnOfOHGi1ce3laenJ7777jsYDAYAwNKlS1tdyNFSn52dnfHrX/8aycnJCA4OBgB4eXnh+eefx/r167FmzRrL9jsv4Wk0GrzyyivYsGEDgFsLOIhawhkU3ffmzZuH+Ph4REZGon///gCA6OhovPnmm+jfv7/lx9hGjx6NDz74AJGRkZDJZBg0aBAuX77cbJu//OUv8cc//hF5eXkYM2YMysvLERkZCQD4y1/+Ak9PTwwfPrzZY6dNm4aYmBh88cUXlhlTZ+jbty8WLlyIqKgoyGQyBAYGwsPD467HtNTnQYMGITQ0FBEREZZLeb///e8RHx+PgoIC6PV6y4KRn7a3aNEiuLi4wNnZGRMnTuy0/lHPwy+LJbpDfX09pk2bhv3793dam2azGfPnz0dubm6jH7js7r7++mts2LABb7/9tq1LoR6KMygiKzp//jyWLFmCuXPnWjWcvv76a7zzzjtNtk+bNg0REREtHrd69WqcOXOmyfbWfrZjw4YNKCwsxJo1a+6pXqK24AyKiIgkiYskiIhIkhhQREQkSQwoIiKSJAYUERFJEgOKiIgk6f8D/SAyElMsrasAAAAASUVORK5CYII=", "text/plain": [ "
" ] }, "metadata": {}, "output_type": "display_data" }, { "data": { "image/png": "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", "text/plain": [ "
" ] }, "metadata": {}, "output_type": "display_data" }, { "data": { "image/png": "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", "text/plain": [ "
" ] }, "metadata": {}, "output_type": "display_data" }, { "data": { "image/png": "iVBORw0KGgoAAAANSUhEUgAABVIAAAVfCAYAAABY3ROgAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjUuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/YYfK9AAAACXBIWXMAAAsTAAALEwEAmpwYAAEAAElEQVR4nOzdd3hUdfr+8XtCQghJ6IqFIiCEoiCIbWmCIAqCUgNIENsqLhZAQaQjCCgoGlAUcQX8AgFhEWwrTYpiASWIFClRiSAttCRASGZ+f3jt/IwkmYnM5JzPmffruuZa5szknGdkuXl4zueccXk8Ho8AAAAAAAAAAPkKs7oAAAAAAAAAALA7BqkAAAAAAAAA4AODVAAAAAAAAADwgUEqAAAAAAAAAPjAIBUAAAAAAAAAfGCQCgAAAAAAAAA+MEgFAACwSFxcnNLS0nJtW7JkiR555BGLKvJt2rRpWrlyZZ6vxcXFqUOHDrr77rt1zz33qG3bturSpYt++OGHIqlt//79evzxx4Oy70OHDqlHjx4XtY/ExESNHTvW5/seeOCBC/5/AQAAAOuFW10AAAAAzPH111/r6quvzvf12bNnq1y5ct7ns2bN0rhx45SUlBT02g4cOKCUlJSg7LtixYpasGBBUPb9V1988UWRHAcAAACFwyAVAADAhs6cOaPmzZtr4cKFqlatmiTp/vvv17333quVK1fK5XJp7969SktLU5MmTTR8+HBFRERo7969Gj9+vE6cOKGcnBwlJCSoa9eu+vrrrzV+/HiVLFlSmZmZev/99/Wf//xHc+fOVVhYmCpUqKARI0aoWrVqevbZZ/Pc/8KFC7Vt2za9+OKLKlasmNq0aVPgZ8jOztbBgwdVunRp77Y33nhDn332mdxut6688kqNGjVKFStWVEJCgmrUqKFt27bp+PHjuvvuu/XEE09IklauXKlp06YpJydHMTExGjp0qOrXr6/ExERt2bJFhw8fVs2aNfXDDz/o0KFDevDBBzVr1iwNGzZM11xzjXr27JmrriVLluijjz6S2+3WoUOHVLFiRU2cOFEVK1bUli1b9NJLLykrK0tHjhzRP/7xD73wwgtKTU1Vhw4d9P333+c6blxcnCZPnuzdd2pqqhISEnTjjTdq586d8ng8GjlypBo3bpyrht27d2vs2LE6ceKEXC6XHnjgAd1zzz0aOnSoJOm+++7TW2+9pcsvv/zv/58IAAAAAcUgFQAAwEL33XefwsL+/92WTp48qbi4OEVFRemee+7RokWLNHjwYP36669KSUlRy5YttXLlSu3cuVPvvfeeIiIi9MADDygpKUk9evTQE088oRdffFH16tXT6dOnFR8f711Bunv3bq1cuVJXXnmlNm7cqLfffltJSUkqV66clixZon/961/66KOPJCnP/ffu3Vuffvqp7r333nyHqPfdd59cLpfS0tIUGRmpli1basKECZKkpUuX6qefftKiRYsUHh6upKQkDR8+XDNnzpT0x4rS+fPn68yZM+revbuuvfZaValSRaNGjdKCBQtUuXJlbdy4UY899pg+/fRTSdJvv/2mDz/8UOHh4fr666/1/PPPa9asWZKk8ePH5/vf/bvvvtOSJUtUrVo1TZ48WePHj9drr72mOXPm6IknntBNN92kjIwM3Xbbbdq2bZvKlCmT6+f/fNy/OnDggJo2bapJkyZp7dq1euqpp7RmzRrv69nZ2erXr58GDx6s22+/XYcOHVK3bt1UtWpVTZgwQUuWLLlgZS8AAACsxyAVAADAQn8dmC1ZskT//e9/JUm9evVS7969NWDAACUlJalr164qVqyYJKlTp06Kjo6WJN19991atWqVbr75Zv3666967rnnvPs7e/astm/frho1aujyyy/XlVdeKUlav3692rVr5z12586dNX78eKWmpua7/969e/v9ebZv366HH35YDRs2VPny5SVJa9as0Q8//KAuXbpIktxut86cOeP92fj4eEVERCgiIkJ33HGHNmzYoOrVq+vmm29W5cqVJUm33HKLypUrp23btkmSrrvuujyHmb40adLEu9K3e/fuuvvuuyVJEydO1Lp16zRjxgzt27dPZ8+eVWZm5gWD1IKOW7p0aXXo0EGS1KJFCxUrVky7du3yvv7zzz/r3Llzuv322yX9cduA22+/XevXr1fDhg0L/VkAAABQNBikAgAA2FS1atUUFxenVatWafny5Vq0aJH3tf8NVCXJ4/EoLCxMOTk5KlWqlD744APva0ePHlVsbKy2bNmikiVL5vqZv/J4PMrOzs53/4VRt25dDR06VMOHD1eDBg1UqVIlud1uPfTQQ+rVq5ckKSsrSydPnvT+zJ8Hk/87pq86//yZCuPPn8/tdnuf33vvvapdu7aaNWumO++8U8nJyXnWUNBx/7zvv+7/f8//6s+fCQAAAPZUuI4YAAAARapXr1568cUX1aBBA1WsWNG7/ZNPPlFWVpbOnTun//znP2rZsqWqVaumyMhI7yD14MGDuuuuu7yrN/+sadOm+vjjj73fDr948WKVKVNGVatWzXf/0h9DQn8HfnfddZeuu+46vfDCC95jvv/++0pPT5ckvfrqqxo8eLD3/cuWLZPb7dbJkyf1ySefqFWrVrr55pv1xRdfaP/+/ZKkjRs36uDBg2rQoMEFxytWrJjOnz/vV21fffWVDh06JElasGCBWrZsqZMnT2rbtm16+umnvZfc//rrr3kOPguSlpamdevWSZJWr16tiIgI1apVy/t6tWrVFBERoc8++0ySdOjQIf33v//VP/7xD+/nYKgKAABgP6xIBQAAsLGWLVtq+PDh6tGjR67tJUqUUK9evXTq1Cm1bdtWXbp0UVhYmF5//XWNHz9eb7/9trKzs/Xkk0/q+uuv19dff53r55s0aaK+ffvqvvvuk9vtVrly5fTmm296V57mtf//1TNp0iSdP39enTp18ln/iBEj1LFjR61fv17dunXToUOH1L17d7lcLl1++eWaOHGi971nz55V165dlZGRoV69eumWW26RJI0aNUr9+/dXTk6OSpQooRkzZig2NvaCY9WsWVPFihVT165dtWjRIg0fPjzPL5uS/ric/plnntGRI0d09dVXa+zYsSpdurT++c9/qlOnTipTpozKli2rRo0a6ZdffvHeWsAf/xtmT548WSVKlND06dNzrUiNiIjQ66+/rnHjxikxMVE5OTn617/+pZtvvlmS1KZNG/Xq1Uuvv/56rgEsAAAArOXy5HWtEgAAAGzhu+++04gRI/Thhx/K5XJJkp599lnVrFlTDz74YFCOGez95yUhIUH33nuv7rjjjqAf63/3oX3zzTcDvu/U1FR16NBB33//fcD3DQAAAGuxIhUAAMCmhgwZom+++UaTJk3yDlEBAAAAWIMVqQAAAAAAAADgA182BQAAAAAAAAA+MEgFAAAAAAAAAB8YpAIAAAAAAACADwxSAQAAAAAAAMAHBqkAAAAAAAAA4AODVAAAAAAAAADwgUEqAAAAAAAAAPjAIBUAAAAAAAAAfGCQCgAAAAAAAAA+MEgFAAAAAAAAAB8YpAIAAAAAAACADwxSAQAAAAAAAMAHBqkAAAAA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UV/z5s29zxs1aqQNGzbI7XbrwIEDcrvdrEYFHObVV1/VqFGjNHjwYD3++OM6duyYypYtq2effdbq0vI1efJk3X///fr555+tLqVQZsyYofnz5ysmJkbbt2/XwYMH9corr+j//u//rC7NaKG7NAUAAAdr06aNvvjiC/Xo0UMej0cvvPCC1SUBgFFGjx6t559/XhEREapQoYJtV6SmpKSoUqVKkiSXy6Vx48apf//+KlGihGrUqKHu3btbXGH+UlJSVLlyZe/za665Ro0bN1Z8fLzcbrdGjhxpYXUAgmHjxo1asGCBsrKydNdddykxMVGStGrVKosry1/t2rX11FNPadCgQapVq5a6d++uhg0bWl2WT+vXr/cOTbt3765HH31UM2fOVI8ePXTvvfdaXJ25XJ787joLAAAAAAAABEiXLl300ksv6fjx43r00Uf18ccfKyoqSg888IAWLlxodXl56tOnj+bMmSNJWr16tZYtW6Zt27YpNjZW//nPfyyuLn+dOnXStGnTdOWVVyolJUWjRo3SO++8o65du2rp0qVWl2csBqkAAAAAAAAIui+//FIvvfSS6tatq5o1a+qtt95SdHS0hgwZotatW1tdXp4SEhI0d+7cC7anpaXZ+vYjGzZs0IgRI1SqVCmdPXtWL774otavX6+KFSuqW7duVpdnLAapAAAAAAAAKHKnT59WZGSkihcvbnUp+Tp69KgqVKhgdRl/i8fj0fHjx2098DUNXzYFAAAAAACAoFu7dq3mzJmj/fv3q3fv3rrzzjvVu3dv7dixw+rS8pWenq7HH39cTz/9dK4vnBo1apR1Rfnh8OHDmjBhgubNm6edO3eqTZs2uuOOO7RlyxarSzMag1QAAAAAAAAEXWJiotq2batx48bpySef1IYNGzR27FiNHj3a6tLyNWLECMXHx+uuu+7Sv/71L23fvl2StG/fPosrK9izzz6rOnXqyOVy6YEHHtCbb76pd999V5MnT7a6NKOFW10AAAAAAAAAnK948eKqWLGiJOmGG26QJNWuXdvKkvzStGlTSVKVKlX0+OOP6+2335bL5bK4qoJlZWWpU6dOkqRvvvlG1atXlyTb1213rEgFACBEJCYmav78+dqxY4emTZsmSVqxYoUOHTpkcWV/WLFihW6//Xbvt6L6Y8mSJZxVBwAAMES9evU0duxYNWzYUM8995xW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H3/0+X5/ValSRfv27fN+vieeeELDhg3zfsNrXvL7zCVLltRNN92k8ePHq2PHjpKk6tWrq2PHjt7m8n/3EPzzpftJSUl6/PHHVb58eY0cOVIrVqzwfuspAPxZtWrVJPl3EqtWrVoKDw9XeHi492TRTz/9pDfffFNvv/22PB6PwsP/+CdapUqV8lwx9L9vua5Zs6aOHDmisLAwRUREeI/7+++/Kzs7O9fPpKSkqGvXrpL+OBHXqFEjJSYmek96VahQQWfPns33M3bt2lUzZ87UQw89pNjYWA0YMCDX69nZ2RowYIA6duyoFi1aaOvWrUpLS/NeQpqRkaFff/2VQSqAXD3pkCFDNGjQIL399tu2/ib5jIwMnThxQpdccok3o8PDw30uVLDa/Pnz9eCDD+rdd99V6dKlrS7HMbjZFwAAhujWrZuOHTume++9V2+//bYkqU+fPhozZowefPBB5eTkSJLq16+v/fv3695779UTTzyhypUr6/Dhw3nus2HDhho8eLBOnDihBg0a6JdfflGHDh0kSf/+978LPFt95513au3atUpISND27dsD9jnLlSunhx9+WL1791Z8fLzq1q1b4BBVKvgzd+/eXatWrfJ+rkcffVSffPKJEhIS9NBDD6lmzZp57u+RRx7RfffdpyNHjujWW28N2OcDYL6wsDC53W7vr6X/f0Ln5Zdf1sCBA3X27NkLTmLlNSioXr26nn76ac2dO1djxozRHXfckWu/f/W/1aS7du3SFVdcoZ07d2rlypWaOnWqRowYIbfbLY/Hk6vGGjVq6IcffpAkffvtt3rppZfyrScvq1at0vXXX6/Zs2frjjvu8P4dJP0xFBk2bJgaNmyoe+65R9IfQ+DLL79c77zzjubOnavevXvruuuu8+tYAJytXbt26tq1q06cOCFJmjBhgjZu3Kjk5GRrCytAo0aN9Nhjj2nz5s3697//rYyMDN19991q166d1aUVqFy5cho0aFBA+3RILo8J66cBAEAu586d05133qnVq1cHbJ9ut1s9e/bUrFmzvJfxO8HWrVv13nvv6cUXX7S6FAAOce7cOXXv3l1NmzZVpUqV1LNnTx05ckSPPvqoSpQoIZfLpbNnz2ro0KH68ssvvfdIXbBggV555RVJUpMmTfTFF19o//79Gj16tM6dO6ezZ89q2LBhuuSSSzRw4EAtXLhQkvTA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668rJiZGY8aM0bZt21ShQgX99ttveuONN1SpUqU8//u0bNlS1atXV40aNdS1a1dNnDhROTk5On78uEaPHq1Tp05px44dGjJkiObNm6ekpCR9+OGHcrlcateunfr06RPs38KQwSAVAAAAAAAAlnn00Uf1008/qVmzZjp58qSGDx+u9PR0lSpVSv/+97/ldrvVvn37Cy5dP3DggJYtW6asrCw1a9ZM/fr106RJk5SQkKAWLVpo48aNmjx5sgYMGKAjR45o8eLFKl68+AXHX7t2rVJTUzV//nydO3dO3bt3V5MmTbR792699NJLqlixombMmKFPP/1UHTp0yLWvdevWqX79+ho2bJheeeUVffTRR2rXrp133xkZGWrfvr1GjBihQYMGad26dYqMjNSJEyf0/vvvKy0tTbfffnuB/30OHjyoJUuWqGzZsvr44481ZMgQxcXFafny5VqyZInGjRunOnXqaPTo0fr111/18ccfa968eZKk+++/X02bNlX16tUD8DsFBqkAAAAAAACwhWrVqkmSIiMjlZaWpoEDB6pkyZLKzMzU+fPnc723Vq1aCg8PV3h4uEqUKCFJ+umnn/Tmm2/q7bfflsfjUXj4H6OvSpUq5TlE/d/P/Pjjj0pISJAkZWdn67ffflPFihU1fvx4lSxZUocOHVKjRo3y3FfdunUlSZdddpmOHj16wf7/9/rll1+uc+fO6bffftN1110nSSpXrpzPIWfZsmVVtmxZSdKll16q119/XSVKlFBGRoZiYmIu+CwHDhxQ3759JUknT57UL7/8wiA1QBikAgAAAAAAwDJhYWFyu93eX0vSunXrdPDgQU2dOlVpaWlasWKFPB5Prp9zuVwX7Kt69ep64IEH1KhRI+3du1fffvttrv3mpXr16rrpppv0/PPPy+126/XXX1flypX1wAMPaMWKFYqJidGQIUO8xy9oX3n5a501a9bUBx98IOmPQefPP/9c4M//+Xjjx4/X5MmTVaNGDb322mv67bffvMfweDyqXr26rr76ar399ttyuVx69913FRcXV6h6kT8GqQAAAAAAALBM+fLldf78eZ09e9a7rX79+nr99dd17733yuVyqXLlyjp8+LDPfQ0ZMkSjR4/WuXPndPbsWQ0bNuyC9zzwwAOaMWOG93mrVq30zTffqFevXsrMzFTr1q0VExOjjh076t5771VU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UgF4hXfeeUdnzpxRTEyM+vbtqwceeEC+vr6KjIzU3XffbXQ8AAAAwG30tgBQNaq63tqcTmfl3DYLAAAAAAAAACyKawoAAAAAAAAAwAUGqQAAAAAAAADgAoNUAAAAAAAAAHCBQSoAAAAAAAAAuMAgFQAAAAAAAABc8DU6AMztxIkT6tevn5YsWaL8/Hw98sgjuuqqqyRJsbGx6tOnj7EBy/Dqq6/q448/VmFhoWJjY3X99dfrmWeekc1mU4sWLTRx4kT5+HjHOYasrCzNmDFDy5cv18GDB8vMOXfuXH366afy9fXVuHHj1LZtW6NjAwAAmJLZetvVq1frX//6lyQpPz9fu3bt0qxZszRt2jQ1atRIkjRq1Chdf/31RsYs8cfe9sknn9Tx48clST/++KPatWun2bNn09sCALyazel0Oo0OAXMqLCzU6NGjtXfvXs2fP1/ffPONcnNz9dBDDxkdrVxffvmlXn/9dc2fP19nz57VkiVLtGPHDj344IO64YYblJSUpK5du6pXr15GR9XChQu1du1aBQYG6q233tLw4cPPy3nFFVdo2rRpWrp0qX766SeNGjVKq1atMjo6AACA6Zixt/2jSZMmKSoqSkeOHFHr1q3Vu3dvoyOV8ufe9ne//vqrhg4dqoULF+rYsWP0toDFZWZmunxNly5dqiCJ+woKCly+xt/fvwqSVMyBAwdcviY8PLwKklgLK1Jx0aZNm6ZBgwbptddekyRt375dBw4c0Pr169WsWTONGzdOISEhBqcsLTMzUy1bttRjjz0mu92usWPH6q233io5S9+tWzd99tlnXjFIbdq0qebMmaOxY8dKknbs2HFezvDwcHXp0kU2m01XXHGFiouLdfLkSdWtW9fI6AAAAKZjxt72d99++6327t2riRMn6q9//at27dqlpUuXqm3btnrqqafk62v8r31/7m1/N2fOHA0ZMkSXXXaZPvjgA3pbwOKeeeYZde3atdznN27c6NawtSp16tRJDRo0kNPplM1mk6SSf3Y6nTp58qS2bt1qbMgyDBw4UK1atVJ56yf37Nmjr776qopTmZ/xf6PClFavXq26deuqa9euJc1m27ZtNWDAAF1zzTV65ZVXNG/ePCUkJBictLScnBwdOXJECxYsUHZ2tkaMGFGqGAYHBys3N9fglL/p3bu3srOzS74uK6fdblft2rVLXvP7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5ql+/fqqX7++li1bJkn6+eef1bBhQy1fvlwzZ87U119/LYfDoWHDhumOO+6okj9bAAAAVK5jx44pPT1dAwYMcPnafv36VUGi81Uk48Vo1aqVWrVq5ZH3BmBdPj4+atOmjaKjo9WsWbOS41lZWWrXrp2BycoXFhamqVOnSpK+++477d69W23atNG///1vg5NdWGBgoP7v//6v1LG///3vBqWxDgap8AorVqxQZGSknnzySX3zzTfKzMxU7dq1z3ud3W5XWFiYXn/9dTkcDt15551lXr5/yy23lJzVuvzyy9WvXz899dRT2rhxo2644YaSpewjR47U6tWrtWTJEmVkZCgnJ0dvv/22fv31V73++uvlDlLfffddPfzww7r99tu1Zs2aUitfe/XqpV69eunw4cMaPXq0pk6dqg0bNig7O1upqanKz8/XwIED1blzZ4WFhVXOHyAAAACqzIIFC7R3715FRUXp5ptv1pkzZzRlyhStWbPmvBP+c+bMUf369RUREaGFCxfKz89P2dnZ6tOnj0aMGKGffvpJEyZMUH5+vgICAvTcc8+puLhYI0aMUO3atdWtWzf97W9/K/nsdevW6f3339e5c+f07LPPqm3btnrzzTf14Ycf6uzZs6pTp47mzp1bknHu3LkaPHiwEhISlJubK6fTWbIaaf369frggw906tQpPfHEE+rRo0eZP++BAweUmJgoX19fORwOzZw5U4cOHdLKlSs1ZswYjRs3TtJviyP279+vTZs26dNPP9Ubb7whHx8fdezYUU899ZTn/8UA8Hrz5s1TZmamiouL1bp1a02cOFE2m00zZ84sWZDkbR599FEtW7ZMq1at0ooVK3TjjTdqxYoV6tevnwYOHGh0vHIVFBSU+5y/v38VJrEWBqnwCtnZ2erataskKTo6+rz/UzudTklSQECATp48qTFjxigoKEhnzpxRYWHhBd87JCRE1113nTIzM7V69Wo9+uijZb7uwIEDat++vSSpVq1aGj16dLnvmZiYqFdffVVvvvmmIiIizhu4Hjt2TE888YRSUlJ05ZVX6r333tOOHTsUFxcnSSoqKtKPP/7IIBUAAMCEhg8fru+++05du3bVr7/+qmeffdatE/5HjhzR2rVrVVBQoK5du2rEiBGaNm2a4uLi1L17d23atEkzZszQk08+qWPHjmnVqlXn9cVXXnmlJk+e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gfbu3auoqCjdfPPNOnPmjKZMmaI1a9Zo+/btOnXqlKKiopSSkqI5c+aofv36ioiI0MKFC+Xn56fs7Gz16dNHI0aM0E8//aQJEyYoPz9fAQEBeu6551RcXKwRI0aodu3a6tatm/72t7+dl2HmzJn6+uuv5XA4NGzYMN1xxx366quvNHfuXDmdTuXl5WnmzJny8/Mr9V4ZGRmKiorS999/L7vdrpdeeklOp1NjxozRW2+9pb59++r666/Xnj17ZLPZNH/+fIWEhGjSpEnavn276tevrx9//FGvvPKKGjduXOafz6233qqIiAg1b95c/fv319SpU1VcXKycnBwlJyfr9OnT2rVrlxISErRixQqlpaXp3Xfflc1mU58+fTR06FBP/yusNhikAgAAAAAAwDDDhw/Xd999p65du+rXX3/Vs88+K7vdrrCwML3++utyOBy68847z7t0/ciRI1q7dq0KCgrUtWtXjRgxQtOmTVNcXJy6d++uTZs2acaMGXryySd17NgxrVq1Sv7+/ud9/oYNG5Sdna3U1FTl5+dr4MCB6ty5s77//ntNnz5dl19+uRYsWKAPPvhAffv2LfVeGRkZatu2rcaPH6/Zs2frP//5j/r06VPy3nl5ebrzzjs1YcIExcfHKyMjQwEBATp16pTefvttnTx5Un/5y18u+Ofz008/afXq1apTp47ee+89JSQkKDIyUu+8845Wr16t559/Xq1atVJycrIOHTqk9957TytWrJAkPfjgg+rSpYsiIiIq4d8UGKQCAAAAAADAK4SHh0uSAgICdPLkSY0ZM0ZBQUE6c+aMCgsLS722ZcuW8vX1la+vr2rWrClJ+u677/Tqq69q0aJFcjqd8vX9bfTVuHHjMoeov3/Pjh07FBcXJ0kqKirSjz/+qMsvv1xTpkxRUFCQfvnlF0VHR5f5Xq1bt5YkNWzYUMePHz/v/X9/vlGjRsrPz9ePP/6o9u3bS5Lq1q3rcshZp04d1alTR5J02WWXaf78+apZs6by8vIUEhJy3s9y5MgRDRs2TJL066+/6uDBgwxSKwmDVAAAAAAAABjGx8dHDoej5J8lKSMjQz/99JNefPFFnTx5Uh999JGcTmep77PZbOe9V0REhB566CFFR0dr37592rx5c6n3LUtERIRuuOEGPffcc3I4HJo/f76aNGmihx56SB999JFCQkKUkJBQ8vkXeq+y/DlnixYt9O9//1vSb4POH3744YLf/8fPmzJlimbMmKHmzZvr5Zdf1o8//ljyGU6nUxEREbr66qu1aNEi2Ww2vfHGG4qMjKxQXpSPQSoAAAAAAAAMU69ePRUWFurcuXMlx9q2bav58+frgQcekM1mU5MmTXT06FGX75WQkKDk5GTl5+fr3LlzGj9+/Hmveeihh7RgwYKSr3v06KGvvvpKgwcP1pkzZ9SzZ0+FhITo7rvv1gMPPKDAwEDVr1/frc93xy233KKMjAwNGjRI9evXV82aNeXn5+fW995999164oknFBYWpoYNGyonJ0eS1KFDB40dO1ZLlizRTTfdpNjYWBUUFKht27a6/PLLKyU3JJvzz+N8AAAAAAAAAB6xb98+7d69W3feeadycnJ011136ZNPPil36wF4DwapAAAAAAAAQBU5c+aM4uPjdeLECRUXF2vIkCEKCwvTG2+8cd5rhw4dql69elV9SJSJQSoAAAAAAAAAuFCx3XEBAAAAAAAAoBpikAoAAAAAAAAALjBIBQAAAAAAAAAXGKQCAAAAAAAAgAsMUgEAAAAAAADABQapAAAAAAAAAOACg1QAAAAAAAAAcIFBKgAAAAAAAAC4wCAVAAAAAAAAAFxgkAoAAAAAAAAALjBIBQAAAAAAAAAXGKQCAAAAAAAAgAsMUgEAAAAAAADABQapAAAAAAAAAOACg1QAAAAAAAAAcIFBKgAAAAAAAAC4wCAVAAAAAAAAAFxgkAoAAAAAAAAALjBIBQAAAAAAAAAXGKQCAAAAAAAAgAsMUgEAAAAAAADABQapAAAAAAAAAOACg1QAAAAAAAAAcIFBKgAAAAAAAAC4wCAVAAAAAAAAAFxgkAoAAAAAAAAALjBIBQAAAAAAAAAXGKQCAAAAAAAAgAsMUgEAAAAAAADABQapAAAAAAAAAOACg1QAAAAAAAAAcIFBKgAAAAAAAAC4wCAVAAAAAAAAAFxgkAoAAAAAAAAALjBIBQAAAAAAAAAXGKQCAAAAAAAAgAsMUgEAAAAAAADABQapAAAAAAAAAOACg1QAAAAAAAAAcIFBKgAAAAAAAAC4wCAVAAAAAAAAAFxgkAoAAAAAAAAALjBIBQAAAAAAAAAXGKQCAAAAAAAAgAsMUgEAAAAAAADABQapAAAAAAAAAOACg1QAAAAAAAAAcIFBKgAAAAAAAAC44Gt0AADm4/i5pcvX+DT8rgqSAIB1uVNrJeotAAAAvJ9VelsGqYCH/cV/sNERKuTDghUuX+OQw+VrWO4OoCxmq4me5KreulNrJeotAACoOmbr5dz5/RZVwyq9LYNUABVW6Cx2+RqKCwBcGndqrUS9BQAAgPezSm/r7fkAeCF3zyQBAC4etRYAAABWYZXelkEqgAordjqNjgAAlketBQAAgFVYpbdlkAqgwgotciYJALwZtRYAAABWYZXelkEqgApzyBpnkgDAm1FrAQAAYBVW6W0ZpAKoMKssyQcAb0atBQAAgFVYpbdlkAqgwgotciYJALwZtRYAAABWYZXelkEqgAortkb9AwCvRq0FAACAVVilt2WQCqDCCmUzOgIAWB61FgAAAFZhld6WQSqACnNY5EwSAHgzai0AAACswiq9LYNUABVWbJEzSQDgzai1AAAAsAqr9LY+RgcAYD6FTh+XD3dkZWUpLi7uvONr1qxR3759NXjwYKWnp1d2fAAwBXdqLfUWAAAAZmCV3pYVqQAqrDLOJC1cuFBr165VYGBgqeMnT57Uyy+/rNWrVyssLEzDhg3TTTfdpMaNG1/yZwKAmVTWWXvqLQAAAIxmld6WFakAKqxYPi4frjRt2lRz5sw573h2drYiIyNVu3Zt+fj46Nprr1VWVpYnfgwA8Gru1FrqLQAAAMzAKr0tK1IBVJg7y+3T0tKUlpZW8nVMTIxiYmJKvu7du7eys7PP+75mzZpp7969On78uIKDg7Vp0yZdddVVlZIbAMzE3UubqLcAAADwdlbpbRmkAqiwYjcK4J+Lnbtq1aqlxMREjRo1SrVr11abNm1Up06di4kJAKbmTq2VqLcAAADwflbpbbm0H0CFOeTj8nGxioqKtHPnTq1YsUIvvfSS9u/fr+jo6EpMDwDm4E6tpd4CAADADKzS27IiFUCFFThrVPp7vvPOOzpz5kzJmaf77rtPAQEBevDBB1W3bt1K/zwA8HaeqLUS9RYAAABVzyq9rc3pdDor/V0BlPiL/2CjI1TIhwUrXL7mvwdau3xN7/CdlREHgMWYrSZ6kqt6606tlai3AACg6pitl3Pn91tUDav0tqxIBVBhBU5KBwB4GrUWAAAAVmGV3tYaPwWAKnUp+5YAANxDrQUAAIBVWKW3ZZAKoMKKnTajIwCA5VFrAQAAYBVW6W0ZpAKosEKLLMkHAG9GrQUAAIBVWKW3tcZPAaBKFVtkST4AeDNqLQAAAKzCKr0tg1QAFWaVJfkA4M2otQAAALAKq/S2DFIBVJhVluQDgDej1gIAAMAqrNLbWuOnAFClHLLGmSQA8GbUWgAAAFiFVXpba2xQUI28//77kqQzZ85o2rRpevDBBzVjxgzl5eUZnAzVSYHT1+UDMDvqLYzmTq2l3gIAAHfQ28JoVultGaSaTGpqqiRpypQpqlWrlp599lk1bNhQSUlJBidDdeJw2lw+ALOj3sJo7tRa6i0AAHAHvS2MZpXe1vtHvSjTwYMHNWXKFElS8+bN9eGHHxqcCNWJVe62B7iDegujUGsBAEBlo7eFUazS21rjp6hGfvjhB73xxhvy9fXVzp07JUnbtm1TYWGhwclQnRQ6a7h8AGZHvYXR3Km11FsAAOAOelsYzSq9LYNUk3n11VcVHBysq666Snv27NGJEyf0/PPPsxwfVcrh9HH5AMyOegujuVNrqbcAAMAd9LYwmlV6Wy7tN5maNWuqU6dO6tSpk5xOp0aMGKFp06YZHQvVTLFF7rYHXAj1Fkaj1gIAgMpCbwujWaW3ZZBqMg8++KBq1qypyy67TE6nUwcOHNDEiRMlScuWLTM4HaqLQgelA9ZHvYXRqLUAAKCy0NvCaFbpba3xU1Qjq1at0sSJExUbG6vOnTsrLi6Ooocq57DImSTgQqi3MBq1FgAAVBZ6WxjNKr0tg1STqVevnl588UVNmzZN3377rdFxUE0VOrx/A2jgUlFvYTRqLQAAqCz0tjCaVXpb79/FFefx9fXV+PHjS5bkA1WtWD4uH4AVUG9hJHdqLfUWAAC4i94WRrJKb8uKVBPr16+f+vXrZ3QMVEMOpzWW5APuot7CCNRaAADgCfS2MIJVelsGqQAqrNBpjSX5AODNqLUAAACwCqv0tt6/ZhaA13E4bS4f7sjKylJcXNx5x9euXav77rtP999/v1asWFHZ8QHAFNyptdRbAAAAmIFVeltWpAKoMIfz0s/BLFy4UGvXrlVgYOB5z73wwgt69913FRQUpDvvvFN33nmnatWqdcmfCQBmUhm1VqLeAgAAwHhW6W1ZkQqgwgqdPi4frjRt2lRz5swp87nIyEjl5uaqoKBATqdTNps19lIBgIpwp9ZSbwEAAGAGVultWZEKoMLcOZOUlpamtLS0kq9jYmIUExNT8nXv3r2VnZ1d5ve2aNFC999/vwIDA9WrVy+FhYVdemgAMBl3z9pTbwEAAODtrNLbMkgFUGEOuT6r8+di567du3fr008/1fr16xUUFKSnn35a77//vu64446LiQoApuVOrZWotwAAAPB+VultGaQCqLBCh+futhcaGqqaNWsqICBANWrUUN26dXX69GmPfR4AeCtP1lqJegsAAICqY5XelkEqgApz9056FfHOO+/ozJkzJWefBg8eLD8/PzVt2lT33XdfpX8eAHg7T9RaiXoLAACAqmeV3tbmdDqdlf6uAEr8xX+w0REq5MOCFS5fE/vF312+JvXG1yojDgCLMVtN9CRX9dadWitRbwEAQNUxWy/nzu+3qBpW6W1ZkQqgwtzdJBoAcPGotQAAALAKq/S2DFIBVJinluQDAP4fai0AAACswiq9LYNUABVWZJEzSQDgzai1AAAAsAqr9LYMUgFUmFXOJAGAN6PWAgAAwCqs0tsySAVQYVYpgADgzai1AAAAsAqr9LYMUgFUmFWW5AOAN6PWAgAAwCqs0tsySAVQYVY5kwQA3oxaCwAAAKuwSm/LIBVAhRU5rHEmCQC8GbUWAAAAVmGV3pZBqhfZtGmTDh06pHbt2ik8PFwBAQFGRwLKZJUzSai+qLcwA2otAABwB70tzMAqvS2DVC8xa9Ys/fzzz9q3b5/8/f312muvadasWUbHAsrktEgBRPVEvYVZUGsBAIAr9LYwC6v0ttZYV2sBW7Zs0QsvvKCgoCDdd999ys7ONjoSUK4ip4/LB+CtqLcwC3dqLfUWAIDqjd4WZmGV3pYVqV6iuLhY+fn5stlsKi4ulo+P9//Hg+rLKmeSUD1Rb2EW1FoAAOAKvS3Mwiq9LYNUL/F///d/6tevn06ePKkBAwbowQcfNDoSUC6r7G2C6ol6C7Og1gIAAFfobWEWVultGaR6iR49eujmm2/WwYMH1bhxY+Xk5BgdCShXsUXutofqiXoLs6DWAgAAV+htYRZW6W0ZpHqJG2+8US+//LK6du0qSRo9erSWLVtmcCpUhg8LVhgdodI5nUYnAC4e9dZYVqyJnkKtBQAArlR1b0svh4tlld6WQaqXiIiI0BtvvKGcnBzdfffdcnr4vzDHzy09+v6Vzafhd0ZHwB8Um2ADaKA81FvjUMsrhloLAABcqere1ozM1o9btWe2Sm/LINVLBAcH65VXXtGYMWN0/Phx+fn5GR0JKJdV9jZB9US9hVlQawEAgCv0tjALq/S21hgHW4DT6ZS/v79eeukl7dmzR1u3bjU6ElAup9P1A/BW1FuYhTu1lnoLAED1Rm8Ls7BKb8uKVC+RkpIiSapRo4amTZumW2+91eBEQPkcFtkkGtUT9RZmQa0FAACu0NvCLKzS2zJINdj8+fP16KOPatasWbLZSi9zvv322w1KBVyYVZbko3qh3sJsqLUAAKA89LYwG6v0tgxSDdajRw9J0qBBgwxOArivspbbZ2VlacaMGVq+fHnJsWPHjmnMmDElX+/atUvx8fGKjY2tnA9FtUW9hdlU5qVN1FsAAKyF3hZmY5XelkGqwaKioiRJzZo1U25urnx8fLRo0SLFxcUZnAwoX2UsyV+4cKHWrl2rwMDAUscbNGhQUgz/97//afbs2Ro4cOAlfx5AvYXZVNblT9RbAACsh94WZmOV3tYaGxRYQHx8vI4fP64XX3xRnTt31j/+8Q+jIwHlcrrxcKVp06aaM2dO+Z/hdOq5555TcnKyatSocemhgf8f9RZm4U6tpd4CAFC90dvCLKzS2zJI9RI2m03XXXedTp8+rTvvvFM+PvyrgfdyOm0uH2lpaerXr1/JIy0trdR79O7dW76+5S+K//jjj9WiRQtFRER4+sdBNUO9hVm4U2uptwAAVG/0tjALq/S2XNrvJYqKijR9+nR16tRJX3zxhQoLC42OBJTL6XC9SXRMTIxiYmIu+jPWrl2roUOHXvT3A+Wh3sIs3Km1EvUWAIDqjN4WZmGV3pZTFV4iJSVFTZo00d///nedPHlS06ZNkyQVFBQYnAw4n9Pp+nGptm/frujo6Et/I+BPqLcwC3dqLfUWAIDqjd4WZmGV3pYVqV7iqquu0lVXXSVJ6tOnT8nxv/71r1q2bJlBqYCyOStpk+g/euedd3TmzBnFxMTo5MmTCgkJkc3m3hkroCKotzALT9RaiXoLAICV0NvCLKzS2zJI9XLOyhjHA5Wssv6zbNy4sd566y1JUt++fUuO161bV//+978r50MAN1Fv4W0q8z9J6i0AANULvS28jVV6WwapXo4VIvBK/J0MC6LewutQawEAwEWit4XXsUhvyyAVQIW5u0k0AODiUWsBAABgFVbpbRmkejmW48MbOZ3WKIDAH1Fv4W2otQAA4GLR28LbWKW3ZZDq5a6++mqjIwDn4+9kWBD1Fl6HWgsAAC4SvS28jkV6WwapXuLTTz/VihUrdO7cuZJjy5Yt08SJEw1MBZTDImeSUD1Rb2Ea1FoAAOACvS1MwyK9LYNUL/HSSy8pMTFR9evXNzoK4JpFziSheqLewjSotQAAwAV6W5iGRXpbBqleolatWrr++uuNjgG4xSqbRKN6ot7CLKi1AADAFXpbmIVVelsGqQZLS0uTJPn5+WnChAlq06aNbLbf/uOKiYkxMhpQPoucSUL1Qr2F6VBrAQBAOehtYToW6W0ZpBrs2LFjkqR27dpJko4fP25kHMA9FtnbBNUL9RamQ60FAADloLeF6Vikt2WQarCRI0dKkg4ePKhvv/1Wd911l2bMmKFBgwYZnAwon81hdAKg4qi3MBtqLQAAKA+9LczGKr2tj9EB8JuEhAQ1btxYktS9e3eNHz/e4ETABThtrh9V6JdfftHevXt14MABjRs3Trt27arSz4e5UG9hGu7U2iqst9RaAAC8D70tTMMivS2DVC/Svn17SdJ1110nh8Mio3pYk9ONRxWKj4/X8ePHNXv2bHXu3Fn/+Mc/qjYATId6C1Nwp9ZWYb2l1gIA4J3obWEKFultGaR6ibCwMKWlpWnPnj1KT09XcHCw0ZGA8jnceFQhm82m6667TqdPn9add94pHx9KG8pHvYVpuFNrq7DeUmsBAPA+9LYwDYv0tnTAXmLq1Knau3evpk+frn379iklJcXoSED5vGg5viQVFRVp+vTp6tSpk7744gsVFhZW6efDXKi3MA0vu/yJWgsAgPeht4VpWKS35WZTXmLlypWl9jKZOXOm4uPjy3zt119/rU6dOsnhcCg1NVW7du1SmzZtNHDgQNWoUaOqIqMa87ZNolNSUvTZZ59pwIABWrdunaZNm2Z0JHgxd+sttRZGo9YCAABX6G1hFlbpbVmRarD09HTFxMRoyZIlGjRokAYNGqSBAwcqMzOz3O95+eWXJUnTp0/Xnj171KtXLx06dEjPP/98VcUGvMpll12m2267TadPn9aBAwe43BRlqmi9pdYCpVFrAQDwHvS2wKW52N6WFakGu+eee3TTTTfp1Vdf1fDhwyVJPj4+qlevnsvv3bZtm/75z39K+u3ufHFxcR7NCvzOVsU3k3Ll8ccfV2xsrP773//q6quvVlJSkhYvXmx0LHiZi6231FoYhVoLAADKQ28Ls7FKb8tSAoP5+/urcePGSkpK0tGjR3XkyBEdPnxYH374Ybnf89NPP+mjjz5SaGiosrOzJUm//PKLzp07V1WxUd05bK4fVejcuXPq0aOHfv75Z/39739XcXFxlX4+zKGi9ZZaC8O5U2ursN5SawEA8B70tjAdi/S2rEj1EqNGjVJhYaGOHj2q4uJiXXbZZbrrrrvKfG1CQoK2b9+u4uJirVu3Tvfff78GDRqkKVOmVHFqVFtediapsLBQS5cuVZs2bbR3716dPXvW6EjwYu7WW2otDEetBQAALtDbwjQs0tuyItVL5OTkaPHixWrbtq1Wr16t/Pz8cl8bHx+vK6+8UgsXLtSwYcMUGhqqTz75RDfffHMVJkZ1ZnO6flSlhIQEHT16VI8++qi++OKLUputA3/mbr2l1sJo7tTaqqy31FoAALwPvS3Mwiq9LYNUL1GzZk1J0tmzZ1WzZk3ZbOUvZ46KitKuXbs0dOhQbd68uaoiAv+Pw42HG7Kyssrck2fbtm0aPHiwYmNj9fjjj1/wxIIkRUdH6/rrr1daWpoaNmyotm3bVuSnQTXjbr2l1sJw7tTaKqy31FoAALwPvS1MwyK9LZf2e4m//OUvmjt3rqKiojRw4EAFBQWV+9qAgAAlJSXp22+/1WuvvabJkyfrxhtvVJMmTTR06NAqTI3qqjLOEi1cuFBr165VYGBgqeNOp1MTJkzQyy+/rGbNmik9PV0//vijIiIiyn2vmTNn6uDBg4qOjtaaNWv09ddf65lnnrn0kLAkd+sttRZGq6wz8pVVb6m1AAB4H3pbmIVVelsGqV7igQceKPnn7t27q1mzZuW+1un87b++a6+9VnPmzFFubq42b96sAwcOeDwnIElyXvoG0E2bNtWcOXM0duzYUscPHDig2rVr64033tD333+v7t27X3CIKkmbN2/WypUrJUn/93//p4EDB15yPliXu/WWWgvDVUKtlSqv3lJrAQDwPvS2MA2L9LYMUr3Et99+q4kTJ+r48eO64oorNHnyZLVs2bLM1/br16/U16GhoerRo0dVxAQkSTY3ltunpaUpLS2t5OuYmBjFxMSUfN27d++Su0X+UU5Ojv73v/8pKSlJTZs21fDhw3XNNdfopptuKvezioqK5HA45OPjI4fDccGtMQB36y21FkZzp9ZKVVdvqbUAAHgfeluYhVV6WwapXmLKlCl64YUXdPXVV2vPnj1KTk7WihUrynztfffdV8XpgD9xY0n+n4udu2rXrq1mzZqpefPmkqSuXbtq+/btFxyk3nnnnYqNjVW7du20bds29enTp8Kfi+rD3XpLrYXh3Lz8qarqLbUWAADvQ28L07BIb8sg1UsEBATo6quvliRFRkbKz8/P4ERA+dw9k3QxmjRpory8PB08eFDNmjXT119/rf79+5f52pkzZ5acNbr88sv1ySefqFWrVjp58qTnAsL0qLcwC0/WWsn9ekutBQDAe9Hbwiys0tsySDXY78uVfX19lZycrOuuu07btm1TSEiIwcmAC6ikTaL/6J133tGZM2cUExOjKVOmKD4+Xk6nUx06dNAtt9xS5vf8cb+T8PBw3XrrrZUfDJZBvYXpeKDWShWvt9RaAAC8D70tTMciva3N+fuOwzDE3Llzy31u5MiRHvtcx89l77/qrXwafmd0BPxB5HOzXb5mz4QnqyAJ4D7qrfGo5RXjTq2VqLcAAFRHRvW2ZmS2ftyqPbNVeltWpBqsvAJXVFRUxUmACuD0C0yIegvTodYCAIBy0NvCdCzS2/oYHQBle+SRR4yOAJTL5nT9AMyCegtv5U6tpd4CAIA/oreFt7JKb8sg1UssXrz4gl8DXsXpxgPwUtRbmIY7tZZ6CwBAtUZvC9OwSG/LINVLbNiwQcXFxUbHANxic7h+AN6KeguzcKfWUm8BAKje6G1hFlbpbdkj1Uvk5OSoa9euaty4sWw2m2w2m1auXGl0LKBsJjhLBJSHegvToNYCAAAX6G1hGhbpbRmkeokFCxYYHQFwmxnOEgHlod7CLKi1AADAFXpbmIVVelsGqV7C19dX06dP18mTJ3X77bcrMjJSV155pdGxgLJZ5EwSqifqLUyDWgsAAFygt4VpWKS3ZY9ULzFhwgTdf//9KiwsVKdOnTRlyhSjIwHlssKd9lB9UW9hFla5sykAAPAceluYhVV6WwapXuLcuXO66aabZLPZFBERoYCAAKMjAeVzuPEAvBT1FqbhTq2l3gIAUK3R28I0LNLbcmm/lwgICNDGjRvlcDi0detW+fv7Gx0JKJcZzhIB5aHewiyotQAAwBV6W5iFVXpbVqR6ieeee06rV69WTk6OlixZouTkZKMjAeVzuvEAvBT1FqbhTq2l3gIAUK3R28I0LNLbsiLVSzRs2FCzZ882OgbgFqvcbQ/VE/UWZkGtBQAArtDbwiys0tsySPUSCxYs0KJFi1SzZs2SY5mZmQYmAi7ABGeJgPJQb2Ea1FoAAOACvS1MwyK9LYNUL/Hee+9p48aNCgwMNDoK4JJV9jZB9US9hVlQawEAgCv0tjALq/S2DFK9ROPGjUudQQK8mVUKIKon6i3MgloLAABcobeFWVilt2WQ6iUKCwvVt29ftWzZUpJks9k0c+ZMg1MB5bBIAUT1RL2FaVBrAQCAC/S2MA2L9LYMUr3E3/72N6MjAG6zyibRqJ6otzALai0AAHCF3hZmYZXelkGqwT755BPdeuutOnDgwHnPXX/99QYkAtxgkTNJqF6otzAdai0AACgHvS1MxyK9LYNUg506dUqSdOzYMWODABVglb1NUL1Qb2E21FoAAFAeeluYjVV6WwapBrvvvvskSSNHjtTRo0dVVFQkp9Opo0ePGpwMKJ9VluSjeqHewmyotQAAoDz0tjAbq/S2DFK9xLhx47R161adPXtW586dU5MmTfTWW2957PN8Gn7nsfdGNWCRM0monqi3MA1qLQAAcKGqe1szoh/3EhbpbRmkeondu3frP//5j5KSkvTkk0/qiSeeMDqS1+nlM8DoCBX2kSPd6AieUUkFMCsrSzNmzNDy5ctLHX/jjTeUnp6uunXrSpImTZqkiIiIyvlQVHvUW2OZsZZ7isu/Iyqx2aTeAgBgTfS21mTGnrm69LYMUr1EnTp1ZLPZdObMmZJ/4YC3qowl+QsXLtTatWsVGBh43nPbt2/XtGnTdM0111z6BwF/Qr2FWVTW5U/UWwAArIveFmZhld7Wx2PvjApp06aNFi9erMsuu0xPPvmkzp07Z3QkoFw2p9PlIy0tTf369St5pKWllXqPpk2bas6cOWW+/44dO/Taa68pNjZWr776alX8SKhGqLcwC3dqLfUWAIDqjd4WZmGV3pYVqV7i3nvv1WWXXaaaNWsqIyNDbdu2NToSUC53ziTFxMQoJiam3Od79+6t7OzsMp+78847NXjwYIWEhGjkyJH65JNPdOutt15sXKAU6i3Mwt2z9tRbAACqL3pbmIVVeltWpHqJ8ePHKyQkRL6+vurRo4fq169vdCSgfE43Hhf71k6n/u///k9169aVv7+/unfvrp07d156ZuD/R72FabhTa6m3AABUa/S2MA2L9LasSPUSQUFB+sc//qHw8HD5+Pw2377QBB4wks2Dd9uz2+2666679N577ykoKEhffvml7r//fs99IKod6i3MwpO1VqLeAgBgBfS2MAur9LYMUr3E559/rg4dOujEiROSpPz8fIMTAeWrrE2i/+idd97RmTNnFBMToyeffFJDhw6Vv7+/brrpJnXv3r3yPxDVFvUWZuGJWitRbwEAsBJ6W5iFVXpbm9Pp9PBMGBeSnp6ut99+W3v37tXVV18tSXI4HCoqKtK//vUvg9N5l14+A4yOUGEfOdKNjuARN8TNcvmaL5ePqYIkgPuot97BjLXcU1z9HeFOrZWotwAAVEf0ttZmxp65uvS2rEg12D333KObbrpJr776qoYPHy5J8vHxUb169QxOBpTP00vyAU+g3sJsqLUAAKA89LYwG6v0tgxSDebv76/GjRvrueeeMzoK4DabwyIVENUK9RZmQ60FAADlobeF2Vilt2WQCqDirFH/AMC7UWsBAABgFRbpbRmkAqgwW7HRCQDA+qi1AAAAsAqr9LYMUgFUmFX2NgEAb0atBQAAgFVYpbdlkAqg4pwWqYAA4M2otQAAALAKi/S2DFIBVJjNYXQCALA+ai0AAACswiq9LYNUABVmlSX5AODNqLUAAACwCqv0tgxSAVScRZbkA4BXo9YCAADAKizS2zJIBVBhVlmSDwDejFoLAAAAq7BKb8sgFUCFWWVJPgB4M2otAAAArMIqvS2DVAAVV2yRCggA3oxaCwAAAKuwSG/LIBVAhVnlTBIAeDNqLQAAAKzCKr0tg1QAFWeRTaIBwKtRawEAAGAVFultGaQCqDCrbBINAN6MWgsAAACrsEpvyyAVQIXZLHImCQC8GbUWAAAAVmGV3pZBKoCKs8iZJADwatRaAAAAWIVFelsGqSaWk5Mju92u0NBQ1a5d2+g4qEZsDmucSQLcRb2FEai1AADAE+htYQSr9LYMUk1o27Ztmjx5shwOh4KCgpSXlyen06mkpCRFR0cbHQ/VgUWW5AOuUG9hKGotAACoRPS2MJRFelsGqSaUkpKiOXPmqFGjRiXHjhw5oieeeELp6ekGJkN1YbNG/QNcot7CSNRaAABQmehtYSSr9LY+RgdAxRUVFZUqfJLUqFEj2Ww2gxKhurEVO10+3JGVlaW4uLhyn58wYYJmzJhRWbGBCqPewkju1FrqLQAAcBe9LYxkld6WFakm1L17dw0bNkydO3dWaGio8vLylJmZqW7duhkdDdVFJSzJX7hwodauXavAwMAyn1+5cqW+++47XXfddZf8WcDFot7CUJV0+RP1FgAASPS2MJhFeltWpJrQyJEjNXbsWNWsWVM5OTny9/fXU089pZEjRxodDdWEzeF0+XCladOmmjNnTpnPffPNN8rKylJMTExlRwcqhHoLI7lTa6m3AADAXfS2MJJVeltWpJpQfHy8xo0bd8FlzIBHuXEmKS0tTWlpaSVfx8TElCpmvXv3VnZ29nnfd/ToUc2bN09z587V+++/Xzl5gYtEvYWh3DxrT70FAADuoLeFoSzS2zJINaH//e9/+utf/6ohQ4aoX79+7GeCqudw/ZI/Fzt3ffDBB8rJydHf//53HTt2TOfOnVNERIT69et3EUGBS0O9haHcqLUS9RYAALiH3haGskhvyyDVhK688krNmzdPL7/8su6++27ddddd6tatm5o0aaKQkBCj46EasDncrIAXYejQoRo6dKgkafXq1dq/fz+/1MMw1FsYyZO1VqLeAgBQ3dDbwkhW6W3ZI9WEbDabwsLC9Oyzz2rp0qUKDQ3V/PnzFRsba3Q0VBdOp+tHBb3zzjullu8D3oB6C0O5U2uptwAAwE30tjCURXpbVqSaUP369Uv+uW7duho8eLAGDx5sYCJUO5V0Iqlx48Z66623JEl9+/Y973lWRsFo1FsYqhJP2lNvAQAAvS0MZZHelkGqCc2aNcvoCKjmPL0kH/AW1FsYiVoLAAAqE70tjGSV3pZBqgnFxcWpsLCw1DGn0ymbzaaVK1calArVykUstwfMiHoLQ1FrAQBAJaK3haEs0tsySDWhp556Ss8++6zmzZunGjVqGB0H1VGxNQog4Ar1Foai1gIAgEpEbwtDWaS3ZZBqQu3atdM999yjPXv2qFevXkbHQTVks8iZJMAV6i2MRK0FAACVid4WRrJKb8sg1aT++te/Gh0B1ZlFCiDgDuotDEOtBQAAlYzeFoaxSG/LIBVAxRVbY5NoAPBq1FoAAABYhUV6WwapACrOImeSAMCrUWsBAABgFRbpbRmkAqg4ixRAAPBq1FoAAABYhUV6WwapACquuNjoBABgfdRaAAAAWIVFelsGqQAqziJnkgDAq1FrAQAAYBUW6W0ZpAKoOItsEg0AXo1aCwAAAKuwSG/LIBVAxVnkTBIAeDVqLQAAAKzCIr0tg1QAFWeRAggAXo1aCwAAAKuwSG/LIBVAxVlkk2gA8GrUWgAAAFiFRXpbBqkAKs4iZ5IAwKtRawEAAGAVFultGaQCqDiHNQogAHg1ai0AAACswiK9LYNUABXmtMiSfADwZtRaAAAAWIVVelsGqQAqziJL8gHAq1FrAQAAYBUW6W0ZpAKoOIfD6AQAYH3UWgAAAFiFRXpbBqkAKswqS/IBwJtRawEAAGAVVultfYwOAMCEnE7XDzdkZWUpLi7uvOP//e9/df/996t///5aunRpZacHAHNwp9ZSbwEAAGAGFultWZEKoOIq4UzSwoULtXbtWgUGBv7prYs1c+ZMrVq1SkFBQerTp4/69u2runXrXvJnAoCpVNJZe+otAAAADGeR3pYVqQAqzOlwuny40rRpU82ZM+e84zVq1NB7772n0NBQnTp1Sg6HQ/7+/p74MQDAq7lTa6m3AAAAMAOr9LasSAVQcU7Xm0SnpaUpLS2t5OuYmBjFxMSUfN27d29lZ2eX+b2+vr768MMPNXnyZHXv3v28M00AUC24UWsl6i0AAABMwCK9LYNUABXmzibRfy52FfWXv/xFPXv21DPPPKM1a9bo/vvvv+j3AgAzcndDfuotAAAAvJ1VelsGqTCNjxzpRkfA/8+T/y7sdruGDx+uJUuWyN/fX4GBgfLxYRcSwCqo5e7z9J8V9RYAAMA7WbFntkpvyyAVgFd45513dObMGcXExKhv37564IEH5Ovrq8jISN19991GxwMAy6DeAgAAwCqqure1OZ1O1zu5AgAAAAAAAEA1xvVbAAAAAAAAAOACg1QAAAAAAAAAcIFBKgAAAAAAAAC4wCAVAAAAAAAAAFxgkAoAAAAAAAAALvgaHQDmduLECfXr109LlixRfn6+HnnkEV111VWSpNjYWPXp08fYgGV49dVX9fHHH6uwsFCxsbG6/vrr9cwzz8hms6lFixaaOHGifHy84xxDVlaWZsyYoeXLl+vgwYNl5pw7d64+/fRT+fr6aty4cWrbtq3RsQF4AYfDoeTkZO3Zs0f+/v56/vnn1axZM6NjGe6Pf281b97c6DgAAADwcgUFBS5f4+/vXwVJKubAgQMuXxMeHl4FSayFQSouWmFhoZKSklSzZk1J0o4dO/Tggw/qoYceMjhZ+b788kv973//U2pqqs6ePaslS5YoJSVFo0eP1g033KCkpCStX79evXr1MjqqFi5cqLVr1yowMFCSysx5xRVX6KuvvlJ6erp++uknjRo1SqtWrTI4OQBvsG7dOhUUFCgtLU1bt27V1KlT9corrxgdy1B//nsLAAAAVSszM9Pla7p06VIFSdzXqVMnNWjQQE6nUzabTZJK/tnpdOrkyZPaunWrsSHLMHDgQLVq1UpOp7PM5/fs2aOvvvqqilOZH4NUXLRp06Zp0KBBeu211yRJ27dv14EDB7R+/Xo1a9ZM48aNU0hIiMEpS8vMzFTLli312GOPyW63a+zYsXrrrbd0/fXXS5K6deumzz77zCsGqU2bNtWcOXM0duxYSb8Nqv+cMzw8XF26dJHNZtMVV1yh4uJinTx5UnXr1jUyOgAvsGXLFnXt2lWS1L59e23fvt3gRMb7899bAAAAqFrPPPNMSY9alo0bN7o1bK1KN998sxYsWFDu88OHD6/CNO7r3bu3nn/++XKff/bZZ6swjXUwSMVFWb16terWrauuXbuW/ELatm1bDRgwQNdcc41eeeUVzZs3TwkJCQYnLS0nJ0dHjhzRggULlJ2drREjRpQ6qxQcHKzc3FyDU/6md+/eys7OLvm6rJx2u121a9cuec3vxxmkArDb7aVOZtWoUUNFRUXy9a2ef/WX9fcWAAAAqlb//v01evTocp9/8cUXqyyLu2699dZSv4//2YWGrEZ6/vnn9c0332jLli06e/as6tSpo5tvvrlke6sLDVlRPu/YCBKms2rVKn3++eeKi4vTrl27lJCQoG7duumaa66RJPXq1Us7d+40OOX5ateurS5dusjf318REREKCAgoNTjNy8tTWFiYgQnL98d9W3/PGRISory8vFLHQ0NDjYgHwMv8uT44HI5qO0SVyv5769ixY0bHAgAAqFZGjx6t77//Xvv27ZMkLVmyRLNnzy75vfxCQ1ajzJgxQw8++KB++OEHo6NUyIIFC5SamqqQkBDt3LlTP/30k2bPnq1//vOfRkczNQapuCj//Oc/9eabb2r58uVq1aqVpk2bpkcffVTbtm2TJG3atElt2rQxOOX5OnbsqI0bN8rpdOqXX37R2bNnddNNN+nLL7+UJGVkZKhTp04Gpyxb69atz8sZHR2tzMxMORwOHTlyRA6Hg9WoACRJ0dHRysjIkCRt3bpVLVu2NDiRscr6e6tBgwZGxwIAAKhWXnrpJU2cOFFjx47VqFGjdOLECdWpU0fPPPOM0dHKFRUVpdGjRys+Pl6JiYn63//+Z3Qkt2zcuFHTp09XbGys5s2bp++//15z587VO++8Y3Q0U6u+S1NQ6ZKTk/Xcc8/Jz89P9evX13PPPWd0pPPceuut2rx5s/r37y+n06mkpCQ1btxYEyZM0KxZsxQREaHevXsbHbNMCQkJ5+WsUaOGOnXqpJiYGDkcDiUlJRkdE4CX6NWrlz777DMNGjRITqdT//jHP4yOBAAAgGpu06ZNWrlypQoKCnTXXXdpzpw5kqT169cbnKx8NptN7du316pVq/Txxx9r6dKlevrppxUaGqp//etfRscr15kzZ/Tjjz/qyiuv1KFDh5Sfn6+ioiKdO3fO6GimZnOWd/suAAAAAAAAoJLcf//9mj59unJycjR8+HC99957CgwM1EMPPaS33nrL6HhliouL0/Lly8877u03es7MzNSECRMUFhamc+fO6YUXXtDGjRt1+eWXa8CAAUbHMy0GqQAAAAAAAPC4zz//XNOnT1fr1q3VokULvfbaawoODlZCQoJ69uxpdLwyHT9+XPXr1zc6xkVxOp3Kycnx6oGv2TBIBQAAAAAAQJXLzc1VQECA/P39jY5Srh9++EEzZ85UQECARo4cqauuukqSNHHiRE2aNMnYcBdw9OhRLVq0SGFhYerZs6dGjRqlGjVqaOrUqWrfvr3R8UyLm00BAAAAAADA4zZs2KBly5bp8OHDGjJkiO644w4NGTJEu3btMjpauSZMmKCYmBjdddddeuyxx7Rz505J0v79+w1OdmHPPPOMWrVqJZvNpoceekivvvqq3njjDc2YMcPoaKbGzaYAAAAAAADgcXPmzNG8efOUlJSkJ554Qtddd512796tiRMnKi0tzeh45erSpYskqWnTpho1apQWLVokm81mcKoLKygo0H333SdJ+uqrrxQRESFJXp/b27EiFQAAAAAAAB7n7++vyy+/XJJ03XXXSZKioqKMjOSSr6+vPv74YxUXFysiIkITJkzQI488ouPHjxsd7YLCwsI0f/58OZ1OLV26VJL073//WwEBAQYnMzcGqbCEOXPmKDU1Vbt27dLcuXMlSR999JF++eUXl987ffp09e3bV19++eUlZVi9erXWr19/Se8BAJ50KbWyKnz00Uf6y1/+omXLlrn9PatXr+byJAAAAJNo06aNJk+erA4dOmjcuHH66KOP9Oyzz6p58+ZGRyvXlClT9OGHHyo3N1eSdOONN2rcuHHy8/MzONmFzZw5U8HBwaVWoP7yyy+aNm2aganMj5tNwRLmzJmj+vXrKzY2tuRYXFyckpOTXRbk2267Tf/+978VEhLi6ZgAYKhLqZVVITExUb169VKPHj3c/p7Vq1dr//79euqppzyYDAAAAJXB4XDo3//+tzIzM5WTk6PatWurY8eOGjBggNfecMput5fMC7777jvt3r1bbdq08Yr+GVWPPVLhFfLy8hQfH6/Tp0/r6quv1v/+9z/Vrl275Jf71NRUHT9+XKNGjdLMmTO1fft2nTp1SlFRUUpJSSl5ny+//FIrV67UPffco127dikhIUEDBgzQDz/8oISEBBUXF+vee+/V22+/rYCAAM2dO1dHjx7VI488osWLF+uFF17Qtm3bVFhYqFGjRqlnz55l5v3www+1cOFC+fr66rLLLtPs2bM1b9481a9fX/Xr1y9ZTfXzzz+rYcOGWr58uWbOnKmvv/5aDodDw4YN0x133FElf7YArMOoWpmdna34+Hg1bNhQhw8f1rXXXqtJkyaVGszu27dPycnJWr58ufr27atOnTppz549ioiIUL169fT111/L399fr732Wpln79evX6+MjAxt375dderU0d69e5WamiqHw6EePXro8ccfd/nnU9bPPGjQID333HNq0aKFNmzYoE8++UTx8fEaP368cnJyJEnPPvusIiMjdeuttyoiIkLNmzdXp06dzqvzPj5cyAMAAHApfHx81KZNG0VHR6tZs2Ylx7OystSuXTsDk5Xv0Ucf1bJly7Rq1SqtWLFCN954o1asWKF+/fpp4MCBRscrV0FBQbnPeevQ2gwYpMIrrFixQpGRkXryySf1zTffKDMzU7Vr1z7vdXa7XWFhYXr99dflcDh05513lnlJ6i233KJWrVopOTlZl19+ufr166ennnpKGzdu1A033FCyJ8jIkSO1evVqLVmyRBkZGcrJydHbb7+tX3/9Va+//nq5g9R3331XDz/8sG6//XatWbNGdru95LlevXqpV69eOnz4sEaPHq2pU6dqw4YNys7OVmpqqvLz8zVw4EB17txZYWFhlfMHCKBaMKpWStIPP/yg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qKC3335bffv21fnz56850JSuPGjjn//8p/r376/58+drz549zn4ir0DeAsjHRVekuipvyVoApZYXZa1UevIWAEojw0aqxWJRcnKy0tPTlZGR4fRZewClj8VmvMTHx6tbt272JT4+Pt8+OnbsKD+/whfFp6am6rvvvlPfvn21bNkyff3119qxY8c168nJyZHVapUkWa3WEvugCvIWQF5mstaTeUvWAiitvClrpdKTtwBQGhlO7R8yZIg2btyorl27qn379uratasn6gLgxSxW43UiIyMVGRnp9L4rVKigO++8U7Vq1ZIktWrVSgcOHFDz5s2L3KZz586KiopS48aNtX//fnXq1Mnp43oD8hZAXmayVvJc3pK1AEorxrYAALMMG6l//etfVb9+fZ0+fVobN25U2bJlPVEXAG9m8ob716NGjRpKT0/XiRMndOedd2r37t3q0aOHw3Xnzp1rP0NfpUoVbdmyRfXr11dKSor7CnQj8hZAPm7MWsl83pK1AEo9xrYAAJMMG6n/+c9/9K9//Uu5ubl66KGHZLFY9Mwzz3iiNgDeyg2DzQ8//FAZGRmKjIzU9OnTNWrUKNlsNjVp0kT333+/w23y3l+qZs2aatu2resL8yDyFkA+bvqHvbN5S9YCKPUY2wIATLLYbLZrfm307t1b7777rgYOHKh3331X3bt319q1az1VH9zE+mud4i7BKT5VjxR3Ccij7rR5huv8OHGEByopXcjb0qmk5a07keXOMZO1EnnrLLIW3qIkfj+U1hxnbAsAMMvwilRfX18FBATIYrHIYrEoKCjIE3UB8GZunm56syJvAeRD1roFWQugEPIWAGCSYSO1adOmGjlypM6ePatJkybprrvu8kRdALyYhcGmW5C3APIia92DrAVQEHkLADDLsJE6cuRIJSYmqkGDBqpVqxb3aQEgmXySNJxD3gLIh6x1C7IWQCHkLQDApCIbqfHx8fl+LleunM6dO6f4+HhFRka6vTAA3ouz9q5F3gJwhKx1LbIWQFHIWwCAWUU2UpOTkz1ZB4ASxMJZe5cibwE4Qta6FlkLoCjkLQDArCIbqUOGDHH4ek5OjtuKAVBCcNbepchbAA6RtS5F1gIoEnkLADDJx9kNBg0a5I46AJQkNhMLbhh5C9zkzGQteXvDyFoAZC0AwCzDRurSpUuv+TOAm4/FarzAeeQtgLzM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lZaWppCQkHzbXbx4UTVq1FDjxo3zbe/v76+KFSvKZrNp1qxZatCggWrWrKny5curTJkySk5O1vPPP6+RI0cWWXPB4/n6+ionJyff+44+R97Xr9YoSS1atFBoaGih4/j6+qpfv35avny5OnTooIoVK+ry5ctKSkpSbm6uEhMTlZGRUWSdgMtZbcaLgbCwMM2fP9/he99++6327dunyMhIV1cOkbcSeYsSwkzWkrdei6wla1GCkLUAAJO85mFTqampqlSpkiQpJCRE6enp9vfS09PzDdqkK2e4875fvnx5U9vllZmZqRdffFFly5bV5MmT7a//+OOPGjlypF544QX7lQGOFDye1WqVn59fke9frefq62XKlLHXbuTdd99VUlKSBg0apE2bNmn27NmKjY1VQECA6tSp43CQCriLxcT0pvj4ePvUPkmKjIzMN3js2LGjTp8+XWi7c+fO6fXXX9eCBQv0ySefuKZg5EPeXht5C29hJmsl8tZbkbXXRtbCmzC2BQCY5TVXpN566626cOGCJKlWrVo6ceKEzp8/r6ysLO3evVtNmjTJt36DBg30zTffSJISExPVrFkzRUREaNu2bbJarTpz5oysVqsqVqzo8Hg2m03PPPOM6tatq6lTp8rX11eSdPToUQ0bNkxz585VmzZtrllzRESEEhMTJV256qBOnTr53m/UqJH27NmjzMxMXbx4UUlJSapTp44iIiL05Zdf2mtv2rRpkcdYtGiR1q1bJ+nKGf6rdW7btk1Lly7VkiVLdPLkSf3tb3+7Zq2AS5m4j1RkZKTWrl1rX8yegf/000+Vmpqqp556Sm+++aY++ugjrV271s0f6OZC3jpG3sLrmLxHKnnrnchax8haeCWyFgBgktdckdq4cWP7jbf9/f01duxYDRw4UDabTd27d1eVKlV09OhRLV++XLGxsRozZowmTpyoV199VeHh4erYsaN8fX3VrFkzRUZG2p86WpRNmzZp586dysrK0tatWyVJI0eO1JtvvqmsrCxNnz5d0pUz7//617/05ptvql69emrdurV9Hx06dNBXX32l3r17y2azacaMGZKkZcuWKSwsTA888IBiYmIUHR0tm82mESNGKDAwUIMHD9aYMWOUkJCg0NBQzZ07t8g6u3fvrjFjxmjNmjXKzc21H6Ny5crq2bOnypQpoy5duqh27do39gcAnGDJ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AVz1xhtv6OjRo6pXr57+9re/KSMjQ9OnT9e6desKndC5etIpPDxcixcvlr+/v06fPq1OnTpp8ODB+uWXXzRx4kRlZmYqMDBQ06ZNU25urgYPHqwKFSqodevWevLJJ+3H3rRpkz755BNdvnxZEyZMUKNGjbR8+XJ99tlnunTpkkJDQ7VgwQJ7jQsWLFB0dLTGjBmjixcvymazadasWZKunMT69NNPdf78eQ0bNkzt2rVz+HmPHz+ucePGyc/PT1arVXPnztXJkye1cuVKjRw5Ui+++KKkK/l/7Ngx7dixQ1988YXefvtt+fj4qGnTpho9erT7/zAAALgZjVR4hdOnT6tVq1aSpIiICAUEBOR732azSZICAwOVkpKikSNHKjg4WBkZGcrOzr7mvkNCQvTXv/5V27Zt09q1a/XMM884XO/48eO6++67JUm33HKLhg8fXuQ+x40bp0WLFmn58uUKDw8v1HBNTk7WsGHDNHPmTN1xxx3asGGDDh48qJiYGElSTk6Ofv75Z/5RDsApxZmVYWFhCgkJkSTddtttyszMvOb+GjZsKEkqX768atWqZf9vo+0k6dSpU6pdu7bKlCkjSab+8V3UZ3744YfVrVs3DRw4UGfPnlXDhg312muv6euvv9Ynn3wiSfrjjz8kSaGhoQoNDZVknPMAbm5PP/20jhw5olatWumPP/7QhAkTTJ3EOnPmjNavX6+srCy1atVKgwcP1qxZsxQTE6M2bdpox44dmjNnjkaMGKHk5GStWbOmUNbfcccdmjp1qv773//aTxKdP3/e3rQcOHCgvv/+e3uNQ4YM0UsvvaR27dopKipK3377rfbv3y9JqlKliqZPn65vvvlGS5YsKbKRun37djVq1EjPP/+8du/erYsXL9rfq1GjhuLi4pSVlaWnn35a//jHP5SZman58+drzZo1CgoK0vPPP6+vvvpKLVq0cPFfAgAAz2KOGrxC3bp1tWfPHknSjz/+qKysLAUEBNiveDp06JAkKTExUb/88oteffVVjRw5UpcvX7Y3DgqyWCz293r16qVVq1bp999/V7169RyuHx4eru+//16SdPHiRQ0cOLDIeuPj4zV06FAtX75c0pUHpFx14cIFPfvssxo3bpzq1q1r3/e9996ruLg4vfPOO3r44YdVo0YN078fAJCKNystFkuhbQMDA+3HPnjwoOH6ZoWFhenYsWPKysqSJD333HOGD8Qq6jMHBwfr3nvv1fTp0/Xoo49KupLJ/fv3V1xcnF577TX763mn7l8r5wEgr5o1a0rKf0Jn0qRJDk9i1alTR35+fgoODrafLDpy5IgWLVqkmJgYvf766/r9998lSdWrVy/URJWkv/71r5Kk2rVrKzk5WT4+PvL397dfGfrrr78qJycn3zbHjx9XkyZNJF05EXc1966e9KpUqZIuX75c5Gfs0aOHypcvr//7v//Te++9J19f33zv5+TkaMSIEXr00UfVpk0bnTx5UikpKXrqqacUExOjpKQknTx50twvFAAAL8YVqfAKPXv21Pjx49WnTx9Vq1ZNktSvXz9NmTJF1apVU+XKlSVJjRo10sKFC9WnTx9ZLBbVqFFD586dc7jPJk2a6IUXXtBbb72lxo0b68SJE+rTp48kadmyZQoLC8v3cKgHHnhAO3bsUFRUlHJzc/Xss88WWW+jRo00aNAglS1bVsHBwbr//vvt/9ieN2+ezp07pwULFshqtcrf319Lly7Vzp07FR0drYyMDLVv395+ZRcAmFVcWXn1pFBBDz/8sIYPH65du3bZ/zHuChUrVtSTTz6pvn37ymKxqG3btqpSpco1tynqM9eoUUO9evVSdHS0YmNjJV25kmz8+PFKSEhQWlqa/cFbBfdXMOcB4CofHx9ZrVb7f0v/O6Hz2muvKSUlRRs3bix0EsvRSabw8HANGDBAERERSkpK0q5du/Ltt6D9+/erS5cu+vHHH1WtWjUdPnxYmzZt0qpVq3Tp0iV169ZNNpstX421atXS999/r3r16mnXrl364osvVKZMGdMnvTZv3qymTZtqyJAh+uijj7RkyRI99thjkq7Mhhg/fryaNGlif6169eq6/fbb9dZbb8nf319r167lfqoAgFLBYivqEhWgmGRmZurhhx/W559/7rJ9Wq1WRUVFaenSpTQwAZQKZKV5+/fv1/LlyzV79uziLgVAKXH1nvctW7ZU9erVFRUVpeTkZD399NP2BuXly5c1btw4bd++3X6P1JUrV2revHmSpBYtWuirr77SqVOnFBsbq8zMTF2+fFnjx4/XbbfdppEjRyohIUGSNGDAAL3xxhtatGiRDh06pPT0dGVlZSk2NlZ33nmnBg0aZL+KPyAgQD169FDHjh3tNQ4cOFAvvvii0tPTJUkzZszQunXrHD4w0JGTJ09qzJgx8vf3l9Vq1bhx45SWlqaVK1fqwQcf1IsvvqjGjRsrNzdXkjR58mQdPHhQK1asUG5uru644w7NnDlTQUFB7v7TAADgVjRS4XVc3Rw4deqUhgwZom7duunvf/+76e2ysrIcTu+vWbOmpk6d6pLaAOB6eUtWOmv//v165ZVXCr3+8MMPKzo6usjtYmNjlZSUVOj1xYsX26fHOrJ8+XKtXr1ar732mv70pz9dV80AAAAAINFIBQAAAADo+k9aAQBws6CRCgAAAAAAAAAGHN/BHAAAAAAAAABgRyMVAAAAAAAAAAzQSAUAAAAAAAAAAzRSAQAAAAAAAMAAjVQAAAAAAAAAMPD/ABx0Bm1a9q54AAAAAElFTkSuQmCC", "text/plain": [ "
" ] }, "metadata": {}, "output_type": "display_data" } ], "source": [ "hyperopt_report_cli(\n", " 'hyperopt_results/random_serial/hyperopt_statistics.json',\n", " output_directory='./visualizations'\n", ")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "#### Generate parallel coordinates plot on hyperparameter optimization" ] }, { "cell_type": "code", "execution_count": 33, "metadata": {}, "outputs": [], "source": [ "hyperopt_hiplot_cli(\n", " 'hyperopt_results/random_serial/hyperopt_statistics.json',\n", " output_directory='./visualizations'\n", ")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "To view parallel coordinates plot, using your browser open html page in the `visualizations` directory, i.e, `visualizations/hyperopt_hiplot.html`. The browser should display something similar to the image below." ] }, { "cell_type": "code", "execution_count": 29, "metadata": {}, "outputs": [ { "data": { "image/png": "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gAABAgSmKyDIOl2elBariaquW9NtNYKra9f8nSe1q/cbgqz10thAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIEJhGQJC1DsncNUHVCKxGcHW9mr8RZK10CLJWKmU/AgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgEBKs32QtX1qk1ab0jkHV6Pz6vKpY02UtU2jfhuCrI1i8yUCBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIEBgNhWY7YKsEVFdLnVKEVqN/1ZPnWuiq22rcvsFWavCaBICBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIEBgNhGYLYKsXTt0qAmtzpXWTTXh1TRXmndKu2a5vYKszcJqUgIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQKCVCrT6IOvpj++bftKh00y5fYKsM4XZQQgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAIFWItDqg6zXPX5wat+heTqw1v0NCLLWFfGZAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIFC/gCBr/TYN3iLI2mAyXyBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgRmYwFB1irefEHWKmKaigABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIEGj1AoKsVbzFgqxVxDQVAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAg0OoFBFmreIsFWauIaSoCBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECg1QsIslbxFguyVhHTVAQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgECrFxBkreItFmStIqapCBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAgVYvIMhaxVssyFpFTFMRIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECrV5AkLWKt1iQtYqYpiJAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgRavcAsFWQd9sbn6elH3mrQTdmtb4/Url3bBn2nsTt/NGxkGvjAaw36+o77b5Q6delQ8Xeef/ydNGTwxxXvv26P5dKq63ereH87EiBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgRmlsAsFWSdWSiOQ4AAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAg0PwCgqzNb+wIBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECZQQEWcugWEWAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIND8AoKszW/sCAQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAmUEBFnLoFhFgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECDQ/AKCrM1v7AgECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQJlBARZy6BYRYAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAg0PwCgqzNb+wIBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECZQQEWcugWEWAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIND8AoKszW/sCAQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAmUEBFnLoFhFgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECDQ/AKCrM1v7AgECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQJlBARZy6BYRYAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAg0PwCgqzNb+wIBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECZQQEWcugWEWAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIND8AoKszW/sCAQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAmUEBFnLoFhFgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECDQ/AKCrM1v7AgECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQJlBCoOsr799ttTynzfKgIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQKNEhg6dGhF32szYMAAQdaKqOxEgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBQicCUKZXFU9s89thjle1ZyVHtQ4AAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgMNsLjB8/viKDNuPGjRNkrYjKTgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABApUI/Pd//3cluyVB1oqY7ESAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIFCpgCBrpVL2I0CAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQqKqAIGtVOU1GgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBQqYAga6VS9iNAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIEKiqgCBrVTlNRoAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgUKmAIGulUvYjQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBCoqoAga1U5TUaAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIFCpgCBrpVL2I0CAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQqKqAIGtVOU1GgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBQqYAga6VS9iNAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIEKiqgCBrVTlNRoAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgUKmAIGulUvYjQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBCoqoAga1U5TUaAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIFCpgCBrpVL2I0CAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQqKqAIGtVOU1GgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBQqYAga6VS9iNAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIEKiqgCBrVTlNRoAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgUKmAIGulUvYjQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBCoqoAga1U5TUaAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIFCpgCBrpVL2I0CAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQqKqAIGtVOU1GgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBQqYAga6VS9iNAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIEKiqgCBrVTlNRoAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgUKmAIGulUvYjQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBCoqoAga1U5TUaAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIFCpgCBrpVL2I0CAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQqKqAIGtVOU1GgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBQqYAga6VS9iNAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIEKiqgCBrVTlNRoAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgUKmAIGulUvYjQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBCoqoAga1U5TUaAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIFCpgCBrpVL2I0CAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQqKqAIGtVOU1GgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBQqYAga6VS9iNAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIEKiqgCBrVTlNRoAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgUKmAIGulUvYjQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBCoqoAga1U5TUaAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIFCpgCBrpVL2I0CAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQqKqAIGtVOU1GgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBQqYAga6VS9iNAgAABAgQIECBAgACBLDBy5Mj8d/75509t2rRJU6ZMSaNGjcrrFlhggZmiNGLEiPTBBx/kY3fv3n2mHLOSg/wYFpWcl30IECBAgAABAgQIECBAgAABAgRmT4ExY8ak8ePHp44dO6YuXbo0CqEaczTqwPV8aezYsWncuHFNuqZ6pp5pq1ua6Uy7cAciQIAAAQL1CAiy1gNjNQECBAgQIECAAAECBAhMK/Dxxx+nfffdN2/o379/6tSpU3r77bdT375987oHH3wwtW/fPgdMJ0yYkIOuc84557QTNWHNTTfdlO688848Q4Rpb7/99ibMVt2vlrOo7hHMRoAAAQIECBAgQIAAAQIECBAg0FoEvv/++zRp0qRcT2vXrl2zXNapp56aBg0alHbeeefUu3fvRh2jGnM06sD1fOm8885LAwcOTDvssEPab7/96tmrZa9uaaYzS2tm/OZn1rU4DgECBAhUV0CQtbqeZiNAgAABAgQIECBAgECrFqg0yPrss8+m008/Pc0333ypX79+VTOJQueOO+6Y4m+EaKMb6/HHH1+1+Zs6kSBrUwV9nwABAgQIECBAgAABAgQIECAw+wgceuih6d133019+vRJW221VbNceDUCk9WYo5oXJ8haTc2ZO9fM+M3P3CtyNAIECBColoAga7UkzUOAAAECBAgQIECAAIHZQKBckDVeg/X888/nq990001T27Zt0zPPPJPOOOOMqgdZo7Afxc4Y0Zl1kUUWycst5Z9yFi3l3JwHAQIECBAgQIAAAQIECBAgQIBAyxI45JBD0rBhw5o1yPrmm2+mzz//PC211FJp2WWXbRRANeZo1IHr+VJrCLK2NNN6qKu+emb85qt+0iYkQIAAgZkiIMg6U5gdhAABAgQIECBAgAABAk0XmDx5cp4kgqLNNcaOHZtfZda+ffuyhygXZC23Y3MFWYcMGZIOP/zwfMiHH344h2bLHf/bb79N4dW1a9dym5u0LuYOn44dOzZpnkq/HN1n67sflc5hPwIECBAgQIAAAQIECBAgQIAAgZYnUGmoL+pDUeuac845p7mIGdXzpvlCPSsmTZqUj9GUOlQ15qjn9KZaXUmQdcqUKSnOZ4455pjqu/V9aIzj9O5LHGf8+PFNriHOLNP6XMqtj4f5O3funNq0aTPV5koMG/Kbb8pvcaoT84EAAQIEZgkBQdZZ4jY5SQIECBAgQIAAAQIEZmeBwYMHp379+qWhQ4emdu3apbXXXjv96le/Su+880569tlnU8+ePdNmm22Wia644ooUT/NvvfXW07yOLMKlt912W+68cOSRR9aSjhw5Mv3tb39LTz31VPrmm29yaHLFFVdMq622WooOq8stt1ztvuWCrB999FG69NJL8/fOOuusFK8a+/DDD3Onh/hidHpYYYUV8lx//etf81zHH3986tatW+28sTBq1Kh08skn53UHHHBAWnPNNafaftFFF+UOFdGlIsbqq6+eFlxwwXTsscfmzxMnTkw33HBD+te//pXimmJ06dIlbbnllmm33XbLy3llI/756quv0i233JKefPLJFIXaKNJGN9hNNtkk7bXXXrUF8boWETq+995704z+j++NN9447bHHHrVnFmHZsHrttdfSJ598kuaZZ558P+K+rrvuurX7WSBAgAABAgQIECBAgAABAgQIEJj1BIo6XdSSoqY199xzp4UWWig/wB0PZp9++uk5tHraaaflutugQYNyrezss8/OF9uQel7Uy6JeGDWyX/7yl/n711xzTXr11VdzPSrqXFG/ilpjhCaXXnrptP3226fNN9+8FrYacxSTvfHGG7kW+dZbb+V6YtS64ljvv/9+ijpoHDvqn9Mb0wuyRo3zwQcfzHXECRMmpGWWWSbPt/vuu6dOnTpNNW1DHEeMGFHvfYlaZpxT3LsTTzwxXXfddflaohPu/PPPn+t6ffv2TXPNNVft8atlGmHaW2+9Nb8xK+qIUctdf/31cy02zmPeeedNRxxxRO1xG7pw4403phdeeCHtsMMOuQYac4bbxRdfnFZZZZW8XElteXq/+agdx1ATbejdsT8BAgRal8CM/re04mrbjBs3bkrxwV8CBAgQIECAAAECBAgQmDkCjz32WPrzn/+cfvjhh3zACEZG94UoMEcHhniqf++990677LJL3h6hzldeeSX16tUrRXG2dDzyyCN5rgiWXnnllXlTzBuh1gjJxlhggQVSzf/9l+Lp+RhR3L3wwgtrXztWLsj69ttvpyjExogi8UEHHZRiv9Kxxhpr5IBrnFMU58ud30MPPZSuuuqqztK+mAAAQABJREFUXMCO4meEUEtHzDt8+PDSVbkQfPvtt+dw6THHHJML1LFDPK0f1xadF2J07949F5rrdgnIG2fwT1hEF9gI58aI4m8U9aOwGmOdddZJ55xzTl6uaxHnce2116b77rsvb6/vn5///OfpuOOOy5sjqBth4C+//DJ/jvscRfcYcf5RGN92223zZ/8QIECAAAECBAgQIECAAAECBAjMegIDBw7Mwce6Zx5hyKjP7bvvvrm+tfLKK+fAaewXgc8Isja0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kENqP1sgQIAAAQIECBAgQIAAAQIECBAgQKD1CUyePDk/ZP7MM8+UvbhVV101b+/UqVPZ7VZOK/D666+nE088MU2YMGGajW3btk3HHXdc6tGjxzTbrCBAgAABAi1RQJC1Jd4V50SAAAECBAgQIECAAIEKBO677770wQcf5I6h0a1gVhjRYeH2229PUZBeffXVUwRZo6hqECBAgAABAgQIECBAgAABAgQIECDQugUmTZqUnnzyyfTEE0+kTz/9NHcMXWGFFVL8t/nmm6fOnTu3boBmuLpPPvkkPfTQQ2nIkCG5q+3iiy+eVlpppbT++uunCAcbBAgQIEBgVhEQZJ1V7pTzJECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAi0MgFB1lZ2Q10OAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQGBWERBknVXulPMkQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECLQyAUHWVnZDXQ4BAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAYFYREGSdVe6U8yRAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQItDIBQdZWdkNdDgECBAi0DoGxY8emcePGpY4dO6YuXbq0jotyFQQIECBAgAABAgQIECBAgAABAgRaocCUKVPSqFGj8pUtsMACjbrCaszRqAPX86URI0ak8ePHp4UWWih16tSpnr1+/NXh/t1336V55503zTPPPD/qCbW0e/ijYjg4AQIECBAgQIAAAQIEGiggyNpAMLsTIECAAIGZIXDeeeelgQMHph122CHtt99+M+OQjkGAAAECBAgQIECAAAECBAgQIECg1Ql8//33adKkSal9+/apXbt2zXJ9b7/9durbt2+e+8EHH8zHauiBqjFHQ485vf333Xff9PHHH6czzjgjde/efXq7NnpbBD8nTJiQ2rRpk+acc85GzXPqqaemQYMGpV69eqXdd9+9UXNU60st7R5W67rMQ4AAAQIECBAgQIAAgZkhIMg6M5QdgwABAgQINFBAkLWBYHYnQIAAAQIECBAgQIAAAQIECBAgUEbg0EMPTe+++27q06dP2mqrrcrs0fRV1QgwVmOOpl/J/80wM4Kszz77bDr99NPTfPPNl/r16/d/B2/AkiBrA7DsSoAAAQIECBAgQIAAgRYsIMjagm+OUyNAgACB2VdAkHX2vfeunAABAgQIECBAgAABAgQIECBAoHoChxxySBo2bFizBlnHjBmTnn/++XzSm266aWrbtm2DL6AaczT4oNP5wswIsj7zzDO542trCbK2tHs4ndtrEwECBAgQIECAAAECBFqcgCBri7slTogAAQIECKRUSZA1CqPxarR55523IrJvv/02v9asY8eOM9y/IfvOcDI7ECBAgAABAgQIECBAgAABAgQIEPiRBCoNskadbfLkyWVfcT927NhcV2vfvn2Tr2L8+PGpkvrc9A5UjTmmN39sKxdk/frrr9Pcc89dUVA3LMNtrrnmqvdQlQZZo1YZ83Xt2nWaucp1ZJ04cWJq06ZNvmfTfKGJK2Z23TR+l9X43TXxsn2dAAECBAgQIECAAAECzS4gyNrsxA5AgAABAgQaLlBfkPWHH35It956a3ryySfTZ599lieOAu6GG26Y9tlnn1xILj3aV199lW655Za8fwRfo4C7yCKLpE022STttddeaY455qjdvSH71n7JAgECBAgQIECAAAECBAgQIECAAIEWKBAhydtuuy199NFHKYKNEcBcaKGF0uGHH54DkfFK+znnnDOddtpp6dJLL02DBg1Ka665Zjr77LPz1YwcOTL97W9/S0899VT65ptvcphwxRVXTKuttlqKrqvLLbdc7VXHMWKOCByeddZZOej5wQcf5IfVo3Z34oknpuuuuy4NHjw4ff7552n++efP8/Tt27c26FmNOYoTivBj1BCjS+wnn3ySz3X99dfP5x3nEQ/GH3HEEcXuZf8WQdaTTz45vfLKK+lf//pXCpMwW2GFFdJBBx2Ull122Wm+O3DgwHTfffflLrhRywzzMFt99dXTFltsUVuPjHk//PDD7BGTxFwxb9yfGHHPbrjhhtrjxrouXbqkLbfcMu222255OdaVBlmjs2sc/80330xTpkxJSy+9dNp5551Tjx49YtdGj0rrpuXu4b333ptm9D/GbrzxxmmPPfaoPb8Iy/71r39Nr732Wr5/88wzTzbceuut07rrrlu7nwUCBAgQIECAAAECBAi0JoEZ/d9OxbW2GTdu3JTig78ECBAgQIBA8wqUC7JG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Ddm3dB7LBAgQIECAAAECBAgQIECAAAECBFqyQEODrBE2jCBmjKuuumqawOHBBx+cohNpjx490gknnJD3KxdgLNZV2k212D8mfPDBB1N0NC3WVTrHsGHDclfUmCM6fkYn1GJEQPekk05KL774YoOCrD/72c/y94p54u+gQYNygDSWL7vssrTCCiukU045JXeqjTpkLJeOImAb64raZSzHA/dnnHFGmm+++VK/fv1iVYqg6qGHHpqXDz/88LTFFlvk5fhn0qRJaZ999kmff/557gIbHVibO8jakLppcb/iXIt7GMtFrTfu48knnzxNrTb2iXH99dene++9N7Vt2zbdeeedKTqvlo6o40bDgzXXXDMdddRRpZssEyBAgAABAgQIECBAYJYXEGSd5W+hCyBAgACB1ihQFDd32GGHtN9+++VXc0WQNYrRUYA+66yzUrxeK8a3336bC5cffPBBfs1XdF2IEUXgKAavt9566bTTTsuvpMobav655JJL0qOPPpq7SsR+Ddm3mMNfAgQIECBAgAABAgQIECBAgAABAi1dIEKREY7cZZdd0t57751PNzqNRsfRGP37989dMvOHmn8++eSTHJaMzwceeGDadttt86bJkyfnoOF9992XP8cbkeLNSDHKBRiLdZWGUIv9Y74iBFmsq3SOCHrG9cbr6aOGGDW/eH19vJnp9ttvz+HImL+0Y2tYxAPvMbbffvu01FJL5eWiI2t8KLquxnJ0Y40w5ptvvpkfto+H7mOcf/756fHHH0/zzz9/uummm3IQN9ZH6DfCpl988UV8zB1hf/KTn+Tl6D4aXUjnnHPOfH5dunTJ6+N4b731Vu64eu655+Y6aFxb3Ksbbrgh71MEaJs7yNqQumlxv+IEi3sYgdTwiBG/v/gd1jeiC3AEr+NaI6Tbq1ev2l0HDx6c3aMjbAStt9xyy9ptFggQIECAAAECBAgQINAaBARZW8NddA0ECBAg0OoE6gZZ4wJffvnldPzxx+dXdkVBOJ68jwL6a6+9lkaNGpVf1RUF4+iAUOx/3HHH5eUoDq+99tpp3LhxuQgcodcYUejdcMMN89yV7pu/6B8CBAgQIECAAAECBAgQIECAAAECs4DA5ZdfngYMGJCDlcsss0zq27dvrqPVF2SNzqXRdTXCoO3atUtrrbVWDrq+/vrr6auvvsrB0K+//jp3y/z1r3+d9t9//xYTZI3b8eqrr+YaYoQh4zX23bp1Sx9++GEaP3587d0qDbJGbfHoo4/O284+++y07rrr5uXSIGusWGyxxfJ/EQqO64+Oseecc05affXV8/5PPfVU/hwfFlpooVy7jOPG/h06dMh1zAkTJmTPPfbYI62xxhq5phnLMRZZZJH80H0EOSMke+yxx+YA7rzzzptWXnnlNGTIkDR69Oi87y9/+cvajqTNHWSNmmylddO6QdaJEyemnj175npunHgEksuNeGtWdGONUXRljeUIIy+33HK5A+1LL72U5wnvqB1H11aDAAECBAgQIECAAAECrUlAkLU13U3XQoAAAQKtRqDoYFB0ZC0u7JVXXsmFygiulo4lllgiv+Jr6aWXLl2d7r///tylILoulI54LVV0ANh6661rVzdk39ovWSBAgAABAgQIECBAgAABAgQIECDQggWis2cELouOoBECXHDBBWu7rtbtyBqXEg+BxxuRIohZjM6dO6ftttsud8I85ZRTctA11sWr4COsGZ1QYxSdOIt1c8wxR3rooYeKaWr/RjgyQpJRo4suncX+TZmjmDzmuvrqq3P4MwKtcZ4RHN18882zRQRNb7nllrz7G2+8kY488si8/Kc//SkHTeNDvCXqo48+yoHRO+64Iy/nnWr+iXBpGKyyyirFqvw3Oo/efffdObRabFh++eVzUHbQoEH5mFGnjPBvdH+NUXQ8jeU4x6iLxhg6dGiKYO2IESPy5/gnLLfZZpu011575TByrIuOrtHZtXCMdcX4z3/+k2um9d2DYr8Z/a20blr3HkaQdaeddprR9GnJJZdM1157bd4vgtRxvBtvvDEHeYsvxzX06NEjHXTQQbVv6iq2+UuAAAECBAgQIECAAIHWICDI2hruomsgQIAAgdlKIIq9UUyP13JFETo6SSy66KL1PtEfr/uKTgxRrI9OCdE9IQrIxau6SvEasm/p9ywTIECAAAECBAgQIECAAAECBAgQaKkCUU+L2lh0WF144YXrraOVnn98J8KUn3/+eYq3HUUNruiCGWHD6L4Z9bV4wLyljriGsWPH5i6ycY6PPfZYuvDCC3OXzyuuuKJBp/3ZZ5/law6L6PJaWNSdJIKn77zzTt6+6qqrpuj+Wox4OD8842H86BZbjJEjR6YIfca+dWuWn376aXr//ffzNUTgMx7Q/zHGzK6bxvEiGBvX37Vr1/wWrgUWWODHuHTHJECAAAECBAgQIECAwEwREGSdKcwOQoAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQKD5BCK0etRRR+UDHHDAAbXdVWNFhG+j+2l0L91yyy3TYYcd1nwn0kJnjiBvdOitdESwtlevXpXubj8CBAgQIECAAAECBAgQaIKAIGsT8HyVAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAQEsROPjgg/ObnKJzas+ePdN6662XohPqgAED0sMPP5y70l5wwQVplVVWaSmnPNPO46677kqDBg2q+HjR/fW0006reH87EiBAgAABAgQIECBAgEDjBQRZG2/nmwQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgRajMDrr7+eTjzxxDRhwoRpzqlt27bpuOOOSz169JhmmxUECBAgQIAAAQIECBAgQODHFBBk/TH1HZsAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIBAFQU++eST9NBDD6UhQ4ak0aNHp8UXXzyttNJKaf3110+rrrpqFY9kKgIECBAgQIAAAQIECBAgUB0BQdbqOJqFAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECggQKCrA0EszsBAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgEB1BARZq+NoFgIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAgQYKCLI2EMzuBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAEC1REQZK2Oo1kIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQaKCDI2kAwuxMgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECFRHQJC1juOYMWPS+PHjU8eOHVOXLl3qbJ324/fff58+/fTT1KZNm7TkkktOu4M1BAgQIECAAAECBAgQIECAAAECBAikkSNHZoX5558/19KmTJmSRo0aldctsMAChJooMGHChPT++++nL774Iq2wwgpp4YUXbuKM0//62LFj07hx4yquo05/NlsJECBAgAABAgQIECBAgAABAgQIEJidBQRZ69z9U089NQ0aNCjtvPPOqXfv3nW2Tvvx448/Tvvuu2+aY4450kMPPTTtDtYQIECAAAECBAgQIECAAAECBAgQmM0FihpaMPTv3z916tQpvf3226lv375Z5sEHH0zt27fPy/Hg+KRJk/Lndu3a5XX+mb5A+J544onp888/zzuG629/+9vpf6mJW88777w0cODAtMMOO6T99tuvibP5OgECBAgQIECAAAECBAgQIECAAAECs7OAIGuduy/IWgfERwIECBAgQIAAAQIECBAgQIAAAQJNFGhIkPXQQw9N7777burTp0/aaqutmnjk2ePrt99+e7rtttvyxS6zzDJp7733ThtssEGzXrwga7PympwAAQIECBAgQIAAAQIECBAgQIDAbCUgyFrndr/55pu5c8FSSy2Vll122Tpbp/1YFOF1ZJ3WxhoCBAgQIECAAAECBAgQIECAAAECIVDU0GK56Mg6ZsyY9Pzzz8eqtOmmm6a2bdvm5UMOOSQNGzZMkDVrVPbP6aefnp599tn0q1/9Kh155JGVfamJewmyNhHQ1wkQIECAAAECBAgQIECAAAECBAgQqBUQZK2laNxCUYQXZG2cn28RIECAAAECBAgQIECAAAECBAg0r8DkyZPzAYqgaHMcLY4xduzYNNdcc5WdvqihxcYiyFp2x5qVMyvIOmnSpBTn3b59+/pOpcnrJ06cmDp06DDNPLE+6onVuicnn3xyeuGFF9Luu++eevXqNc3xYsUPP/yQvvrqq9S1a9eqXHMlQdb4TYRvcxhPmTIlxT0Mx0rH999/n+/5nHPOOdVXvv3223yOHTt2nGq9DwQIECBAgAABAgQIECBAgAABAgQIzByBVh9kveKKK1J0Wd16662neRXZM888k1+5FZ1Xi04FN9xwQ95/yy23TL/85S9r78KECRPSzTffnF5++eX00UcfpSWWWCKttdZauVvEH//4x1wwfeihh2r3t0CAAAECBAgQIECAAAECBAgQIEDgxxQYPHhw6tevXxo6dGhq165dWnvttXPHznfeeSd37+zZs2fabLPN8ik2tIZWXNfAgQPTfffdlzuoRlByoYUWSquttlpaffXV0xZbbFEbMiwXZI0a26WXXpoDhGeddVZ67rnncq0u1kfQc+65587zxTyPPPJIPuTxxx+funXrVhw+/x01alSKIGeMAw44IK255pp5ufSfa665Jr366qtpjz32SG3atEn33ntvCocIQi699NJp++23T5tvvnntV6Ij7EUXXZQ/X3nllbXri4WTTjopjR49OneNXXnlldMHH3yQItjZuXPnFOd45513ZuORI0emNdZYI7tHrfG//uu/UtQQY/4IuK644orp4IMPTvF2qMaMuKZbbrklDR8+PH333Xc5pLrkkkum3XbbLa277rp5ytdffz1dd911+ZgR5Izrj+P+/ve/r92nMceuL8ga1/y3v/0tPfXUU+mbb77J9zeOF7+L6Ly73HLL5cOdeeaZ6bPPPsv3K+5b3XHrrbdmw7jfYVqMmPfBBx/M1xM122WWWSb/tiPE26lTp2K3NGLEiBSdaiO0etppp+Xf2qBBg/Lxzj777BzqDbsnn3wyRXfgcFlkkUXSJptskvbaa6/a327thBYIECBAgAABAgQIECBAgAABAgQIEGg2gVYfZD322GPTK6+8kjsRRDGzdEQB/M9//nOKIGtRkD711FNTFDR33nnn1Lt377x7PJF/yimnpLfeeqv063k5ukxEkVhH1mlorCBAgAABAgQIECBAgAABAgQIEPiRBB577LFc94pwaYzo/BndRyOsF8G+8ePHp7333jvtsssueXtDa2jxpXhI/IwzzsjfjznnmWee9MUXX+TP8U8EQ48++uj8uVyQ9e233059+/bN2yOY+O9//zuHQfOKkn8ijBjBwwi3RrfRujW+CIZeddVVOTAZAcouXbqUfPt/FouaX9QB33vvvRTdPMMi/saI5RNPPDH97Gc/y58jIHrMMcfk9QMGDMjrSv+J8/jyyy9zzXCjjTZKpdcSD8DH9ZbOH9+NueMa6475558/n390Sm3oiPBvhDTrjnD97W9/mwPAl19+eQ7sxj5xnyL8GSPCzRFGjYBpY0a5IGv83qJhQISnYyywwAJp3LhxuVtvfI6g6YUXXpjrsTfddFMO/Ma6uG9xbsWI32qEjqOD7E477ZT22WefvOnqq6/OIdb4EL/p+K/4jce9jd/KggsumPctfnPRDTbCxnFPY0TAN+714Ycfnj788MO8bt55581GUQeOsc4666RzzjknL/uHAAECBAgQIECAAAECBAgQIECAAIHmFxBkrSDIeu211+bOEhFW3XfffXMHhSiQRoH1gQceyHdJkLX5f6yOQIAAAQIECBAgQIAAAQIECBAgMGOBCONFSDVe6x5dWONtQhHUiyBfhA+jS2aM2KexQdYIGu66664pjhVvNjrkkEPyg97x+frrr0+PPvpoDhlG59N4XXsRKozj9u/fPwcaS8OfEWSN+lrU3CKEObymw+hBBx2Uw5ix/txzz80dPqP7ZoRWS0cETuPaIiganVLLjSLIGtui++kRRxyRVlpppdzVMx5gj66u0UX2ggsuyF9vSpA1ApnRZXWzmm63Eew94YQTcnfQmDi6i0Yn3J/+9Ke5A+0ll1ySjxfnEIHYxozoshrX99JLL+V7usMOO+SQatznCIDG7yC61MY1R8fcF198MUW9MzrfrrrqqrWdZxt67HJB1qiVRvfbePg/7tnyyy+fA9Rxr+M+RRg5fnPx24sutkUn1uiou/HGG9eeQjQmiHB1jAivLl3TNbcITkdAeM8990w77rhjvs7YN44VzQbC9bT/DfaW/uZing033DD94he/yHPFG7yiwUHcq4svvjgHa2OfCAZHODt+39HFtm7339jHIECAAAECBAgQIECAAAECBAgQIECg+gKCrDMIskbxPV6zVbdLRXErosj8/PPP68hagPhLgAABAgQIECBAgAABAgQIECDwowrEK9n79euXO6TefPPNOUhanNDLL7+cjjvuuPyxKUHWr7/+OtfMIlQYnTUjKFuMzz//PAcV43MEJuNV96WhwvqCrNE5M0aEYocNG5b69OmTttpqq7zu2Wefzd0240MEZaPraYzRo0fnzp3RWbVuGDLv8L//FEHW6NZ64403prnnnrt28x133JHCKa4hHlyP0ZQga92usfEmqL///e953iKUmT/U/LP//vvnrqDRfTTCmY0dce0vvPBCDq5GB9MY4RRB4giVRiiz9B4NHDgwh5ojJHz33XdP9Rup9BzKBVn/8pe/pIcffjjftwMPPHCqqaLD6VNPPZU23XTTdPzxx+dthx56aHr33Xdz6LcIrsaGwqw0uBwNBuJ3tM022+TfSOnkRcg1OrTedtttab755pvqN1e3w2qEoaOTb3TDjd9Dhw4daqeLIG4Eb+ONXRGMNQgQIECAAAECBAgQIECAAAECBAgQaH4BQdYZBFkjpBph1SiCRlG3c+fOU92V4vVdOrJOxeIDAQIECBAgQIAAAQIECBAgQIDAjyQQQdUIrEbHygj/lY4IfEbQMbp0NiXIWjpnsRwP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hx/Gc889Zwj3l8iqy2prf61k1cpXTZqIi4szn9u8eTN+8pOfmK7aKLKO/MXKLZAACZAACZAACZAACZAACZAACZAACZAACRyaQGgPQ9/97ndx7rnnmg9pyqQKfNu2bTOvQ0XWwdahabfrK1asMHVtmoYaTPTUOjSVRuvr6802HnroIeTm5h5QKtyxYwduueUWs9zSpUv71qF1cSqSXn755SY11izw6T+hcmLwfa3nCyZ/6nyVELWo8JiTk2OmByuyKisVJzWpMzSlVtNgVdZsb2836z2WRda1a9fijjvuMPsZmm5aV1eHH/7whybZduHChVDhdShlX5G1qqrKJOXqur75zW/ioosuMqvdty725JNPxu23377XJoPrCr55+umnm16wgq91HFxGxVztQSsoF7vdbqiwrfJ2cnKySee1WCwHvOaC6/v5z39u0lfnzZsHFb+1bjdY7r//frzxxhsmVVaXYyEBEiABEiABEiABEiABEiABEiABEiABEiABEhh5Ase9yPrxxx+bilnt2ioqKsq0pNdK1Y6Ojj66/YmsutDGjRvx4x//GLqO2NhYzJgxw1Rir1u3zlSwd3V1mcrOF198sW+dnCABEiABEiABEiABEiABEiABEiABEiABEiCBo0FA67BuvfVWbN261XQdr42zU1NT8cknn+xVJxYqsg62Du3dd981Db71+FQenDlzJioqKoyAqumeKi9qwqZ2Af/lL3/ZCK/7dvN+MJFVG6O/8sorpt5N911lV03YDBYVSTX9U8uUKVNw3333BWdBG55rMqeWX/3qV5g7d66ZHqzIqh+67bbboFy0aP2hyq27d+82r5WxlmNZZNX9U0lTBc+wsDBMnjzZMNXrQPff6XTiD3/4A/Ly8nTRQZegWKrC79e+9jWzTk1d3bVrF6xWqzn3mpq7ZcsWtLS0mHrV1tZWU0d79tln75Wqqum+ocm+oecuuGONjY34xje+gc7OTrOOOXPmmCRZPUcqTusx/vKXv+w756HS87PPPmsSfIPr0iRflXm1qGg9e/Zsk/qqkrf24KVFr5lFixaZaf5DAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiQwsgSOe5FV8b366qt48MEHTWVzEOf8+fNx5plnmpb8oSJrsMutL37xi7j++uuDi0OlVW3ZHyrAasqrtsr/9re/bSpnKbL24eIECZAACZAACZAACZAACZAACZAACZAACZDAUSSgsp/Wh7311lt9e6Gi33nnnWeSKlX+CxVZdaHB1KHp8prEqt3Aq7QaLOPHjzci6Zo1a/DYY4+Z+jjtBv6EE07oS+sMSoWauqrpq1pCE1lVJtRekYKpripMqigbLC+88AI06VVLaNKovlZ59//9v/+nk6YuT0VaLcE6v32PWecF5dd9e1xqa2sziaZBaVaX1YbyKm0uW7bMfC4osgaPZd916GeC+ztx4kT88Y9/1Lf6itY3vv322/jsZz/bl07bN3MQE0Fh9YYbbsBll13W90lNlP2f//kfaL1lUL7VmSqvampqkE/fBwYxEUzlDYqs+lGVQFUmVak5WCIjI3HxxReba095qeiq7/3nP/8JLoJAIGCEZxVdNWn1kUcegaaq7ls0EVe3G0wVDs7PyMgw1/Mpp5wSfAuhCbHBa65vpkw8//zz+Pvf/75XnbHO13Os18n5558fujinSYAESIAESIAESIAESIAESIAESIAESIAESIAERpDAmBBZlZ9W2paVlZnW/9rVWFpa2qCxaoqEJkXU1NSYFAat8D1QheqgV8wPkAAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkMAIENAUSxUHVbBUydTlcuFHP/qR6YHoQFLnYOvQtJv6nTt3mjqyqVOnIiYmpu8ompqaUFtba6RJTeYcTNH0UxVZNdlTxUaVcIPlgw8+wC9+8QsjQz7++OMIDw8PzhqRsaaJlpSUGMExJydnxLc3EgfhdrtNWq72LKXSZ3p6+ojVa+q5U/lXz72mnWqqbrAOVWVarV/V61DraEOLprmWlpbi6quvxpe+9KXQWXtN6zo0bVXlYRVgtZ5X02aD29hr4UO8UOFbE2r1WrPb7YZL8L+TQ3yUs0mABEiABEiABEiABEiABEiABEiABEiABEiABIaRwJgRWYeRGVdFAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAsckgf5k9WNyh/fZqTvuuANr167FRRddZJKD95k9al+2t7ebpOrBHMDZZ58NTXM+nKLJ3PumGPe3Pm2QoEL5cJaioiJ897vfNY0qNK07Pj5+OFfPdZEACZAACZAACZAACZAACZAACZAACRwHBCiyHgcnkYdAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAkpgNIqsmu6sKbsVFRV49NFHTQrzww8/bFJyj5ezqunOv/zlLwd1OEuWLMGJJ544qM/su/DTTz+NNWvW7Pv2QV9HRUXhrrvuOuj8wcxYvXo1NEX5iSeeMGnMKuZ+//vfH8wquCwJkAAJkAAJkAAJkAAJkAAJkAAJkMAYIUCRdYycaB4mCZAACZAACZAACZAACZAACZAACZAACZAACZAACZDA8U9gNIqsK1euxG9/+9u+k3PBBRfgpptu6nvNidFJ4Ktf/SoqKyvNzkdEROCBBx5AWlra6DwY7jUJkAAJkAAJkAAJkAAJkAAJkAAJkMCIEqDIOqJ4uXISIAESIAESIAESIAESIAESIAESIAESIAESIAESIAESOHIEnnvuOZSXl5skzwULFhy5DR/GlrZt24bHH38cKjtOnz4dKrJaLJbDWCM/eiwQeOihh7B7925kZGSYc5qTk3Ms7Bb3gQRIgARIgARIgARIgARIgARIgARI4BgkQJH1GDwp3CUSIAESIAESIAESIAESIAESIAESIAESIAESIAESIAESIAESIAESIAESIAESIAESIAESIAESIAESIAESGAsEKLKOhbPMYyQBEiABEiABEiABEiABEiABEiABEiABEiABEiABEiABEiABEiABEiABEiABEiABEiABEiABEiABEiCBY5DAqBVZtVsh7W7o/fffx9atW5GWlobTTz8d8+fPN5h7e3uPQdzcJRIgARIgARIgARIgARIgARIgARIgARIgARIgARIgARIggaESaGtrw7Jly/r9+JIlSxAWFtbvMjpTl+ns7MS///1v7NixA1FRUVi8eDEWLlyIyMhI8DnDIRFyARIgARIgARIgARIgARIgARIgARIgARIYFgKjUmRVifUPf/gD7rnnHvT09OwFIiYmBq+99hry8vL2ep8vSIAESIAESIAESIAESIAESIAESIAESIAESIAESIAESIAERjeBt956C1dffXW/B1FcXIyIiIh+l1GJdfXq1bjmmmugcmxoiY6OxgsvvIDJkyeHvs1pEiABEiABEiABEiABEiABEiABEiABEiCBESIwKkXWZ555BrfccotBoi2jzz//fHg8HvzrX/9CeXk5xo0bhzfffBNOp3OEsHG1JEACJEACJEACJEACJEACJEACJEACJEACJEACJEACJEACR5rAww8/jDvvvBM5OTnIzc3db/M2mw2PPvoo7Hb7fvNC32hsbDTpqx0dHabHt0svvdTIr0uXLsX27duRkJCAV199FdnZ2aEf4zQJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkMAIEBiVIuvcuXNRU1ODCy+8EA899FBfF0Eqs5555pkoKSkxrah/85vfjAAyrpIESIAESIAESIAESIAESIAESIAESIAESIAESIAESIAESOBoEPjxj39sRNX7778fl19++ZB3QWVYlWJjY2NNL29BKba9vR1nnHEGqqqqcMUVV+C+++4b8jb4QRIgARIgARIgARIgARIgARIgARIgARIggYERGHUia1lZmWklrYen3f7s2xr6iSeewK233oqkpCRs3boVfr9/YCS4FAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQwDFN4Morr8Tbb78NTU6dP3/+kPbVYrGY5wylpaX49re/jR/96Ed7reeRRx7B7bffjujoaGzbtq0vTGOvhfiCBEiABEiABEiABEiABEiABEiABEiABEhg2AiMOpFVd/iqq66CVjRVV1cjEAjsBeP999/HF77wBfPemjVrkJWVtdd8viABEiABEiABEiABEiABEiABEiABEiABEiABEiABEiABEhh9BKxWK+bNm4fdu3ejsLAQcXFx2LVrlxknJyfD5/Oht7f3kAemy+Tk5JjnCy+99BK0F7jQUlRUZFJZ9b1ly5Zh2rRpobM5TQIkQAIkQAIkQAIkQAIkQAIkQAIkQAIkMMwERp3Iun79elxwwQUGw4oVKzBp0qS9kDz77LO4+eabzXuvvvoqZs2atdd8viABEiABEiABEiABEiABEiABEiABEiABEiABEiABEiABEhh9BHp6eoyAquNTTjkFq1atMvKqHomKrN/97nfx1a9+db8AjH2PtLy8HIsWLTJv79ixAy6Xa69FNEAj2BvcU089hVNPPXWv+XxBAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiQwvARGncja0dFh5FVtMX3bbbeZiqlgC2ubzYYlS5bgvffeM5SefPJJnHbaafsRq62thUquAyl5eXlISkrC9OnTB7I4lyEBEiABEiABEhjFBLZu3Yq6uroBH8Hs2bNN6suAP8AFSYAESIAESIAERh0Bt9ttJJmB7nhMTMx+qW4D/SyXIwESIAESIAES6J9AWVkZFi9e3LeQPhPIzMxEZWUl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8jwfAQQQQAABBBBAAAEEEEAAAQQQ6F2BARdktYap/fffX1asWCGnnXaaXHvttdLY2OhUfD6fXHDBBfLwww/LHnvsIQsWLJCmpqbeFWNvCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggg0OcCPQ2ycp2hz986TgABBBBAAAEEEEAAAQQQQAABBBBoITDggqx29rfccotcc801rurq008/LQcccIALrL766qty6qmnSk1NjVx33XVy8sknt3ixzCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggMDOIRBOkPX++++X5cuXy4QJE+T0008PvnCuMwQpmEAAAQQQQAABBBBAAAEEEEAAAQT6XGBABlmrqqrk+OOPl8WLFzvA3XbbTeLj4+WDDz6Q+vp6OeaYY+TOO++kGmuf/3pxAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIbB+BcIKs3/3ud+WNN95wPb3NmzcveCJcZwhSMIEAAggggAACCCCAAAIIIIAAAgj0ucCADLKaWmlpqZxyyiny3nvvBRFjY2PlkEMOkbvuuktsmgEBBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQR2TgGfzyezZ8+WvLw8uemmm+R73/temxd64oknyn//+1856KCD5LHHHmuxnusMLTiYQQABBBBAAAEEEEAAAQQQQAABBPpMYMAGWT2xwsJCef/9911F1r322suNvXWMEUAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAIHOBLjO0JkO6xBAAAEEEEAAAQQQQAABBBBAAIHtLzDgg6zbn4gjIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAghsDwGCrNtDlX0igAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCHQpEHaQ9b333mvqcm9sgAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAQJgCmzZtCmtLH0HWsJzYCAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAgTAGCrGFCsRkCCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAA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7gZZLSBr4W2r7tp66CjI2tPXZxUy7N+BDRaY/etf/+qmW/8wk5KSEsnKymq9inkEEEAAAQQQQAABBBBAAAEEEEAAgXYELLgZSa9Xtsue9sATGmS1tlfrncp6CSooKBCr7mrtbzbYzdN33nmnKxLgFuiP0LawznoOsnYlu+nZ64nIei7Kzc2V4cOHu6qs1v7rDY8++qgLu3pBVm+5jcPtxct7DkFWT4IxAggMVgGCrIP1ned1I4AAAggggAACCCCAAAJ9J9CnQVZ72R999JG7o711N+ehJNY4+cMf/tCFWr3loWFU6ybKC6x6621sjaXW7VNdXZ1rrJw/f75YSPb+++93VQZsm5NPPllOOeUUm2wzWNDWqmraMHPmTLFKlxrodZU0rTsrC8FaGNUbrBrnqlWrXDVN61pr48aNMnnyZLnlllvcJnY+xx13nFhlztAQo/f8jsZekDWqvlKsiqY1vHpjm7aHvS5v3NF+bLkFWW++4263iQVZx+WObbP58wtfkseeeNotv+2GP0tmRnqLbey1n/aTQED4W9/4qpxy4gkt1vfGzJtvvysPP/pYl7uy0GNaeroUawCyq+HA/feVbx56SJdBVgtUWgXf7jTUWBjbftdqa2tdd2N33x0wbu+czjzzTFm9erWrSGHdv1kDfGjjvXVdlq6vqfVgFYIffPBBt/jUU0+Vk046KbhJd4OsFrq2iwjtDe0FWSN5fZWVlWJBcK+a7I033timCkd758EyBBBAAAEEEEAAAQQQQAABBBBAAIHOBSLt9SqSHnheeOEFqaiokP33398FS0PP1NpF7WZtrzeuSy65RPbbb7/gJqFtYbawo56D/vjHP8pbb73lnmftgFZUwNpbvSEvL08swPrqq6+6sVVtDQ2ydrcXL2+/BFk9CcYIIDBYBbpzfWSwGvG6EUAAAQQQQAABBBBAAAEEelegz4Os9nIs4GZdp7/++uuuW/OtW7e2eZUW9vv1r38tX//61926cIKstqEF6KzKow3PPvusq7z6pz/9Sd544w23zLprt26m2huKi4vl+9//vluVk5PjuqeyGatyYNUObLBQrFcBwKs6aZVcbb0XUrTQoDW0rly5Un7+85+75x199NHys5/9zE139WNHB1n/piHWBRpmteGe22+QxMTENqf4f2ec4wLC++8zR8766Q/brI9kwfsffCh33ftAsGpD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AAIQgAAEIAABCEAAAt0ggJA1kHqWwb3f//73fSZjdtpppyZBgiZsrrvuuqara9nYzTffvLHv/PPPNx988EHjuza22GKLPh5Z9XB+yy23mIceesi89NJLduBekz1zzjmnWWihhcyVV17ZFEfaF1/M2kuiQHkr+/Of/2zFuRLo+mGaaaZp4uI4JTFUGdx3333mvffeM9NOO23TssIaaFb5PPnkk/Z6WtpSQWymm246M9tss9myihOZvPzyy+bqq6/2k2dmmGEGs/XWWxtNyOqa4qpryGvsYostZjbddFMryNRJyucdd9xhnnjiCTuhqEH5hRde2Pz2t79tm4fZXq+zSUJWMfMnN3upzipthO4RCJlwdKnTfaulwt5++207uCkxh0TrusdnnHFGs+yyy5pVVlnFHd7nc9SoUXZpcHmDlN12NkOiIsUh+77ccsvZFwK0zw+PPfaYue2226yt+fDDD427toS0U001lbURur7sCKG9BEIHtl0qnJhVE9fOo6bKadCgQe6QUj+z9EXcheP6CBKWqY+w8sorG01Y+UETYvvss09j16OPPmruvPPOxndtzDvvvGb99ddv2nfOOeeYESNGNO3bZpttzPTTT2/3PfLII+bBBx80r732mhUXavJcaXNBadJ9Is/za621VqwIPO2eTmp/XR/i9ddf79O3mnrqqc2ss87qkmLbZ32JCstc36Jx4JgNTSr97ne/s8J1f/9WW21lbYe/L7p91llnWRbqv0XDkksu2XhxRgzF0g/Kq/pEslny2OpEQLIbOn755Ze3f2ULOZSGPPYuqT8msalYZA177713H5HkRx99ZPu9Kmf1FR0XveA1xRRTmLnmmsssscQSjT5Xq2tWpV+hPqz6nC+88ILtz6rdUn1W+av+6n7zg178Ur9Toax7WxPpN954o+3byoZKDKfVEVzQBLts5XzzzWdWWmkl26d1v7nPkInzpDqk+iP78/DDD7soG596ES9NmBd3jymCe++919qtRmRjNmaaaSaz5ZZb+rtabrdKs14muOqqq2y78dZbb9l7Xc8iq6++ullvvfUa974iLquckuxjrz2fOHutezj0mc8vhG7Yx5A6nLcdlM0944wz/Cza7SJtktpfte1qf9SOyHZqn2yfbIhWgIlrx5LqkfpC99xzj7VHEtbr2XeOOeawaW3VbshOqZ3SNWUj9GzcSpTT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iF0uykzPJepr6/lCfX0CBCAAgXYQKMPOtXvsqci4Q5G0lTXWkrXcumn/u/nMnpe3ew7SGHzoWJZfJkXG84ryKjIO2a164njnfe4skueivP1y77VtZ4sRsvZayZAeCEAAAhCAAAQgAAEIQKBbBBCyBpJ3D5RZhKx6OL/pppvMSy+9ZF5//XUrZJ1++umNBJBDhgwxM888c+DV+/9hefh2g8oLL7xgjjjiCHvpZZZZxuy2226NZBx55JHm+eeft99PO+00M8000zR+Y6P/EShaZ//xj3+Ye+65x4oADzjggMyCtjiiZducV199tSHcnmqqqcxWW23V57JvvfWWefjhh43ujQ8++MCMHDnSHqNB5EGDBpk111zTLLnkkn3O+9Of/mSP7/NDwg7ZzYUWWsi88cYb5vrrr084su9PE044odl5552bfpDw/IorrjAvv/yyFQDpx3HHHdfeu6uvvrpZYYUVrCio6aT/ftGEyB133GHuvfdemw99V5h88snNPPPMY37961+bqaee+r9Hh32E8FZMEtRfe+21RserEdcA8rTTTmvblk022cQMGDAg9YISrj7yyCNG9VDxfPbZZ0b7JOJVee2xxx6xcTz22GPmoYcesu3axx9/bAXLs88+u5lvvvlsWSstISGk/pdVznHp0eC76uCHH35of15vvfXMbLPN1nSo7qdzzz23aV/Sl7XXXtuWQdwxKrO//e1vRvy0rUkahYknntgcfPDBfa4dF4f2FbU7yvfxxx9vRdtLLbWUWWWVVVpdKnZ/SLm5E/PaBnc+nxCoG4Es97cm8R599FEzfPhw884771i7oslMBdmVOeaYw8gmyTYTIAABCEAgH4EsdjnuCln7TcOGDbPPVOqfjho1yqjfpr61nsOWW245s+qqqwa9rFC0vxeXF/ZBAAL9k0BeO1f22FOUbpFxhyJpK2OsRXnJYof13FyW/de1s7Y9ecbzdJ0777zTPPvss9pMDXphfNttt+1zXB7eGnt78sknLTOxU5up5yCJWNVeajxO42KTTTZZn+u5HXnH88oY48g7Dll2PREL1dO0cbEynjuL5Dlv/XRlXZVPZ4sRslalxEgnBCAAAQhAAAIQgAAEINBuAghZAwm7B8pQIasmHo4++mg7uRx3CYmqJF7RRDOhuDinUwwRsnaKdO9fJ6tNiObomGOOMc8884zdfdFFFxX2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5L2/1SbI47eW0vzkk0/shLLaJGfzoysT9GeG5A0CEIBAmQTy2mWXhrz9JvU/9adnsddee83cdtttjT62niu0+oSesVqFov29VvGyHwIQ6H8Estg5/zm3HePdRcYdiqatyFhLXK3Ia4eL2n+lJW/b4/IR+syuF6I1zjH22GO7U+2nnkluueUWI7GoC5tssonZYIMN3FdTNm+92H3QQQc1BLRa8UYvWkeDno/yjOdF4/G/h/IqMg7pX0/bofWkrHExd/2sz51l5tmlIZS3O74qn84WI2StSomRTghAAAIQgAAEIAABCECg3QQQsgYSdg+UaULWe++914pbFO0yyyxjdtttt9graIL5rLPOsr8tscQS1tNe7IE12RnKVzhChKz33XefFbHpeISsokAom0CWOht37aKDyy7OvDZnq622aggrXVyhn62EkDrfF5HrexYBje/JMkmIqnijQSLYPffc03o40G9bbrmlWXvttaOHma+++srstNNOduBXAl55bPBFthJiyjZLqOiCBsDd0u/ywuoG5OXRVPbIDdzLi4iEoFrO3gmIZpllFis+9YWsLt7QT5/3PffcY6644orGAL0fh0S5mtT+7LPP7G6V8VprrWW3/UmhVl5Vv/vuO7PzzjsbechQOGKMAFkeX8VL4iiF0047zUwzzTR2O/rPr9Mnn3yyFfBGj3Hf/WMvuugiK5R2vyV9hpazP2CfFF/0N+VX+Q4J8ua79957m2+//dYeLq8YEsP6HsFXWWUVo5cn4oK8smoQXkHCYpVzWihqd/JOqLl05S03nV/ENrjr8wmB/kyg6P3ts7nzzjuNXkZQ0IsoernLtVX+cWxDAAIQgEBrAkXtcpF+k58qvfgkgY68mv0/9s4C3I7i/MNDg5dAggSXBHfX4lJCaCBAcA/uaYHiUNxdgxV3irtrKBR39wdNCBQI1v7//Kad07l795wzu7Pnnrt33+957j0ro+/Mfufst99+I/F/4/vp3Hbs7z1XDp8QgEDPJ5BFz+W1PemF1xCJsTsU0ba8tpa0vhWlh7Pqf7Ul5runyHv2c88914ipxF8tyB749V9RvPWCulYEcg+4tBLOYYcdZlencHW5z7z2PJc/+RnKK9YOmaw3uV9vnnSFXazefWcr+hzKO8mnDPtOF+PIWobRoo0QgAAEIAABCEAAAhCAQFcQcPf5zeoa59cb0P9rlijL+U+++Momb+YYuvT8C2cpNjXtyBefTT2e5aC7oWzWXi3NrAgZkjRDjavzzjvvNHLekTRydHHpe/pnKF9xCHFkffjhh42WOZLgyGox8K9gAlnmbFrVMcZlv7y8OkcRWHfccUe/qOBtGYXnmmuuTum1rJgcHV3EUEVC1vUXInL8lCOqnAMlzR7QJsv0HxRMNtlk9vpPi8YqY7kM6hI5Hcr5MCly6DzooIPMO++8Y08pqqYeHksUZVuR7ySKHis9n5QnnnjCyJHTiZa1VxTTongr+oKikirqhJxO+/bta51GFVVUjkuPPvqordpvn4tErROnn3563WXstezfCy+8YPNvvfXWNlKor3Ods6ZNkPg3bNiwmoNtPWdZlyXv/A8dZ/+hiasz5FNRZBVNNlQ0L5zTs6IcL7fcch0cNrWv42nyxhtv2AcuOidHM/ebIC2tOxard3x+oVFgXd36zDtuMbrBr59tCPRkArHXt89G38P6HlVEJEmzlwv8vGxDAAIQgMB/CMTq5by/m9L433zzzeayyy6zp5Zeeml735SWTsdif+/VK5fjEIBAzyOQRc/ltT3Ve7EzSTPG7lBU2/LYWpL90H6RejiL/lfdeb97ir5nf+6554xsYZJpppnGnHbaaXbb/xfLW86SsmG9+eabtth+/fpZJ9Z6qwjltefNNttsfrPtdhZesXbITpWnHEibJ11hF6t331l0n7PwTsHT7Q85XYwja7cfKhoIAQhAAAIQgAAEIAABCHQRARxZA0G7G8pmjqwynhxwwAG21FlnndUcffTRqTVcfvnl5qabbrLnhg4dajbYYIPUdFU5GMpXPHzjZj1nqRBHVkVjVOREfSbFj6gno6ucjSWvvPJKLVpfMuLuoYceal5++WWbrlHUQpuAf6UnkGXOpnU2r3E5WVYrdc4XX3xhI3SqTi1j6TtnJtuhBwcnnniiUVRSiXSadFuo+EZOOWbK2TK5HHu9suR4usceexhFPZVsueWWZs0110xN7i9D1eglAt8ILGO4HOO1vOc222xTK7ee/lHUUDmByqguCY1Km4V3rRGJDTkDf/zxx/aor4cUBUXRZiX1HJF17pRTTrHLUmtbUVyHDBlijjzySPP888/rkNl3333NIossYrf9f3KWEnf1XTr1yiuvbBj9L8/8zzLOftvqbfvRKcRH0dGzyvHHH2+eeuopm22rrbYygwYNMiNHjjQnn3yyPdbIMVZRtTRvJeONN551Tkj7PrIJ/vsvVu/EPlDLM26xusHvP9sQ6MkEYq/vJJu9997bvP/++/awokcvueSSySTsQwACEIBAAwKxejnP76Z6zdHvTf3ulMw444z2vqte2tjfe/XK5TgEINDzCGTRc620PYlsjN2h1W1T++rZWnQuKUXq4Sz6X+3I893Tint23YfofkSi1YPcajT2QMC/Zry1Mo4cZWWnl0w11VTWXq/PNCnSnpeVV4wdMq0vaceyzhNXRhF2sbT7ziL7nJW361uZPp0uxpG1TKNGWyEAAQhAAAIQgAAEIACBVhLAkTWQrruhbObIOnr06FrUPS3xfNFFF6U6Y/lOj3ojeJVVVglsSc9MFspXvY91ZN1+++1rS2dfeumlJi1qI46sPXOeFdmrLHM2rd48xuW0clqpc0IdK5955hkb2VRRVSVZnfPloLj77rvXlq93kUDT+pt2zF/KSk6wcjqVY2Ca+Ebatdde22y66aZpyYwMpS66du/evW2kUznKatwkcjiU/qjnbOvrKTm/rr766qn1+AdDeft5/G1FUlU0CsmCCy5Ye6lC+4p+4SK1br755mbw4ME63EkOP/xw8+KLL9rjenCgiE9+FIf11lvPbLjhhp3y+U7+in5xzjnndErjH8gz/7OMs19XvW1/LuR1ZPXHWcZ7RcX1HVT1wEZRctOW9H799ddt5F+1r5mjuOtDrN6JfaCWddxidYPrN58QqAKB2Os7ycj/vatI03KsRyAAAQhAIJxArF7O+rupUcvuueceo5foJHPPPbeRPamexP7eq1cuxyEAgZ5HIIuea6XtSWRj7A6tblsjW0varChSD2fR/2pL1u+eVt2z+za1mWeeufYyRhqv5LFmvOXEKnudW01oyimntN+L9ZxYVX5R9rw8vHzbU1Y7ZJJNvf2s88SV47ctr10s7b7TLzemz3l4u76V6dPpYhxZyzRqtBUCEIAABCAAAQhAAAIQaCUBHFkD6bobymaOrCrOX2o47cGxDC5ybFIEOzm3jBgxwmgp7CpLFr6+41C9iIiNIrLut99+5u2337a4FdVEBrWkXHPNNea6666zh4nImqTDvghkmbNpxLIYl0eNGmVkmJfIgTK5rFWrdE6IY+Xf//53o8ifzol1/fXXN/rLIrfddpu5+OKLbZYpppjCRmPViwAhIn2qpdu/+uorm7yZ0+itt95qLrnkEpu2UbRMP0LsAgssUFsCXlE3tfyZRA+Q9SA5Tfwly+RcOsccc6Ql63AshHeHDN6OIqHKkfLDDz+0Rw888ECjdjvxjdqKMKvIq0nGP/zwg33o8e2339psSjPddNMZf0m4ueaay0Z0deW6T+lL6U3JwIEDzbBhw9yp1M8s818FZB3n1EoTB33Deh6D/eeff26GDx9em/tyFtaSeRLfkL/jjjualVdeOVG7Mf4LE7/73e9q0Vk7JfQOxOqd2AdqWcatCN3gdZ1NCPR4AqHXt/S9JM1B3kHyIyDp2IUXXmgmmWQSd5pPCEAAAhAIIBCql+sVFfq7SSta9OrVq14x9ri/CkCz39qxv/caNoSTEIBAjyKQVc/F2J60BLlssfqUyPbjLwEfa3eIaVujQW1ma0nLG6qHi9b/akvod4/StvKe3be3LLfcctZupzqbSTPeslsdc8wxtUissm/p2Ys+m0msPS8vr1g7ZCvmiWPlj1PSLhZz3xnbZ7UvL2/XtzJ9Ol2MI2uZRo22QgACEIAABCAAAQhAAAKtJIAjayBdd0MZ4sh655132gfGKlrLvh177LEdHIbOP/98c/fdd9uaF154YSPHyqpLFr6xjqxyunvssccs8j/84Q9miy22qOGXM97f/vY362DklkjHkbWGhw2PQJY562WrbWYxLiuy8+23317Le8UVV3TQKa3SOc0cKx9//HEb6dMZN3Ut6ZrKInLo32WXXcw333xjs/nXW0g5t9xyi42MqrSKwqDru140VqV59dVXzSGHHKJNK/vuu69ZZJFF3K79VIRY6ZmPPvrI7m+00UZm3XXXtduHHXaYeemll+y2HIrlpJp0JvId6RWxVUtqNWqTLezXf814u3TJTznxnn766bV2pS03qj5pGXtFoZCkjdWVV15pdZ/OTzvttObkk0+2fdP4yjH366+/1in7AEIPIpzIoVNOtGPHjrWHtLxb0tnapXWfWea/8mQdZ1dPo89GBnvlk5PqoEGDzAorrNApcrcenpxwwgm1CCDTTz+9XeLVzQV9x+u7XqJ5KecDRWd1MmbMGKOHBM5peNdddzXLL7+8O133M1bvhD5Qq9eA0HErQjfUawPHIdBTCYRe30888YTR7wC9NCIneKd3HBctm6mlWd955x17KDTis8vPJwQgAAEI/IdAqF6uxyv0d5N+Rw8YMMDqdf1uTMqDDz5ozjrrrNrhpKNJ7cR/N2J/7yXLYx8CEOi5BLLquRjb03fffWe0+o6TIUOGmE022cTtmli7Q0zbao1IbITYWhJZ7G6oHi5a/6vy0O+emHv2O+64w9qfdD8yyyyz2D77/+SULGdTZyvUCkzLLrusnyR1uxlv2Q9lk3vzzTdtft3n6CVu3yE6teD/Hoyx58XwirVDtmKeOE6N7GIx952xfY7h7fpWpk+ni3FkLdOo0VYIQAACEIAABCAAAQhAoJUEcGQNpOtuKEMcWeWQpQhsLkLhPPPMYw02E044oTX03H///bVa5Vikh9BVlyx8Yx1Z/UiL4i6HrP79+xtdDDK2OUcvNya+Y52/fLaMcDLGOVF0xpdfftnuyplOjmBIzyWQZc6KwltvvWXefffdGpDLLrus5vinpd6lHySaN8nlf5s5srZK5zRzrDzxxBPtG/JqtyLFLrXUUtpsKJtuummHSAl+VMoQR1S/cDlOygnWOQNut912ZrXVVvOTpG4fffTR5tlnn7Xn5Ggq/rqeJ5poIvPBBx9Yx1MZXSWK6qAlyyaeeGK7/8Ybb9jl4F0UEUVkVRRYGdDVHj1ovuqqq2zEbWXQg5o11ljD5m32rxlv5f/LX/5i5p13XqMl01Sfou499dRTRo5LkkknndTIOTfNkfSRRx6xDq824a//Vl99dbP00ksbObnqnBxwnSSjiSuKrSIqSMRp1VVXtfNUzr733Xef1Z86J4dOOcAmJWb+5x3nZBuS+40M9kqruSo2ckCVs7PGunfv3ubTTz81+h757LPPakUmeelFiL322st8/PHHNo3Ga+ONN7YRW9977z0bvVbOrBKNp+9cbQ/W+ZdV7+h3iOakm6+vvfaaHWsVr/74D5P0W8TNc1d93nErQje4NvAJgaoQCL2+R44cWdOz+o6ac8457e/YPn36WF2sh/jue1HfzfreqBc9vCps6ScEIACBPARC9bIrO+/vJjmm6je1VkvQ6gdyatWqMfoN/PTTT9fuW1SPv1KEqzf2954rh08IQKB6BLLquRjbUzNHVtGPsTvEtE116zdzXltLXj0cq//V7rzfPTH37DfddJO5/PLLVb2ZddZZrb1EtiCtpCO7+UMPPVSzQeiZiNgmJQ9vrUC055571oqS7VQ2mkai9q211lo2SYw9L4aXKo+xQxYxT+oxamQXi73vjOlzLO96/e2ux50uxpG1u44Q7YIABCAAAQhAAAIQgAAEupoAjqyBxN0NZYgjq4qUQ6SitcmIU08GDx5sHajqna/S8Sx8Yx1Z5WCkt5ldtMUkZz30lyFMRlAJjqxJQuyLQJY5q/SKyqmoBc1kiSWWsA5wfrpmjqxK2wqd08yx0jcs+u1ttO1H69SDjN12263mcKPl2OUgGSr+cvZyFJQDuR4AN5PRo0fbSNiK9uCL8roXEHRcTn2K7pB0Cr3mmmuM6vYlmVfnks7ufvq07Wa8lUfOkC5adLIMOdMqwrdYpImcGWVI1lxpJMnIKEorh059pzkH4LT8WhrwoIMOMtNNN12n0zHzP+84d2pE4kAjg72SOkfWRLYOu/q+WGeddYyi9iblxRdftBHZG/0OkPO2mIW++JBV78jBWY7WIaLxnWmmmTokzTtusbqhQyPYgUBFCIRe3/4DxUZopJ8UJWno0KGNknEOAhCAAATqEAjVyy573t9NzkHFlVPvc+qpp7bLJyd/68f+3qtXH8chAIGeTyCrnhORvLanEEfWWLtD3rapXzG2lrx6OFb/q915v3ti7tl9R1a1oZ5MM8001kaVZu/IwzvpyFqvXv/4QgstZPbff//aobz2vBheqjzGDlnEPKkBSGw0sovF3nfG9DmWd6Kb3X7X6WIcWbv9UNFACEAAAhCAAAQgAAEIQKCLCHR7R9Yu4tC0GndDGerIqgK1/PQ555xjtOyyL5NMMomRE6ucX5D/EMjCVwYovXEuueCCC1LfvvYjD6633npmww03/E9F//0vhzEtz+ciqLqTimylaLpvv/127e1yP8qjotkp8p5Ey0BrOWgn/hJFZ5xxRoeoky4Nnz2HQJY5q15ffPHF5rbbbmsKQBEy//jHP3ZI50elkGOKormmLVVftM6R0VHXg0QObnJ08+WUU04xWu4pi8iRUhERJDfffLPti7Z17ckRtVevXtptKnI4lZP5999/b9OqnSuvvHLTfC6B8l199dXmrrvuqi135s7pc5llljFbbrml6du3r3+4ti0nRS0d/8knn9SOuQ1Fxdtiiy06RLt05xp9NuOtvGnGfkVIVTRctTcZUTNZn5Z20zxU1Fg9JPJFfRXHhRde2D9c21Z6faf9/e9/7/CShuakxlTzNvlg3WXOO/9jx9nVn/Y5YsQIG01W5xQ9VU7kvtx9993m0UcfNa+//notmoh/Xg9j5HytqC31RPND3wdu6TuXTswUDXzYsGFNx8zl0WdWvaOHharDRWT1y0pun3TSSTaysH8877jF6ga/DWxDoCoEQq9vRVvV6g7ST4rwnCZ6oUD6XJH9EAhAAAIQyEcgVC+70vP+btI9nCLoa5UFRWFNiu6PBg4caF+cmmCCCZKnTezvvU4FcgACEKgMgax6zoHJY3uSDUYr1rh70zRbrcqPsTsof562KV+MrSWvHo7V/2p33u+emHt2rTxz++23Gy0971YHUluc6EVv2ee0+lHa95bS5eGtepP2UldnvU+trqNVi3zJY8+L4eXqzmuHLGKeuDYkPxvZxYq478zb5yJ4J/vanfedLsaRtTuPEm2DAAQgAAEIQAACEIAABLqSAI6sgbTdDWUWR1ZX9Ndff220fI2iss0yyyw2Wp2cWJD/EYjh+79Ssm3JeKq3ubWUuB4OyRFLznQIBEIItGPOhrRLadA5oaSMdYRVdGYZxBXpVBEjZpxxRjPZZJM1LUROoXpRQfnlhKol6JVXDo5pjsZNCwxI8M4779ilo/V9opci5Hyq75Ws9clBVPpP/Zb+Uxnqe8h3kzjJeUp55bSriLXNHGgDutZtkyg6t8Z51KhRdr4o8qyWew2ZI65Tiv4rZipL3zPiLQfkrNKd9U7WvpAeAhDoSCDP9a3ve72cpb8ff/zRKMqzlvWs9xJGxxrZgwAEIACBRgTy6OVG5TU7J+ct6fMvv/zSjBkzxv5G172FXk4IWXWiWfmchwAEIJAkEKvnWml7irU7ZG1bUbaWJOOQ/bLqf43Rp59+am0lsnnouyr0e6udvDUm7bDnubkg5848dsh2zpPY+868fXbMevqn08U4svb0kaZ/EIAABCAAAQhAAAIQgEAoARxZA0m5G8o8jqyBVVQ6GXwrPfyl7DxztpTDRqMhUGoC6J1SDx+Nh0BDAlzfDfFwEgIQgECXE0AvdzlyKoQABLqYAHqui4FTHQQgAIEUAk4X48iaAodDEIAABCAAAQhAAAIQgEAlCeDIGjjs7oYSR9ZAYBmTwTcjMJK3nQBztu1DQAMgUDkC6J3KDTkdrhABru8KDTZdhQAESkEAvVyKYaKREIBABAH0XAQ8skIAAhAoiIDTxTiyFgSUYiAAAQhAAAIQgAAEIACB0hPAkTVwCN0NJY6sgcAyJoNvRmAkbzsB5mzbh4AGQKByBNA7lRtyOlwhAlzfFRpsugoBCJSCAHq5FMNEIyEAgQgC6LkIeGSFAAQgUBABp4txZC0IKMVAAAIQgAAEIAABCEAAAqUngCNr4BC6G0ocWQOBZUwG34zASN52AszZtg8BDYBA5Qigdyo35HS4QgS4vis02HQVAhAoBQH0cimGiUZCAAIRBNBzEfDICgEIQKAgAk4X48haEFCKgQAEIAABCEAAAhCAAARKTwBH1sAhdDeUOLIGAsuYDL4ZgZG87QSYs20fAhoAgcoRQO9UbsjpcIUIcH1XaLDpKgQgUAoC6OVSDBONhAAEIgig5yLgkRUCEIBAQQScLsaRtSCgFAMBCEAAAhCAAAQgAAEIlJ4AjqyBQ+huKHFkDQSWMRl8MwIjedsJMGfbPgQ0AAKVI4DeqdyQ0+EKEeD6rtBg01UIQKAUBNDLpRgmGgkBCEQQQM9FwCMrBCAAgYIIOF2MI2tBQCkGAhCAAAQgAAEIQAACECg9ARxZA4fQ3VDiyBoILGMy+GYERvK2E2DOtn0IaAAEKkcAvVO5IafDFSLA9V2hwaarEIBAKQigl0sxTDQSAhCIIICei4BHVghAAAIFEXC6GEfWgoBSDAQgAAEIQAACEIAABCBQegI4sgYOobuhxJE1EFjGZPDNCIzkbSfAnG37ENAACFSOAHqnckNOhytEgOu7QoNNVyEAgVIQQC+XYphoJAQgEEEAPRcBj6wQgAAECiLgdDGOrAUBpRgIQAACEIAABCAAAQhAoPQEcGQNHEJ3Q4kjayCwjMngmxEYydtOgDnb9iGgARCoHAH0TuWGnA5XiADXd4UGm65CAAKlIIBeLsUw0UgIQCCCAHouAh5ZIQABCBREwOliHFkLAkoxEIAABCAAAQhAAAIQgEDpCeDIGjiE7oYSR9ZAYBmTwTcjMJK3nQBztu1DQAMgUDkC6J3KDTkdrhABru8KDTZdhQAESkEAvVyKYaKREIBABAH0XAQ8skIAAhAoiIDTxTiyFgSUYiAAAQhAAAIQgAAEIACB0hPAkTVwCN0NJY6sgcAyJoNvRmAkbzsB5mzbh4AGQKByBNA7lRtyOlwhAlzfFRpsugoBCJSCAHq5FMNEIyEAgQgC6LkIeGSFAAQgUBABp4txZC0IKMVAAAIQgAAEIAABCEAAAqUngCNr4BC6G0ocWQOBZUwG34zASN52AszZtg8BDYBA5Qigdyo35HS4QgS4vis02HQVAhAoBQH0cimGiUZCAAIRBNBzEfDICgEIQKAgAk4X48haEFCKgQAEIAABCEAAAhCAAARKTwBH1sAhdDeUOLIGAsuYDL4ZgZG87QSYs20fAhoAgcoRQO9UbsjpcIUIcH1XaLDpKgQgUAoC6OVSDBONhAAEIgig5yLgkRUCEIBAQQScLsaRtSCgFAMBCEAAAhCAAAQgAAEIlJ5A2x1ZS0+QDkAAAhCAAAQgAAEIQAACEIAABCAAAQhAAAIQgAAEIAABCEAAAhDISABH1ozASA4BCEAAAhCAAAQgAAEI9FgCOLL22KGlYxCAAAQgAAEIQAACEIAABCAAAQhAAAIQgAAEIAABCEAAAhCAQHclgCNrdx0Z2gUBCEAAAhCAAAQgAAEIdDWBtjuyluUG7ZMvvrJjU5b2dvVEiq0PvrEEyd/VBJizXU2c+iAAAfQOcwACPZcA13fPHVt6BgEIlJMAermc40arIQCBcALouXBWpIQABCDQKgLo4laRpVwIQAACEIAABCAAAQhAoKwEcGQNHDluKANB5UwG35zgyNY2AszZtqGnYghUlgB6p7JDT8crQIDruwKDTBchAIFSEUAvl2q4aCwEIJCDAHouBzSyQAACECiYALq4YKAUBwEIQAACEIAABCAAAQiUngCOrIFDyA1lIKicyeCbExzZ2kaAOds29FQMgcoSQO9UdujpeAUIcH1XYJDpIgQgUCoC6OVSDReNhQAEchBAz+WARhYIQAACBRNAFxcMlOIgAAEIQAACEIAABCAAgdITwJE1cAi5oQwElTMZfHOCI1vbCDBn24aeiiFQWQLoncoOPR2vAAGu7woMMl2EAARKRQC9XKrhorEQgEAOAui5HNDIAgEIQKBgAujigoFSHAQgAAEIQAACEIAABCBQegI4sgYOITeUgaByJoNvTnBkaxsB5mzb0FMxBCpLAL1T2aGn4xUgwPVdgUGmixCAQKkIoJdLNVw0FgIQyEEAPZcDGlkgAAEIFEwAXVwwUIqDAAQgAAEIQAACEIAABEpPAEfWwCHkhjIQVM5k8M0JjmxtI8CcbRt6KoZAZQmgdyo79HS8AgS4viswyHQRAhAoFQH0cqmGi8ZCAAI5CKDnckAjCwQgAIGCCaCLCwZKcRCAAAQgAAEIQAACEIBA6QngyBo4hNxQBoLKmQy+OcGRrW0EmLNtQ0/FEKgsAfROZYeejleAANd3BQaZLkIAAqUigF4u1XDRWAhAIAcB9FwOaGSBAAQgUDABdHHBQCkOAhCAAAQgAAEIQAACECg9ARxZA4eQG8pAUDmTwTcnOLK1jQBztm3oqRgClSWA3qns0NPxChDg+q7AINNFCECgVATQy6UaLhoLAQjkIICeywGNLBCAAAQKJoAuLhgoxUEAAhCAAAQgAAEIQAACpSeAI2vgEHJDGQgqZzL45gRHtrYRYM62DT0VQ6CyBNA7lR16Ol4BAlzfFRhkuggBCJSKAHq5VMNFYyEAgRwE0HM5oJEFAhCAQMEE0MUFA6U4CEAAAhCAAAQgAAEIQKD0BHBkDRxCbigDQeVMBt+c4MjWNgLM2bahp2IIVJYAeqeyQ0/HK0CA67sCg0wXIQCBUhFAL5dquGgsBCCQgwB6Lgc0skAAAhAomAC6uGCgFAcBCEAAAhCAAAQgAAEIlJ4AjqyBQ8gNZSConMngmxMc2dpGgDnbNvRUDIHKEkDvVHbo6XgFCHB9V2CQ6SIEIFAqAujlUg0XjYUABHIQQM/lgEYWCEAAAgUTQBcXDJTiIAABCEAAAhCAAAQgAIHSE8CRNXAIuaEMBJUzGXxzgiNb2wgwZ9uGnoohUFkC6J3KDj0drwABru8KDDJdhAAESkUAvVyq4aKxEIBADgLouRzQyAIBCECgYALo4oKBUhwEIAABCEAAAhCAAAQgUHoCOLIGDiE3lIGgciaDb05wZGsbAeZs29BTMQQqSwC9U9mhp+MVIMD1XYFBposQgECpCKCXSzVcNBYCEMhBAD2XAxpZIAABCBRMAF1cMFCKgwAEIAABCEAAAhCAAARKTwBH1sAh5IYyEFTOZPDNCY5sbSPAnG0beiqGQGUJoHcqO/R0vAIEuL4rMMh0EQIQKBUB9HKphovGQgACOQig53JAIwsEIACBggmgiwsGSnEQgAAEIAABCEAAAhCAQOkJ4MgaOITcUAaCypmsLHw//fRTc80119hezjPPPGbVVVet9fjGG280H3zwgd3feuutTe/evWvn2Oh5BLr7nP3ll1/Mzz//bCaaaKIuhf/jjz+a7777zkw66aRm3HHHzVT3Dz/8YNTuSSaZJFM+lzhvn//v//7PfP311/aa7dWrlysu+FP51ee87R47dqxlNd544wXXmZZQ/CQTTjhh2ulOx2J5dyow8IDjLdZ59GRRvP79738blSVeecY9sLs2WWyfXV2xeqddY672t7Nux49PCHRnArHXd3fuG22DAAQgUEYC3UUvd+Vv1jKOE22GAATyEyhCz+W1w4S2WraWiSee2IwzzjihWWrpWt22WkXdaCOmz7G2rbxjVZS9pBsNQ5c0JS/v2MbFzDG/7q60YcbYqv02t2q7CF3cqrZRLgQgAAEIQAACEIAABCAAgXYQwJE1kHrsDeWDDz5obr/9dmt4O/TQQ4MdjQKbV/pksXy7CsCLL75o/vznP9vqVlxxRbPPPvvUqtb2Cy+8YPfPP/98M/3009fOsdHzCMTO2VbohH/+85/W0fqVV14xb731lnVknWWWWczcc89tBg0aZGadddZMA/HGG2+Yq6++2uaZeuqpzfbbb98p/zvvvGMeeeQRo2vjk08+MV999ZWREfo3v/mNmWaaacwqq6xiBg8enOqwqLyPPvqozasvo9GjR9vy5RA644wzmnXWWccst9xyner0D+Tpsxx8//GPf5iHH37YvPfee7bdMmrKmVFtnn/++c0WW2xh+vbt61fVYVt9lE5X38VaBuTJJpvMDBgwwAwcONAsv/zyHdInd+QUf/nll5vXX3/dfPTRR5bXTDPNZMdKdausZqIy7r77bvPEE08YbcspU/Lb3/7WHHnkkWbOOefsUEQM71tuucU899xzHcqrtzPllFOanXbaqXZaxunHH3/ctvP99983n332mRFvidqqlwLWW289s+CCC9byJDeK4CUngMcee8w88MADlvuYMWOMjumBmObZfvvtV6v27bffNldccUVtP2RDD9f23HNPm7SIPqfVmVXvxIz5N998Y0499dS0ZqQe0xhqLJ3E1O3K4BMCVSKQ9fquxybku7teXo5DAAIQgMD/CMTq5bz3e1l+s15wwQUm1KjnerbGGmuYxRZbzO3yCQEIVJhAXj2Xxw6TBfPIkSPNQw89ZF599VXz+eef25eGZdeSzUB2Itmb6klRbQvV4UXrYdmaDj74YGszkV3pD3/4Q72u1o7H9DnWtpVnrFplL8lzH5SHtwMfk1dlhM4xV58+8/COsee5umPmmCujK22YskfltVW79nblZ15d3JVtpC4IQAACEIAABCAAAQhAAAJdSSDU5j3Orw4y/1dkw8p2gxbb3gMOOMA888wzFuG1116bO3JfkWPQncqK5dtVfcGRtatId/96Yuds0TpBDqT777+/dcxMoycnuyOOOMI6SqadTx6Ts+Hw4cONnP0kM888sznnnHM6JJPjtu/M3eGktyOnTDnEyRnWiZxtndOfO5b2ueiiixo5/6dFzMzbZ599Wp06Jl777ruvWXzxxTslGTVqlDn++OPN888/3+mcO7DCCitYfmnRUe+8805z7rnn1hxPXR73KV577723Ud/ryVNPPWWOOeYY8/3336cmOeqoo8zCCy9cOxfL+4QTTjD33XdfrbxGG9NOO6258MILa0nk/C/d2UyGDh1qttlmm07JiuClByXHHnusdabtVMGvB+SAeeKJJ9ZO6eHAYYcdVtsP2ZBT7nXXXWeTxva5Xn1Z9E7smOshg6KLh4rm7Morr2yTx9YdWifpINCTCGS5vuv1O+S7u15ejkMAAhCAQEcCsXrZv+cItQFl/c26yy67GDlrZJEddtjBDBkyJEsW0kIAAj2UQB49l9cOE4rwpptuMiNGjLAvSKflkXOn7reLtBGl1ROqw4vWw3Le/dOf/mSbtPbaa5sdd9wxrXm1YzHjEWvbyjtWrbCX5L0Pysq7Bv7XjZi8Kid0jrk68/KOseep7pg55trelTbMGFu1a29Xf+bRxV3dRuqDAAQgAAEIQAACEIAABCDQlQRwZA2kneeGUpE0BFiGhltvvbVWU+hDjFqGCmzk4dsOLDiytoN696wzz5xtlU5QhFFFwPz4448tLC1Rv8ACC1iH+WeffdYosqJkookmMieddJJRlNZG8u2331on0w8++KCWLM2RVc75Mrw6UbnTTTedfaCgKKWK0OpE0UbPOuus2sMG/1pSmv79+5sZZpjBRpF96aWXjNrgZNNNNzWbbbaZ27WfMX32oyfLYXWuueYyk046qdXXiuDgRA6leoDiR0dVxAXld46Z448/vllyySVNv379zJtvvlmLyqwyNt98c7PJJpu44uynnF/lIOtEzDRWP/30k40WqigHEkWlveiii2zEUpfWfd54443mvPPOs9FEdWzcccc14isH0q+//tq89tpr1gnTd2SN5Z3F8K1o1IpK7UQPYWTglzje6p+i2IqHliVzIraKdu2kCF5ffvmlOeSQQzo85O/Tp4+Nnqvxe/fdd80UU0zRwZFVUW7lQJ1FfEfWmD43qjOL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To0uYd4+H3DAAa6IDp+KKKoIdx999JE9riinethSb7l4RQpVRINnnnmmQzluRw6TL7zwgt1VGddff707ZT/vuOMOc+GFF3aIZOoSyGlQzr5jxoyxhxwzP8q0ol8oCkmayAlYzoIStUNjVU9CedfL7x8/7bTTjPolcVFk/fONtm+55RajqLUSOUyrr77DbB5eKkuO185Btl6UEDm6aqlVF8lWjtMuQq7K8EUPozQebj5vu+22dvlDP03odrM+1ysnVu8UOeaffPKJdZJwDtwu8nC9thdZd706OA6BMhMIub79B7hZ+xr63Z21XNJDAAIQ6KkEQvRyo76H3u8V/ZvVj4IW+uJVo35wDgIQ6LkEsui5ou0wSapaTUsvu0rOP/98M/300yeT2H3dv+slXolsHzPPPLNpRdtCdbhtSOJfjB4OdY4sos95bVsxY5VAlbqbZi8ZNWpU7YW+1EwNDja6DwrlnVZ8TF6VFzrHWs27nj0vZo51ZxtmVlt12tgXfSyLLi66bsqDAAQgAAEIQAACEIAABCDQHQngyBo4KrE3lKHGicDm9LhkWfiGOLLed9991tFMoHBk7XHTpVt0KMucTWtwUTpBS6MfeuihtopGDoQ333yzOfvss206OUIqAutmm22W1rSmx+TEOe+88zZNpwSK0njBBRfYtMstt5zZf//9U/Np2XpF03CRShVFWddumuTtc5rzpyLIysH1tddes1VNM800VnfUi/7ht+eVV14xzz33nJFjkdo944wzmnnmmccuwS4nR0n//v1rTpp+XhlOH3zwQaMvYT08UH2KWqsxVORanZMcdNBB9pjvBLjyyiubvffe255P/nv11VeNlnmXyCn02muvTSax+1l4pxaQOPiPf/zDtlWHp5tuutqYJ5Kl7orduuuua6O0KoGipIqFL1l5Ka+Lvq1tOQ5PO+202uwkGn/nlKwItopkmyb+Q4o+ffoYLcXXLBprWjk6FtLntLwxeqfoMVf7FMX49ddft01VlOeVVloprdmmFXWnVsRBCJSYQMj1rQe4XfHdXWKMNB0CEIBAYQRC9HKjykLv94r8zaqXuPTilV44kriX4hq1k3MQgEB1CWTRc0XaYdKI+7beRi9JbrDBBrWVdNwLq61oW6gOT/YlVg/7dodGq+wU2eestq2YsUrySttPs5fope9W3AeF8k5rZ0xelRc6x1rN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DkmtnPWa1SGnVBn3pROVshwc68vncWSVk+q6665rVllllfGiKssB709/+lMtIvass85ql/RyDyZSytb32f8cMuaUtlPkfNttt5kXXnjBOjHOOeecfrftvh5E6AUM9xBHER5WXHHF8fJlHWjlyKq5pzmopOjkMu4rOqtLo0aNsvPDOZO6JeZTv08VtWHPPfc0ihCqJF0vne8nLcsqfa8088wzm1NPPbXpAyxXttW1ETtmV3+jbR69oyXx5MDtZJo1/kbtpLLX7yDNNyU53stJ1V1/rk3/hRNFbNXSiD179nSn2UKgcgTyXN8p3yWVA8uAIQABCEQSyKOXs5oIvd/TbzW93DN69GhbzR577GH0UplLchKRY86YMWPsoWOPPbb2YmNKWVc/WwhAoLoE8uq5FJvE559/bqNHOtobb7yxGThwoPto71tjdaEqie3bY489ZmRj0wufeqm2/r71s88+s6sLvf7667av9SvIpNgdUu7Z//Of/5jDDjusxm///fc3Sy65ZO2zdtQ32dDfffdde3zLLbc0v/nNbzrkyfrQyraV+t3TGfcyekFWTp3ODiHb6Mknn5w1vMxjrcas7+F+/frZeSK7Vn267777zDnnnFM7HGIDVeY8/ZadZvfddzeffvqpbcd/XlFruG4nZY6pqhRZxV6T/hBixpxyTfptd+W+08U4snYlddqCAAQgAAEIQAACEIAABCZkAjiyBkrH3VC2cmTVW7/77LNPrVa9Fa2le5qlueee22y44YbNsnT7c6F8BSLVkdV/G1316SHRXHPNZXQxyJnLOT3pnJJvGPKX9JOTl5y9XPKXbpezrJyiSN2XQJ45KwqvvvqqeeONN2pALr/88trDSC17Pumkk9pzmjduiTKXuZUjq4yYinTpoi4utNBC1glRdcqIe88997iqrGOfjPMhqZWznuqQsVfLsivioqIAvP/++2bYsGHmxRdfrDXRv39/s9NOO433QED9lbHXReT473//ax588EFbTvX5jpTq8+STT16rU8umyTgph0gZ65Vfulbt6xr/4IMPannro9SmlFWlKWNOaTtFzjfccIO54oorLBN954iZHHwVrVd67f7776/JQfPn8MMPt3lD/rRyZNVLAfvuu6957733bHUzzDCD2WqrrWyE3DfffNNGl5Izq9LCCy9cexhTxPep5pMeZrikpeuXX355O3d0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Vzbrr7/+eHncgZRrI2XMrv2sbareyTN+175e4Ljiiivs/H733XetzGeffXarV8R+qqmmclmDturDhRdeaGWpAltssYWZb775gsqSCQLdmUCe6ztFP3VnhowNAhCAQJEEulIv615q6NChQd2ffvrpza677hqUl0wQgAAEmhHIo+f8eoq2w/h1a//RRx81999/v/nPf/5jPvzwQ+twJ7uW7OiyW8gO0Sil9C2lrN+fmPtuVz6lrOrIYyNybbptatuqJ/Z+P2+/U2SVaodM4dUu+4rkIhu07HqyF8shVLacfv36mbXXXtvIPtYspfD2680rZ5WN0Qdl+10Vq4t9tuxDAAIQgAAEIAABCEAAAhDoTgRwZA2UZp4bSjk6ylGoVdp0003Njjvu2CpbJc7n4dtOIDiytpP+hNV26pw96KCDzNNPP20Hde2110a/Ce+oyLHwwAMPtM5+7pi/VQSLo48+2jqf+cdlRFUE4dD0f//3f2b11VevZR85cqR11nznnXdqx+p3FMFxyJAhZs011+xwKqWsKoodc4dOeB/00sFee+1lnVF1eI455jDnnnuul6PjrpwVTzjhBPvSQscz4z7J4ffkk0/ucKro74fQeSTn5n322adDX7I+/PKXvzRyrPajncpB33fazyqnYzKCyylaDsB+ShmzPz6/Tn9fc3v//fc3Sy+9tH84c18Pwv7whz/YcxtttJEZPHhwZj4dTLk2UsbcsENjT6TqnTzjVz/knH7++efbKMNZ/ZLMpRM0b0KTnFivu+66WnbJeMUVV6x9ZgcCVSWQ5/pO0U9V5cu4IQABCOQl0JV6+U9/+pO5++67g7qoFwQvuuiioLxkggAEINCMQB495+op2g7j6nXbG264wZx33nnWIdId87dyuNP9tm+zcOdT+pZS1rXvtnnvu105bVPKqrxvQ8lra0xtW+3H3u/n6XeKrFLtkBqjS3l5tcu+ojGfdNJJ5tlnn3VdH2+7yiqrWHuoVpeqTym86+vKI2eVjdUHZftdFaOL69nyGQIQgAAEIAABCEAAAhCAQHcigCNroDTz3FDKSUbGDCU52CywwALWSU1vu8po4KIV6rycgxTZs+opD992ssKRtZ30J6y2Y+asInVK6coQd/PNN9cGlNe4XCv4446iVioqz3vvvWePaNnvxRZbzOqdZ555xihym9Jkk01mTjnlFKMorS7l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AQhAAAJdSGDUqFHmiy++aNkijqxfjvP2xZG15VyJyuBu2Cd0vvL8vv766+0Y559/frPKKqvUxnvrrbead999134eOHCgmXLKKWvn2Ol+BCb0Oas3FPR/0kknTYL/1Vdf2fKTTDJJUj1dXbgr+/3DDz+Yzz//vLLX/Pfff2++/PJLoznSo0eP3KLOIyux/vTTT83PfvYz06tXr6i2vvnmm2hZpVxXn332mfn666/NtNNOayaaaKLcfVeBCUnv5JGbG6yuk8knnzx6/K4ethDojgRSru9U3dgdeTImCEAAAqkEUvRyatt++dTf2n5d7EMAAhDwCRSh51Lukf2+NNpPuYdM6VtKWX8seXV4qn1Jtpmf//zn9r/fj87e/+6778zHH39s7TRls0PGzjHZd1RWtikxz5Pc/ZueHcTY0VLnSZ6+dlbeGJuS60ve68qV0zalrF9PkftOF+PIWiRV6oIABCAAAQhAAAIQgAAEykxA99wjRoxoOQQcWQtyZH311VdrjpAzzjij2W677VrCr0IGd8M+oTuyvvTSS+bwww+3IllxxRXNkCFDauI54ogjzIsvvmg/n3766WbmmWeunWOn+xFInbMPPfSQueuuu6xD1/7772+dAFMpyVHuhhtuMC+//LJ5/fXXrSNr3759jZyu11hjDTPXXHO1bELO2vfee6954oknjPZlBFeaYoopzEEHHWTmmWeepnXImHrcccdZh70VVljBrLXWWk3z+ydTyqb22/UjVEerr5Lfo48+alnrjZDevXubOeaYw/Tv399o7PXpjTfeMH//+9/rDzf9PNlkk5ndd9/d5pF8zzvvvKb5/ZMbbLCBlb1/zN+P5S2j77///W+jOSxeo0ePtoZgOWcuv/zyZq+99vKbGW8/j6xk2H788cfNk08+ad555x07J/XDRUlzcr755jMa5yKLLDJeOzrw1ltvmccee8xId7///vvmk09+jAY/9mHBLLPMYtZbbz3b58zCPx5Mua70csOVV15phg0bZh1wVWXPnj3t98M666xjVlttNeuU26x9/1yq3omVufqQR25+n6VLHnnkEauX9INTD2rmnXdeKzPxl1MyCQIQyOeonqob4Q0BCEAAAq0J5PndVeTv9NTf2q1HRg4IQAAC4wjk0XM+s5R7ZL+eRvsp95ApfUsp68YSo8Nj7EuuPW11r37ttdda+8z/+3//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", "text/plain": [ "" ] }, "execution_count": 29, "metadata": {}, "output_type": "execute_result" } ], "source": [ "from IPython.display import Image\n", "Image(filename='./images/parallel_coordinates_plot.png')" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.8.12" } }, "nbformat": 4, "nbformat_minor": 2 } ================================================ FILE: examples/insurance_lite/config.yaml ================================================ input_features: - name: image_path type: image preprocessing: height: 224 width: 224 in_memory: true num_channels: 3 encoder: vit img_height: 224 in_channels: 3 use_pretrained: true hidden_size: 768 num_hidden_layers: 12 num_attention_heads: 12 intermediate_size: 3072 - name: insurance_company type: category preprocessing: missing_value_strategy: fill_with_const fill_value: UNKNOWN - name: cost_of_vehicle type: number preprocessing: missing_value_strategy: fill_with_mean normalization: zscore - name: expiry_date type: date preprocessing: missing_value_strategy: fill_with_const fill_value: "" datetime_format: "%Y-%m-%d" - name: min_coverage type: number preprocessing: missing_value_strategy: fill_with_mean normalization: zscore - name: max_coverage type: number preprocessing: missing_value_strategy: fill_with_mean normalization: zscore - name: condition type: category preprocessing: missing_value_strategy: fill_with_const fill_value: UNKNOWN combiner: type: concat num_fc_layers: 3 output_size: 256 output_features: - name: amount type: number preprocessing: normalization: zscore trainer: epochs: 10 early_stop: 0 batch_size: 8 preprocessing: split: type: random probabilities: [0.7, 0.1, 0.2] ================================================ FILE: examples/insurance_lite/train.py ================================================ #!/usr/bin/env python # # Simple Model Training Example on multi-modal data. # Import required libraries import logging import os import shutil from ludwig.api import LudwigModel from ludwig.datasets import insurance_lite # clean out prior results shutil.rmtree("./results", ignore_errors=True) # Download and prepare the dataset dataset = insurance_lite.load() # Define Ludwig model object that drive model training model = LudwigModel(config="./config.yaml", logging_level=logging.INFO, backend="local") # initiate model training ( train_stats, # dictionary containing training statistics preprocessed_data, # tuple Ludwig Dataset objects of pre-processed training data output_directory, # location of training results stored on disk ) = model.train(dataset=dataset, experiment_name="simple_experiment", model_name="simple_model") # list contents of output directory print("contents of output directory:", output_directory) for item in os.listdir(output_directory): print("\t", item) ================================================ FILE: examples/kfold_cv/README.md ================================================ # K-Ffold Cross Validation Example This directory contains two examples of performing a k-fold cross validation analysis with Ludwig. ## Classification Example This example illustrates running the k-fold cv with the `ludwig experiment` cli. To run this example execute this bash script: ``` ./k-fold_cv_classification.sh ``` This bash script performs these steps: - Download and prepare data for training and create a Ludwig config file - Execute `ludwig experiment` to run the 5-fold cross validation - Display results from the 5-fold cross validation analysis Sample output: ``` Cleaning out old results Downloading data set Preparing data for training Saving training and test data sets Preparing Ludwig config Completed data preparation Training: 100%|████████████████████████████████████████████████████████████████████████████████| 12/12 [00:00<00:00, 23.14it/s] Evaluation train: 100%|████████████████████████████████████████████████████████████████████████| 12/12 [00:00<00:00, 98.62it/s] Evaluation test : 100%|█████████████████████████████████████████████████████████████████████████| 3/3 [00:00<00:00, 321.03it/s] Training: 100%|███████████████████████████████████████████████████████████████████████████████| 12/12 [00:00<00:00, 190.18it/s] Evaluation train: 100%|███████████████████████████████████████████████████████████████████████| 12/12 [00:00<00:00, 331.68it/s] Evaluation test : 100%|█████████████████████████████████████████████████████████████████████████| 3/3 [00:00<00:00, 298.08it/s] <<<< DELETED LINES >>>>> Training: 100%|███████████████████████████████████████████████████████████████████████████████| 12/12 [00:00<00:00, 248.00it/s] Evaluation train: 100%|███████████████████████████████████████████████████████████████████████| 12/12 [00:00<00:00, 400.31it/s] Evaluation test : 100%|█████████████████████████████████████████████████████████████████████████| 3/3 [00:00<00:00, 340.35it/s] Evaluation: 100%|████████████████████████████████████████████████████████████████████████████████| 3/3 [00:00<00:00, 27.87it/s] retrieving results from results # # K-fold Cross Validation Results # {'combined': {'accuracy_mean': 0.9736263736263737, 'accuracy_std': 0.011206636293610508, 'loss_mean': 0.06359774886251807, 'loss_std': 0.011785678840394689}, 'diagnosis': {'accuracy_mean': 0.9736263736263737, 'accuracy_std': 0.011206636293610508, 'average_precision_macro_mean': 0.995842104045726, 'average_precision_macro_std': 0.002339014329647542, 'average_precision_micro_mean': 0.995842104045726, 'average_precision_micro_std': 0.002339014329647542, 'average_precision_samples_mean': 0.995842104045726, 'average_precision_samples_std': 0.002339014329647542, 'loss_mean': 0.06359774886251807, 'loss_std': 0.011785678840394689, 'roc_auc_macro_mean': 0.9973999160508542, 'roc_auc_macro_std': 0.0011259319854886507, 'roc_auc_micro_mean': 0.9973999160508542, 'roc_auc_micro_std': 0.0011259319854886507}} ``` ## Regression Example This illustrates using the Ludwig API to run the K-fold cross validation analysis. To run the example, open the jupyter notebook `regression_example.ipynb`. Following steps are performed: - Download and prepare data for training and create a Ludwig config data structure from a pandas dataframe structure - Use `ludwig.api.kfold_cross_validate()` function to run the 5-fold cross validation - Display results from the 5-fold cross validation analysis Expected output from running the example: ![](../images/regression_kfold_cv_example_results.png) ================================================ FILE: examples/kfold_cv/display_kfold_cv_results.py ================================================ #!/usr/bin/env python import argparse import os.path import pprint import sys from ludwig.utils.data_utils import load_json if __name__ == "__main__": parser = argparse.ArgumentParser( description="Display K-fold cross validation results", prog="display_kfold_cv_results", usage="%(prog)s [options]", ) # ---------------------------- # Experiment naming parameters # ---------------------------- parser.add_argument( "--results_directory", type=str, default="results", help="directory that contains the K-fold cv results" ) args = parser.parse_args(sys.argv[1:]) results_directory = args.results_directory print("Retrieving results from ", results_directory) kfold_cv_stats = load_json(os.path.join(results_directory, "kfold_training_statistics.json")) print("#\n# K-fold Cross Validation Results\n#") pprint.pprint(kfold_cv_stats["overall"]) ================================================ FILE: examples/kfold_cv/k-fold_cv_classification.sh ================================================ #!/bin/bash # # Download and prepare training data # python prepare_classification_data_set.py # # Run 5-fold cross validation # ludwig experiment \ --config config.yaml \ --dataset data/train.csv \ --output_directory results \ --logging_level 'error' \ -kf 5 # # Display results from K-fold cv # python display_kfold_cv_results.py --results_directory results ================================================ FILE: examples/kfold_cv/prepare_classification_data_set.py ================================================ #!/usr/bin/env python # Download and prepare training data set # Create Ludwig config file # # Based on the # [UCI Wisconsin Breast Cancer data set](https://archive.ics.uci.edu/ml/datasets/breast+cancer+wisconsin+(original)) # import os.path import shutil import pandas as pd import requests import yaml from sklearn.model_selection import train_test_split from ludwig.constants import TRAINER # Constants DATA_SET_URL = "https://archive.ics.uci.edu/ml/machine-learning-databases/breast-cancer-wisconsin/wdbc.data" DATA_SET = "wdbc.data" DATA_DIR = "./data" RESULTS_DIR = "results" # Clean out previous results print("Cleaning out old results") if os.path.isfile(DATA_SET): os.remove(DATA_SET) if os.path.isfile("config.yaml"): os.remove("config.yaml") shutil.rmtree(RESULTS_DIR, ignore_errors=True) shutil.rmtree(DATA_DIR, ignore_errors=True) # Retrieve data from UCI Machine Learning Repository # Download required data print("Downloading data set") r = requests.get(DATA_SET_URL) if r.status_code == 200: with open(DATA_SET, "w") as f: f.write(r.content.decode("utf-8")) # create pandas dataframe from downloaded data print("Preparing data for training") raw_df = pd.read_csv(DATA_SET, header=None, sep=",", skipinitialspace=True) raw_df.columns = ["ID", "diagnosis"] + ["X" + str(i) for i in range(1, 31)] # convert diagnosis attribute to binary format raw_df["diagnosis"] = raw_df["diagnosis"].map({"M": 1, "B": 0}) # Create train/test split print("Saving training and test data sets") train_df, test_df = train_test_split(raw_df, train_size=0.8, random_state=17) os.mkdir(DATA_DIR) train_df.to_csv(os.path.join(DATA_DIR, "train.csv"), index=False) test_df.to_csv(os.path.join(DATA_DIR, "test.csv"), index=False) print("Preparing Ludwig config") # Create ludwig input_features num_features = ["X" + str(i) for i in range(1, 31)] input_features = [] # setup input features for number variables for p in num_features: a_feature = { "name": p, "type": "number", "preprocessing": {"missing_value_strategy": "fill_with_mean", "normalization": "zscore"}, } input_features.append(a_feature) # Create ludwig output features output_features = [{"name": "diagnosis", "type": "binary", "num_fc_layers": 2, "output_size": 64}] # setup ludwig config config = { "input_features": input_features, "output_features": output_features, TRAINER: {"epochs": 20, "batch_size": 32}, } with open("config.yaml", "w") as f: yaml.dump(config, f) print("Completed data preparation") ================================================ FILE: examples/kfold_cv/regression_example.ipynb ================================================ { "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# K-fold cross validation - Regression Model\n", "Based on the [Ludwig regression example](https://ludwig-ai.github.io/ludwig-docs/examples/#simple-regression-fuel-efficiency-prediction) \n", "\n", "[Data set](https://archive.ics.uci.edu/ml/datasets/auto+mpg)\n", "\n", "This example demonstrates teh following:\n", "\n", "- Download a data set and create a pandas dataframe\n", "- Create a training and hold-out test data sets\n", "- Create a Ludwig config data structure from the pandas dataframe\n", "- Run a 5-fold cross validation analysis with the training data\n", "- Use Ludwig APIs to train and assess model performance on hold-out test data set" ] }, { "cell_type": "code", "execution_count": 1, "metadata": {}, "outputs": [], "source": [ "import logging\n", "import os\n", "import os.path\n", "import shutil\n", "import tempfile\n", "\n", "import matplotlib.pyplot as plt\n", "import numpy as np\n", "import pandas as pd\n", "import requests\n", "import scipy.stats as stats\n", "import seaborn as sns\n", "from sklearn.model_selection import train_test_split\n", "\n", "from ludwig.api import kfold_cross_validate, LudwigModel" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Contstants" ] }, { "cell_type": "code", "execution_count": 2, "metadata": {}, "outputs": [], "source": [ "DATA_SET_URL = 'http://archive.ics.uci.edu/ml/machine-learning-databases/auto-mpg/auto-mpg.data'\n", "DATA_SET = 'auto_mpg.data'\n", "RESULTS_DIR = 'results'" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Clean out previous results" ] }, { "cell_type": "code", "execution_count": 3, "metadata": {}, "outputs": [], "source": [ "if os.path.isfile(DATA_SET):\n", " os.remove(DATA_SET)\n", " \n", "shutil.rmtree(RESULTS_DIR, ignore_errors=True)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Retrieve data from UCI Machine Learning Repository" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Download required data" ] }, { "cell_type": "code", "execution_count": 4, "metadata": {}, "outputs": [], "source": [ "r = requests.get(DATA_SET_URL)\n", "if r.status_code == 200:\n", " with open(DATA_SET,'w') as f:\n", " f.write(r.content.decode(\"utf-8\"))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Create Pandas DataFrame from downloaded data" ] }, { "cell_type": "code", "execution_count": 5, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "(398, 8)" ] }, "execution_count": 5, "metadata": {}, "output_type": "execute_result" } ], "source": [ "raw_df = pd.read_csv(DATA_SET,\n", " header=None,\n", " na_values = \"?\", comment='\\t',\n", " sep=\" \", skipinitialspace=True)\n", "\n", "\n", "raw_df.columns = ['MPG','Cylinders','Displacement','Horsepower','Weight',\n", " 'Acceleration', 'ModelYear', 'Origin']\n", "raw_df.shape" ] }, { "cell_type": "code", "execution_count": 6, "metadata": {}, "outputs": [ { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
MPGCylindersDisplacementHorsepowerWeightAccelerationModelYearOrigin
018.08307.0130.03504.012.0701
115.08350.0165.03693.011.5701
218.08318.0150.03436.011.0701
316.08304.0150.03433.012.0701
417.08302.0140.03449.010.5701
\n", "
" ], "text/plain": [ " MPG Cylinders Displacement Horsepower Weight Acceleration ModelYear \\\n", "0 18.0 8 307.0 130.0 3504.0 12.0 70 \n", "1 15.0 8 350.0 165.0 3693.0 11.5 70 \n", "2 18.0 8 318.0 150.0 3436.0 11.0 70 \n", "3 16.0 8 304.0 150.0 3433.0 12.0 70 \n", "4 17.0 8 302.0 140.0 3449.0 10.5 70 \n", "\n", " Origin \n", "0 1 \n", "1 1 \n", "2 1 \n", "3 1 \n", "4 1 " ] }, "execution_count": 6, "metadata": {}, "output_type": "execute_result" } ], "source": [ "raw_df.head()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Create train/test split" ] }, { "cell_type": "code", "execution_count": 7, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "(318, 8)\n", "(80, 8)\n" ] } ], "source": [ "train_df, test_df = train_test_split(raw_df, train_size=0.8, random_state=17)\n", "print(train_df.shape)\n", "print(test_df.shape)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Setup Ludwig config" ] }, { "cell_type": "code", "execution_count": 8, "metadata": {}, "outputs": [], "source": [ "num_features = ['Cylinders', 'Displacement', 'Horsepower', 'Weight', 'Acceleration', 'ModelYear']\n", "cat_features = ['Origin']" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Create Ludwig input_features" ] }, { "cell_type": "code", "execution_count": 9, "metadata": {}, "outputs": [], "source": [ "input_features = []\n", "# setup input features for number variables\n", "for p in num_features:\n", " a_feature = {'name': p, 'type': 'number', \n", " 'preprocessing': {'missing_value_strategy': 'fill_with_mean', 'normalization': 'zscore'}}\n", " input_features.append(a_feature)\n", "\n", "# setkup input features for categorical variables\n", "for p in cat_features:\n", " a_feature = {'name': p, 'type': 'category'}" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Create Ludwig output features" ] }, { "cell_type": "code", "execution_count": 10, "metadata": {}, "outputs": [], "source": [ "output_features =[\n", " {\n", " 'name': 'MPG',\n", " 'type': 'number',\n", " 'num_fc_layers': 2,\n", " 'fc_size': 64\n", " }\n", "]" ] }, { "cell_type": "code", "execution_count": 11, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "{'input_features': [{'name': 'Cylinders',\n", " 'type': 'number',\n", " 'preprocessing': {'missing_value_strategy': 'fill_with_mean',\n", " 'normalization': 'zscore'}},\n", " {'name': 'Displacement',\n", " 'type': 'number',\n", " 'preprocessing': {'missing_value_strategy': 'fill_with_mean',\n", " 'normalization': 'zscore'}},\n", " {'name': 'Horsepower',\n", " 'type': 'number',\n", " 'preprocessing': {'missing_value_strategy': 'fill_with_mean',\n", " 'normalization': 'zscore'}},\n", " {'name': 'Weight',\n", " 'type': 'number',\n", " 'preprocessing': {'missing_value_strategy': 'fill_with_mean',\n", " 'normalization': 'zscore'}},\n", " {'name': 'Acceleration',\n", " 'type': 'number',\n", " 'preprocessing': {'missing_value_strategy': 'fill_with_mean',\n", " 'normalization': 'zscore'}},\n", " {'name': 'ModelYear',\n", " 'type': 'number',\n", " 'preprocessing': {'missing_value_strategy': 'fill_with_mean',\n", " 'normalization': 'zscore'}}],\n", " 'output_features': [{'name': 'MPG',\n", " 'type': 'number',\n", " 'num_fc_layers': 2,\n", " 'fc_size': 64}],\n", " 'training': {'epochs': 100, 'batch_size': 32}}" ] }, "execution_count": 11, "metadata": {}, "output_type": "execute_result" } ], "source": [ "config = {\n", " 'input_features' : input_features,\n", " 'output_features': output_features,\n", " 'trainer': {\n", " 'epochs': 100,\n", " 'batch_size': 32\n", " }\n", "}\n", "config" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Perform K-fold Cross Validation analysis" ] }, { "cell_type": "code", "execution_count": 12, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "starting 5-fold cross validation\n", "training on fold 1\n", "CPU times: user 40.7 s, sys: 5.38 s, total: 46 s\n", "Wall time: 40.6 s\n" ] } ], "source": [ "%%time\n", "with tempfile.TemporaryDirectory() as tmpdir:\n", " data_csv_fp = os.path.join(tmpdir,'train.csv')\n", " train_df.to_csv(data_csv_fp, index=False)\n", "\n", " (\n", " kfold_cv_stats, \n", " kfold_split_indices \n", " ) = kfold_cross_validate(\n", " num_folds=5,\n", " config=config,\n", " dataset=data_csv_fp,\n", " output_directory=tmpdir,\n", " logging_level=logging.ERROR\n", " )\n" ] }, { "cell_type": "code", "execution_count": 13, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "{'loss_mean': 8.111681,\n", " 'loss_std': 2.4598064,\n", " 'error_mean': 0.0380627,\n", " 'error_std': 0.5965346,\n", " 'mean_squared_error_mean': 8.111682,\n", " 'mean_squared_error_std': 2.4598064,\n", " 'mean_absolute_error_mean': 2.0598435,\n", " 'mean_absolute_error_std': 0.2779836,\n", " 'r2_mean': 0.8666786,\n", " 'r2_std': 0.03552912}" ] }, "execution_count": 13, "metadata": {}, "output_type": "execute_result" } ], "source": [ "kfold_cv_stats['overall']['MPG']" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Train model and assess model performance" ] }, { "cell_type": "code", "execution_count": 14, "metadata": {}, "outputs": [], "source": [ "model = LudwigModel(\n", " config=config,\n", " logging_level=logging.ERROR\n", ")" ] }, { "cell_type": "code", "execution_count": 15, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "CPU times: user 8.34 s, sys: 1.78 s, total: 10.1 s\n", "Wall time: 15 s\n" ] } ], "source": [ "%%time\n", "training_stats = model.train(\n", " training_set=train_df,\n", " output_directory=RESULTS_DIR,\n", ")" ] }, { "cell_type": "code", "execution_count": 16, "metadata": {}, "outputs": [ { "name": "stderr", "output_type": "stream", "text": [ "/opt/project/ludwig/data/preprocessing.py:1045: SettingWithCopyWarning: \n", "A value is trying to be set on a copy of a slice from a DataFrame.\n", "Try using .loc[row_indexer,col_indexer] = value instead\n", "\n", "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", " computed_fill_value,\n" ] } ], "source": [ "test_stats, mpg_hat_df, _ = model.evaluate(dataset=test_df, collect_predictions=True, collect_overall_stats=True)" ] }, { "cell_type": "code", "execution_count": 17, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "{'MPG': {'loss': 8.303831,\n", " 'error': -0.45136052,\n", " 'mean_squared_error': 8.303831,\n", " 'mean_absolute_error': 2.2274728,\n", " 'r2': 0.8558148},\n", " 'combined': {'loss': 8.303831}}" ] }, "execution_count": 17, "metadata": {}, "output_type": "execute_result" } ], "source": [ "test_stats" ] }, { "cell_type": "code", "execution_count": 18, "metadata": {}, "outputs": [ { "name": "stderr", "output_type": "stream", "text": [ "/usr/local/lib/python3.6/dist-packages/seaborn/_decorators.py:43: FutureWarning: Pass the following variables as keyword args: x, y. From version 0.12, the only valid positional argument will be `data`, and passing other arguments without an explicit keyword will result in an error or misinterpretation.\n", " FutureWarning\n" ] }, { "data": { "image/png": "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", "text/plain": [ "
" ] }, "metadata": { "needs_background": "light" }, "output_type": "display_data" } ], "source": [ "a = plt.axes(aspect='equal')\n", "sns.scatterplot(test_df['MPG'].values, mpg_hat_df['MPG_predictions'].values,\n", " s=50)\n", "plt.xlabel('True Values [MPG]')\n", "plt.ylabel('Predictions [MPG]')\n", "lims = [0, 50]\n", "plt.xlim(lims)\n", "plt.ylim(lims)\n", "_ = plt.plot(lims, lims)\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Compare K-fold Cross Validation metrics against hold-out test metrics" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Hold-out Test Metrics" ] }, { "cell_type": "code", "execution_count": 19, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "{'loss': 8.303831,\n", " 'error': -0.45136052,\n", " 'mean_squared_error': 8.303831,\n", " 'mean_absolute_error': 2.2274728,\n", " 'r2': 0.8558148}" ] }, "execution_count": 19, "metadata": {}, "output_type": "execute_result" } ], "source": [ "test_stats['MPG']" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### K-fold Cross Validation Metrics" ] }, { "cell_type": "code", "execution_count": 20, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "{'loss_mean': 8.111681,\n", " 'loss_std': 2.4598064,\n", " 'error_mean': 0.0380627,\n", " 'error_std': 0.5965346,\n", " 'mean_squared_error_mean': 8.111682,\n", " 'mean_squared_error_std': 2.4598064,\n", " 'mean_absolute_error_mean': 2.0598435,\n", " 'mean_absolute_error_std': 0.2779836,\n", " 'r2_mean': 0.8666786,\n", " 'r2_std': 0.03552912}" ] }, "execution_count": 20, "metadata": {}, "output_type": "execute_result" } ], "source": [ "kfold_cv_stats['overall']['MPG']" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.6.9" } }, "nbformat": 4, "nbformat_minor": 4 } ================================================ FILE: examples/lbfgs/config.yaml ================================================ input_features: - name: RESOURCE type: category - name: MGR_ID type: category - name: ROLE_ROLLUP_1 type: category - name: ROLE_ROLLUP_2 type: category - name: ROLE_DEPTNAME type: category - name: ROLE_TITLE type: category - name: ROLE_FAMILY_DESC type: category - name: ROLE_FAMILY type: category - name: ROLE_CODE type: category output_features: - name: ACTION type: binary preprocessing: split: type: fixed defaults: category: encoder: type: sparse trainer: batch_size: 32769 # entire training set train_steps: 1 steps_per_checkpoint: 1 learning_rate: 1 regularization_lambda: 0.0000057 optimizer: type: lbfgs max_iter: 100 tolerance_grad: 0.0001 history_size: 10 ================================================ FILE: examples/lbfgs/model.py ================================================ import logging import pandas as pd from ludwig.api import LudwigModel from ludwig.datasets import amazon_employee_access_challenge df = amazon_employee_access_challenge.load() model = LudwigModel(config="config.yaml", logging_level=logging.INFO) training_statistics, preprocessed_data, output_directory = model.train( df, skip_save_processed_input=True, skip_save_log=True, skip_save_progress=True, skip_save_training_description=True, skip_save_training_statistics=True, ) # Predict on unlabeled test config = model.config config["preprocessing"] = {} model.config = config unlabeled_test = df[df.split == 2].reset_index(drop=True) preds, _ = model.predict(unlabeled_test) # Save predictions to csv action = preds.ACTION_probabilities_True submission = pd.merge(unlabeled_test.reset_index(drop=True).id.astype(int), action, left_index=True, right_index=True) submission.rename(columns={"ACTION_probabilities_True": "Action", "id": "Id"}, inplace=True) submission.to_csv("submission.csv", index=False) ================================================ FILE: examples/llama2_7b_finetuning_4bit/README.md ================================================ # Llama2-7b Fine-Tuning 4bit (QLoRA) [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/drive/1c3AO8l_H6V_x37RwQ8V7M6A-RmcBf2tG?usp=sharing) This example shows how to fine-tune [Llama2-7b](https://huggingface.co/meta-llama/Llama-2-7b-hf) to follow instructions. Instruction tuning is the first step in adapting a general purpose Large Language Model into a chatbot. This example uses no distributed training or big data functionality. It is designed to run locally on any machine with GPU availability. ## Prerequisites - [HuggingFace API Token](https://huggingface.co/docs/hub/security-tokens) - Access approval to [Llama2-7b-hf](https://huggingface.co/meta-llama/Llama-2-7b-hf) - GPU with at least 12 GiB of VRAM (in our tests, we used an Nvidia T4) ## Running ### Command Line Set your token environment variable from the terminal, then run the API script: ```bash export HUGGING_FACE_HUB_TOKEN="" ./run_train.sh ``` ### Python API Set your token environment variable from the terminal, then run the API script: ```bash export HUGGING_FACE_HUB_TOKEN="" python train_alpaca.py ``` ## Upload to HuggingFace You can upload to the HuggingFace Hub from the command line: ```bash ludwig upload hf_hub -r / -m ``` ================================================ FILE: examples/llama2_7b_finetuning_4bit/llama2_7b_4bit.yaml ================================================ model_type: llm base_model: meta-llama/Llama-2-7b-hf quantization: bits: 4 adapter: type: lora input_features: - name: instruction type: text output_features: - name: output type: text trainer: type: finetune learning_rate: 0.0003 batch_size: 2 gradient_accumulation_steps: 8 epochs: 3 learning_rate_scheduler: warmup_fraction: 0.01 backend: type: local ================================================ FILE: examples/llama2_7b_finetuning_4bit/run_train.sh ================================================ #!/usr/bin/env bash # Fail fast if an error occurs set -e # Get the directory of this script, which contains the config file SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) # Train ludwig train --config ${SCRIPT_DIR}/llama2_7b_4bit.yaml --dataset ludwig://alpaca ================================================ FILE: examples/llama2_7b_finetuning_4bit/train_alpaca.py ================================================ import logging import os import yaml from ludwig.api import LudwigModel config = yaml.safe_load(""" model_type: llm base_model: meta-llama/Llama-2-7b-hf quantization: bits: 4 adapter: type: lora input_features: - name: instruction type: text output_features: - name: output type: text trainer: type: finetune learning_rate: 0.0003 batch_size: 2 gradient_accumulation_steps: 8 epochs: 3 learning_rate_scheduler: warmup_fraction: 0.01 backend: type: local """) # Define Ludwig model object that drive model training model = LudwigModel(config=config, logging_level=logging.INFO) # initiate model training ( train_stats, # dictionary containing training statistics preprocessed_data, # tuple Ludwig Dataset objects of pre-processed training data output_directory, # location of training results stored on disk ) = model.train( dataset="ludwig://alpaca", experiment_name="alpaca_instruct_4bit", model_name="llama2_7b", ) # list contents of output directory print("contents of output directory:", output_directory) for item in os.listdir(output_directory): print("\t", item) ================================================ FILE: examples/llm_base_model_dequantization/README.md ================================================ # Convert quantized base model to fp16 Ludwig has utility functions to convert nf4 quantized bitsandbytes base models back to fp16 for more efficient inference. This is desireable since inference with bitsandbytes is slow because every forward pass through the model requires dequantizing the model weights from nf4 to fp16 layer by layer and then quantizing it back to nf4 to keep memory usage constant. By dequantizing the base model in fp16 upfront, you can get the same effect of the quantized weights without sacrificing on inference performance. ## Visual Illustration ### Without dequantization upfront | **Request 1:** | **Request 2:** | **Request 3:** | | ------------------------------------------ | ------------------------------------------ | ------------------------------------------ | | - Quantized bitsandbytes model | - Quantized bitsandbytes model | - Quantized bitsandbytes model | | - Dequantization of layer 1 (nf4 to fp16) | - Dequantization of layer 1 (nf4 to fp16) | - Dequantization of layer 1 (nf4 to fp16) | | - Forward Pass (using dequantized weights) | - Forward Pass (using dequantized weights) | - Forward Pass (using dequantized weights) | | - Quantization of layer 1 (fp16 to nf4) | - Quantization of layer 1 (fp16 to nf4) | - Quantization of layer 1 (fp16 to nf4) | | - Dequantization of layer 2 (nf4 to fp16) | - Dequantization of layer 2 (nf4 to fp16) | - Dequantization of layer 2 (nf4 to fp16) | | - Forward Pass (using dequantized weights) | - Forward Pass (using dequantized weights) | - Forward Pass (using dequantized weights) | | - Quantization of layer 2 (fp16 to nf4) | - Quantization of layer 2 (fp16 to nf4) | - Quantization of layer 2 (fp16 to nf4) | | - ... | - ... | - ... | | - Final Output | - Final Output | - Final Output | ### With dequantization upfront | **Request 1:** | **Request 2:** | **Request 3:** | | -------------------------------- | -------------------------------- | -------------------------------- | | - Dequantized base model in fp16 | - Dequantized base model in fp16 | - Dequantized base model in fp16 | | - Forward pass through layer 1 | - Forward pass through layer 1 | - Forward pass through layer 1 | | - Forward pass through layer 2 | - Forward pass through layer 2 | - Forward pass through layer 2 | | - ... | - ... | - ... | | - Final Output | - Final Output | - Final Output | ## Running the example script The example `phi_2_dequantization.py` shows how you how you can quantize and then dequantized Phi-2. This process can be repeated for any other base model supported by Ludwig that is quantized using 4 bits nf4 bitsandbytes quantization. You will need a GPU to run the script successfully. Beneath the surface, this script: 1. Loads the base model in 4 bit nf4 quantization 1. Dequantizes the model layer by layer back into fp16 in-place. 1. Write the new dequantized weights to disk at `save_path` 1. Write the tokenizer to disk at `save_path` Make sure you update the paths at the top of the file for base model, save path, and huggingface repo ID! ## Bonus If desired, you can also use Ludwig to push the new dequantized model weights straight to HuggingFace hub! ```python from ludwig.utils.hf_utils import upload_folder_to_hfhub upload_folder_to_hfhub(repo_id=hfhub_repo_id, folder_path=save_path) ``` ### Dequantized base models already on huggingface hub - [CodeLlama 7b Instruct](https://huggingface.co/arnavgrg/codallama-7b-instruct-nf4-fp16-upscaled) - [CodeLlama 13b Instruct](https://huggingface.co/arnavgrg/codellama-13b-instruct-nf4-fp16-upscaled) - [CodeLlama 70b Instruct](https://huggingface.co/arnavgrg/codellama-70b-instruct-nf4-fp16-upscaled) - [Llama 2 7b](https://huggingface.co/arnavgrg/llama-2-7b-nf4-fp16-upscaled) - [Llama 2 7b Chat](https://huggingface.co/arnavgrg/llama-2-7b-chat-nf4-fp16-upscaled) - [Llama 2 13b Chat](https://huggingface.co/arnavgrg/llama-2-13b-chat-nf4-fp16-upscaled) - [Llama 2 70b Chat](https://huggingface.co/arnavgrg/llama-2-70b-chat-nf4-fp16-upscaled) - [Mistral 7b](https://huggingface.co/arnavgrg/mistral-7b-nf4-fp16-upscaled) - [Mistral 7b Instruct](https://huggingface.co/arnavgrg/mistral-7b-instruct-nf4-fp16-upscaled) - [NousMistral Yarn 7b 128K](https://huggingface.co/arnavgrg/NousResearch-Yarn-Mistral-7b-128k-nf4-fp16-upscaled) - [Microsoft Phi-2](https://huggingface.co/arnavgrg/phi-2-nf4-fp16-upscaled) - [Zephyr 7b Beta](https://huggingface.co/arnavgrg/zephyr-7b-beta-nf4-fp16-upscaled) ================================================ FILE: examples/llm_base_model_dequantization/phi_2_dequantization.py ================================================ import logging import os import yaml from huggingface_hub import whoami from ludwig.api import LudwigModel from ludwig.utils.hf_utils import upload_folder_to_hfhub hf_username = whoami().get("name") base_model_name = "microsoft/phi-2" dequantized_path = "microsoft-phi-2-dequantized" save_path = "/home/ray/" + dequantized_path hfhub_repo_id = os.path.join(hf_username, dequantized_path) config = yaml.safe_load(f""" model_type: llm base_model: {base_model_name} quantization: bits: 4 input_features: - name: instruction type: text output_features: - name: output type: text trainer: type: none backend: type: local """) # Define Ludwig model object that drive model training model = LudwigModel(config=config, logging_level=logging.INFO) model.save_dequantized_base_model(save_path=save_path) # Optional: Upload to Huggingface Hub upload_folder_to_hfhub(repo_id=hfhub_repo_id, folder_path=save_path) ================================================ FILE: examples/llm_few_shot_learning/simple_model_training.py ================================================ #!/usr/bin/env python # # Simple Model Training Example # # This is a simple example of how to use the LLM model type to train # a zero shot classification model. It uses the facebook/opt-350m model # as the base LLM model. # Import required libraries import logging import shutil import pandas as pd import yaml from ludwig.api import LudwigModel # clean out prior results shutil.rmtree("./results", ignore_errors=True) review_label_pairs = [ {"review": "I loved this movie!", "label": "positive"}, {"review": "The food was okay, but the service was terrible.", "label": "negative"}, {"review": "I can't believe how rude the staff was.", "label": "negative"}, {"review": "This book was a real page-turner.", "label": "positive"}, {"review": "The hotel room was dirty and smelled bad.", "label": "negative"}, {"review": "I had a great experience at this restaurant.", "label": "positive"}, {"review": "The concert was amazing!", "label": "positive"}, {"review": "The traffic was terrible on my way to work this morning.", "label": "negative"}, {"review": "The customer service was excellent.", "label": "positive"}, {"review": "I was disappointed with the quality of the product.", "label": "negative"}, {"review": "The scenery on the hike was breathtaking.", "label": "positive"}, {"review": "I had a terrible experience at this hotel.", "label": "negative"}, {"review": "The coffee at this cafe was delicious.", "label": "positive"}, {"review": "The weather was perfect for a day at the beach.", "label": "positive"}, {"review": "I would definitely recommend this product.", "label": "positive"}, {"review": "The wait time at the doctor's office was ridiculous.", "label": "negative"}, {"review": "The museum was a bit underwhelming.", "label": "neutral"}, {"review": "I had a fantastic time at the amusement park.", "label": "positive"}, {"review": "The staff at this store was extremely helpful.", "label": "positive"}, {"review": "The airline lost my luggage and was very unhelpful.", "label": "negative"}, {"review": "This album is a must-listen for any music fan.", "label": "positive"}, {"review": "The food at this restaurant was just okay.", "label": "neutral"}, {"review": "I was pleasantly surprised by how great this movie was.", "label": "positive"}, {"review": "The car rental process was quick and easy.", "label": "positive"}, {"review": "The service at this hotel was top-notch.", "label": "positive"}, ] df = pd.DataFrame(review_label_pairs) df["split"] = [0] * 15 + [2] * 10 config = yaml.safe_load(""" model_type: llm base_model: facebook/opt-350m generation: temperature: 0.1 top_p: 0.75 top_k: 40 num_beams: 4 max_new_tokens: 64 prompt: task: "Classify the sample input as either negative, neutral, or positive." retrieval: type: semantic k: 3 model_name: paraphrase-MiniLM-L3-v2 input_features: - name: review type: text output_features: - name: label type: category preprocessing: fallback_label: "neutral" decoder: type: category_extractor match: "negative": type: contains value: "positive" "neural": type: contains value: "neutral" "positive": type: contains value: "positive" preprocessing: split: type: fixed """) # Define Ludwig model object that drive model training model = LudwigModel(config=config, logging_level=logging.INFO) # initiate model training ( train_stats, # dictionary containing training statistics preprocessed_data, # tuple Ludwig Dataset objects of pre-processed training data output_directory, # location of training results stored on disk ) = model.train( dataset=df, experiment_name="simple_experiment", model_name="simple_model", skip_save_processed_input=True ) training_set, val_set, test_set, _ = preprocessed_data # batch prediction preds, _ = model.predict(test_set, skip_save_predictions=False) print(preds) ================================================ FILE: examples/llm_finetuning/README.md ================================================ # LLM Fine-tuning These examples show you how to fine-tune Large Language Models by taking advantage of model parallelism with [DeepSpeed](https://www.deepspeed.ai/), allowing Ludwig to scale to very large models with billions of parameters. The task here will be to fine-tune a large billion+ LLM to classify the sentiment of [IMDB movie reviews](https://www.kaggle.com/datasets/lakshmi25npathi/imdb-dataset-of-50k-movie-reviews). As such, we'll be taking a pretrained LLM, attaching a classification head, and fine-tuning the weights to improve performance of the LLM on the task. Ludwig will do this for you without no machine learning code, just configuration. ## Prerequisites - Installed Ludwig with `ludwig[distributed]` dependencies - Have a CUDA-enabled version of PyTorch installed - Have access to a machine or cluster of machines with multiple GPUs - The IMDB dataset used in these examples comes from Kaggle, so make sure you have your credentials set (e.g., `$HOME/.kaggle.kaggle.json`) ## Running DeepSpeed on Ray This is the recommended way to use DeepSpeed, which supports auto-batch size tuning and distributed data processing. There is some overhead from using Ray with small datasets (\<100MB), but in most cases performance should be comparable to using native DeepSpeed. From the head node of your Ray cluster: ```bash ./run_train_dsz3_ray.sh ``` ### Python API If you want to run Ludwig programatically (from a notebook or as part of a larger workflow), you can run the following Python script using the Ray cluster launcher from your local machine. ```bash ray submit cluster.yaml train_imdb_ray.py ``` If running directly on the Ray head node, you can omit the `ray submit` portion and run like an ordinary Python script: ```bash python train_imdb_ray.py ``` ## Running DeepSpeed Native This mode is suitable for datasets small enough to fit in memory on a single machine, as it doesn't make use of distributed data processing (requires use of the Ray backend). The following example assumes you have 4 GPUs available, but can easily be modified to support your preferred setup. From a terminal on your machine: ```bash ./run_train_dsz3.sh ``` ================================================ FILE: examples/llm_finetuning/imdb_deepspeed_zero3.yaml ================================================ input_features: - name: review type: text encoder: type: auto_transformer pretrained_model_name_or_path: bigscience/bloom-3b trainable: true adapter: lora output_features: - name: sentiment type: category trainer: batch_size: 4 epochs: 3 gradient_accumulation_steps: 8 backend: type: deepspeed zero_optimization: stage: 3 offload_optimizer: device: cpu pin_memory: true ================================================ FILE: examples/llm_finetuning/imdb_deepspeed_zero3_ray.yaml ================================================ input_features: - name: review type: text encoder: type: auto_transformer pretrained_model_name_or_path: bigscience/bloom-3b trainable: true adapter: lora output_features: - name: sentiment type: category trainer: batch_size: 4 epochs: 3 gradient_accumulation_steps: 8 backend: type: ray trainer: use_gpu: true strategy: type: deepspeed zero_optimization: stage: 3 offload_optimizer: device: cpu pin_memory: true ================================================ FILE: examples/llm_finetuning/run_train_dsz3.sh ================================================ #!/usr/bin/env bash # Fail fast if an error occurs set -e # Get the directory of this script, which contains the config file SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) # Train deepspeed --no_python --no_local_rank --num_gpus 4 ludwig train --config ${SCRIPT_DIR}/imdb_deepspeed_zero3.yaml --dataset ludwig://imdb ================================================ FILE: examples/llm_finetuning/run_train_dsz3_ray.sh ================================================ #!/usr/bin/env bash # Fail fast if an error occurs set -e # Get the directory of this script, which contains the config file SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) # Train ludwig train --config ${SCRIPT_DIR}/imdb_deepspeed_zero3_ray.yaml --dataset ludwig://imdb ================================================ FILE: examples/llm_finetuning/train_imdb_ray.py ================================================ import logging import os import yaml from ludwig.api import LudwigModel config = yaml.safe_load(""" input_features: - name: review type: text encoder: type: auto_transformer pretrained_model_name_or_path: bigscience/bloom-3b trainable: true adapter: type: lora output_features: - name: sentiment type: category trainer: batch_size: 4 epochs: 3 backend: type: ray trainer: use_gpu: true strategy: type: deepspeed zero_optimization: stage: 3 offload_optimizer: device: cpu pin_memory: true """) # Define Ludwig model object that drive model training model = LudwigModel(config=config, logging_level=logging.INFO) # initiate model training ( train_stats, # dictionary containing training statistics preprocessed_data, # tuple Ludwig Dataset objects of pre-processed training data output_directory, # location of training results stored on disk ) = model.train( dataset="ludwig://imdb", experiment_name="imdb_sentiment", model_name="bloom3b", ) # list contents of output directory print("contents of output directory:", output_directory) for item in os.listdir(output_directory): print("\t", item) ================================================ FILE: examples/llm_instruction_tuning/train_alpaca_ray.py ================================================ import logging import os import yaml from ludwig.api import LudwigModel config = yaml.safe_load(""" model_type: llm base_model: bigscience/bloomz-3b adapter: type: lora input_features: - name: instruction type: text output_features: - name: output type: text trainer: type: finetune batch_size: 4 epochs: 3 backend: type: ray trainer: use_gpu: true strategy: type: deepspeed zero_optimization: stage: 3 offload_optimizer: device: cpu pin_memory: true """) # Define Ludwig model object that drive model training model = LudwigModel(config=config, logging_level=logging.INFO) # initiate model training ( train_stats, # dictionary containing training statistics preprocessed_data, # tuple Ludwig Dataset objects of pre-processed training data output_directory, # location of training results stored on disk ) = model.train( dataset="ludwig://alpaca", experiment_name="alpaca_instruct", model_name="bloom560m", ) # list contents of output directory print("contents of output directory:", output_directory) for item in os.listdir(output_directory): print("\t", item) ================================================ FILE: examples/llm_text_generation/simple_model_training.py ================================================ #!/usr/bin/env python # # Simple Model Training Example # # This is a simple example of how to use the LLM model type to train # a model on a simple question and answer dataset. It uses the # facebook/opt-350m model as the base LLM model. # Import required libraries import logging import shutil import pandas as pd import yaml from ludwig.api import LudwigModel # clean out prior results shutil.rmtree("./results", ignore_errors=True) qa_pairs = [ {"Question": "What is the capital of Uzbekistan?", "Answer": "Tashkent"}, {"Question": "Who is the founder of Microsoft?", "Answer": "Bill Gates"}, {"Question": "What is the tallest building in the world?", "Answer": "Burj Khalifa"}, {"Question": "What is the currency of Brazil?", "Answer": "Real"}, {"Question": "What is the boiling point of mercury in Celsius?", "Answer": "-38.83"}, {"Question": "What is the most commonly spoken language in the world?", "Answer": "Mandarin"}, {"Question": "What is the diameter of the Earth?", "Answer": "12,742 km"}, {"Question": 'Who wrote the novel "1984"?', "Answer": "George Orwell"}, {"Question": "What is the name of the largest moon of Neptune?", "Answer": "Triton"}, {"Question": "What is the speed of light in meters per second?", "Answer": "299,792,458 m/s"}, {"Question": "What is the smallest country in Africa by land area?", "Answer": "Seychelles"}, {"Question": "What is the largest organ in the human body?", "Answer": "Skin"}, {"Question": 'Who directed the film "The Godfather"?', "Answer": "Francis Ford Coppola"}, {"Question": "What is the name of the smallest planet in our solar system?", "Answer": "Mercury"}, {"Question": "What is the largest lake in Africa?", "Answer": "Lake Victoria"}, {"Question": "What is the smallest country in Asia by land area?", "Answer": "Maldives"}, {"Question": "Who is the current president of Russia?", "Answer": "Vladimir Putin"}, {"Question": "What is the chemical symbol for gold?", "Answer": "Au"}, {"Question": "What is the name of the famous Swiss mountain known for skiing?", "Answer": "The Matterhorn"}, {"Question": "What is the largest flower in the world?", "Answer": "Rafflesia arnoldii"}, ] df = pd.DataFrame(qa_pairs) config = yaml.safe_load(""" input_features: - name: Question type: text output_features: - name: Answer type: text model_type: llm generation: temperature: 0.1 top_p: 0.75 top_k: 40 num_beams: 4 max_new_tokens: 5 base_model: facebook/opt-350m """) # Define Ludwig model object that drive model training model = LudwigModel(config=config, logging_level=logging.INFO) # initiate model training ( train_stats, # dictionary containing training statistics preprocessed_data, # tuple Ludwig Dataset objects of pre-processed training data output_directory, # location of training results stored on disk ) = model.train( dataset=df, experiment_name="simple_experiment", model_name="simple_model", skip_save_processed_input=True ) training_set, val_set, test_set, _ = preprocessed_data # batch prediction preds, _ = model.predict(test_set, skip_save_predictions=False) print(preds) ================================================ FILE: examples/llm_zero_shot_learning/simple_model_training.py ================================================ #!/usr/bin/env python # # Simple Model Training Example # # This is a simple example of how to use the LLM model type to train # a zero shot classification model. It uses the facebook/opt-350m model # as the base LLM model. # Import required libraries import logging import shutil import pandas as pd import yaml from ludwig.api import LudwigModel # clean out prior results shutil.rmtree("./results", ignore_errors=True) review_label_pairs = [ {"review": "I loved this movie!", "label": "positive"}, {"review": "The food was okay, but the service was terrible.", "label": "negative"}, {"review": "I can't believe how rude the staff was.", "label": "negative"}, {"review": "This book was a real page-turner.", "label": "positive"}, {"review": "The hotel room was dirty and smelled bad.", "label": "negative"}, {"review": "I had a great experience at this restaurant.", "label": "positive"}, {"review": "The concert was amazing!", "label": "positive"}, {"review": "The traffic was terrible on my way to work this morning.", "label": "negative"}, {"review": "The customer service was excellent.", "label": "positive"}, {"review": "I was disappointed with the quality of the product.", "label": "negative"}, {"review": "The scenery on the hike was breathtaking.", "label": "positive"}, {"review": "I had a terrible experience at this hotel.", "label": "negative"}, {"review": "The coffee at this cafe was delicious.", "label": "positive"}, {"review": "The weather was perfect for a day at the beach.", "label": "positive"}, {"review": "I would definitely recommend this product.", "label": "positive"}, {"review": "The wait time at the doctor's office was ridiculous.", "label": "negative"}, {"review": "The museum was a bit underwhelming.", "label": "neutral"}, {"review": "I had a fantastic time at the amusement park.", "label": "positive"}, {"review": "The staff at this store was extremely helpful.", "label": "positive"}, {"review": "The airline lost my luggage and was very unhelpful.", "label": "negative"}, {"review": "This album is a must-listen for any music fan.", "label": "positive"}, {"review": "The food at this restaurant was just okay.", "label": "neutral"}, {"review": "I was pleasantly surprised by how great this movie was.", "label": "positive"}, {"review": "The car rental process was quick and easy.", "label": "positive"}, {"review": "The service at this hotel was top-notch.", "label": "positive"}, ] df = pd.DataFrame(review_label_pairs) config = yaml.safe_load(""" model_type: llm base_model: facebook/opt-350m generation: temperature: 0.1 top_p: 0.75 top_k: 40 num_beams: 4 max_new_tokens: 64 prompt: task: "Classify the sample input as either negative, neutral, or positive." input_features: - name: review type: text output_features: - name: label type: category preprocessing: fallback_label: "neutral" decoder: type: category_extractor match: "negative": type: contains value: "positive" "neutral": type: contains value: "neutral" "positive": type: contains value: "positive" """) # Define Ludwig model object that drive model training model = LudwigModel(config=config, logging_level=logging.INFO) # initiate model training ( train_stats, # dictionary containing training statistics preprocessed_data, # tuple Ludwig Dataset objects of pre-processed training data output_directory, # location of training results stored on disk ) = model.train( dataset=df, experiment_name="simple_experiment", model_name="simple_model", skip_save_processed_input=True ) training_set, val_set, test_set, _ = preprocessed_data # batch prediction preds, _ = model.predict(test_set, skip_save_predictions=False) print(preds) ================================================ FILE: examples/mnist/README.md ================================================ # MNIST Hand-written Digit Classification This API example is based on [Ludwig's MNIST Hand-written Digit image classification example](https://ludwig-ai.github.io/ludwig-docs/examples/#image-classification-mnist). ### Examples | File | Description | | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | simple_model_training.py | Demonstrates using Ludwig api for training a model. | | advance_model_training.py | Demonstrates a method to assess alternative model architectures. | | assess_model_performance.py | Assess model performance on hold-out test data set. This shows how to load a previously trained model to make predictions. | | visualize_model_test_results.ipynb | Example for extracting training statistics and generate custom visualizations. | ================================================ FILE: examples/mnist/advanced_model_training.py ================================================ #!/usr/bin/env python # # Multiple Model Training Example # # This example trains multiple models and extracts training statistics import glob import logging import os import shutil from collections import namedtuple import yaml # ## Import required libraries from ludwig.api import LudwigModel from ludwig.constants import TRAINER from ludwig.datasets import mnist from ludwig.visualize import learning_curves # clean out old results shutil.rmtree("./results", ignore_errors=True) shutil.rmtree("./visualizations", ignore_errors=True) file_list = glob.glob("./data/*.json") file_list += glob.glob("./data/*.hdf5") for f in file_list: try: os.remove(f) except FileNotFoundError: pass # read in base config with open("./config.yaml") as f: base_model = yaml.safe_load(f.read()) # Specify named tuple to keep track of training results TrainingResult = namedtuple("TrainingResult", ["name", "train_stats"]) # specify alternative architectures to test FullyConnectedLayers = namedtuple("FullyConnectedLayers", ["name", "fc_layers"]) list_of_fc_layers = [ FullyConnectedLayers(name="Option1", fc_layers=[{"output_size": 64}]), FullyConnectedLayers(name="Option2", fc_layers=[{"output_size": 128}, {"output_size": 64}]), FullyConnectedLayers(name="Option3", fc_layers=[{"output_size": 128}]), ] # list_of_train_stats = [] # load and split MNIST dataset training_set, test_set, _ = mnist.load(split=True) # ## Train models for model_option in list_of_fc_layers: print(">>>> training: ", model_option.name) # set up Python dictionary to hold model training parameters config = base_model.copy() config["input_features"][0]["fc_layers"] = model_option.fc_layers config[TRAINER]["epochs"] = 5 # Define Ludwig model object that drive model training model = LudwigModel(config, logging_level=logging.INFO) # initiate model training train_stats, _, _ = model.train( training_set=training_set, test_set=test_set, experiment_name="multiple_experiment", model_name=model_option.name, ) # save training stats for later use list_of_train_stats.append(TrainingResult(name=model_option.name, train_stats=train_stats)) print(">>>>>>> completed: ", model_option.name, "\n") # generating learning curves from training option_names = [trs.name for trs in list_of_train_stats] train_stats = [trs.train_stats for trs in list_of_train_stats] learning_curves( train_stats, "Survived", model_names=option_names, output_directory="./visualizations", file_format="png" ) ================================================ FILE: examples/mnist/assess_model_performance.py ================================================ #!/usr/bin/env python # # Load a previously saved model and make predictions on the test data set # import os.path # ## Import required libraries import pandas as pd from sklearn.metrics import accuracy_score from ludwig.api import LudwigModel from ludwig.datasets import mnist # create data set for predictions test_data = {"image_path": [], "label": []} dataset = mnist.Mnist() test_dir = os.path.join(dataset.processed_dataset_path, "testing") for label in os.listdir(test_dir): files = os.listdir(os.path.join(test_dir, label)) test_data["image_path"] += [os.path.join(test_dir, label, f) for f in files] test_data["label"] += len(files) * [label] # collect data into a data frame test_df = pd.DataFrame(test_data) print(test_df.head()) # retrieve a trained model model = LudwigModel.load("./results/multiple_experiment_Option3/model") # make predictions pred_df, _ = model.predict(dataset=test_df) print(pred_df.head()) # print accuracy on test data set print("predicted accuracy", accuracy_score(test_df["label"], pred_df["label_predictions"])) ================================================ FILE: examples/mnist/config.yaml ================================================ input_features: - name: image_path type: image preprocessing: num_processes: 4 encoder: stacked_cnn conv_layers: - num_filters: 32 filter_size: 3 pool_size: 2 pool_stride: 2 - num_filters: 64 filter_size: 3 pool_size: 2 pool_stride: 2 dropout: 0.4 fc_layers: - output_size: 128 dropout: 0.4 output_features: - name: label type: category trainer: epochs: 5 ================================================ FILE: examples/mnist/simple_model_training.py ================================================ #!/usr/bin/env python # # Simple Model Training Example # # This example is the API example for this Ludwig command line example # (https://ludwig-ai.github.io/ludwig-docs/latest/examples/mnist/). import logging import shutil import yaml from ludwig.api import LudwigModel from ludwig.datasets import mnist # clean out prior results shutil.rmtree("./results", ignore_errors=True) # set up Python dictionary to hold model training parameters with open("./config.yaml") as f: config = yaml.safe_load(f.read()) # Define Ludwig model object that drive model training model = LudwigModel(config, logging_level=logging.INFO) # load and split MNIST dataset training_set, test_set, _ = mnist.load(split=True) # initiate model training train_stats, _, output_directory = model.train( # training statistics # location for training results saved to disk training_set=training_set, test_set=test_set, experiment_name="simple_image_experiment", model_name="single_model", skip_save_processed_input=True, ) ================================================ FILE: examples/mnist/visualize_model_test_results.ipynb ================================================ { "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# Ludwig Visualization Demonstration" ] }, { "cell_type": "code", "execution_count": 1, "metadata": {}, "outputs": [], "source": [ "import warnings\n", "warnings.simplefilter('ignore')\n", "from ludwig.api import LudwigModel\n", "from ludwig.datasets import mnist\n", "from ludwig.visualize import compare_performance, compare_classifiers_performance_from_pred, \\\n", " confusion_matrix\n", "from ludwig.utils.data_utils import load_json\n", "import pandas as pd\n", "import os\n", "import os.path\n", "import shutil\n", "\n", "shutil.rmtree('./viz2', ignore_errors=True)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Prepare test data set for use" ] }, { "cell_type": "code", "execution_count": 2, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ " image_path label\n", "0 /opt/project/examples/mnist/data/mnist_png/tes... 0\n", "1 /opt/project/examples/mnist/data/mnist_png/tes... 0\n", "2 /opt/project/examples/mnist/data/mnist_png/tes... 0\n", "3 /opt/project/examples/mnist/data/mnist_png/tes... 0\n", "4 /opt/project/examples/mnist/data/mnist_png/tes... 0\n" ] } ], "source": [ "# create test dataframe\n", "test_data = {'image_path': [], 'label': []}\n", "dataset = mnist.Mnist()\n", "test_dir = os.path.join(dataset.processed_dataset_path, 'testing')\n", "for label in os.listdir(test_dir):\n", " files = os.listdir(os.path.join(test_dir, label))\n", " test_data['image_path'] += [os.path.join(test_dir, label, f) for f in files]\n", " test_data['label'] += len(files) * [label]\n", "\n", "# collect data into a data frame\n", "test_df = pd.DataFrame(test_data)\n", "print(test_df.head())" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Generate predictions the test data set for the different neural network options" ] }, { "cell_type": "code", "execution_count": 3, "metadata": {}, "outputs": [], "source": [ "# get list of models to visualize results\n", "models_list = ['Option1', 'Option2', 'Option3']\n", "test_stats_list = []\n", "preds_list = []\n", "\n", "for m in models_list:\n", " # retrieve a trained model\n", " model = LudwigModel.load('./results/multiple_experiment_'+ m + '/model')\n", "\n", " # make predictions\n", " test_stats, pred_df, _ = model.evaluate(dataset=test_df, collect_predictions=True, collect_overall_stats=True)\n", " \n", " # collect test statsitics\n", " preds_list.append(pred_df['label_predictions'].astype('int'))\n", " test_stats_list.append(test_stats)\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Show model performance on test data set" ] }, { "cell_type": "code", "execution_count": 4, "metadata": {}, "outputs": [ { "data": { "image/png": "iVBORw0KGgoAAAANSUhEUgAAAagAAAEYCAYAAAAJeGK1AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAALEgAACxIB0t1+/AAAADh0RVh0U29mdHdhcmUAbWF0cGxvdGxpYiB2ZXJzaW9uMy4yLjEsIGh0dHA6Ly9tYXRwbG90bGliLm9yZy+j8jraAAAgAElEQVR4nOzdeVwV1f/H8dflApcrIJuCCC647wluuOKC4oZ7pSn6TSszNU3NzF+laZYtSrlWZllpaqVZ6Df3LXFJEHPDDUFBNgWR9e7z+4Ovt0jNNbnq5/l4+IA7Z5Yz5/qYNzNz5oxKURQFIYQQwsbYlXYFhBBCiBuRgBJCCGGTJKCEEELYJAkoIYQQNkkCSgghhE2SgBJCCGGTJKAecpcvX2bw4MEEBgYye/bs0q6OuImYmBjCwsJKuxp3rXbt2pw/f/6W86WkpFC7dm1MJtMdb+NelhWPJvvSrsDjqGPHjly+fBm1Wo1Wq6Vdu3a8+eabODs73/G6Vq9ejYeHB4cOHUKlUv0LtRX3Q9OmTdm0aVNpV0OIh4qcQZWSTz/9lLi4OH766SeOHTvG4sWL72h5RVGwWCykpqZSvXr1uwon+Uv1wZB2FuLuSECVMh8fH9q2bcuZM2cAOHz4MAMHDqRp06b06tWLAwcOWOeNiIggMjKSgQMH8sQTTzB58mTWrVvH0qVLCQwMZO/evRgMBmbNmkWbNm1o06YNs2bNwmAwAHDgwAHatWvH559/TuvWrXn99deZP38+L7/8MpMmTSIwMJDw8HASExP57LPPaNmyJSEhIezZs8dahzVr1tCtWzcCAwPp1KkTq1atspZdW/+XX35Jy5YtadOmDWvWrLGW63Q6Zs+eTYcOHWjSpAmDBg1Cp9Pdcr//Li0tjTFjxhAcHEyLFi2YMWMGABaLhUWLFtGhQwdatmzJ5MmTycvLA/68fLRmzRpCQkJo1qwZK1eu5MiRI4SHh9O0aVPregDWrl3LwIEDmTFjBk2aNKFr167s27fvjtrhr+18bdo1n3/+OW3btiUwMJCwsDDrum/n+7tZ+/5dRkYGL774Is2bN6dz5858//331rL58+czbtw4Jk+eTGBgID169ODo0aM3Xddf7dy5kz59+hAUFERISAjz58+/bp41a9ZY92Hp0qXW6RaLhc8//5zQ0FBatGjBuHHjyMnJua3tiseQIh64Dh06KNHR0YqiKEpqaqrSvXt3JTIyUklPT1eaN2+u7Ny5UzGbzcqePXuU5s2bK1lZWYqiKMqQIUOUkJAQ5fTp04rRaFQMBoPy2muvKXPnzrWu++OPP1aefPJJ5fLly0pWVpby9NNPK5GRkYqiKMr+/fuVunXrKh988IGi1+uVoqIiZd68eUqDBg2U3bt3K0ajUXn11VeVDh06KIsWLVIMBoOyevVqpUOHDtb179ixQzl//rxisViUAwcOKI0aNVKOHTtWYv0ff/yxYjAYlJ07dyqNGjVScnJyFEVRlOnTpytDhgxR0tPTFZPJpMTGxip6vf6W+/1XJpNJCQ8PV2bNmqUUFBQoOp1OOXjwoKIoivLDDz8ooaGhyoULF5T8/Hxl9OjRyqRJkxRFUZTk5GSlVq1ayptvvqnodDrlt99+Uxo0aKCMGjVKuXz5spKenq4EBwcrBw4cUBRFUdasWaPUrVtX+eqrrxSDwaBs2LBBCQoKUq5cuXLb7fDXdt6/f7/Stm1bRVEUJSEhQWnXrp2Snp5urdv58+dv+/u7Wfv+3TPPPKNMmzZN0el0yokTJ5QWLVooe/fuVRRFsX7vO3fuVEwmk/LRRx8pTz755E3/z9aqVUtJSkqy1uPkyZOK2WxW4uPjlZYtWypbtmwp0c6vvPKKUlBQoJw8eVJp0aKF9f/7smXLlCeffFJJS0tT9Hq98uabbyqvvPJKiWWNRuNN6yEeLxJQpaBDhw5K48aNlSZNmijt27dXpk2bphQVFSmfffaZ9YB6zfDhw5W1a9cqilIcUB9//HGJ8r8HVKdOnZSdO3daP+/evdsaMPv371fq16+v6HQ6a/m8efOU//znP9bP27ZtUxo3bqyYTCZFURQlLy9PqVWrlnL16tUb7suoUaOUZcuWWdffsGHDEgeY4OBgJS4uTjGbzUrDhg2V+Pj469Zxq/3+q0OHDiktWrS44UFs6NChyvLly62fExISlHr16ilGo9F68LsWCoqiKM2bN1c2bNhg/TxmzBjlq6++UhSlOKBat26tWCwWa3n//v2Vn3766bba4e/t/NeASkpKUoKDg5Xo6GjFYDCUWM+tvr+bte/fpaamKnXq1FHy8vKs0z766CPltddeUxSl+HsfNmyYtezMmTNKw4YNb7hvilIyoP7unXfeUWbNmqUoyp8hc/bsWWv5+++/r7z++uuKoihK165drSGpKIqSkZFx3XckASWukU4SpWThwoW0atWqxLTU1FQ2btzIjh07rNNMJhMtWrSwfvb19f3H9WZmZlKxYkXr54oVK5KZmWn97OHhgUajKbGMl5eX9XcnJyc8PDxQq9XWzwCFhYWULVuWXbt2sXDhQpKSkrBYLOh0OmrVqmVd3t3dHXv7P/9babVaCgsLuXLlCnq9nkqVKl1X59vZ72vS0tKoWLFiiW38dd/9/Pysn/38/DCZTGRlZd1wXzUazXWfCwsLrZ99fHxK3Nv7a1veqh1u1M7XVKlShalTpzJ//nzOnj1LmzZtmDJlCj4+Prf8/m7WvjdqCzc3N1xcXEqs69ixY9bP5cqVs/7u5OSEXq/HZDLdsG3/6o8//uCjjz7izJkzGI1GDAYDXbt2LTHPX/+f+vn5cfr0aaD4ux49ejR2dn/eXbCzsyvxHQlxjdyDsiG+vr707t2bmJgY67/Dhw/zwgsvWOe5VWcIb29vUlNTrZ/T0tLw9va+7eX/icFg4OWXX2b48OFER0cTExNDu3btUG5jQPxrB+zk5OTrym5nv/86b1pa2g07Hnh7e3Px4kXr59TUVOzt7UuE0J3IyMgosW/X2vJ22uFW7RweHs7KlSvZsWMHKpWKjz76yLoP//T93S5vb2+uXr1Kfn5+iXX5+Pjc8br+buLEiXTq1Ildu3YRGxvLwIEDr/s/kJaWZv09NTXVug8VKlRgyZIlJb7ro0eP3pd6iUePBJQN6dWrFzt27OC3337DbDaj1+s5cOAA6enpt72OHj16sHjxYrKzs8nOzmbhwoWEh4ffl/oZDAYMBgOenp7Y29uza9cuoqOjb2tZOzs7+vfvz3vvvUdGRgZms5m4uDgMBsMd7XejRo0oX748c+bMobCwEL1eT2xsLAA9e/bk66+/Jjk5mYKCAiIjI+nWrdstzwhuJjs7m2+++Qaj0civv/5KQkICISEh99QOAOfOnWPfvn0YDAYcHR3RaDTWM4r79f35+voSGBjI3Llz0ev1nDx5kh9//JFevXrd8br+rqCgADc3NzQaDUeOHGH9+vXXzbNo0SKKioo4c+YMa9eupXv37gAMGjSIjz/+2PqHRHZ2Nlu3br3nOolHk1zisyG+vr4sWrSIDz/8kIkTJ2JnZ0ejRo2YPn36ba/jpZdeoqCgwHog6tq1Ky+99NJ9qZ+LiwtvvPEG48ePx2Aw0KFDBzp27Hjby7/22mvMmTOHAQMGUFhYSJ06dVi6dOkd7bdarebTTz/lnXfeoUOHDkDx2UiTJk3o378/GRkZDBkyBL1eT5s2bXjzzTfven8bNWrE+fPnCQ4Oply5csybNw8PDw+Ae2oHg8HAnDlzSEhIwMHBgcDAQGsPwvv5/c2dO5dp06bRtm1bypYty9ixY6+7rHw3pk2bxvvvv8+MGTNo3rw53bp1Izc3t8Q813oOKorC8OHDadOmDQBDhw61TsvMzMTLy4vu3bsTGhp6z/USjx6VcjvXZ4R4zKxdu5YffviBlStXlnZVhHhsySU+IYQQNkkCSgghhE2SS3xCCCFskpxBCSGEsEmPbC++Q4cOodVq73g5s9lsfUj1dhmNRhwcHB7ItmR7trG9R3nfZHs3ptfrady48R1vS9y9Rzag1Go1devWvePlLl26RPny5e9omfPnz1OlSpUHsi3Znm1s71HeN9nejcXHx9/xdsS9kUt8QgghbJIElBBCCJskASWEEMImSUAJIYSwSRJQQgghbJIElBBCCJskASWEEMImSUAJIYSwSRJQQgghbNIjO1js8eMnqF+/XmlXQwjxiIiPj7+r0WnE3Xtkhzqys1NRdcqG0q6GEOIR8euwaqVdhceOXOITQghhkySghBBC2CQJKCGEEDZJAkoIIYRNkoASQghhkySghBBC2CQJKCGEEDbpkX0OSgjxcDFkniMv7ldMVy5i51QW57rt0NZsgcpOXWI+xWwk/8gWdMnHsBTl4VC+Cq6Nu+Hg6YdiMpD3xyb0KSew6PJx9A7AJbA7Du4VsBh15B/eiP5iPBZDEY4+1XEN7IZ9WW8Us4nCU3soPLUXi74Ah3KVcQ3sjoNXpVJqDQGP8EgS8fHxdPv6XGlXQwhxG3J//4krO5bi4uLCE088wfnz50lJScEpoAne/d9EpS7+W9qsyydj5esYMxOpWrUqPj4+HD58GL3RhEf7Z8n/YxPGrGSqV6+Ol5cXcXFxGC0KHh1GkBf7C6YradSsWRN3d3fi4uIwY4dXjwnkxfyC/uIJqlatSsWKFYmLi6NIp8er6xhcGnUBih/UlZEkHiy5xCeEKFXG7Itc2fkVAwYMICUlhT179pCUlMSCBQvQJcaSF/df67w5O7+CKyn88ssvJCYm8ttvv3HhwgU6hLTjyvYvUBdcYvPmzZw9e5Y9e/Zw/vx5WrVozpWtn6Ex5LJz505Onz5NdHQ0586dI7BRAy7/PBtDajzffvstiYmJREdHc+HCBcK6dCZ7y6eY86+UYus83iSghBClKu/QerROGhYuXMjly5fx9/fnm2++YfTo0bRr14682CgAFEWh8FQ0ERERhIeH88orr+Dh4YHZbGbx4sXY2dnx3HPP0blzZ1544QXKly+PRlO8XoAxY8YQEhLCkCFD8PX1xdPTk/nz5wPQt29fhgwZQmRkJAEBAZjNZhYtWoSdYibvj42l1jaPOwkoIUSpMlw6T2BgIN7e3mzdupWLFy+ydu1aAMLCwjDlpGEx6sBiwqLLp0qVKgAkJCRQUKQjIyOD2rVrU7lyZapWrWotu5qbR1ZWFo0bN6Z8+fLWsrNnz5KVlUVOTg6tWrXC2dmZsLAwAH744QeSkpL47bffqFatGjVq1MB4KelBN4n4HwkoIUSpshTmUKFCBQByc3NL/Lw2vejMAQzpZ1G7eLJlyxYAZs2axaeLFtK4cWMAfH192bx5MwAffvghXyz5nJo1a1rLNm3aBMAnn3zCsmXL8PPzs27jn7ZvLsj5F/de/BMJKCFEqbIr40ZmZiYArq6uJX5em3456kPSl7+KOT+bPXv20L17d44cOUKZMmXYt28fAMnJyWzevJk+ffpw8uRJ1Go1sbGxWCwWLl68yLp163jqqadITEzEZDJx9OhRDAYDGRkZ/7h9dRm3B9cYogQJKCFEqXIsV5m4uDiysrLo0KEDZcuWpUePHgBs27YNgKVLl7Jy5UoAHBwciI+PZ8iQIbz//vvUqVOH/fv3k5KSglar5dChQwwePJh58+ZRu3ZtduzYQVZWFi4uLkRHRzNo0CC++OILatSowaZNm8jPz7duJzw8HC8vL1q3bs2FCxc4e/YsDuUql07DCHkOSghRulwadyft0H+ZMGECn376KVevXgXg66+/ZuvWrQC0adOGsmXLFs/v4kJiYiL5+fm4uLhw+vRpRowYAYCXlxcXLlywlh0/fpwXX3wRAD8/P06ePGktO3z4MGPHjgXg+++/Z+DAgUydOpWpU6eSl5dHREQERouCyxNdH3STiP+RgBJClCrH8lVwa/MM33zzDRs2bKBZs2YkJiZy6tQpVPaOKCYD3bt3R60ufmD3ypUr1KlTh2rVqnHlyhUOHDgADlq0NVqQcvYA9erVIyAggEuXLnHw4EHsnFzQVm/G6dMxNGjQgCpVqpCRkUFsbCx2ZdzwGfQeV7Z/QZ8+fWjQoAF+fn7s27eP3NxcPEJHYl+2XCm30ONLHtQVQtgEXcoJ8uP+i/HKRey0xSNJONcNIf/YNnRJh0FR0FSqj0rtQNG5GMx5WagcNDhVbYzrE2HYacuSf3gjRYmHMOdnoXIsgzYgEJdGXbDTOJMXtwFd0mHMBVew05RBW60pzg07o9a6opgM5B/bTuGp6D9HkgjqiaZCDWv95EHdB08CSgghboME1IMnnSSEEELYJAkoIYQQNkkCSgghhE2SgBJCCGGTJKCEEELYJAkoIYQQNum2Aio9PZ1Ro0bRpUsXQkNDeeeddzAYDDedPzc3lxUrVlg/Z2Rk8PLLL991JSMjIwkJCSEwMPCu1yGEEOLhcsuAUhSFMWPGEBoayubNm9m0aROFhYVERkbedJnc3FzruFkAPj4+zJs3764r2aFDB3744Ye7Xl4IIcTD55ZDHe3fvx+NRkP//v0BUKvVTJ06lU6dOuHv78+ePXvIz88nIyODXr16MWbMGObMmcOFCxfo3bs3rVq1YvDgwbz44ousX78evV7P9OnTOXbsGGq1milTphAcHMzatWvZvn07RUVFJCcnExoayuTJkwGsw+kLIYR4fNwyoM6cOUP9+vVLTHNxccHX1xez2czRo0eJiopCq9UyYMAAQkJCmDhxImfOnOHnn38GICUlxbrstUt/UVFRJCQkMGLECOt7WuLj41m3bh2Ojo507dqViIgIfH1972rHFMVC0uwed7WsEEIAYNSBgxNQfHwSD9Y9DxbbqlUrPDw8AOjcuTOxsbGEhobedP7Y2FiGDBkCQPXq1alYsSKJiYkAtGzZ0voelurVq3Px4sW7DiiVyg6my3tchBD3YPrV0q7BY+2W96Bq1KjB8ePHS0zLz88nLS0NtVqNSqUqUfb3z3fC0dHR+rtarcZsNt/1uoQQQjzcbhlQLVu2pKioiHXr1gFgNpuZPXs2ffv2RavVEh0dTU5ODjqdjq1btxIUFISzszMFBQU3XF/Tpk2JiooCIDExkbS0NKpVq3Yfd0kIIcSj4JYBpVKpWLhwIRs3bqRLly6EhYWh0WiYMGECAI0aNWLs2LH06tWLsLAwGjZsiIeHB0FBQfTs2ZP333+/xPqeeeYZFEUhPDycV155hffee6/EmdONfPDBB7Rr146ioiLatWvH/Pnz72GXhRBCPAzu6XUba9eu5dixY7z11lv3s073RXx8PHVXB5d2NYQQD7O/3IOKj4+X1208YDKShBBCCJt0T734+vXrR79+/e5XXYQQQggrOYMSQghhk+75OSghhLgbiqLw+0UzMakWtA7Qpbo9/mVv/DfzlSKF3edNpOQqVHFX0aW6PY7q4kda0vIs7Dpv5lKBgocW2lS2p6r7n+s5ednMngtm9Cao6q6ibRV7HOzgwEUzp7MsGMwQ4K6iQ4A9ZRzu/jEZcf9JQAkhHriLuRYGriliz4U/n3W0t4PRzRyZ00WD2u7PoFh22MC4jTpy9X8u7+eq4qveWn6/aGb6Lj0my59lKmB0Mwdeb6th+M9FbEq4vecpvZ1VfNNHS1gNOSzaCrnEJ4R4oBRF4akfiziS48yiRYtIT0/n5MmTPD9yFJ8cMBC5/883Jfx23sTwn3U0bd2R6Oho0tPT2bBhA14BDemyvJA3duh58ulBHD58mEuXLnHixAnGjB3LgoNG/ObmE52hZfbs2SQmJpKRkcG2bdsA8PPzY/369SQnJ5ORkcHWrVupWPMJ+n9fyMVcy82qLh4wCSghxAO1PdHM3mQzH374IaNGjWLhwoXEx8ezaNEiunXrxvvRBvSm4qdf5v1uoKKfH1FRUTg4OPDcc89Rp04dfvnlFxwcHHBzc+Pbb79Fq9UyZMgQrl69yrx586zdwVesWMGECRP46quvGDVqFAcOHADA29sbe3t7ZsyYwZdffkmnTp344YcfKDDCupOmUmsbUZIElBDigdqZZEKtVjNs2DDi4+OZOXMmU6ZMAWD48OFcLlQ4can4LCb+koVmzZpRpkwZ1q1bx6b/rmfLli1UqVKFjh07oigKBoOBtLQ0du7YRlJSEgAGgwE/Pz969erF6tWr2b9/P5cvX7Y+s3n48GG6du3KkiVLeP3110lLS6NKlSqoVCouFcoZlK2QgBJCPFDJuQq+vr5oNBrS0tIArD8DAgIAuHC1OCR8XFScO3cOgG7dutGyTTvat28PQNWqVcnNzeX555+nTZs2FBbpGThwIBMnTiQhIYFmzZoBMGjQIFatWsWuXbs4cOAAZcqUQVEUfF1U1nJfX1+WLFmCoii08FM/sLYQ/0wCSgjxQDnZQ1FREQD29vYlfhYWFgLQZ3UR3h/msT3RzJEjR5gwYQJ16tRhy5Yt1vUUFRXh4+PDwoULiYmJITQ0lP/+97+8++671KtXzzoe6JEjRyhXrhwzZswgKCiIvn37ApCWrzB69GiWL1/OypUrGTduHC6OSCcJGyIBJYR4oKp72JGVlUVqaiq1a9fG3t7e+s65o0ePAsWv7hkw7EXKlCkDQGRkJOXLl8fJyYm4uDgAtmzZQsOGDXFzc2PLli0Unt5NVFQUGo2GZs2aER8fj8ViITMzE2d7i/Uszd7eHpVKxfvvv8+CBQuYM2cOgwcPxmQykW+AH0/IPShbcU9j8dkyGYtPCNt07oqFWvPzeX7kiyxevJiYmBgqVqyIm5sbTzzxBAkJCaxcuZKBAwdSsWJF0tLS2LVrFykpKQQEBNCyZUvmzp3LxIkT8fT05OTJkzg4OPDjjz/SvXt33N3dadiwIefOnWPp0qUMHTqU7777jk6dOqFSqWjUqBHVqlXj999/ByA9Pd1atwYNGvB0QC4Le2iLJ8hYfKVKzmWFEA9UNQ87Xm7hSOSnn3L69Gn69etHTk4OS5cutb68dPXq1Rw5coS8vDwAli1bRrNmzTh48CBTp05l586d1PS048LVbJo0acLQoUPx8/Pjiy++YOXKldb7ViNHjmTbtm20adOGhQsX8sUXX5CVlYVGo2Hq1KnX1a2oqIiyGnlY11bIGZQQ4oEzWxTm/25g7j4DybnFh6DWldTM7KAh6rSJz2MNGMxQw9MOb2cVMalmCozFywa4qxjdzJGXWzgSm2bm9W16diWZuXYga1rRjpkdnGhVSc2MXXqWHDKQqwe1CjoEqHmyngMf7TWQlHN9b70G3nasHqClptf/OkrIGVSpkoASQpQaRVHIKFDQ2qtwc7r5mYvZonCpUEGtgnJlVNe9udtoVrhcqOChVeFkf+MyrzIq6/BIt00CqlTJJT4hRKlRqVRUcLl1aKjt/nk+B7UKX9cbl/9TmbBt0otPCCGETZKAEkIIYZMkoIQQQtgkCSghhBA2SQJKCCGETZKAEkIIYZMkoIQQQtgkCSghhBA2SQJKCCGETXpkR5JQLJYSw5QIIcQdM+rAwam0a/HYemTPoIymu3uny6VLl+54mfPnzz+wbcn2bGN7j/K+yfb+QsKpVD2yASWEEOLhJgElhBDCJklACSGEsEkSUEIIIWySBJQQQgibJAElhBDCJklACSGEsEkSUEIIIWySBJQQQgibJAElhBDCJklACSGEsEkSUEIIIWySBJQQQgibpFIURSntSvwbjh8/Qf369Uq7GkKIR0R8fDx169Yt7Wo8Vh7Z90HZ2amoOmVDaVdDCPGI+HVYtdKuwmNHLvEJIYSwSRJQQgghbJIElBBCCJskASWEEMImSUAJIYSwSRJQQgghbJIElBBCCJv0yD4HJYR4eCgWM/lHtpAX919M2Rex07riXKctZYMHoC7jVmJeU24mOb99hy75KJaiXBzKVaZsk3DK1A3BlJPO1T0r0KWcwKLLx7F8VVyb9qJM7daYsi+Ss2cF+osnsegLcPSpRtlmfXD0rkb21s8wZqeU2I7KXkPZ5n1xqd/hQTaF+AsJKCFEqcv6dR4Fx7bRtGlT2g/tR2JiIj///DMFp6LxHRZpDSnjlVTSv5mAk52Fp3r3xsfHh82bN3Mi6iOcjm5Dn3oKF42agb16Ua5cOTZu3Mipn2ejrdYUXfJR3Jy19O8TjoeHBxs2bCBh7TsAODs7MyA8vESdTp48yR+bF+Ncpy0qtRwqS4O0uhCiVOlSTlBwbBtvvPEGM2fOJDk5GV9fXw4fPkxwcDC5+3/Eo+MIAK5s/wI3rQMxMTF4enqSkJBAZGQko0ePZtGiRXh7exMbG4tWq+XChQt8/PHHPPvssyxbtgx/f39iYmJQqVSkp6cTGRnJoEGD+P777ylXrhwrV64sUa+PPvqIw6++imI2SkCVErkHJYQoVQVHt+Lp6cnrr79ObGwsVapUYdq0aTRt2pSnn36a/CObURQFRVHQnT9CREQE1apVY8yYMTRt2pT4+HhmzpyJk5MTI0aMwN/fn+eee46goCCSkpJ47733sLe3Z+TIkfj4+BAREUFgYCCXL1/mgw8+KFGXF154Aa1Wi1ar5fXXXwdA5aApjWYRyBmUEKKUGbMvEtSgAWXKlOH3339HURQOHDgAQPPmzfnuu++wFOVi5+SCYjbi7OwMgEqlsv709PSkWrVqJcqu/atQoQL+/v7XlQFUqVKF8uXLW+vy4Ycf8u677xIbG8v48eM5efIklqK86+6DWetuNJKSkoJOp/t3Gucx4OTkhL+/Pw4ODteVSUAJIUqVxVCIu3t1AOuB/tpPd3d3AK5Gf4e9WwUAVq1axWuvvcaCBQuYOHEiderUAcDT05MVK1Ywfvx4li5dyttvv02VKlUA8PDw4Ntvv2XUqFEsX76cjIwMvL29rWVXr17lww8/JC4ujsaNGzN58mRWr17NE088QeGpaFwDu9+w7ikpKbi6ulK1alVr6InbpygKWVlZpKSkEBAQcF25BJQQolTZu3iRnJwMQLly5Ur8TEkp7lmXd+jPNxMkJiZSv359evfuDUB4eDidO3cmPj6erKws6tevT3h4OEajkYEDB9KyZUvOnj1LXl4eDRo0oEePHhQVFTF8+HDq16/P+fPn0ev1TJ48GYCVK1fSr18/GjVqhEajwZSTftO663Q6Cad7oFKp8PLy4tKlSzzgE1AAACAASURBVDcsl3tQQohS5ehbiz/++INTp07RrVs3wsLCGDlyJADff/89AAcPHuTcuXNA8SWhkJAQdu/eTVFREZ06dWL9+vVkZWVRtmxZgoOD2blzJwBt27ZlzZo15OXl4eXlRePGjdm+fTtarZbg4GBWrVqFXq+nb9++TJo0iZYtWzJy5EgCAgI4c+YMer0eddnyN6z3NRJO9+af2k/OoIQQpco1qAe5B38iIiKCzz77jI0bN3Lp0iXGjh3LkSNHrptfrVYzf/58PD09MRgMrF69mnHjxgHg6OjIp59+iru7Ozqdjm+++YZXXnkFAK1Wy5dffknZsmUpLCzks88+Y+LEiQAUFhYybtw4PvzwQwBiYmJ44YUXUDlqKVOr1QNqCfF3ElBCiFKlLuNGufBXiYn6iKCgINzc3MjPz8dsNqOtGUzRmQM0a9bMOn9BQQFeXl54eHhQWFiIXq/HwTsAj479uLz9Czw9PXF3d6egoACDwYBjhZq4B/YlZecy3N3dcXd3Jz8/H6PRiMavLq61q7Fp0wYqVaqEq6srBoMBvV6PnZML5XtPwd7V67b3RWc04+Sgvm9tc7/X97CRgBJClLoyNZrj/9JXFBzfiTE7BRcnV5zrtsXBqxKGS0nokg6DoqCp1ACV2p6ic7GY8i6jcXDCrcoTOFV9ApXKDk2lBuiS4jDlZeHkqMWjaiCayg1RqVQ4VWqI7vwfmPKz0WrK4BkQhMa/PiqVCtfAnhSdP4zpShoaewdcPP0oU7s1dhrnO9oPJwf1fX2Td9LsHrecJyUlhRdffJH169eXmP7JJ5/QrFkzWrVqxbJly3j66afRarX3pV5bt26latWq1KhR46bzREREMHnyZBo2bHjX25GAEkLYBDuNM65B1x+QHctXxbF81ZLTvK/v8QWgqVADTYUbHzQ1FWujqVj7hmUO5SrhUK7SnVXYxl277AnwzTff0KtXr/saUO3bt//HgLofJKCEEOIhZzabeeONN4iLi8PHx4dFixYxffp02rdvT2ZmJpmZmQwbNgx3d3eWLVvG//3f/3Hs2DFUKhX9+/fnP//5zw3X+/3337N69WqMRiNVqlThgw8+ID4+nu3bt/P777+zePFi5s+fT+XKlW9aN4vFwtSpU/Hx8bHeD7xdElBCCPGQO3/+PHPnzuWdd95h3LhxbNq0yVo2dOhQli1bxtdff42npyfHjh0jIyPDekkwNzf3puvt3LkzTz31FACRkZH8+OOPRERE0LFjR9q3b0/Xrl3/sV5ms5lJkyZRs2ZNRo0adcf7Jd3MhRDiIefv70/dunUBqF+/PhcvXrzpvJUqVSI5OZmZM2eye/duXFxcbjrvmTNneOaZZwgPDycqKoozZ87cUb3eeuutuw4nkIASQoiHnqOjo/V3tVqN2Wy+6bxubm78/PPPNG/enFWrVvF///d/N513ypQpvPXWW0RFRTFmzBgMBsMd1SswMJADBw6g1+vvaLlr5BKfEELcJzqj+bZ63t3J+u5HN3NnZ2cKCgrw9PQkOzsbR0dHwsLCCAgI4NVXX73pcgUFBZQvXx6j0UhUVBQ+Pj4l1ncrAwYMICYmhnHjxrFgwQLs7e8scuQMSggh7pP7/czS/VrfU089xXPPPUdERASZmZlERETQu3dvXn31VSZMmHDT5caNG8eTTz7JoEGDqFatmnV69+7dWbp0KX369OHChQv/uO1nn32WevXqMXnyZCwWyx3VW6UoinKrmdLT03n77bdJSEjAYrHQvn17Jk+eXOK08q9yc3OJiopi8ODBAGRkZDBr1izmzZt3R5UDKCoqYty4cVy4cAG1Wk2HDh2YNGnSLZeLj4+n29fn7nh7QghxI78Oq2a9z3NNfHz8ddPEnbtZO97yDEpRFMaMGUNoaCibN29m06ZNFBYWEhkZedNlcnNzS7z8y8fH567C6Zrhw4ezceNGfvrpJw4dOsSuXbvuel1CCCEeDre8ILh//340Gg39+/cHim/ATZ06lU6dOuHv78+ePXvIz88nIyODXr16MWbMGObMmcOFCxfo3bs3rVq1YvDgwdYnnfV6PdOnT+fYsWOo1WqmTJlCcHAwa9euZfv27RQVFZGcnExoaCiTJ0+2DuoIxTcC69WrR0ZGxr/bKkII8Rh5++23OXToUIlpQ4cOtR73/61lb+WWAXXmzBnq169fYpqLiwu+vr6YzWaOHj1KVFQUWq2WAQMGEBISwsSJEzlz5gw///wz8OeQ+QArVqwAICoqioSEBEaMGGHtsx8fH8+6detwdHSka9euRERE4Ovra102NzeXHTt2MGzYsFvumKJY7uvNSiHEY8ioAwcnoPj49KiaNm1aqSx7K/fci69Vq1Z4eHgAxQ91xcbGEhoaetP5Y2NjGTJkCADVq1enYsWKJCYmAtCyZUtcXV2tZRcvXrQGlMlkYsKECURERFCp0q2HJFGp7GD6jd+CKYQQt2X61dKuwWPtlvegatSowfHjx0tMy8/PJy0tDbVafd27PO7l3Sj/1Jf/zTffpGrVqjcdkkMIIcSj5ZYB1bJlS4qKili3bh1QPHTF7Nmz6du3L1qtlujoaHJyctDpdGzdupWgoKB/7CPftGlToqKigOI3Y6alpZXovngjkZGR5OfnM3Xq1DvdPyGEEA+pWwaUSqVi4cKFbNy4kS5duhAWFoZGo7H2nW/UqBFjx46lV69ehIWF0bBhQzw8PAgKCqJnz568//77Jdb3zDPPoCgK4eHhvPLKK7z33ns37a4OxV3cP/30U86ePUvfvn3p3bs3P/zwwz3uthBC/AuMOtte30Pmtp6Dupm1a9dy7Ngx3nrrrftZp/siPj6euquDS7saQoiH2V/uQd3oWZ0bPr9zP+9929A9MJPJdMcjQdyumz0HJUMdCSHEQ+6ll14iPT0dvV7P0KFDefrpp9m9ezeRkZGYzWY8PDz4+uuvKSgo4J133uHYsWMAjBkzhrCwMAIDA4mLiwNg48aN7Ny5k9mzZzNlyhQcHR2Jj48nKCiIHj16MGvWLPR6PU5OTrz77rtUq1YNs9nMRx99xG+//YZKpeKpp56iRo0afPvttyxatAiA6OhovvvuOxYuXHjb+3VPAdWvXz/69et3L6sQQghxj959913c3d3R6XQMGDCATp068eabb7J8+XIqVapETk4OAIsWLcLFxcXaD+Dq1VufoWVkZLBq1SrUajX5+fmsWLECe3t79u7dS2RkJPPnz2f16tVcvHiRdevWYW9vT05ODm5ubrz99ttkZ2fj6enJ2rVr7/jZKDmDEkKIh9y3337Lli1bAEhLS2P16tU0bdrU+kiOu7s7APv27WPu3LnW5dzcbn05smvXrqjVxWMC5uXl8dprr3H+/HlUKhVGo9G63oEDB1ovAV7bXu/evfnll1/o168fcXFx1/VJuBUJKCFEqTl40cyKo0YyCyxU87BjRKAjAR7X990qMCgsP2LkUJoZowVCqqh5uoEDTvYqMvItLD9iJP6yBYMZqrqrGNTAgbrl1aTmFZedumzBpECAu4pnGjpQqawdUadN7E8xk5ZvoayjiiBfNUMaOeDsePePypSGAwcOsHfvXlavXo1WqyUiIoK6dety7tzdjUX691dj/PU18Z988gktWrRg4cKFpKSkMHTo0H9cV79+/Rg1apR18IU7vYclASWEeOAUReGlDTo+jTXi7OxMxYoV+XF/ErP35LOguxMvNv2zZ++py2a6LC/kwlWFcuXKYW9vz1eH05mxW8/MDk6M2lBEnkGFr68vjo6OrIq/yKzfChjUwIGfTxkpMKrw8/NDrVbz3fGLvLO7APP/uoZptVr8/auQk5HD54cu8e4ePTuHOd8wJG1VXl4ebm5uaLVaEhISOHz4MHq9npiYGJKTk62X+Nzd3WnVqhUrVqywvgPq6tWruLm5Ua5cORISEggICGDr1q04OzvfdFvXXrnx008/Wae3atWK1atX06JFC+slPnd3d3x8fPD29mbx4sUsW7bsjvdNAkoI8cD9cMLEp7FGJk2axLRp03BxcSE1NZUXX3yRMeuj6BigppaXGkVReGZtEXonb/ZsWEPr1q0B2LJlCwMHDmTw2mxq1KjBwQ0bqFWrFgCXL19m2LBhrPjvf6lXrx5RUVHWZy0zMjIYPHgw27ZtY/bs2UyaNMl6+WrPnj306tWLF9bnsiXixgfoWzLq7m/Pu78MtXQz7dq1Y9WqVXTr1o2AgAAaN26Mp6cnM2bMYOzYsVgsFry8vPjqq68YNWoUM2bMoGfPntjZ2TFmzBi6dOnCxIkTGTlyJJ6enjRo0IDCwsIbbuu5555jypQpLF68mJCQEOv0J598kqSkJHr16oW9vT1PPfWUdcSg8PBwsrOzqV69+h3v/j11M7dl0s1cCNvV4ot8CtzrcuzYMX799VemTp3Kjz/+iKurKwEBAfynnpGFPbScuGSm/qICPv/8c55//nlCQ0Oxs7Nj8+bNfPLJJ4wfP57Zs2fz2muvERERwcGDBzl58iS7d+8mJCSE+fPnM2bMGPr168e5c+c4fPgwv/76K927d2f8+PEkJSVx4sQJ3n77bQYOHMibb77Je7PeIX+qK072qrvrZi5KmDFjBnXr1uXJJ5+86Tx3/boNIYS4nyyKwuF0Cz179gSKB5A+fPgwUVFReHt7ExwcTFx68YvtUnKL/36uU6cOUHwzft++fUDxX+aAdSi26tWrWwe2vtaN+trPmjVrXlf28ccfs27dOk6fPs2vv/4KgL29PWYFLI/kn+0PXr9+/Th16hS9e/e+q+XlEp8Q4oHKLlIwmMHPzw+AK1eulPjp5+fHpgMWzl2xoP3fESo6Opq2bduyaNEi63if15Zfu3Ytzz//PNOnTwfgwoUL1p5qq1atYvjw4dbeYwkJCcyfPx+A5wId+CLOSEBAANOnTyc5OZlFixYRUkVNGYeHq6OErVq7du09LS9nUEKIB8pNo8JOVXyvCKBMmTIA1hvzly9fJrNAofq8fNotK74XMn36dGbOnEn16tXRaDRkZWWRnp4OwIcffkjbtm3p0KEDFStWxNXV1Toc2rx582jevDnNmzenSpUqVKhQge+++w6AL+KMNGnShH379mE2m2nXrh2ZmZk809DhjvbnEb1L8sD8U/tJQAkhHigHtYpqHnZER0cD0L59e1QqFW3btkWn0xEbG4u7uztff/01L730EgBOTk7MmDGDtm3b8sUXX+Dl5WV939y1M6mkpCQyMzMpLCykYsWK15Wlp6ej0+msZV26dGHnzp3k5eUxaNAgFKW4l+CrW3QUGG4vdJycnMjKypKQukuKopCVlYWT0407gsglPiHEAzeyiQOvbtnGmjVrGD16NMOGDcPFxYX/+7//IzMzE19fX4YOHYqjoyOLFi2iY8eOLFu2jOzsbKpWrcqhQ4eYOXMmAEuWLKFbt27ExMSQn5+Pn5+f9XLf559/TkhICMePH0en0+Hl5WW93Dd8+HBcXFyKewEePAjA8uXLiYiIIC1foYbnrS/z+fv7k5KSwqVLl/6dhnoMODk54e/vf8My6cUnhHjgiowKIcsKOJhqoWXLltSqVYv9+/dz6tQpoPjdcK1atSIzM5MTJ07g7OxM+/bt8fHx4ezZs+zevRs/VxVP1Xcgcr8BPz8/goOD0Wg0/PHHHyXeYVe5cmWaN2+Og4MDcXFxnDx5EoB69erh7e1dol4ZGRkknoknbaIr7k637sUn/l0SUEKIUlFkVPgs1vC/kSQUqnnYMbKJIx0D1EzboefEZQuOauhWw54zWRb2ppjJLlLwdlYxoK4DI5s64qlVse2ciQUHDZy8bMFgVghwt2NwQweGNXZg01kTi2OMnM6yYLIUb2PoE8X3mL7+w4jBXLJOGjUMD3RkYIP/3YeSgCpVElBCCHEzElClSjpJCCGEsEkSUEIIIWySBJQQQgibJAElhBDCJklACSGEsEkSUEIIIWySBJQQQgibJAElhBDCJklACSGEsEmP7GCxisVyf1+9LIR4/NzGK9fFv+eRPYMymkx3tdzdjEp8/vz5B7Yt2Z5tbO9R3jfZ3l9IOJWqRzaghBBCPNwkoIQQQtgkCSghhBA2SQJKCCGETZKAEkIIYZMkoIQQQtgkCSghhBA2SQJKCCGETZKAEkIIYZMkoIQQQtgkCSghhBA2SQJKCCGETZKAEkIIYZMkoIQQQtgklaIoSmlX4t9w/PgJ6tevV9rVEEI8IuLj46lbt25pV+Ox8si+sNDOTkXVKRtKuxpCiEfEr8OqlXYVHjtyiU8IIYRNkoASQghhkySghBBC2CQJKCGEEDZJAkoIIYRNkoASQghhkx7ZbuZCiIePxVCE6WoGdpoyqF3Lo1KpbjifoihYinIxF+RgX7Y8dpoyJcsKczAX5mLv5oOdo1PJsoIczEW52Lv7YOfwZ5lFl48p7zKo7LB3LVdinaJ0SEAJIUqdRV9Izu6vyT+yFcWkB8CxQg3c2z+LtsoTJebVJR8je+tnGDMTiyeo7XGp3xH39s9iyEjgyrbPMV6+AIDK3hHnBp3waP8f9CnxZG9fgin7YnGZgwaXRl1wbtCJ7E0LMKSf/ctWVGirN8Wz8yjs3bz/9f0XN/bIjiQRHx9Pt6/PlXY1hBC3oCgKmd+/hSnlKP/5z38ICwsjNTWVBQsWcCbhHBUGf4CmYm0A9OlnSf92EtUDqvDSSy/h7+/P7t27Wbp0KTqdDlBRu3YtRo0aRYUKFdi+fTtfffUVRqMRUNGgQX1eeOEFvL292bRpE99++y0mk4myZcsydepUqlevjslk4vjx4yxcuJA8VRkqDl+ASu3Ar8OqyUgSD5jcgxJClCrd+T/QJcUxd+5clixZgoODA/379ycmJoYK3uXJ+W25dd6rvy2nYgVvYmJiCA8PJzU1lcjISJYsWQJAQEBVYmJi6Ny5M5mZmSxatIj58+cDUKdObQ4ePEi7du24fPkyX375JR988AEAXl5etGvXjpycHAICApg5cyZffvklpuyL6JKPP+gmEf8jASWEKFWFp/fh5ubGyJEj+f333+nTpw9jx46lbNmyjBw5El1SHBZ9IQD69DP07NkTd3d3Zs+ezYTJr7Njxw6GDBmCv78/ffr0wcXFhRkzZjBu4mT279/PiBEjKFeuHAMGDMDJyYk33niDsa9MJC4ujpdeeglXV1cSExNp1aoVzz//PN27dwegatWqQPG9KVE6JKCEEKXKdDWdGjVq4OjoyOnTpwE4e7b4flC9esUDPptyMwFQ2dlTWFgcVjVr1sTRTqFy5coA1KlTp0SZ1sEOPz8/7O3tqVmzZokyZ40Dvr6+aDQaqlUrHmOvatWqrFixgl27dnHp0iXGjx8PYL28KB48CSghROmyWHBwcACK70f99ee16ZfWzebSutmY87P48ccf2bt3L1OmTKGgoMB6puPg4MDy5cuJiYlh5syZ5ObmUqFCBQAcHR358ssvOXr0KHPnziUnJwd3d/cS2zCbzeTk5JCdnU358uUZNmwYAPrUUw+mHcR1JKCEEKXKvmx5zp0r7tDk7+8PgJ+fHwAJCQnF08vaU8UuCwCdTkfbtm0JDAykZcuW/PLLL5hMJn7//XcKCgoIDg4mKCiIFi1asHXrVvR6PbGxseTk5BAUFETTpk1p2rQpe/fuJS8vj6NHjwKQnJzM6NGjCQkJITU1lWHDhqFWq//sLSgeOOlmLoQoVU5VA8k8spk1a9bQp08fZs2aRVhYGGaz2dr5Yfny5QQHB2NvX3zIWrBgAYcOHaJhw4Y89dRTfPPNN2RlZeHo6EhkZCRxcXEEBQXRs2dPFi9eTH5+PmXLlmXmzJkcPXqU4OBgOnbsyJw5c9Dr9Tz77LM0b96ckydPUrduXSpWrMjhw4cxm82oy5YvzeZ5rElACSFKVZnarXAoX5Xnn3+eCxcu0LdvX1JTU+nWrZv1ntTvv/9Obm6udRlfX18mTpxIfn4+kyZNYt68eQBYLBYqV65Mp06duHr1KuPHj2fBggUAmEwmatasSVhYGFeuXOGll17is88+AyApKYnBgwfTsWNHrl69yuLFi3nvvfewK+NGmdqtH3CLiGvkOSghRKkz5WZyecPH6C8csU6zc3LFrfUgcg+uw/y/ThI3ZKfGuW47XAN7cGndu5jzs/9SZo9z/Q64NArl0k/vYim8+meZ2gGXhqHYu3kXd2W3mEus1tGnOl7dx+HoXdyJQp6DevAkoIQQNsNwKQnjpfPYaZzRVG6AnYMTitmEISMBFAsO5SqjUjuiTz+DOfcSKrUDGr+6qF08AFBMBvRppzHnXUZlrykucy7uDGEx6jCkny0uc3BC418PtbZscZkuH31GAub8bOw0ZbB3q1C8rb8MtSQB9eDJJT4hhM1wLF8Vx/JVS0xTqe2v6+rt5F/vhsur7B1xqtTghmV2Dk43L3NyuW5IJVH6pBefEEIImyQBJYQQwiZJQAkhhLBJElBCCCFskgSUEEIImyQBJYQQwibdVkClp6czatQounTpQmhoKO+88w4Gg+Gm8+fm5rJixQrr54yMDF5++eW7ruSIESPo1asXPXr04K233sJsNt96ISGEEA+1WwaUoiiMGTOG0NBQNm/ezKZNmygsLCQyMvKmy+Tm5rJy5UrrZx8fH+tQJHfjk08+4ZdffmH9+vVcuXKFjRs33vW6hBBCPBxu+aDu/v370Wg09O/fHwC1Ws3UqVPp1KkT/v7+7Nmzh/z8fDIyMujVqxdjxoxhzpw5XLhwgd69e9OqVSsGDx7Miy++yPr169Hr9UyfPp1jx46hVquZMmUKwcHBrF27lu3bt1NUVERycjKhoaFMnjwZABcXF6B4LC2j0Vji6W4hhBCPplsG1JkzZ6hfv36JaS4uLvj6+mI2mzl69ChRUVFotVoGDBhASEgIEydO5MyZM/z8888ApKSkWJe9dukvKiqKhIQERowYwaZNm4Di4YnWrVuHo6MjXbt2JSIiAl9fX6D4Mt+RI0do164dYWFht9wxRbGQNLvHbTaDEELchFEHDk7Ex8eXdk0eO/c81FGrVq3w8CgeB6tz587ExsYSGhp60/ljY2MZMmQIANWrV6dixYokJha/b6Vly5a4urpayy5evGgNqKVLl6LX65k0aRL79++ndet/HmFYpbKD6W73untCiMfd9Ku3nkf8K255D6pGjRocP368xLT8/HzS0tJQq9XXXW67l8tvjo6O1t/VavV1nSE0Gg2dOnVi27Ztd70NIYQQD4dbBlTLli0pKipi3bp1QPFrkWfPnk3fvn3RarVER0eTk5ODTqdj69atBAUF4ezsTEFBwQ3X17RpU6KiogBITEwkLS2NatWq3XT7BQUFZGYWD7VvMpnYuXPnP84vhBDi0XDLgFKpVCxcuJCNGzfSpUsXwsLC0Gg0TJgwAYBGjRoxduxYevXqRVhYGA0bNsTDw8P6Nsv333+/xPqeeeYZFEUhPDycV155hffee6/EmdPfFRUVMWrUKMLDw+nTpw9eXl4MHDjwHndbCCGErbun90GtXbuWY8eO8dZbb93POt0X8fHx1F0dXNrVEEI87P53Dyo+Pl7eB/WAyUgSQgghbNI99eLr168f/fr1u191EUIIIazkDEoIIYRNkle+CyFKzf4UE5H7DcSmmtE6qOhZ057xwY74uJT821lnUph/wMD6MyYu5lqo6m7Hc0GOPFXfnitFCnP2GdiWaOJSgYKnVkW7KvZMauWIq6OKufsMbD5nIrNAoaanHS82daBbDXsWHjSw4YyJxCsWAPzL2tGnjj2jmjqisZfRamzBPXWSsGXSSUII2/bFIQPPR+nw8vKiS5cuXLlyhS1btlDOyUL0cGeqexaHVJFRIWRZAQdTLbRo0YJq1aoRGxvL6dOn6VbDntNZZpJy7WjXrh1+fn5kZmayc+dO7CwG7O2gwKiidevWVKpUib1793L+/HmcHaDACE2aNKFWrVqoVCpOnTpFbGwsnQLUbI4og921Zzqlk0SpkTMoIcQDl12k8MomHV26dOGnn37Czs4OJycnjh8/TuvWrZmyrYAfniwDwEd7DcSkKfzwww/069ePc+fOUaNGDd5++22mT5+OnZ0d0dF7CA4OJj4+ntq1a3Pq1CmaNGmCzmhk69ZNtG3bluTkZKpUqcL48eNZsGABWq2WmJgYsrKycHV1xdHRkSVLlvDCCy+wJcFMWA05PJY2uQclhHjgvjtqJN8Ac+bMwWQy4efnx5AhQ6hfvz5jxozhxxMmMguKL739etZEmzZtGDBgAJ988gk1a9Zkw4YNTJ06lUqVKuHv709wcDA//fQTv4wNZOnSpdStW5e6devSu3dvOnbsyLRp06hevToxMTG8++67uLm5odfrqVy5MuXKlcPf3x+dTmcdFPvEJXmljy2QgPr/9u4+KKp6DwP4s7yJXkHFF3CCYKbx6nAlF8vCVFReJFl2gVHMEmWKtJobFpNZNtjVqXFqrBmVRhT/8I6KzSCBjQplK23YhQm7I6JoLXYFAeXFi7YIusLu9/7BsOnNal08cLDn8w+we5bnnLO759mz5+z+iGjA/XDFhtGjR2Pq1Kk4deoU2tvb8fXXXwMAZs+eDQAw/7e3oH62CsaNGwcA6OjoANA7pI+npydmzJiBxsZGfPtt7x5U67S/Izo6GtXV1Th79qzjdhaLBUDv17T5+PggLCwMdrsdbW1tWLx4MV5++WV4e3tj3759AIDJ47hpVAPuwxLRgGu/8UvpdHV13fGz7/Id33ej/prAXQOYTCY0NzcjMzMT06ZNg06nc0xrt9thMpnw5JNPYsWKFQgMDMTOnTvR3d2N4uJiWCwWbNy4EU8//TSio6MBAOPHjwcAjBo1Clu2bMG4ceNw/fp1mM1mAEA3d6BUgS8TiGjATRzphkuXLgGAYzSEvp99l+ed7kZq0Q2cbrXj6tWreOyxx/Dhhx/ixx9/dAyIajabMWvWLGRlZSE7OxuPT9diQ63ACAAACfRJREFUw4YNyMjIgE6nQ0NDA8LDw5GdnY3q6moUFRU5bgf0jvYdGBiICRMmoLW1FVu3boWfnx++/KlnQNcH3R0LiogGXPhEN3R1dcFoNEKr1WLu3LlIT08HAMc4ctnZ2TCbzfDz8wMAhIaGYteuXSgqKsK8efPQ0NCA48ePo+9E5KCgIAwb/hcEBwcDgOPySZMmYfv27TAajYiMjER1dTVqamowffp0GAwGhISEQKvVwtfXFyICq9UKb55mrgp8i4+IBlxKqCfWHbNi9erVyM/Ph8lkgs1mw969e7Fnzx4AQEBAACZNmgR3d3cAwM6dOx0jGZw8eRIrV66EzWZDRUUFdu3ahfT0dKSkpAAADhw4gJKSEgBAQUGBY1Tu8vJyPP/88wCAhx56CIWFhY7/f+XKFaxatQqdnZ3QTRoxcCuDfhM/B0VEg6KsvgeGT7vws7V3gFKLxYK2tjY86u+G6hY7vLy84Obmhps3bwIAPD09ERISgu7ubtTV1cF3GPDPxOHYVnkLpjobfH194e/vj7a2Nly7ds2R4+3tjYcffhhdXV1obGzE+BEaBPpqcLLZjhEjRiAoKAidnZ1obm5GT08P/jHXCxvmef8yo/wc1KBhQRHRoGm/Idh98ha+v2zDCA8NEv7qAcNkD5xptePA2W5024CpE9wwZZw7in7oxn+u2uHhpkFEoDtSH/XEaG8NbHbBIXMPSi/Y0Nppx9jhGsx+2AOLQz3w78s2FJ7rQf3Pdgxz1yAy2B1Lp3pihCdQdK4H/2qwoanDjuEeGoSM1mDJ3zwROt79zplkQQ0aFhQR0e9hQQ0aniRBRESqxIIiIiJVYkEREZEqsaCIiEiVWFBERKRKLCgiIlIlFhQREakSC4qIiFSJBUVERKr0wH5ZrNjtjk+AExG5rPsm4On9x9PRfffA7kF197g2nktbW9s936a+vn7AspinjrwHedmY939YToPmgS0oIiIa2lhQRESkSiwoIiJSJRYUERGpEguKiIhUiQVFRESqxIIiIiJVYkEREZEqsaCIiEiVNCIigz0TSqiqqsKwYcMGezaI6AFhtVqh1WoHezb+VB7YgiIioqGNb/EREZEqsaCIiEiVWFBERKRKLCgiIlIlFhQREakSC4qIiFRpSBdUWVkZ4uLiEBsbi9zc3F9df+vWLbz++uuIjY1FSkoKGhsbFc07ceIEkpOTERoaii+++KJfWc7k7d69G/Hx8dDr9UhLS0NTU5OieZ9++in0ej0SExPx7LPP4vz584rm9fnyyy8xefJknD59WrGswsJCREREIDExEYmJiThw4IDLWc7kAUBxcTHi4+Oh0+nwxhtvKJq3adMmx7LFxcXh8ccfVzTv0qVLWL58OZKSkqDX6/HNN98oltXU1IS0tDTo9XosX74czc3NLmcBwLp16zBz5kwkJCTc9XoRwfvvv4/Y2Fjo9XrU1NT0K49+hwxRPT09Eh0dLRcvXhSr1Sp6vV5qa2vvmGbfvn2yfv16ERE5fPiwvPbaa4rmNTQ0yLlz5+TNN9+UkpISl7OczauoqJCuri4REcnLy1N8+To6Ohy/G41GeeGFFxTN68t87rnnJCUlRaqrqxXL+uyzz2Tjxo0u/X9X8i5cuCCJiYly7do1ERG5cuWKonm327Nnj7z99tuK5mVlZUleXp6IiNTW1sr8+fMVy8rIyJDCwkIRESkvL5c1a9a4lNWnsrJSzpw5Izqd7q7Xm0wmSU9PF7vdLidPnpTFixf3K49+25Ddg6qurkZwcDCCgoLg5eUFnU6HY8eO3TFNaWkpkpOTAQBxcXGoqKiAuPi5ZGfyAgMDMWXKFLi59X+1OpMXERGB4cOHAwC0Wm2/Xjk6kzdy5EjH7zdu3IBGo1E0DwC2bt2KlStX9utbQZzNul+cycvPz8eyZcswatQoAMDYsWMVzbvdkSNHfnPv4H7laTQaXL9+HQDQ0dGBCRMmKJb1008/ISIiAkDvc6K/9+2MGTMc98vdHDt2DElJSdBoNNBqtbBYLGhtbe1XJt3dkC2olpYWBAQEOP729/dHS0vLr6aZOHEiAMDDwwM+Pj64evWqYnn3073mFRQUIDIyUvG8vLw8xMTEYPPmzcjKylI0r6amBs3NzZg3b57LOc5mAcDRo0eh1+uxevVqXL58WdG8uro6XLhwAUuXLsWSJUtQVlamaF6fpqYmNDY2OjboSuW9+uqrOHToECIjI7Fq1SqXHyvOZE2ZMgVHjx4FAHz11Vfo7Ox0+XnuyjwFBAQoui34MxuyBUW/+Pzzz3HmzBm8+OKLimctW7YMRqMRa9asQU5OjmI5drsdH3zwAd566y3FMm43f/58lJaW4tChQ3jqqacUz7XZbKivr8fevXvx8ccfY/369bBYLIpmAr17T3FxcXB3d1c8Jzk5GWVlZcjNzcXatWtht9sVyVq7di1OnDiBpKQkVFZWwt/fX/Hlo4ExZAvK39//jre0Wlpa4O/v/6tp+l4J9/T0oKOjA2PGjFEs735yNq+8vBw7duxATk4OvLy8FM/ro9PpYDQaFcvr7OyE2WzGihUrEBUVhaqqKrzyyisunSjhzLKNGTPGsf5SUlL6deDb2cdmVFQUPD09ERQUhJCQENTV1SmW16e4uBg6nc6lnHvJKygowMKFCwEA4eHhsFqtLu3VOLsuP/nkExw8eBCZmZkAAF9f33vOcnWempubFd0W/JkN2YIKCwtDXV0dGhoacOvWLRw5cgRRUVF3TBMVFYWioiIAvWeCRUREuHzcxJm8+8mZvLNnz+Ldd99FTk5Ov45hOJt3+wbUZDIhODhYsTwfHx989913KC0tRWlpKbRaLXJychAWFqbIst1+DKG0tBSPPPKIYssGADExMaisrAQAtLe3o66uDkFBQYrlAb3HaiwWC8LDw13KuZe8iRMnoqKiwpFrtVrh5+enSFZ7e7tj7yw3NxeLFi1yccmcExUVhYMHD0JEUFVVBR8fH5ePsdEfGOyzNPrDZDLJggULJDo6WrZv3y4iIlu2bBGj0SgiIjdv3pSMjAyJiYmRRYsWycWLFxXNO3XqlMyZM0emTZsmTzzxhMTHxyual5aWJjNnzhSDwSAGg0FeeuklRfPee+89iY+PF4PBIKmpqWI2mxXNu11qaqrLZ/E5k/XRRx9JfHy86PV6SU1NlfPnz7uc5Uye3W6XTZs2ycKFCyUhIUEOHz6saJ6IyLZt22Tz5s39ynE2r7a2Vp555hnR6/ViMBjk+PHjimWVlJRIbGysLFiwQN555x2xWq39WrbMzEyZNWuWhIaGypw5cyQ/P1/2798v+/fvF5He+27Dhg0SHR0tCQkJ/Xpc0u/jcBtERKRKQ/YtPiIierCxoIiISJVYUEREpEosKCIiUiUWFBERqRILioiIVIkFRUREqvQ/iOUlr4XY7mYAAAAASUVORK5CYII=\n", "text/plain": [ "
" ] }, "metadata": {}, "output_type": "display_data" } ], "source": [ "# overall model performance\n", "compare_performance(\n", " test_stats_list,\n", " 'label',\n", " model_names=models_list,\n", " output_directory='./viz2',\n", " file_format='png'\n", ")" ] }, { "cell_type": "code", "execution_count": 5, "metadata": {}, "outputs": [ { "data": { "image/png": "iVBORw0KGgoAAAANSUhEUgAAAagAAAEYCAYAAAAJeGK1AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAALEgAACxIB0t1+/AAAADh0RVh0U29mdHdhcmUAbWF0cGxvdGxpYiB2ZXJzaW9uMy4yLjEsIGh0dHA6Ly9tYXRwbG90bGliLm9yZy+j8jraAAAgAElEQVR4nOzdeXxMd/fA8c8smcm+SUSQEPsWFLVFUUtRe7RUiWqpR1vaWtpHq7S1a4vfQ9XTatUuaJWiVYqisceeBBGSSGTft9nn90eYNkWtTxJ63q9XXyP3zr33e29frznzvXPuOQqr1WpFCCGEKGeUZT0AIYQQ4lYkQAkhhCiXJEAJIYQolyRACSGEKJckQAkhhCiX1GU9gP+VEydO4ODgcM/bmc1mVCrVPW1jNBqxs7MrlWPJ8crH8R7nc5Pj3Zper6dp06b3fCxx/x7bAKVSqahfv/49b5eWloa3t/c9bRMXF0e1atVK5VhyvPJxvMf53OR4txYVFXXPxxEPRm7xCSGEKJckQAkhhCiXJEAJIYQolyRACSGEKJckQAkhhCiXJEAJIYQolyRACSGEKJckQAkhhCiXJEAJIYQolyRACSGEKJcUj2tH3YiISBo2bFDWwxBCPMJ0RjP2dsU1+6Kiou6rfJq4f49tLT6lUkH1SdvLehhCiL9htVqx6gtAZYfSTntX71VoHVEoSt78sVrMWPSFKLWOKJQ3F4G1GHVgMaPQOKJQKP5YbtCBxYRC61Ri+Q2xc3re55mJh+GxDVBCiPLLarWQf2oHucd+wJSVBCiwrxaIW7uh2FcteefDlJtG1m/LKYo5itVQhFLrhFNgF9yCBmPOSSVr33J0cafBYgaFEm2Verg/FYLWrxEFEXvJC/8RQ/IlAJRaJ7T+gSjUWvRXz2HOzwBAoXXCqf5TuLcfhsrBtbQvh7gNCVBCiFKXvW8FuUe+JygoiODgiWRnZ/Ptt9+SsO49fF6Yib1fIwDMhTkkr5qAvVXPayNfpkaNGpw4cYKNGzdScG4PVpOBSt6eDJ0wnipVqpCWlsa6deu4vGEK2sr10F89R2BgIAPfmI6joyORkZF88803ODg4MLBfP5o0aYJGo+Hs2bOsXbuW1KRoKoV8hkJ17y08xMMnSRJCiFJlykkh99hmRowYwe+//07Xrl0ZM2YMERERBFTzJ2vvMm78NJ5zaANqYwEHDx5k1qxZVKlShWXLlrF+/XosujxUmDly5Agff/wxFSpUYMKECYSHh1PB3Q391XOMHTuWM2fO8NxzzxEQEMD7778PQL169Vi+fDmtW7emV69eLFu2jHXr1mFIiaEw+khZXh7xJxKghBClquB8GFjMfPTRR6SmptK8eXMGDRqEq6sr48aNw5B0EVN2MgD6hEjat29P48aNWbRoES8OG8769evp378/TZo0wdvbGz8/P3766Scm7khi1apVuLu7U7NmTezt7Zk1axZhYWE0bNiQ4OBgateuDUB8fDzVq1enY8eOBAYGkpmZSe/evVEoFBjT4sry8og/kQAlhChVpuwkPD09qVq1KhcuXMBoNHL27FkAmjRpcv09ydffbbUlL3h6emI16vH09AQgMDCQpKQkFixYQLdu3VjQsyrDhg1jzZo1HD9+nHbt2uHs7EyFChVISUnh6tWrvPPOOwBkZGSQnJkLgI+PD05OTpw6dQqr1YrKpUIpXg3xdyRACSFKldWkx8nJCShu2f7n1xvLM3/9kszdSzFmXmPv3r0cOnSI119/natXr9KrVy8AXFxcUKvV1K5dG7PZTGFhIUVFRVSvXh0nJyfbvjw9PXn55Zc5d+4cc+bMoUOHDsUDMRupXbs2e/bsIT09ncGDB6N0dMOpXrvSvBzib0iAEkKUKpWLN9euXcNgMFCpUiUA22tcXPHtNbuiDLiwB4xFGI1G2rdvT7du3ZgwYQILFy4E4NixYzz11FP06tWLzz//nNenzGX69OkEBQXRt29f274OHz7M9h072bBhA4DtWaZWLZpx8OBBDAYDQUFBXLp0CUthDqa89FK9HuL2JEAJIUqVvV9DzGYza9asoUGDBkycOJHp06cDsHz5cgCWLVtGbm6uLXCNHTsWlUqFl5cXw4cP5+TJkxw/fpykpCQAOnfuTJs6lXn22WcBuHbtGqdPn+bs2bO0bNmSLk93IDg4GIvFwqFDh6hZsyZ79uzBy8uLgwcPMmbMGD799FOcnJwoOLen9C+KuCVJMxdClCr76k+g8a3DhAkTsFqtTJ48mZycHMaNG8fWrVsBSEhIICIiApPJBEDHjh356KOPMJlMbNiwgSlTpgBw/vx5Ro0aZds2NTWV999/nz17ioPMoEGDmDdvHt9//z1Xr14lJCSE06dPExgYyOXLlwFo3bq1bWyzZs3CZNKX5uUQf+OxLXUUFRVFjxWXy3oYQohbMOWkkrZ5Nobk6D8WKpQ4B3ahKPYk5ty0v91e41MTr14TyT+7i9xjm8FqKbHeqVFnnAM7k75tPuY/37JTqlHaO2MpzL7tvr0HTMGxViugZCUJKXVU+mQGJYQodWq3ilQaNh99QgSGpIso1FrsazTHzr0SFkMRurjTWM0m7Cr4oXb1Qhd7GmN2MgqlEo1vXbRV6qFQKPB4+hVcmvVCd/Uc5oJsVA7OaKvUx66CHwBV/rWUopjjGLOSUDl7YO/XCJWLF4bkaEw5qTeNy87LH42Xf2lfDnEbEqCEEGVCoVBg79fIVjXiBqXGAcfarUssc6zb9rb7UbtVxNmt062PobLDsU6bm5Zrfeug9a1zH6MWpUmSJIQQQpRLEqCEEEKUS4/vLT6rRUrlCyEeiNWoQ2FnX9bD+Md6fAOUQgkfuZX1KIQQjzDFRzllPYR/tMc3QAkhyi2L1cras0a+PmEkNttCJWclIY3tGNnMDq26ZOPAa3kWZh3Qc/CqmTwDNPNV8nYrDW381JxNMTPvkIHwJDP5BitVXJT0q6dmTEsNWhVsvWhi6QkjkWlm7JQKGngrGdNSQ47Oyq+XTUSmWzBZwMdJwdQOWhr73NzsUJQdCVBCiFJltVoZsqmI0HMm6tevT8eOLYmMjGTMz8cIjTCye5gjGlVxkIpMM9NuWQFF2NOxY2fquruza9cuNizL4OWmdqw5a8TB2Y2nn34aFxcXoqOjeWfXYbZdNOHroiD0nAk/Pz/aP9sevV7P0aNH6bwyHgBXV1cCAwPRarXsP3uWf/+ayc9DnMry0oi/kAAlhChVv8SYCT1nYtq0aUyZMoWCggKcnJxYtWoVw4YNY9lJI6NbaAB4bbsOO9eKHDt4EB8fH7Kzs6lQoQL9+vXj2507qV69OuHh4Tg4OJCYmEitWrX45ptvGDlyJADTp0/nvffeIzc3F71ez7Fjx+jTpw+1a9fm4sWLtjENGDCAi79vLpPrIW5PsviEEKXqv8cN+Pn5MWnSJPbu3YuLiwvLli0jJCSENm3asOS4AYB8g5X9cWbGjBlDzZo1GTRoELVq1UKv1zN//nwAOnXqhKenJxMnTmTuwAZcvnyZAQMGAPDEE0/wwQcfsHbtWry9vfH19eXVV18FICUlhXbt2jF16tSyuQjirkiAEkKUqvPpFlq1aoWdnR379u3DarWyb98+AIKCgriYYcFqtZJvKK7CdqP/U35+PgaDAYPBQMOGDXFzc2PPnj0kJyfz4osvUrHHO1StWpV169YB0K9fPwC0Wi27d+9mxYoVuLi4AOCtyiMsLIyCgoLSPn1xDyRACSFKVWaRFS8vLwAKCwtLvHp5eaEzwVfhRnZfNgOwYcMGLBYLy5cvZ9++fVSsWNH23pycHMLDw2ncuDGDBw9Gp9Nx9OhRoLgRIRQ3Qdy9ezfBwcHs3LkTlUpFSGMNzppSPW1xHyRACSFKla+LgsTERAA8PDxKvF67dg2A0dt1DP2hCID9+/fTunVr1q5dy44dOwgPD6egoIDExETGjh1Lz549GTRoEIGBgRw9epSlS5fi5uZm29fixYv5dPZ0duzYQUBAAP7+/ny0T0++oeS4zqVaGLdDR6Hxsayf/UiSACWEKFXNfFXs37+f7Oxs+vbtS+PGjXnhhRcwmUxs27YNgBMnTtj+7eHhgb29PQsXLiQuLo6mTZuyadMmdDodFktxFfNatWrh6elJxYoVUSqVmEwmtm/fDkDTpk1xdPWkbt265Ofnc+3aNbRaLV26dKFu3bpA8SyrS5cu/N8RA+vPGcvgqohbkSw+IUSpGtdaw4pTObz66qt8/vnnnD59mpycHMaPH2/r0RQQEIBKVfxMkqenJ3v37kWlUmE2m9m8eTNvvfUWAEuWLOHZZ59l4cKFLFy4kMLCQt555x0KCgoIDw9n6tSpTJo0iVdeeYXMzExCQkLQ6/VUrVqVXbt22cY0depUzGYzarWahFyZQZUXj3U/qPrrW9/5jUKIUjf/kJ53dulR22moVq0aiYmJFBYW8kQlJSeTLWg0GqxWK0Zj8WzG2dmZKlWqkJ6eTkZGBg28lSzsbs/wLUUk5Bb/puXm5kZiYiI6nY7Rze0oMMKqM0ZcXFzw9vYmPj4eq9lETU8lFzOKj/FXBoOBbYMd6FnHrnjBnypJSD+o0icBSghRJqIzzCw7aSQ2x0IlJyUhTex4opKS7dEmwuLNKBTQvZaaXL2VXTFmkgssuGkVPFtbTa86atRKBfkGK6vPGDmdbCb3eiWJvnXVBPmrsVqtHLxqJvSckUydlZoeSl5oZEctTyWh54xEpVluGlOrqir61FWjVFyvZiEBqkzdVYBKTk7m448/JiYmBovFQseOHXn33Xdv+Q0EIDc3l61btzJkyBCg+JmDmTNnsnDhwvsa5IIFC9i8eTO5ubmcPHnyrraRACWEeGASoMrUHZMkrFYrY8aMoUuXLuzcuZNffvmFwsJCFixYcNttcnNzbc8iQHG65/0GJ4Cnn36ajRs33vf2QgghHj13TJI4fPgwWq3W9nS2SqXi/fffp3PnzlStWpXff/+d/Px8UlJS6NOnD2PGjGHevHnEx8fTt29f2rZty5AhQxg9ejTbtm1Dr9fz0Ucfce7cOVQqFZMmTaJ169Zs2rSJPXv2UFRUxNWrV+nSpQvvvvsuUJyFI4QQ4p/ljgEqOjqahg0blljm7OyMr68vZrOZs2fPsnXrVhwcHHjuuefo0KEDEyZMIDo6mi1btgCQkJBg23bNmjUAbN26lZiYGEaMGMEvv/wCFE+hN2/ejEajoXv37oSEhODr63tfJ2axWkpMz4UQ4l7pTTqSE1PKehj/WA+cZt62bVvbQ3Zdu3YlPDycLl263Pb94eHhDB06FICaNWtSuXJlrly5AkCbNm1spUhq1qxJYmLifQcopUJJ4IrA+9pWCCEAzr50lmrVqgHFX6BF6bpjgKpVq5ZthnNDfn4+SUlJqFQqFIqSvVv++ve9+HPSxY1nHoQQjyddgo6sA1kY042o3dS4t3XHsZbjTe+zmCzkHMyh8FIhZp0Zh2oOeLT3QO2ixlxgJissC12cDovOgtpDjWszV5zqO6FQKDCkGcj6PQt9oh6FWoG2shb3IHesZisFUQXor+nBAmo3dfE+XeXR0PLkjv832rRpw2effcbmzZvp168fZrOZOXPm0L9/fxwcHAgLCyM7Oxt7e3t+/fVXZs2ahZOT022LMLZo0YKtW7fSpk0brly5QlJSEjVq1CAyMvKhn5wQonxK3ZpK6vepODg4UKtWLeKOx3F5z2U8O3niG+Jr+6JryjVx5ZMr6BP0VKxYEW93by4evUjatjR8B/uSsikFU7YJf39/XFxciD8ST+yvsXh28cTO046UjSmolCrq1KmDXq8n9mgsqZtSbeNwdHREo9GQW5CLLlGH37/8yuqSiFu4YxafQqFg8eLF7Nixg2eeeYZu3bqh1WoZP348AI0bN2bs2LH06dOHbt26ERgYiIeHB82aNaNXr17MnTu3xP5efPFFrFYrvXv3Zty4ccyePfu26eo3fPLJJ7Rv356ioiLat2/PokWLHuCUhRBlqSi2iNTvUxkyZAgJCQmcOXOGa9euMX78eDL3ZJJ3Ms/23murr6FIV7Bt2zZSUlK4cOECkZGR1AuoR+KyRFyVrhw7doy4uDjOnTtHWloa48aNI/PXTFI2pDBo4CDi4uKIjIwkJiaG9evXA+Dv78/FixcpKCggKyuLXr16obuqK6tLIm7jgR7U3bRpE+fOnSuXPVWioqIYeHRgWQ9DCPEXV7+8ivK8kri4OGJjY3n++edZuHAhnTt3pl69elxTXaPGezWwGC1Ejo5kwtsT+Oyzz3j11VfZuXMn58+f59ChQ3Tu3Jnhw4fz7bff8uGHH7Jo6SIiwiOws7PD29ubgIAALly4wMGDBxkxYgQFBQXUqFGDgwcPUrFiRQYOHEhgYCCjRo0iODiYn479RO0ZtUuM9exLZ23/luegSp8UixVClCrdVR3t27fHxcWFLVu2cOHCBb777jtUKhXdu3dHF188kzHlmsAMderUAeDUqVPEX40nMzOTTp06YW9vz7lz5zAajdSpU4fWTVvj5OTEiRMnAHj++eexs7Pj8OHDvPHGGwwcOJDTp08DkJqayueff050dHTZXARxVx4oQAUHB5fL2ZMQovwy5ZioXLkyANnZ2SVeK1eujKXIQtGVIixFFhQaBb/99hsAc+fOZeF/FlKlShUAfH19OXXqFKGhoQwZMsRW/Xz27NlAcYIXwKuvvkpAQADz58+3JXxVGlwJhd39J3SJ0iEzKCFEqVI5qUhLSwPAycmpxOuN5TEfx3Dpg0tYDVbWrVvHqFGj0Ov1+Pr6EhUVhdFoJD09nTFjxhASEsKIESNwcnIiJiaGLVu24ODgQHp6OgCzZs0ieGAwO3bsICgoiKpVq5K8LhnrX/o+6RP0ZIVlYbU8luVJH0kSoIQQpUpbWcvhw4cxGo106tQJpVJJ586dAThw4AAAy5cv55NPPgGKM+02bNjAs88+y8cff4y/vz+7d+8mLy/P1jU3NTUVnU5HXl4erq6u2NvbExYWBoCrqytWoxVXV1fMZjNZWVkolUo8PT1tgdHFxQVPT08SlyaSdzrvr0MWZUQClBCiVHk+7UlCQgLTp0/nqaeeoqioiKFDh7J06VKOHz8OQL9+/XjmmWeA4oy79PR0EhMTOX36NElJSbz55psArFy5kszMTDZu3EhsbCzt27dn5cqVZGVlsX37djZv3szkyZOJjY0lKCiIyZMnU1BQQJUqVcjIyOCjjz4CYMWKFSQnJwNINl85Ik+lCSFKlXMjZ9zbujN9+nTWrVtH8+bNiYyM5OzZPzLmunbtil6vB4rLrXXs2BF/f3+Sk5PZt28faKHigIqc33KeatWqERQUhKurKxcvXuT06dM41HDAarTSv39/WrRoQbVq1Thz5owtKSIlJYU2bdqUGNeNhGaN198/9iJKz2PdD0rSzIUon6xWK7nHc8nal4Uh3WCrJOHexp30n9MpjC4EwKmBE1ajlfyIfEw5JpQOSlwau+DZyRM7dzt0iToydmagu6rDUmRB7anGtakrHh09wApZ+7LIPpKNpcCCpqIG9yB3nOo7kfZjGvok/U3jcqjhgHcvb5Sa4ptLkmZetiRACSHEbUiAKlvyG5QQQohySQKUEEKIcumxTZKwWiwlpudCCHGvLDodSnv7sh7GP9ZjG6AUSiVR9eR+sRDi/tU/Lz2gytJjG6CEEOVbocXCr3l5JBqNeKvVdHVxwU2luuV7I3U6ThcVkW+x0NDentaOjigVCqxWK+FFRUTpdRRYLPio1Tzl5IyXuvijzWC1sj8/n8sGA2oF1NRoaePoiEKhIFqvJ8agx2wFL7WKNo5OqB6gn514+CRACSFK3a95eXyQnESuxWJbNjs1hfcq+vCcu7ttmd5i4YPkZLbn5ZbYvq5Wy8xKvsxKTeFEUVGJdXYKBVN9fPBSqZmSnET6LRqfuqtUZP9l+UseHvy7os/DOD3xkEiShBCiVCUaDUxMukaDli0JCwvDaDRy6tQp2j/zDB+mJHNO90fA+b/0NH7Kz2PatGkkJCSQn5/P6tWryXBx4bm4WM6ZzXz11Vekp6ej1+s5e/Ysnbt1Y0pyMq8lJuDfpAk//fQTRUVF5OTk2PrTab28CA0NJSoqisuXL9O1a1cOFxaW1SURtyEBSghRqpZnZqHQaPj+++/x9/dn2LBhqFQqvvvuOzwrVGBpRgYAJquVDdnZDB8+nClTprBu3TpCQkJ44YUXmD9/PgDPPfccr776Klu2bOGZZ57Bz8+Pr776CgBvb292795N7dq1GTZsGIMHD7ZVq7C3t6ewsJDLly8TEBCAo+PNreZF2ZMAJYQoVeFFhXTq1InKlSuzdu1a1q1bx9KlS3F2dqZ///62W3YpJhNFVitt27YFYM2aNWz94QeSkpIYPHgwKpWKrKwsABITEzl//jx6vd62bMCAAbi7u7Nw4UK0Wi25ubmsXr0agLi4OF555RVbKw9RPkmAEkKUqhSTiWrVqgHFVcj//FqtWjUyzGYMFgteKhUqIDIyEoDRo0cT8vLLVK5cGbVaTeXKlfn5559Zvnw5U6ZMITk5Ga1Wy8iRIwFo3rw5APPnz2fGjBkcOHCApUuXAvBahQqlecriPkmAEkKUKgeFgoKCAgDs7OxKvObn5wPQIvoirS5FYwYWL17Ml19+Sb9+/fjggw9ISEgAoKCggCFDhjB8+HDmzp1LmzZtyM7OJjQ0FJVKRV5ecduMmTNnUr16dcLCwhg5ciTe3t78mpd/07gu6PVE62+uzyfKjgQoIUSp8tdoOHnyJABNmzYt8Xpj+ZvjxzNk+HAATCYTo0ePplKlSrRo0QJ7e3uOHDlCZmYmzZo1A2Dbtm0UnjpFREQENWrUwN3d3dbePScnB3uFgtzc4kxAs9lMtOHWgahv7BVO/yUrUJSdx7pYLP2Dy3oYQoi/+CEnm8nJyYSGhjJo0CCOHj1Ks2bNOHToEO3btwcgNzeXS5cu0axZMxo0aMCmTZuIjo7mySefxNXVlR49erBv3z46duzI7t27iY2N5cyZM/Tp04fDhw8TFBSERqPh9OnTeHt7c+DAAfr06cPatWsJCQmhatWq/P7777i5ueHu7k5aWho5OTnUrl2bt7y8+FcFL6Dkg7pSLLb0yXNQQohS1cvVjdDsbIYMGcJ3333Hk08+yeLFi1m/fr3tPRMmTCAnJweA+Ph4Fi9ejL+/P7/99htr1qwhJTmZVo6O/PbbbzzxxBP07NkTFxcXfvzxR9avX4+9QoHVaOTJJ59kyJAh+Pv7s2bNGr7//nugeFZ1o2PvDZbrz2Td7mFhUfpkBiWEKHX5ZjNfZWbwfU4OWWYzzkolfVxdCfHw5NO0VE4WFaGk+IFcrULJ4cICiqxW1EA7Jyde8axAC0dHduTl8k1GJhf0OkyAm1JJJ2cX3vL2wmi1sjg9gx15ueisViqq1fRxdaWHiyszU1O4YjDcNK4m9vbM9q1sC1IygypbEqCEEGXGarVSZLXioFCg+JsyQ1arlUKrBXuF8pbliCxWKwarFXvlzT+r/926O5EAVbbkFp8QoswoFAoc76L+nUKhwElx+1tvSoUC+9vs5+/WifJNsviEEEKUSxKghBBClEuP7S0+q8VCA+nlIoR4ABa9HqVWW9bD+Md6bGdQRpPpvrZLS0u7523i4uJK7VhyvPJxvMf53OR4f5DgVLYe2wAlhBDi0SYBSgghRLkkAUoIIUS5JAFKCCFEuSQBSgghRLkkAUoIIUS5JAFKCCFEuSQBSgghRLkkAUoIIUS5JAFKCCFEuSQBSgghRLkkAUoIIUS5JAFKCCFEuSQBSgghRLmksFqt1rIexP9CREQkDRs2KOthCCEeYTqjGXu74lbzUVFR1K9fv4xH9M/y2DYsVCoVVJ+0vayHIYS4A4tRj0VfgMrBFYXq7z+SLIYisJhR2juXWG61WrEaCrGaTSgdXFAolCXX6QuwWswoHVxRKBR/rDObMBfmoNQ6otQ43HS82Dk9/3Y8RqORhIQEdDrd3ZyquAV7e3uqVq2KnZ3dTese2wAlhCjfjNnJZO9bQeHFg2Axo9A44Nz4GdzbvYhS62R7n9VqpSBiDzkH12PKugaAnXd13NoMxLFuELlHvifvxDbM+ZkAKDQOODXogPtTIeSd2E7eyZ+wFGZfX+eIc6NOaHxrk3diO4aUGLCYAVB7VsGtzSCcG3W663NISEjAxcWF6tWrlwh84u5YrVYyMjJISEggICDgpvUSoIQQpc6cn0Xy6om4qK28/eZY6taty8GDBwkNDSUl8TyVhn6CQll8ay3v+Bay9nxN69at6dt3LCqVijVr1nD6x09Q2rtg0eXRq1cvOnbsiEaj4fTp06xevZqEUzsA6N+/P0899RQqlYoTJ06wdu1a8k5sIzAwkF7D36VatWpkZWWxZcsWDm+fj9VkwKVp97s6D51OJ8HpASgUCipUqHDbDseSJCGEKHU5R75Dqc/n4MGDzJw5k7p167Jy5UqWL1+OIekCBVH7AbDoC8nev4r+/ftz6NAhgoKCaNOmDSdOnKBz585YdHkMHTqUrVu30r59e6pXr87XX3/NvHnzAPjXv/7Fpk2baNWqFXXq1GH58uXMnDkTgHfffZeRI0dStWpVxo4dy6FDh+jUqRM5YWu5l5/mJTg9mL+7fhKghBClrvD8AQYMGECDBg2YNWsWnTp1YvPmzbz44ovUqlWLwvMHADCkXsZq0vOvf/0LgD59+tC3b1+USiWTJ08GoEmTJgC8/fbbvPTBghLLGjduDMBb4yfy8r9nlVg2ffp0atWqRa9evRg3bhwAPXr0wJyfidVQWBqXQdyB3OITQpQqi1GPOT/TFijOnTsHQEREBP369aNRo0bE/X6i+M3Xv13fmNFUrFgRpbL4e3VgYCAAS5YsoXv37nz99dekp6eTkpLCtGnTAFi0aBGdOnVi2dIvycvLIzExkVmzigNVzLV0237r1q0LwLFjx1BonVDcImHibvw56+9heNj7exAmkwm1unRDhgQoIUSpspqNADg6OgJgNhcnKZhMJttyY3o8GTuXwPX3fvrpp7Rv354TJ06gUCgwm804Oxdn8lWvXp0qVapw5swZkpKSaNWqFfXr12fXrl3UqFGDypUrcxjhBqsAACAASURBVOzYMbKzs2nWrBl169Zl//79cP03rsmTJzNhwgT+85//sGHDBtzaDi6RBXgv7O1UDzV7+E5ZhDe8/vrrJCcno9frGTZsGIMGDWL//v0sWLAAs9mMh4cHK1asoKCggBkzZti+FIwZM4Zu3brxxBNPcPLkSQB27NjBb7/9xpw5c5g0aRIajYaoqCiaNWtGz549mTlzJnq9Hnt7e2bNmkWNGjUwm8189tlnHDhwAIVCwcCBA6lVqxarVq3iiy++ACAsLIy1a9eyePHiuz5/CVBCiFKl1DqhsLPnypUrAPj6+pZ4vXLlCkqlEvurR9Dr9QDs2bOHWrVq0apVK9LS0ti6dStnzpwBYPz48Xh4eBAcHEy2zsz55s2ZOnUqCxcu5J133sHV1ZWePXtisCpJuHKJDz74gKVLl0JBJl9++SWjRo1iypQpzJgxAwBj9rXSviQPbNasWbi7u6PT6Xjuuefo3LkzU6ZMYfXq1fj5+ZGdXZzF+MUXX+Ds7MzWrVsByMnJueO+U1JSCA0NRaVSkZ+fz5o1a1Cr1Rw8eJAFCxawaNEi1q9fT2JiIps3b0atVpOdnY2bmxsff/wxmZmZeHp6smnTJgYMGHBP5yUBSghRqhQKBfZ+jQgNDWX27Nm8+eabKJVKnnvuOSIjIzly5Ag+Pj5cu3aN0NBQBg8eTIsWLXjyySdJSUlhwoQJeHh48OWXXwIQHx8PwOjRo4mJicHf35/IyMgS615//XVSU1OpWLEihw8fBopnZaNGjSImJoaKFSuycOFCjh8/zsqVKzF1fBm1i1cZXJ37s2rVKnbt2gVAUlIS69evp0WLFvj5+QHg7u4OwKFDh5g/f75tOzc3tzvuu3v37qhU1zMq8/L497//TVxcHAqFAqPRaNvvCy+8YLsFeON4ffv25ccffyQ4OJiTJ08yd+7cezovCVBCiFLn2mYQKWveoU+fPkyfPp2ZM2cSFhbGu+++i8ViwWQycfLkSWJjYwFQq9W89dZb+Pj4cOnSJYYOHcq6desA+OCDD3BxcWHUqFFotVp+++03Jk6cCBRn6mk0Gt5++23s7OzYuXMn48ePB8BgMNhua7Vr1w6A3NxcoPgB3kfFkSNHOHjwIOvXr8fBwYGQkBDq16/P5cuX72t/N2atNzg4/PF73H/+8x9atWrF4sWLSUhIYNiwYX+7r+DgYF577TU0Gg3du3e/59+wJEAJIUqdfdX6VOjxJnt2f83utm1ty1Wu3ri2DCbt6CaaNWtmW3748GHq1atn+1uh1uLWZhBOjbuS9sMsQkJCSuxf5eSBZ7c3yDy+lcGDB5dc5+wJwKRJk5g0adJNY7Or4I/areJDOc/SkJeXh5ubGw4ODsTExHDq1Cn0ej3Hjx/n6tWrtlt87u7utG3bljVr1tgyIHNycnBzc8PLy4uYmBgCAgL49ddfcXJyuu2xfHx8APjhhx9sy9u2bcv69etp1aqV7Rafu7s7Pj4+VKxYkSVLlrB8+fJ7PjcJUEKIMuHc+Bkc67ajKOYY5sJs1O6+ONRojkKpwrlJN4wZV1Eo1Wj9AzHnpaO/dh5LUR5KRzccaj6J6nq5I9/h/4c+MQpjRgKYTajdfLCv1hiFWoNzk27or0ZgzEwEq8W2zmqxoI8/i9VScqakUNlh7x9430kSZaF9+/aEhobSo0cPAgICaNq0KZ6enkybNo2xY8disVioUKEC3377La+99hrTpk2jV69eKJVKxowZwzPPPMOECRP417/+haenJ40aNaKw8NZp9iNHjmTSpEksWbKEDh062JY///zzxMbG0qdPH9RqNQMHDmTo0KEA9O7dm8zMTGrWrHnP5/bYFouNioqix4r7m+IKIQSUzKK7VbHYvy57nNPM79e0adOoX78+zz///G3fc7tCvI/O1wQhhCjnHnYwedSDU3BwMBcuXKBv3773tb3c4hNCCPE/sWnTpgfa/vENUFbLXT/kJoQQt2I16lDY2Zf1MP6xHt8ApVDCR3fO8RdCiNtRfHTnB1nF/87jG6CEEOVabLaFzw7q+fmSCb0JWlZRMaGNhiD/kh9LVquVjZEmvj5h4EKGBWeNguB6at5urcHNXsF/jxtZd85IfI4FjQoaVVTxdisNQf4qFh81sCHSxNUcC/ZqaOyjYnwbDTU8lHxzwsixa2aS8y0APF1dzazOWlRKqU5eXkiAEkKUurMpZp76tgAdWnr3HoCzszPbt2/nh2/TWN7XnpeaamzvnbhTz/zDBurVq0fHvi1JTU1l1s6drDxjpIaHkt9izbRo0YKuXQLR6/Xs27ePTisTbdu3bt2abs80oKioiL179/LUt8kAKJVK6tWrR7Um1dDpdHyydy8dq6voUfvmzq6ibEiAEkKUunG/6LB39+H0kSNUrlwZvV7PkiVLePbZZ3n7l730r2+Hq1bBsUQz8w8beOONN1i4cCHR0dH4+fkRFRVFUFAQ8bF6pk+fzgcffEB0dDQVKlTA2dmZDh06cPjwYebPn8+4ceO4cOECPj4+aLVa2rZty6lTp1i0aBGvv/46UNwZ18/Pj8S8x/Kpm3t29uxZtmzZwgcffHDL9SkpKcycOZOFCxf+T8chaeZCiFJ1OcvC7itmJkyYQLVq1QgODqZ69epYrVY++eQTsnXwXWRxjbedMSYUCgWzZ8/mwoUL1K9fn7feeovmzZszfPhwoLjeW0FBARP7NeGll15Co9HQrVs327qMjAw+HtiE0aNH4+DgQNeuXQFYvHgx/v7+JCQkPLyTM+oe3r4e4v5uVIy/W4GBgbcNTgA+Pj7/8+AEMoMSQpSyC+nFH5ZBQUEA7N+/n9zcXKKiomjRogVarZYL6cW/C+UZrGg0GhwdHdHr9agUVnS64g/t1q1b8+WXX7JixQo++eQT2r8yncDAQLKysvjxxx8BWLFiBVOnTqXJix/bKqFv27YNwFZQ1mq1PryuuHb2Dzc56y6SNBISEhg5ciQNGzYkMjKS2rVrM3fuXHr27EmPHj04ePAgI0eOxM3NjUWLFmEwGPDz82P27Nk4OTlx5swZZs2aRWFhIRqNhuXLlxMREcGyZcv48ssvOXr0qK0LsUKhYPXq1WRnZzN69Gi2bduGXq/no48+4ty5c6hUKiZNmkTr1q3ZtGkTe/bsoaioiKtXr9KlSxfefffdezp9CVBCiFKVdX1S4OlZXBOvqKioxKuHhwfLT6dQp4KSxDwrer2BjRs38sILL/Drnt9o0KABAF5exdXGr1y5QkZGBt27d8fLy4uLFy+SlZUFQHR0NDk5OfTo0QNfX18uXLhgKwi7tLc9r259yDOeMnLlyhVmzpxJ8+bNee+991i7di1QXFX8hx9+IDMzk7Fjx/Ltt9/i6OjIV199xbfffsuoUaMYN24cCxYsoHHjxuTn52NvXzKtftmyZUydOpXmzZtTUFCAVqstsX7NmjUAbN26lZiYGEaMGMEvv/wCFFeI2Lx5s61YbEhIiK2tyt2QW3xCiFJV2aV4tpKYWJzI4OHhARQHLKPRSGpqKqkFVkZu1bH6TPGtvqFDhzJs2DDCwsL45JNPADh//jwA33zzDenp6TRq1Iinn36aVq1aMWXKFNRqNd988w1XrlyhSZMmPPvss7Rr145///vfALcMTq9u1fH99duLjxJfX1+aN28OQJ8+fQgPDwfg2WefBeD06dNcunSJwYMH07dvXzZv3sy1a9e4cuUK3t7etu7Gzs7ON1Ucb9asGXPmzGHlypXk5eXdtD48PJw+ffoAULNmTSpXrmzr9dWmTRtcXFzQarXUrFnT9v/8bkmAEkKUqsCKSuyUf1TDHjNmDL1796Zu3bps2bIFi8XC888/T3x8PAMHDgSgVatWHDt2jNWrV9O5c2csFottlmAymfD09CQgIMD2QWswGLBYLJjNZry9vfH397e1iL/Rw6h+/fr07NkTBwcHHBwc6NmzJ/Xr1+fVrUUYzY9WssRfb1He+PtGqwyr1UpQUBBbtmxhy5Yt/PTTT8yaNeuu9j1q1ChmzJiBTqdj8ODBxMTE3PW4NJo/sjFVKtU9/xYmAUoIUaoqOCp5uakdS5cuZcWKFbz//vv8+OOPHD582NbHydHRET8/P1vbh+DgYKKiooiMjKRhw4a8/PLLtl5Ob7zxBiqVisuXLxMaGsqpU6eYM2cOFouFN954AxcXF+Li4li2bBnHjh1j3rx5ALzyyits27YNLy8vKlSowLZt2xgxYgRZOtA9Ou2gALh27Zrtemzbts02m7qhadOmnDhxgri4OAAKCwu5cuUKAQEBpKWl2boT5+fnYzKVPPn4+Hjq1q3LqFGjCAwMtM2ObmjRooWtQ++VK1dISkqiRo0aD+W8JEAJIUrdnC72NKtoZvjw4Xh5eVGlShWCgoLQZ8RT00PBihUrUCgUfPvttwBMnDgRb29vqlevTkBAAKtXreSjDlrmPaNlw4YNVKpUiSpVquDp6ckTTzxBQVo8/eqpWblyJd7e3lStWhUPDw9atmyJOScJgHfeeQeFQlHiv4kTJ1LPS4mz5u9GX/4EBASwZs0aevToQW5u7k09sDw9PZk9ezbjx4+nd+/eDBo0iMuXL6PRaFiwYAEzZsygT58+vPLKKzc1LFyxYgW9evWid+/eqNVq2rdvX2L9iy++iNVqpXfv3owbN47Zs2eXmDk9iMe63Ub99a3LehhCiNuwWK1sv2gqUUliaGM79GZYc8ZIeqEFL0clPeuo+TnaxJkUMzoz1PZUEtLYjmruxd+vo9LMfBdpIja7uFpEA28VQxrb4W6v4GyKmU1RJuJzLDjYQWBFFS8G2pGlsxJ6zkiBoeTHn6OdgsGBdvi7Xf/u/qcsurtpt4FRV5zJ97Dcxf4SEhJsGXWPqtu127irLL7k5GQ+/vhjYmJisFgsdOzY0dZK+VZyc3PZunUrQ4YMAR7soa6ioiLeeust4uPjUalUPP3007bbAEKIR5dSoaB3XTt61y1ZucEJGNuq5GfLGy1v/428vreKKR1u3ZYi0EdFoM/N61y0Ct4N0t5iiwf0sAvL/sML1d7xFp/VamXMmDF06dKFnTt38ssvv1BYWMiCBQtuu01ubi7r1q2z/f2gD3W98sor7Nixgx9++IETJ06wb9+++96XEEI8TqpWrfpIz57+zh1nUIcPH0ar1TJgwACgOBPj/fffp3PnzlStWpXff/+d/Px8UlJS6NOnD2PGjGHevHnEx8fTt29f2rZty5AhQ+77oS4HBwdaty6+VafRaGjQoAEpKSn/26sihBCizN0xQEVHR9OwYcMSy5ydnfH19cVsNnP27Fm2bt2Kg4MDzz33HB06dGDChAlER0ezZcsWgBKlRB7koa7c3Fz27t3LSy+9dMcTs1gtd/UUthBC3I7epCM5Ub4Ql5UHriTRtm1b24N2Xbt2JTw8nC5dutz2/eHh4QwdOhS4/UNdN9YlJibaApTJZGL8+PGEhITg5+d3x3EpFUoCVwQ+0LkJIf7Zzr50lmrVqgHFX6BF6bpjgKpVq5ZthnNDfn4+SUlJqFSq2z4gdj/+7qGuKVOmUL16dVuBSCHEo81qtVIQUUD24WxMuSY0FTV4dvDE3u/mxABTvomsfVno4nRYzVac6jnh3s4dlYMKQ5qBrP1Z6JP0WM1WNF4a3Nq44VjDEX2KnqwDWRiSDFgtVjQVNbi3dUfjrSHnaA6FMYWYckyo7FXYV7fHs4MnKqdbJ1yI0nfHJIk2bdpQVFTE5s2bgeKquHPmzKF///44ODgQFhZGdnY2Op2OX3/9lWbNmuHk5ERBQcEt93c/D3UtWLCA/Px83n///Xs9PyFEOWS1WElYkkDsZ7Goz6upa1cX3UEdl6ZcIv3n9BLvLYorIvq9aNK+T8MrwwvffF+S1iQR/V40GbsziJ4cTdbPWVQpqEINSw10B3VcnnaZK59e4dLkS+T8kkPVoqpUN1Wn6EARMR/GEPV6FNeWX8PuvB31NPXwzvQmdWMq0e9Fo7+mv82o/zk2bdrEtGnTAFi0aBHffPNNmYzjjgFKoVCwePFiduzYwTPPPEO3bt3QarWMHz8egMaNGzN27Fj69OlDt27dCAwMxMPDg2bNmtGrVy/mzp1bYn/3+lBXcnIy//3vf7l06RL9+/enb9++bNy48QFPWwhRlrLDssk5msO0adNITEzk+PHjJCQkMHDgQJI3JKNLLK6TZ7VYSfgygcrulTl16hSXLl3i/PnzHDp0CE+NJ0mrkmhYpyGxsbFERkZy4sQJkpOTGTBgAAURBTzR+AmuXr1KREQEp06dIikpyVafbuHChaSmpnLs2DGio6M5efIknvaeJK64t3pxf6Y3P9zgdq/7s1qtWCyWhzqGsnRXv0H5+vry3//+95brKlWqxBdffHHT8hvlRG64kQap1WqZPXv2Te8PDg4mODjY9veXX35p+/eFCxfuZphCiEdExs4MnnjiCaZMmcKmTZuYOHEiP//8M1988QU//fQTGbsyqDK8CvpEPfpremasmEFgYCBPPvkkGo2GsLAwPvzwQ9544w2GDx9OlSpV6NevH+Hh4Vy9epWxY8fy/fffM3LkSHx8fOjatStxcXFcvHiRN954g59++omIiAg6depEZGQks2fP5uWXX2b06NFMmz4Ni9GC0u7eC+1oVdqH+tv32ZfO3vE9CQkJjBgxgiZNmhAREUGPHj3Yu3cvBoOBrl278uabbwKwefNmvvnmGxQKBXXr1uXTTz9lz549LFmyBKPRiLu7O5999pmtSnx5IO02hBClymq2okvU8eyw4pnMhg0buHLlCj///DNvv/02LVu25FDcIQCM2cWFXWvWrAkU93Cysyt+sLd79+4AHD16FCjuL1WhQoUSy44ePcprr71G+/btSU5OLrHuz1+Cw8LCePnll/+oQ/eI1deJi4tj7ty55Ofn88svv/Ddd99htVp57bXXOHbsGO7u7ixZsoR169bh6elJdnY2AM2bN2fDhg0oFAo2btzI119/zaRJk8r4bP7wQAHqr7MeIYS4E3OBGSzYMnRzcnJKvPr6+qI/pKcorgiu363avXs3QUFBrFy5EpVKZXsfwK5du/j999+ZMGECZrPZVhgWYPv27Rw7dozJkydjsVi4dOkSq1evBsCjvQdZ+7No0KAB06dPJzo6miVLluDcyBml5tEqU1q5cmWaNm3K3LlzCQsLo1+/fkBxUdjY2Fh0Oh3du3e39eByd3cHin9CGTduHGlpaRgMBqpWrVpm53ArMoMSQpQqlaMKFJCWlgYUP1f559e0tDQsRRZiPvyjrcOMGTPIy8ujY8eOJCQkkJGRYWtKOG/ePNq1a0fz5s25fPkyly5dYv369TRp0oRFixbx5JNPUq9ePTIzM4mJiWHVqlW0bduWrP1ZtG/fns2bN3P16lW6d+9OVlYWvr3uvqFeeeHo6AgU/wY1atQoXnjhhRLrV61adcvtZsyYwfDhw+ncuTNHjhzh888//5+P9V48Wl8ThBCPPIVagcZHw969ewFsiVdPP/00+fn5HD9+nAoVKrB582ZbMpabmxtffPEFvXr1YufOnVSoUIFNmzYB2G7r5ebmotPpMJlMtmU3XvPy8igsLMRsNtuW9e/fn507d5Kens6YMWPw9vbGz8+PlI0pmHX31reovGjXrh3ff/+9LYs6JSWFjIwMWrduzY4dO2xB/cYtvry8PHx8fABsmdrlicyghBClrkLnCuxfs5/ly5czcuRIXn75ZSwWC+PHjyczM5MqVarQt29f2wdthw4dWLduHXl5eXh6erJ3715mzpwJwOeff06XLl04deoUOp0ODw8P3nnnHaA4U69du3acP38eo9GIs7Mz7733HgAvvPACWq2W2rVrs3//fgBWrlzJSy+9hDnXjMr+0Xseql27dsTExNhmUI6Ojnz66afUrl2b0aNHExISglKppEGDBsyZM4cxY8bw1ltv4ebmRqtWrUpU/SkPHut2GwOPDizrYQghbsFitBD7WSyFFwpp2LAhtWvX5tixY7aW4HZ2dgQGBpKVlcWVK1fQaDS0bNkSHx8foqOjOXPmDBofDe5B7qT+kIqnhyfNmjVDq9Vy9uxZ4uPjQQWYwcvLi2bNmqFSqThz5oztGAEBAbYqODdkZmYSnxRP3f+ri8pBVSKL7m7abejNerSqh1cl/WHvr7x6oHYbQgjxMCntlAS8E0D2oWxiD8Zy6fglND4aqg2qhmNNR9K2pxGdGI3CU0GVLsXp5icuncB82YzaTY3vMF88gjxQapU4N3Qmc08mYZfDiitJeGvw6+OHa3NXCqMLydybyYFLB4rX+Wrwf94fzJBxMIN0U8mHghVeCvz6+aFyuL/Z08MOJv+E4PR3JEAJIcqEQq3A4ykPPJ7yuGldpYGV7no/jjUdcazpeMt1TnWccKrjdMt1rs1d7/oYomxIkoQQQohySQKUEEKIcumxvcVntVjuqkyIEELcjkWnQ2n/z267XpYe2wClUCqJqndzVogQQtyt+uelB1RZemwDlBCi/IvS6ThQUIDBaiHQ3oGnnJxQ3qKnXIHFwv78fGINBhyVSjo5O+N3vQtClsnEnoJ8koxGNAoltbUa2jk5Y6dQkG4y8Vt+PkkmI/YKJXW0WoKcnFArFKSajJzT6Ug3FT+U+6SjAwGaRzNrbuXKlaxbt45atWqRmppKREQE48aNY8SIEWU9tAciAUoIUep0FguTkpLYmZ8HgFKpxGLJoLZGy+dVqtiCD8DxwkImXLtGmtlkW/ZJWipDPTxo5uDAe0lJ6KzW6/soLt4XoNHQ19WNLzLSMfxlXW2NlqoaO/bm5980rp8CalD9b9r/3IlFr0epfXhB7m73t3btWpYvX46dnR2JiYns3r37oY2hLEmShBCi1C3OSOfXwgJmzJhBVlYWOp2O0NBQMl2cmZh0jRv1A7JMJt68lohXneJqD3q9noSEBF5/4w1WZWUx7to1WgQFcfr0aQwGA4WFhWzZsoUsR0f+Lz2Ndk8/TUREBAaDgYKCAjZu3EjS9eA0dOhQDh48SGJiIseOHQMgvLDwgc5LqdUSVa/+Q/vvboLT1KlTSUhI4NVXX2Xr1q00btwYtfrxmHtIgBJClKp8s5m1WVm8+OKLTJ48mQ0bNvD2228zaNAg5s6dy1mdjsPXA8Uv+Xlkm82EhobSqFEjunTpwq5du/j8889p3bo1UFwstkGDBnTr1o158+bRp08fRo0aBcB//vMfAgIC6NSpE0uWLOG5557jpZdeAorLAO3atQsHBwdbPbpH0bRp06hYsSIrVqxg+PDhZT2ch0oClBCiVJ3X6ymyWhk6dChQ/AH7xRdfEB0dzZAhQ1AoFJwqKgIg3mDE3t6exo0bExERwdHff2fLli1AcXduKK5+bjabSUxMJD29uDJEamrqTesyMjJsywC++uorPvzwQ1u9P1H+PB7zQCHEIyPlelNAf39/oGQwqV27Nt7e3iTrDQD42qnRZem4dOkSTzzxBM8PGcLAgQNLbP/2229z4MABoqKKM+5+/vlnVqxYAcDYsWPZt28fly5dAmDTpk1s2LABgBAPD1Zdr+4tyieZQQkhSpWTsvhjJy+vOEFCcz0p4cZrfn4+G3OyaXDhPLOvB6/nn3+e8PBw5s2bh5ubW4ntQ0ND0Wg0tGnThrFjx9KjRw/effddADZu3IjRaKRly5a8++67BAcHM3bsWKA4+eKvtuTmkGt+NFttPI4kQAkhSpX/9Zbt4eHhQHHbcScnJ+rWrcv58+cpLCykSZMmTJ06laZNmwJw7tw5OnTogI+PD9999x0AP/30EwqFgsDAQK5evUpSeDg7/r+9Ow+IqtwfP/6eGZhhGxBUFgV3UVAUtxLNJfcN1NSyXLLUb1pW2mKWWS7VTbv3p3lLvta1xSwr90xFEyJXNPmKopKCoiwism8Dwyzn9wdJcV0yDBjt8/oHnPOc+TznAefDc86zREQA0LFjR3Q6HW3atOHChQvknTjB7t27AejQoQMACUbjdXU7VlrKE6kp3K2bPGRlZdG7d28+/fRTwsPD6d27N8U3GK14t5BbfEKIWtVcqyXIwYH33nuPRx99lK1bt5Kfn49er+f1118HIDg4mEWLFpGamkpcXBzLly+nU6dOqFQqevToQVRUFBs2bEBRFDZv3sz48eP5JCKCxo0bAxW38oxGIzt27CA0NJRV339PixYtANiyZQsAb775JjNmzMDT0xOAjIwMwsPDWbx4MQZFwfkG87H+iNVo/Esn997uMPOoqKjK76/tbXUvkAQlhKhVKpWKuQ09efLiRfz9/ZkwYQJ6vZ5NmzZx5swZAA4dOsS0adM4ePAgAGvWrGHIkCE4OTmxZMkSdu/eTVM7e9RaLRMnTuTbb78lKCiI0tJSoqKiKntnY8aMYdSoUQQEBLBnzx5++OEHTpw4AUBERMR1G/TFx8fjpFJjX43kBPylc6Bq4v3uNvf0hoWMfqiuqyGEuIlTZaWE5+RwoKQEs6LQ3sGBKe4euGjU/L+sLPItFuppNLRzcOB0WRnnjUbMQCM7O0a71WOyuzsqIDwnh4iiQjLNZuxUKlpqtUx296CviwurcrL5oaiITLMZrUpFa52OKe4eFFutfJqbQ9l/ffw5qFQ86VGfcfXqAVWXOrqdDQtF9ciGhUIIm9LewZEPG/tiVRQUQPO7XksvZ5frylsVBQtc17t52dOTlz09sSgKaip6aNe86unFq55eNzz28K9JSNguSVBCiDp1o7X3blbuVqO6NLd4n1sdu1OKolRJfOLPudVNPBnFJ4QQ1eTg4EBOTs5dO+qvrimKQk5ODg432dLknu1BKVYrgbJUvhDiDvzRKDpfX1/S0tIqV6cQf56DgwO+vr43PHbPJiiT2fzHhW4gKyuLhg0b/qlzLl26RNOmTWsllsSzjXj38rVJvN/80Sg6e3t7mjdv/qfji9sjt/iEEELYJElQQgghbJIkKCGEEDZJEpQQQgibJAlKRwhRJAAAIABJREFUCCGETZIEJYQQwiZJghJCCGGTJEEJIYSwSZKghBBC2CRJUEIIIWySJCghhBA2SRKUEEIImyQJSgghhE2SBCWEEMImSYISQghhk1TKPboV5OnTZ2jXLrCuqyGEuMuVmSw42GtISEggICCgrqvzt3LPblioVqtoNm9HXVdDCPEHFKsFS3EuKjstGie3W5e1mLCU5KNxckNlp61yzFpehrW0ELWDC2qdU9XzzOVYDIVonN1Qaeyviw2gca5X5dg1F98dXt1LE3fonk1QQgjbplgtFB7dTOGxbVhL8gHQ+rShXu9JODYLrlLWlJtOXuTHlCb/HyhWVHY6nNs9SL3ekyqO/biG8stnK8vbe7bAvc/jaJzdyY36GGPKKUBBpXXEpcMg9J1HkBf1H0qTY8Hy6+7bag2OLbri3n869vW8a6sZxC3cs7f4EhISGPr5hbquhhDiJnL2rKL4+E6GDh3K6NGjycvL4+OPPybp/AU8H1mCY9OOAJgLs8n47DncHDQ89dRTNG/enNjYWNauXUuZyQxWKy2aN2PKlCk0btyYq1ev8sUXX3DmzBkAvL29mT59On5+fuzfv5/169djNpvRaDTMmTMHf39/VCoVZ8+e5ZNPPqHApKHRtFWotY7Abz0oucVX+2SQhBCi1ply0ig+vovZs2ezc+dO2rdvz2OPPUZcXBwtWzQnP/ozrv3tXHD4GxzVFo4ePcqLL76ISqVixYoVbNiwASxmdFp7Dh8+zPPPP09ZWRlTp04lJiaGBg0a0KBBA44fP8706dPRaDR88sknrF69GgCtVsuMGTNwd3enU6dOvPfee2zZsgVLURalSUfrsnnEryRBCSFqneHcIVQqeO2110hPT+eBBx7gsccew9nZmdmzZ1N+JRFzQSYAxstn6dOnD61atWLlypU8Net5Nm7cyIgRI+jYsSMNGjTA09OTiIgIFu3PZ8OGDej1enx9fRkxYgTe3t68/fbbTHv6OSIjI5kyZQq+vr6UlpbSqlUrxo0bx/33309paWllD8liyK/L5hG/kgQlhKh15vwreHl50bBhQ86ePYvVauWXX34BoH379pVlAFQqFRaLBYBGjRqBqaziK9CuXTvS09N5++23CQ0NZc2EDkyePJlVq1YRFxdXeV7jxo1Rm8vw8vJCrVbTtm1bANzc3Pjwww/Zs2cPZrOZ2bNnA6Dz8a+9xhA3JQlKCFHrFIsJBwcHgMokcu2ro2PFs5+cXSvJ2bWS8qyLREZGEh0dzYwZM8jLy6NPnz4AODk5odPp6N27N8XFxSQlJZGXl0dISAhubm5s2rSJ48ePs2DBAnJzcyuTn7OzM1CR/FxdXXF0dESv1xMWFgZA+dXk2msMcVOSoIQQtU7j2pD09HRMJlNlb8jHxweACxcqBje52ZlxzT6FGgWz2Uz//v3p2bMn48ePJzw8HICYmBj69OlDr169+PDDD3ll2SqWLl1Kp06dGD58OAaDgfvuu48+ffowduxYvvnmGwCOHq14xpSfn8+kSZPo0aMHJ06c4JFHHsHb25vyK0m13STiBmSYuRCi1jk06UDh4W/5/PPPmTZtGgsXLqRbt24ArFmzBoDVq1czduxYPD09ycrKYv78+Zw7d44WLVowbdo0Dhw4wKlTpzCZTACMGDGC2NhYxowZA0ByckUvaPHixRw/fpz27dvzyCOPsGXLFjIyMhgyZAiDBw/m5MmTNGnShICAALKzs8nKysKlhUcdtIr4b5KghBC1zqFpR3SNA3jhhRcoLS1l6tSp5OXl8eSTTxIZGQlAYmIiMTExmM0V85TatWvHU089hclkIjw8nLfeeguAs2fPMnHiRJ5//nlWr17N1atXeeaZZzh8+DAA3bp1Y8qUKZSUlLB06VLeeecdAPLy8rj//vsZP348JSUl7Nixg8WLF2NBjXNg39pvFHEdmQclhKgT5qJssrf/E2Pqqd9e1Njh2iUMQ9IRzLnptzxf1ySIBsNmUxy/l4KYDb9NuAVQqXFo3onyK0lYDQVVznNs2Q1do7bkH/gSFGuVYxqX+tQf+hyOLbpUvibzoOqOJCghRJ0yXkmi/EoSKnsdjk2D0bi4o5hNlKWdBqsF+/p+qJ1cMaYlYC64AioNukb+aBs2q3wPS0kexvQELIYC1A56dI3aYufaAKvRgDHtDObCq6g09uh8A7H3aPzrOfkYM85iKcpBZafFzs0LnW8gKrWmSv0kQdUducUnhKhTOu9W6LxbVXlNZWd/3XJHjs073fQ9NM7uOPn3uO51tc4Jx5Zdb3JOPZxa3V+NGovaIqP4hBBC2CRJUEIIIWzSvXuLT7HKMvlCiDtjKqvcD0rUvns3QanUsPDWe8sIIcQtLSzAoa7r8Dd27yYoIYTN25loYkVMOf+XYcVZC2H+dsztqcPPrerTh4IyhXcPGNmRaCajWKGFu4rpnbU8EWxPepHCO/uN/HjRQlaJlQZOavo20/BaLx3O9vDWvnL2JpvJKlFo00DN0121jGxrx/87XM6e82YSc62YrdDCXcWEIHue7qbFTq2qoxYRv3dPDzMP+KZ7XVdDCHETSw8YmRdppGnTpgwbNozs7Gy2bduGXmMiZpozrTwqklRBmcL9/ykhMQ8GDRpEs2bNiImJIS4ujgeaaEjMsVJodWDo0KH4+PiQmZnJzp07MRgMANjb2zN06FAaNWrETz/9REJCAgAajYauXbvSoUMHNBoNcXFxxMTEMDbQjm/HOqJSqWDhb3OoZJh57ZMelBCi1qUXWnkj2sjDDz/MV199RUFBAXq9nkuXLtG1a1dejSxhw7iKbduX7DNyvkBNZOQP9OjRg1OnThEeHs4777zD/Pnzsbe358SJWPz9/Tl69CjdunUjOTmZDh06oFarOXjwIP7+/iQlJREeHs4LL7zA8uXLCQkJYf/+/aSlpeHu7o6zszPLli3jlVde4UCKhV5N5eOxrskoPiFErVt30oTJqmLZsmXk5OTg5+fHE088QatWrXjmmWfYdMZMbmnFzZ29F8wMHDiQvn378u6779KlSxd27drFyy+/TOPGjWnevDkBAQGsX7+e8+/1Y926dbRu3ZqWLVsyduxYgoODmTt3Lh07duTYsWMsWrQIV1dXUlNT6dq1K35+frRu3Rqj0cj06dMBOJpuqcvmEb+SBCWEqHXncqz4+PjQtGlT4uLiMBgMHDp0CIDu3bujAOdzK5YhKjGBq6srQOW6fGazGXt7ezp16sT58+eJjIxk4MCBnO84j8GDBxMdHc3Zs2dveJ5erycgIIBLly4RGxtb5XhKSgoA9RzkGZQtkAQlhKh1eWUK7u7uAJSWlgJQVlYGgIdHxUriSw8aWfVzOeUWhT179pCens68efOIjo4mNDQUgPr166MoCufOncPV1ZW+fftSr149zp07h6IobNu2jfz8fJYuXcqBAwfo3r17lRgejiq8vLyIiIjAYDDwxBNPUM8BRrWV23u2QH4KQoha11iv4sfEisVg69evD/yWNNLS0gDYlGBmU8K1BWDz6NSpE+PHj8fFxYVLly4xefJkzpw5w4ABA5g5cyaLFi3i0+WLmPTsfJYsWcK2bdvYuXMnQUFBjBs3Djs7O7Kyshg1ahRnzpwBoIFfayIiItBqtfTt25eTJ08CUC53+GyC9KCEELWuayMN+fn5REZG0q1bN4YNG8YzzzwDwMaNGwFYt24dmZmZuLi4ANC7d2927drFiRMnGDZsGAkJCfz888+VPbB27dphcvahXbt2wG89sx49evDdd99x4cIFBg4cyP79+7l06RItWrTg0KFDNGnShM8++4yQkBCeeuop7Ozs+PyEqbabRNyA9KCEELVufHt7Fvxo5Omnn2bt2rXs2LGDsrIyli9fzqZNm4CKbdnr1atXMdwbWLp0KS1btgQgMjKSp59+GoD9+/ezcuVKZsyYwdixYzGZTKxatYro6GgAPvroI9zc3LBarezcuZMZM2YA0KRJE/R6PRaLhZdffrmybmvXriW3VLpQtkDmQQkh6sThVDNhX5eSbVBo2LAhJSUlGAwG+jTVcCjVgqnqVk2oVCo8PT0xGo3k5+fj46LiqzGOLD1oJCLJgp2dHR4eHuTm5mI2m7FTg9kKarUaT09PDAYDhYWFtHBX0dpDze7zN09CG8c5MibQXuZB1THpQQkh6kSInx3Jz7vw5UkTx6/k42yvIqyNE72bajiXY2XbWTNmK3T0UtPCXc22s2aS83Kx10APP0ceCrDDwU5Fn6YaIpMtRF80k1WSRwN/Nb2bOjGopYaTmVa2nzOTUpCDg52Kvs0cCfW3w2yFjWdMpBZe//d5J281g1vJR6MtuK0e1JUrV1i0aBHnz5/HarXSt29f5s6di1arvWH5wsJCtm/fzoQJEwDIzMzk7bffZuXKldWq5NSpU8nKysJisdClSxfefPNNNJpbL94oPSghxB2THlSd+sNBEoqiMGvWLAYMGMCePXvYvXs3BoOB5cuX3/ScwsJC1q9fX/lvLy+vaicngPfff5/vvvuO77//nry8PCIiIqr9XkIIIe4Of9iPjYmJQafTMWbMGKBi/arXXnuN/v374+vry4EDByguLiYzM5OwsDBmzZrFv/71L1JSUhg5ciQ9evRgwoQJzJgxg++//x6j0cjChQs5deoUGo2GefPm0b17dzZv3kxUVBSlpaWkpqYyYMAA5s6dC1A5isdsNmMymSofmgohhLh3/WGCSkxMrBy2eY2Liws+Pj5YLBbi4+PZvn07jo6OjB07lj59+vDiiy+SmJjItm3bgN/mNQB8+eWXAGzfvp3z588zdepUdu/eDVR0obdu3YpWq2XIkCFMmjQJHx8foOI238mTJ+nduzeDBw/+wwuzKtYq3XMhhKgOQ7mBrIysuq7G39IdPwns0aNH5YzwgQMHEhsby4ABA25aPjY2lokTJwLQsmVLGjVqRHJyMgAhISHo9frKY+np6ZUJas2aNRiNRl566SViYmLo2bPnLeulVqkJ+jzoTi9PCPE3F/94PE2bNq1cBV3Unj9MUK1atars4VxTXFxMRkYGGo3mutttd3L77feDLjQaDRZL1WGgOp2O/v37ExkZ+YcJSghh+8pzysn9MZfS5FLUWjX6YD31Quqh1lZ9PK4oCoWxhRSdKMKcZ8a+gT3uvd1xauGE1WSl4HABxWeKMReZ0ThrcPZ3xr2XOyp7FQUxBRSfKsZcaEbrpcWjrwc6Xx1FJ4ooiivClF0xKdfO3Q7Xzq7og/XyGMFG/OEgiZCQEEpLS9m6dSsAFouFd999l9GjR+Po6MjBgwfJz8+nrKyMvXv30rlzZ5ydnSkpKbnh+3Xt2pXt27cDkJycTEZGBi1atLhp/JKSEq5evQpUPIOKjo6+ZXkhxN2h6EQRifMSyY/Ip51zO7yLvbn86WXOLzqPudBcWU6xKqR+kErqB6loz2oJdArE8n8WLiy+QMb6DC4uvUj6J+m4XXajg2sHGuY0JGNdBklvJHF+4XnSPkrD6aITgU6BlB0uI+mNJJLfTiZlRQqcgECnQNo5t8MuwY6U91NIX5POPTo99K7zhwlKpVLx4YcfEhERwaBBgxg8eDA6nY4XXngBgA4dOvDss88SFhbG4MGDCQoKwt3dnc6dOzNixAiWLl1a5f0ee+wxFEUhNDSUOXPm8I9//OOmw9WhYrmSmTNnEhoayqhRo6hfvz7jx4+/w8sWQtQlq9FK2po0OgVVrEZ+5MgRzp07x44dO1DlqMjcmFlZNjcql8LYQpYuXUpGRgYHDx4kPT2dqVOnkrM7B0OSga+//ppLly4RFRVFUlISu3fvxppjpSyljP/85z+kp6dz8OBB0tLSGPPQGAxJBhwcHMjNzeXIkSPExMRw5coVXn/9dfIP5GNIMtRh64hr7mglic2bN3Pq1CneeOONv7JOf4mEhAQePvpwXVdDCHEDefvySP8knQMHDtClSxfuu+8+hgwZwrJly3j22Wf5IPwDAlYGoHHWcOEfFwhwCeDYsWN89tlnTJs2jaioKDp37kzz5s1xcHAgNTWVH374gTEvjmH1q6t59NFH6dy5M97e3uzcuZNly5axYMECYmNj8fT0pHnz5hiNRnr16sXx48dp0aIFMTExFBYW0rBhQ7wf86bBoAZAxTMokHlQdUEWixVC1LqytDJcXFzo2bMnx48fJz4+nm+++QaAIUOGgAWMV4wAWAotNGvWDICkpCSsKivJycm4uLgQEhLC1atXSUxMpFmzZvRv2Z+2bduSkZHBhQsXqpxnUkykpKTg6elJhw4dsFgsHDhwgJYtWxIQEIBGo2H//v0A2Lvb13qbiOvd0Si+hx56iIceeuivqosQ4m/CXGDG29sbqJjY//uv114vOlEECmhcNRw+fJiioiKefvppPDw8Kudl+vj4UF5ezsqVK1mxYgVr165Fr9fz5ptvUlBQQHR0NOXl5bzyyiu0b9++coTxtdHBDRo0qNy0sKioiC+++AIAjfOtV6oRtUN6UEKIWmfnalc5+Ona1JJrE/KvvZ71XRYX3rqA4ayBy5cv06dPH3bv3k2TJk2IiooCKuZYBgcH8+9//5vPP/8cV1dXli1bxqJFixgwYAAJCQn079+fQ4cO4eXlxb59+wBITU0FKpZx0+l0tGvXjuLiYr766iv0ej1Fx4tqtT3EjUmCEkLUOl1jHYWFhRw7doyOHTvi5+dXuUvuteQzf/58du3ahZubG1AxveXJJ59k1qxZNG/evHI/qWvHy8vLq3y99npmZiaTJ09m7ty5tG7dunKr99atW1fsIWUyUVBQgNlsxsHBAbVajWKVUXy2QJbsFULUOrf73cjckMns2bPZtm0bKSkpABw6dIjw8HAAgoODGTJkSOUo36ioKOrVq4eLiwtZWVlMmDABo9HIoUOHiIyMZMaMGTz00EN4enpy7Nixyvmbp06dwmg0otfrSU9P55FHHkFRFDp37szXX3+NxWJBo9FgNptZvHgxBQUFNAlsUjcNI6q4p/eDklF8QtiugqMFpK5OxVHrSK9evcjLy+Pnn39G7aTGarDi5+eHq6srv/zyCxaLhSZNmhAYGIjJZGL//v2Um8vxGutF/v58jBlGOnToQKNGjbh69SrHjx+vnMvUsmVL/P39MRgMHDx4EAsWUAArBAQE0KxZM0pKSvjll1/IzMykXo96NJ7WGJW6YrKujOKrO5KghBB1puxyGbl7K1aSUGlVuAa74t7XHcNZA/mH81EsCrrGOnTeOgqPF2LKMoEanFo64fGgBzofHZZSC3k/5VGSUIK50IzGRYNTayc8+nlQfKqYwp8LKc8pR22nxqmNEx59PbBztSMnMgdDogFTngm1Vo19fXvc7ndD37HqShKSoOqOJCghhLgFSVB1RwZJCCGEsEmSoIQQQtike3YUn2K1VnbNhRCiOqxGI0aLEZ1GV9dV+Vu6ZxOUSq0moa3cLxZCVF/ALwlIaqo792yCEkLYvnJFIbKoiARjGY4qNX1dXAhwcLhh2eRyIwdLSsgyW/C1t2eIXo9eU7Ek0emyMmIMJeSZLbhrNHR1cqKDgwMqlYqzZWXEGAzkWiw012oZpNfjpFZzxWTiiMHAxfJyLCj42mvp7+JCfTv5WLQV8pMQQtSJX8rKmJWexmWzGa1Wi8lk4t852QzXu/K2jw/aX4d6K4rCBznZrM7JwQrY2dlhNptZlnWVJd7e7CsuZuuv6/jpdDqMxopFZofo9bipNXxTkA//dd4AFxe2FhRg/vV1tVpNeXk5S6+qeMvbh6GurnXRJOK/yCAJIUStMykKsy+no/HxYdeuXRiNRnJzc3nzzTfZUVTIJ7k5lWV3FxcRnpPD5ClTSEtLw2QyERsbS3BICC9cvszWwkLmz59PTk4OZWVl5OXlsXjxYiKKivimIJ/Zs2dz9epVTCYTBw4coEm7dmwsKKBlmzbEx8dTVlaG0Wjk9OnTdH3gAV69ksFlk6kOW0dcIwlKCFHr9hYVkWIy8cEHH9C/f38mTZrEd999x8KFCxkxYgSf5+ZS/usUzQ35+QQGBrJmzRri4uLo1KkTLi4urF+/Hq1Wi6+vL2+99RaJiYm0a9eO+Ph4FixYQOvWrXnggQdYvnw5u3fvplu3brRo0YJ169ahUqlwc3PjwIEDjBgxgjlz5hAYGMgnn3xCuaKwv6S4jltIgCQoIUQdOFFWiouLC2FhYcTGxrJu3TqWLVsGVOy6XWC1kvLroq+pJhPBwcGo1Wr27t3LmRMnOHLkCH5+fvTs2ZPi4mKKi4sxmUwUFRVRXl5OaWkp+fn5dO3aFYCIiAhOxcZy8uRJgoKCCAwM5OjRo8ycOZOIiAjef/99CgsLqV+/PgAlVmvdNIyoQp5BCSFq3RWTGV9fXwCys7OrfG3SpGKh1nSTiSZaLd52dsTHx2O1WpkwYQLZ2dkMHDiwsuyPP/7I888/z8cff1y56OzMmTPJysoiLi4OgKlTp2Jvb09ISEjleadPn6a+RkOOxcL8+fNxdXVl3rx5ANzn5FRLLSFuRXpQQoha56xWV25QqNNVDOR2+HX0XkFBAQAz09MIPneWY6WlxMfHM2XKFLRaLYsWLSItLQ2A/Px8/P39Wb16NTt37qR169Zs2rSJDz74gPbt2xMdHc1zzz2Hp6cnr776KsnJyVVi5CkKK1asYMmSJSxZsoSlS5fiplYToLvxSEJRuyRBCSFqXVOtloyMDNLS0ggKCkKn09GlSxcAjh07BsCkSZN4++23KxPXN998Q8eOHWnTpg3FxcWUlZURGRlJ69atsbOz49ixY1hTUjh27BgajYY2bdoAsHr1atq3b0/Hjh1RqVRkZ2dz5MgRHBwc+Pbbb5k1axbPPPMMixYtQqPRUGC1srtINiy0Bff0YrGMlu3ohbBF6aZyhl64wGOTJ/P5559z6dIlGjRoQEFBAR07diQ7O5tt27YRFhZGvXr1KCgoIDExkcuXL9OsWTOaNGnCrFmz+PDDD3F3dyc+Ph4PDw+io6Pp3bs3xcXFBAUFkZWVRU5ODvHx8fj7+9OwYUMmTZrE119/Td++ffnxxx+vq5uLiwvjtDrmenoS8EtC5euyWGztk2dQQoha19hey/T69Qlfu5bY2FjCwsLIzs7m66+/pujX3suKFSvYuHEjBoMBgDlz5tChQweMRiNbtmzhwoULPOjswtGCAtq3b8/o0aPx8fFh06ZNbNmyhdzcXKDieZS/vz9btmxh48aNpKenAxUJZ/LkydfVzWg04uEoz6BsgfSghBB1QlEUdhUV8XleLmfKynBUq3nQxYUZ9euzu6iI7woKMaPQUqvF086OGIOByyYTGpWKYAdHJrq709/FhQvl5YTnZHP019Ui3DUaujg68aSHB7uKCvmhqIgrZjNalYquTk487u6Bu0bDu1czuWI2X1evtjodb3p5U9/OTnpQdUwSlBCizimKUmWTwFuVA26r7F9xniSouiW3+IQQde52E8efTTB3ep6oWzKKTwghhE26Z3tQitVK4O+650II8WdZjUbUOtlwo67csz0o0w0eft6OrKysP33OpUuXai2WxLONePfytUm830hyqlv3bIISQghxd5MEJYQQwiZJghJCCGGTJEEJIYSwSZKghBBC2CRJUEIIIWySJCghhBA2SRKUEEIImyQJSgghhE2SBCWEEMIm3bPbbcTFxaGTZUqEEH8Ro9FIcHBwXVfjb+WeTVBCCCHubnKLTwghhE2SBCWEEMImSYISQghhkyRBCSGEsEmSoIQQQtgkSVBCCCFs0l2doPbt28fgwYMZOHAgH3300XXHy8vLmT17NgMHDmTcuHGkpaXVaLyff/6Z0aNHExgYSERExB3Fup14n376KcOGDSM0NJTHH3+c9PT0Go23fv16QkNDGTlyJI8++ihJSUk1Gu+a3bt306ZNG+Lj42ss1ubNm+nevTsjR45k5MiRbNiwodqxbicewM6dOxk2bBjDhw/nxRdfrNF477zzTuW1DR48mK5du9ZovMuXLzNp0iRGjRpFaGgoP/30U43FSk9P5/HHHyc0NJRJkyZx5cqVascCePXVVwkJCWHEiBE3PK4oCm+99RYDBw4kNDSU06dP31E8cQvKXcpsNiv9+/dXUlJSFKPRqISGhiqJiYlVyqxbt05ZsGCBoiiK8v333yvPP/98jcZLTU1VEhISlJdfflnZtWtXtWPdbrzDhw8rBoNBURRF+fLLL2v8+oqKiiq/37t3r/Lkk0/WaLxrMR977DFl3LhxysmTJ2ss1qZNm5RFixZV6/2rEy85OVkZOXKkkp+fryiKomRnZ9dovN9bu3atMm/evBqN9/rrrytffvmloiiKkpiYqDz44IM1FuvZZ59VNm/erCiKohw6dEh56aWXqhXrmqNHjyqnTp1Shg8ffsPj0dHRytSpUxWr1aocP35cGTt27B3FEzd31/agTp48SdOmTfHz80Or1TJ8+HAiIyOrlImKimL06NEADB48mMOHD6NUc17y7cTz9fWlbdu2qNV33qy3E6979+44OjoCEBwcfEd/Od5OPBcXl8rvS0tLUalUNRoP4P3332f69Ol3tCrI7cb6q9xOvG+//ZYJEybg5uYGQP369Ws03u/t2LHjpr2DvyqeSqWiuLgYgKKiIjw9PWss1vnz5+nevTtQ8X/iTn+23bp1q/y53EhkZCSjRo1CpVIRHBxMYWEhV69evaOY4sbu2gSVmZmJt7d35b+9vLzIzMy8royPjw8AdnZ26PV68vLyaizeX+nPxtu4cSO9e/eu8XhffvklAwYM4L333uP111+v0XinT5/mypUr9O3bt9pxbjcWwJ49ewgNDeW5554jIyOjRuNdvHiR5ORkxo8fz8MPP8y+fftqNN416enppKWlVX6g11S8WbNmsX37dnr37s3//M//VPt35XZitW3blj179gDwww8/UFJSUu3/59Wpk7e3d41+Fvyd3bUJSvxm27ZtnDp1imlslNdMAAADgUlEQVTTptV4rAkTJrB3715eeuklwsPDayyO1Wrl3Xff5ZVXXqmxGL/34IMPEhUVxfbt2+nRo0eNx7VYLFy6dIkvvviCf/3rXyxYsIDCwsIajQkVvafBgwej0WhqPM7o0aPZt28fH330EXPnzsVqtdZIrLlz5/Lzzz8zatQojh49ipeXV41fn6gdd22C8vLyqnJLKzMzEy8vr+vKXPtL2Gw2U1RUhLu7e43F+yvdbrxDhw7xv//7v4SHh6PVams83jXDhw9n7969NRavpKSEc+fOMXnyZPr160dcXBwzZ86s1kCJ27k2d3f3yvYbN27cHT34vt3fzX79+mFvb4+fnx/NmjXj4sWLNRbvmp07dzJ8+PBqxfkz8TZu3MjQoUMB6NSpE0ajsVq9mtttyw8++ICtW7cyZ84cAFxdXf90rOrW6cqVKzX6WfB3dtcmqKCgIC5evEhqairl5eXs2LGDfv36VSnTr18/tmzZAlSMBOvevXu1n5vcTry/0u3EO3PmDG+88Qbh4eF39AzjduP9/gM0Ojqapk2b1lg8vV7PkSNHiIqKIioqiuDgYMLDwwkKCqqRa/v9M4SoqChatmxZY9cGMGDAAI4ePQpAbm4uFy9exM/Pr8biQcWzmsLCQjp16lStOH8mno+PD4cPH66MazQa8fDwqJFYubm5lb2zjz76iDFjxlTzym5Pv3792Lp1K4qiEBcXh16vr/YzNvEH6nqUxp2Ijo5WBg0apPTv319ZtWqVoiiKsmLFCmXv3r2KoihKWVmZ8uyzzyoDBgxQxowZo6SkpNRovBMnTii9evVSOnbsqNx3333KsGHDajTe448/roSEhChhYWFKWFiY8tRTT9VovCVLlijDhg1TwsLClIkTJyrnzp2r0Xi/N3HixGqP4rudWP/85z+VYcOGKaGhocrEiROVpKSkase6nXhWq1V55513lKFDhyojRoxQvv/++xqNpyiKsnLlSuW99967ozi3Gy8xMVF55JFHlNDQUCUsLEzZv39/jcXatWuXMnDgQGXQoEHKa6+9phiNxju6tjlz5ig9e/ZUAgMDlV69einffvut8tVXXylfffWVoigVP7uFCxcq/fv3V0aMGHFHv5fi1mS7DSGEEDbprr3FJ4QQ4t4mCUoIIYRNkgQlhBDCJkmCEkIIYZMkQQkhhLBJkqCEEELYJElQQgghbNL/B54HaTLIbbgHAAAAAElFTkSuQmCC\n", "text/plain": [ "
" ] }, "metadata": {}, "output_type": "display_data" } ], "source": [ "# Classifiction performance metrics by model\n", "train_metadata_json = load_json('./results/multiple_experiment_Option1/model/training_set_metadata.json')\n", "compare_classifiers_performance_from_pred(\n", " preds_list,\n", " test_df['label'].to_numpy().astype('int'),\n", " train_metadata_json,\n", " 'label',\n", " 10,\n", " model_names=models_list,\n", " output_directory='./viz2',\n", " file_format='png'\n", ")" ] }, { "cell_type": "code", "execution_count": 6, "metadata": {}, "outputs": [ { "data": { "image/png": "iVBORw0KGgoAAAANSUhEUgAAAVcAAAEYCAYAAADoP7WhAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAALEgAACxIB0t1+/AAAADh0RVh0U29mdHdhcmUAbWF0cGxvdGxpYiB2ZXJzaW9uMy4yLjEsIGh0dHA6Ly9tYXRwbG90bGliLm9yZy+j8jraAAAgAElEQVR4nO3deVyU5fr48c+jKKKAisqgZv4Cw0xN+2apiRsGorgnUsdMLLOjJalpWppLLnROWma2kWVanUxcwNTcMCX3XSvTErE0YThfRECRbeb+/cFhvnlUZhhmmGG43q/X83o12z3XEFzecz33c92aUkohhBDCpqo5OgAhhHBFklyFEMIOJLkKIYQdSHIVQgg7kOQqhBB2IMlVCCHsQJKruEmrVq0YOHAg/fr1Izo6mhs3blg91rRp09iyZQsA06dP59y5c3d87sGDBzl27FiZ3yM4OJgrV65YfP9fPfjgg2V6r/fee49PP/20TK8RVZckV3GTWrVqkZCQwMaNG6lRowarVq266fGioiKrxp0/fz4tWrS44+OHDh3i+PHjVo0thDNyc3QAwnl16NCBs2fPcvDgQd599128vb1JSUlh8+bNLFy4kEOHDlFQUMDw4cN54oknUEoxd+5c9u7dS+PGjalRo4ZprBEjRvDKK6/Qtm1bkpKSeOeddzAYDNSvX5/58+ezatUqqlWrxoYNG3j99dfx9/dn1qxZXL58GYDXXnuNhx56iMzMTF5++WX0ej3t27fHkmtgxo0bR1paGvn5+Tz99NNERkaaHluwYAF79+6lYcOGvPPOO/j4+PDHH38wZ84cMjMzqVWrFnPnziUgIMD2P2Dh2pQQf9G+fXullFKFhYXq73//u/rqq6/UgQMHVLt27dQff/yhlFJq1apV6v3331dKKZWfn68GDx6s/vjjD7V161YVFRWlioqKVFpamnrooYfUd999p5RS6qmnnlKnTp1SGRkZqlu3bqaxMjMzlVJKLVmyRC1btswUx6RJk9Thw4eVUkr9+eefKiwsTCml1Ny5c9V7772nlFLq+++/V4GBgSojI+OWz9GzZ0/T/SXvcePGDRUeHq6uXLmilFIqMDBQJSQkKKWUeu+999ScOXOUUko9/fTTKiUlRSml1IkTJ9SIESNuG6MQpZGZq7hJXl4eAwcOBIpnrkOHDuX48eO0bduWZs2aAbB3717Onj3L1q1bAcjJyeH333/n8OHDhIeHU716dXQ6HZ06dbpl/BMnTtChQwfTWPXq1bttHPv27bupRnvt2jWuX7/O4cOHWbp0KQA9evSgbt26Zj/TF198wfbt2wFITU3l999/p379+lSrVo2+ffsCMHDgQF588UWuX7/O8ePHeemll0yvLygoMPseQvw3Sa7iJiU11/9Wu3Zt038rpZgxYwZdu3a96Tm7d++2WRxGo5HVq1fj7u5ernEOHjzIvn37+Oabb/Dw8GDEiBHk5+ff9rmapqGUwtvb+7Y/AyHKQk5oiTILCgri66+/prCwEICUlBRyc3N5+OGH+e677zAYDKSnp3Pw4MFbXtu+fXuOHDnCxYsXAbh69SoAderU4fr16ze9xxdffGG6/csvvwDw8MMP8+233wLFyTwrK6vUWHNycqhbty4eHh4kJydz4sQJ02NGo9E0+/7222956KGH8PT05K677uK7774Div8hOXPmTNl+QEIgyVVYISIighYtWjBkyBD69evHzJkzMRgMhISE0Lx5c/r27cvUqVNp3779La/18fHhjTfeYPz48QwYMICJEycC0LNnT7Zv387AgQM5cuQI06dP56effqJ///707duXr7/+GoAXXniBI0eOEB4ezvbt22nSpEmpsXbr1o2ioiL69OnDokWLboqpdu3anDp1in79+nHgwAFeeOEFAN566y3WrFnDgAEDCA8PZ8eOHbb60YkqRFNKWg4KIYStycxVCCHsQJKrEELYgSRXIYSwA0muQghhB5JchRDCDiS5CiGEHUhyFUIIO5Dk6iRkubEQrkWSqwP9NaFqmsa1a9c4c+YMc+bMYefOnQ6MTAhRXpJcHUjTNADS0tI4efIkY8eOZfPmzWzfvh03N+mpI0RlJn/BDnD58mV0Oh3Vq1fniy++ICkpCV9fX4YNG8Zdd93FDz/8gL+/v6PDFEKUg/QWqEBKKVJTUxk5ciRfffUVtWvXZvPmzbRr1w5fX1/q16/PW2+9xT333MPQoUMdHa4Qohxk5lqBNE3D09OTRo0a4evrC8DQoUOpVq24OnP9+nXS0tLo06ePI8MUQtiA1FwryIULF4DiZtTVq1e/aaO/ki8Pb7/9NgBt2rSp8PiEELYlM1c7U0pRWFjI+PHjefTRRxk7diyZmZnk5OSYthopERQUxH333Wd6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40bq169P3759UUpVmt0IhBCVmCtfRNCnTx86dOjAxx9/bNoe5fPPP6+ouIQQVVxl30PrjjXXpUuX0qhRI55++mlmzJjB/v37TZfAVgZJSUn07t2bkJAQYmNjHR2OEKIMXLrm+thjj/HYY4+Rm5tLYmIiK1as4MqVK8yaNYuQkBCCgoIqMs4yMRgMvPHGGyxfvhydTsfQoUMJDg6mRYsWjg5NCGEJFygLmF0tULt2bfr3789HH33E7t27uf/++/nkk08qIjarnTp1iubNm9OsWTNq1qxJeHg4iYmJjg5LCFEWNrz81WAwMGjQIJ5//nkALl68SEREBCEhIUyYMIGCggIACgoKmDBhAiEhIURERHDp0iWrwy/TxlJ169YlMjLS6Xu56vV6/Pz8TLd1Op20SRSiktGUmaMMY61cuZKAgADT7YULFxIVFcX27dvx9vZmzZo1AMTFxeHt7c327duJiopi4cKFVscvu/YJIZyPucRahplrWloau3btYujQocVDK8WBAwdMm68OHjzY9M12586dDB48GCjenLU855pcMrnqdDrS0tJMt/V6vWxQKERlY6OywIIFC5gyZYppB+jMzEy8vb1xcys+5eTn52f6ZqvX62ncuDEAbm5ueHl5kZmZaVX4Lplc27Zty4ULF7h48SIFBQVs2rSJ4OBgR4clhLCUhS0Hk5OTGTJkiOn45ptvbhrm+++/x8fHhzZt2lRs/FixtXZl4ObmxsyZMxk9ejQGg4HHH3+ce++919FhCSEsZFFXLAUBAQGsW7fujs85duwYO3fuJCkpifz8fK5du8b8+fPJzs6mqKgINzc30tLSTN9sdTodqamp+Pn5UVRURE5ODvXr17fqM7hkcgXo3r073bt3d3QYQggr2WIngpdffpmXX34ZgIMHD/LZZ5+xaNEioqOj2bp1K+Hh4axfv970zTY4OJj169fz4IMPsnXrVjp16mRqt1pWLlkWEEJUcnbeiWDKlCksX76ckJAQrl69SkREBABDhw7l6tWrhISEsHz5ciZPnmz1e7jszFUIUXnZY/fXjh070rFjRwCaNWtmWn71V+7u7ixZsqRsA9+BJFchhPNRgLnLW538Ci1JrkIIp+TyOxEI4YpW3ne3zcfs87N16yHN+a51PbuM69RcoLeAJFchhNMpXopVevYsa821oklyFUI4JVuf0KpoklyFEM5HygJCCGF79liKVdEkuQohnI+Fl786M0muQgjnI2UBIYSwPUvKAs6eXF22t0B2djbR0dGEhYXRp08fjh8/7uiQhBAWU6AsOJyYy85c58+fT9euXVmyZAkFBQXk5eU5OiQhhKVcoObqkjPXnJwcDh8+bNrWoWbNmnh7ezs4KiGExVxga22XTK6XLl3Cx8eHV199lUGDBjF9+nRyc3MdHZYQwlJ2bjlYEVwyuRYVFXH69GmefPJJ4uPj8fDwIDY21tFhCSEsVHL5a6mHk9dcXTK5+vn54efnR7t27QAICwvj9OnTDo5KCFEWZrfWdu7c6prJtVGjRvj5+XH+/HkA9u/ff9Oe5UIIJ+cCZQGXXS3w+uuvM3nyZAoLC2nWrBkxMTGODkkIYSFXWOfqssm1VatWpe4KKYRwYkpZ0HLQubOryyZXIUQlJzNXIYSwLUtOWDn7CS1JrkII56MAM2UBs487mCRXIYTzcYHLXyW5CiGckAWNWSS5CqelabYf0x5ncO0Rpx3Ya5fWAaczbD7mhvsb2HxMW5KaqxBC2IMFu79Ky0EhhLCGua5X0hVLCCHKprgsoMwe5qSmpjJixAj69u1LeHg4K1asAODq1auMGjWK0NBQRo0aRVZWFgBKKebNm0dISAj9+/fn559/tvozSHIVQjgnG/QVqF69OtOmTWPz5s188803/Otf/+LcuXPExsbSuXNntm3bRufOnU1d85KSkrhw4QLbtm1j7ty5zJ492+rwJbkKIZyPMtNu0Gj+8lgAX19fWrduDYCnpyf+/v7o9XoSExMZNGgQAIMGDWLHjh0Apvs1TaN9+/ZkZ2eTnp5u1UeQmqsQwvkoLFiKpUhOTmbixImmuyIjI4mMjLzt0y9dusQvv/xCu3btyMjIwNfXFyjuopeRUbwiQ6/X4+fnZ3qNn58fer3e9NyycNnk+vnnnxMXF4emaQQGBhITE4O7u7ujwxJCWMLCiwgCAgIsatB0/fp1oqOjee211/D09Lx5HE1Ds8NyP5csC+j1elauXMnatWvZuHEjBoOBTZs2OTosIYTFLNn91bKRCgsLiY6Opn///oSGhgLQoEED09f99PR0fHx8ANDpdKSlpZlem5aWhk6ns+oTuGRyBTAYDOTl5VFUVEReXp5V03ohhGNYtM2LBTVXpRTTp0/H39+fUaNGme4PDg4mPj4egPj4eHr16nXT/UopTpw4gZeXl9W5wyXLAjqdjmeeeYaePXvi7u5Oly5dCAoKcnRYQgiLWXL5q/nkevToURISEggMDGTgwIEATJo0iTFjxjBhwgTWrFlDkyZNWLx4MQDdu3dn9+7dhISE4OHhwYIFC6z+BC6ZXLOyskhMTCQxMREvLy9eeuklEhISTD9cIYSTU5i/SMCCskCHDh04e/bsbR8rWfP6V5qmMWvWLPMDW8AlywL79u3jrrvuwsfHhxo1ahAaGsrx48cdHZYQwlJKoRmNpR4YnfsSLZdMrk2aNOHkyZPcuHEDpZRsUChEZVOyFMsGJ7QcxSXLAu3ataN3794MHjwYNzc3WrVqdce1b0IIJ2SjsoAjuWRyBYiOjiY6OtrRYQghrKBhvneAbFAohBBlpTBfU1XOXXOV5CqEcEKyE4EQQtieJTVX5564SnIVQjghZb6mKjVXIYQoK6XAYGZq6uTrXCW5VmVO/i+/iWaH5dhGg+3HtBN7bCY4/Mwlm47nc73QpuOZ1rKae44Tk+QqhHBOckJLCCFsTMoCQghhB0qZX8cq61yFEMIKUhYQQggbUwrMNcOWE1pCCFFGSpmvqTp5zdUlWw6WMBgMDBo0iOeff97RoQghysKiloPOPXN16eS6cuVK6eMqRGVUMnMt7ZDk6hhpaWns2rWLoUOHOjoUIUSZWbL7q3MnV5etuS5YsIApU6Zw/fp1R4cihCgrF1jn6pIz1++//x4fHx/atGnj6FCEENZQoJTRzCEz1wp37Ngxdu7cSVJSEvn5+Vy7do3JkyezcOFCR4cmhLCEJUuxzD3uYC6ZXF9++WVefvllAA4ePMhnn30miVWIykQpMJhpruPkZQGXTK5CiEpOumI5v44dO9KxY0dHhyGEKAOlFMrMzFRJbwEhhLCC9BYQQggbs6Tmau5xB3PJpVhCiEpOKZSx9MPSmmtSUhK9e/cmJCSE2NhYOwf+fyS5CiGcT0k/11IP88nVYDDwxhtvsGzZMjZt2sTGjRs5d+5cBXwAKQsIIZyMpmnc2/H/4V7HvdTnefrURtO0Up9z6tQpmjdvTrNmzQAIDw8nMTGRFi1a2CzeO6nSybV527tY8vMbjg5DiIpn43JlvpZvs7E8PT0JHtAd1d/8zHTz5s1MmDDBdDsyMpLIyEjTbb1ej5+fn+m2Tqfj1KlTNou1NFU6ubZv397RIQgh/oumadSuXdui50ZERBAREWHniKwjNVchhMvS6XSkpaWZbuv1enQ6XYW8tyRXIYTLatu2LRcuXODixYsUFBSwadMmgoODK+S9q3RZQAjh2tzc3Jg5cyajR4/GYDDw+OOPc++991bIe2vK2ft2CSFEJSRlASGEsANJrkIIYQeSXIUQwg4kuQohhB1IchVCCDuQ5CqEEHYgyVUIIezg/wOdcPs8eDN9egAAAABJRU5ErkJggg==\n", "text/plain": [ "
" ] }, "metadata": {}, "output_type": "display_data" }, { "data": { "image/png": "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\n", "text/plain": [ "
" ] }, "metadata": {}, "output_type": "display_data" }, { "data": { "image/png": "iVBORw0KGgoAAAANSUhEUgAAAVcAAAEYCAYAAADoP7WhAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAALEgAACxIB0t1+/AAAADh0RVh0U29mdHdhcmUAbWF0cGxvdGxpYiB2ZXJzaW9uMy4yLjEsIGh0dHA6Ly9tYXRwbG90bGliLm9yZy+j8jraAAAgAElEQVR4nO3deVyU5fr48c+jKKKAisqgZv4Cw0xN+2apiRsGorgnUsdMLLOjJalpWppLLnROWma2kWVanUxcwNTcMCX3XSvTErE0YThfRECRbeb+/cFhvnlUZhhmmGG43q/X83o12z3XEFzecz33c92aUkohhBDCpqo5OgAhhHBFklyFEMIOJLkKIYQdSHIVQgg7kOQqhBB2IMlVCCHsQJKruEmrVq0YOHAg/fr1Izo6mhs3blg91rRp09iyZQsA06dP59y5c3d87sGDBzl27FiZ3yM4OJgrV65YfP9fPfjgg2V6r/fee49PP/20TK8RVZckV3GTWrVqkZCQwMaNG6lRowarVq266fGioiKrxp0/fz4tWrS44+OHDh3i+PHjVo0thDNyc3QAwnl16NCBs2fPcvDgQd599128vb1JSUlh8+bNLFy4kEOHDlFQUMDw4cN54oknUEoxd+5c9u7dS+PGjalRo4ZprBEjRvDKK6/Qtm1bkpKSeOeddzAYDNSvX5/58+ezatUqqlWrxoYNG3j99dfx9/dn1qxZXL58GYDXXnuNhx56iMzMTF5++WX0ej3t27fHkmtgxo0bR1paGvn5+Tz99NNERkaaHluwYAF79+6lYcOGvPPOO/j4+PDHH38wZ84cMjMzqVWrFnPnziUgIMD2P2Dh2pQQf9G+fXullFKFhYXq73//u/rqq6/UgQMHVLt27dQff/yhlFJq1apV6v3331dKKZWfn68GDx6s/vjjD7V161YVFRWlioqKVFpamnrooYfUd999p5RS6qmnnlKnTp1SGRkZqlu3bqaxMjMzlVJKLVmyRC1btswUx6RJk9Thw4eVUkr9+eefKiwsTCml1Ny5c9V7772nlFLq+++/V4GBgSojI+OWz9GzZ0/T/SXvcePGDRUeHq6uXLmilFIqMDBQJSQkKKWUeu+999ScOXOUUko9/fTTKiUlRSml1IkTJ9SIESNuG6MQpZGZq7hJXl4eAwcOBIpnrkOHDuX48eO0bduWZs2aAbB3717Onj3L1q1bAcjJyeH333/n8OHDhIeHU716dXQ6HZ06dbpl/BMnTtChQwfTWPXq1bttHPv27bupRnvt2jWuX7/O4cOHWbp0KQA9evSgbt26Zj/TF198wfbt2wFITU3l999/p379+lSrVo2+ffsCMHDgQF588UWuX7/O8ePHeemll0yvLygoMPseQvw3Sa7iJiU11/9Wu3Zt038rpZgxYwZdu3a96Tm7d++2WRxGo5HVq1fj7u5ernEOHjzIvn37+Oabb/Dw8GDEiBHk5+ff9rmapqGUwtvb+7Y/AyHKQk5oiTILCgri66+/prCwEICUlBRyc3N5+OGH+e677zAYDKSnp3Pw4MFbXtu+fXuOHDnCxYsXAbh69SoAderU4fr16ze9xxdffGG6/csvvwDw8MMP8+233wLFyTwrK6vUWHNycqhbty4eHh4kJydz4sQJ02NGo9E0+/7222956KGH8PT05K677uK7774Div8hOXPmTNl+QEIgyVVYISIighYtWjBkyBD69evHzJkzMRgMhISE0Lx5c/r27cvUqVNp3779La/18fHhjTfeYPz48QwYMICJEycC0LNnT7Zv387AgQM5cuQI06dP56effqJ///707duXr7/+GoAXXniBI0eOEB4ezvbt22nSpEmpsXbr1o2ioiL69OnDokWLboqpdu3anDp1in79+nHgwAFeeOEFAN566y3WrFnDgAEDCA8PZ8eOHbb60YkqRFNKWg4KIYStycxVCCHsQJKrEELYgSRXIYSwA0muQghhB5JchRDCDiS5CiGEHUhyFUIIO5Dk6iRkubEQrkWSqwP9NaFqmsa1a9c4c+YMc+bMYefOnQ6MTAhRXpJcHUjTNADS0tI4efIkY8eOZfPmzWzfvh03N+mpI0RlJn/BDnD58mV0Oh3Vq1fniy++ICkpCV9fX4YNG8Zdd93FDz/8gL+/v6PDFEKUg/QWqEBKKVJTUxk5ciRfffUVtWvXZvPmzbRr1w5fX1/q16/PW2+9xT333MPQoUMdHa4Qohxk5lqBNE3D09OTRo0a4evrC8DQoUOpVq24OnP9+nXS0tLo06ePI8MUQtiA1FwryIULF4DiZtTVq1e/aaO/ki8Pb7/9NgBt2rSp8PiEELYlM1c7U0pRWFjI+PHjefTRRxk7diyZmZnk5OSYthopERQUxH333Wd6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40bq169P3759UUpVmt0IhBCVmCtfRNCnTx86dOjAxx9/bNoe5fPPP6+ouIQQVVxl30PrjjXXpUuX0qhRI55++mlmzJjB/v37TZfAVgZJSUn07t2bkJAQYmNjHR2OEKIMXLrm+thjj/HYY4+Rm5tLYmIiK1as4MqVK8yaNYuQkBCCgoIqMs4yMRgMvPHGGyxfvhydTsfQoUMJDg6mRYsWjg5NCGEJFygLmF0tULt2bfr3789HH33E7t27uf/++/nkk08qIjarnTp1iubNm9OsWTNq1qxJeHg4iYmJjg5LCFEWNrz81WAwMGjQIJ5//nkALl68SEREBCEhIUyYMIGCggIACgoKmDBhAiEhIURERHDp0iWrwy/TxlJ169YlMjLS6Xu56vV6/Pz8TLd1Op20SRSiktGUmaMMY61cuZKAgADT7YULFxIVFcX27dvx9vZmzZo1AMTFxeHt7c327duJiopi4cKFVscvu/YJIZyPucRahplrWloau3btYujQocVDK8WBAwdMm68OHjzY9M12586dDB48GCjenLU855pcMrnqdDrS0tJMt/V6vWxQKERlY6OywIIFC5gyZYppB+jMzEy8vb1xcys+5eTn52f6ZqvX62ncuDEAbm5ueHl5kZmZaVX4Lplc27Zty4ULF7h48SIFBQVs2rSJ4OBgR4clhLCUhS0Hk5OTGTJkiOn45ptvbhrm+++/x8fHhzZt2lRs/FixtXZl4ObmxsyZMxk9ejQGg4HHH3+ce++919FhCSEsZFFXLAUBAQGsW7fujs85duwYO3fuJCkpifz8fK5du8b8+fPJzs6mqKgINzc30tLSTN9sdTodqamp+Pn5UVRURE5ODvXr17fqM7hkcgXo3r073bt3d3QYQggr2WIngpdffpmXX34ZgIMHD/LZZ5+xaNEioqOj2bp1K+Hh4axfv970zTY4OJj169fz4IMPsnXrVjp16mRqt1pWLlkWEEJUcnbeiWDKlCksX76ckJAQrl69SkREBABDhw7l6tWrhISEsHz5ciZPnmz1e7jszFUIUXnZY/fXjh070rFjRwCaNWtmWn71V+7u7ixZsqRsA9+BJFchhPNRgLnLW538Ci1JrkIIp+TyOxEI4YpW3ne3zcfs87N16yHN+a51PbuM69RcoLeAJFchhNMpXopVevYsa821oklyFUI4JVuf0KpoklyFEM5H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\n", "text/plain": [ "
" ] }, "metadata": {}, "output_type": "display_data" }, { "data": { "image/png": "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\n", "text/plain": [ "
" ] }, "metadata": {}, "output_type": "display_data" }, { "data": { "image/png": "iVBORw0KGgoAAAANSUhEUgAAAVcAAAEYCAYAAADoP7WhAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAALEgAACxIB0t1+/AAAADh0RVh0U29mdHdhcmUAbWF0cGxvdGxpYiB2ZXJzaW9uMy4yLjEsIGh0dHA6Ly9tYXRwbG90bGliLm9yZy+j8jraAAAgAElEQVR4nO3deVyU5fr48c+jKKKAisqgZv4Cw0xN+2apiRsGorgnUsdMLLOjJalpWppLLnROWma2kWVanUxcwNTcMCX3XSvTErE0YThfRECRbeb+/cFhvnlUZhhmmGG43q/X83o12z3XEFzecz33c92aUkohhBDCpqo5OgAhhHBFklyFEMIOJLkKIYQdSHIVQgg7kOQqhBB2IMlVCCHsQJKruEmrVq0YOHAg/fr1Izo6mhs3blg91rRp09iyZQsA06dP59y5c3d87sGDBzl27FiZ3yM4OJgrV65YfP9fPfjgg2V6r/fee49PP/20TK8RVZckV3GTWrVqkZCQwMaNG6lRowarVq266fGioiKrxp0/fz4tWrS44+OHDh3i+PHjVo0thDNyc3QAwnl16NCBs2fPcvDgQd599128vb1JSUlh8+bNLFy4kEOHDlFQUMDw4cN54oknUEoxd+5c9u7dS+PGjalRo4ZprBEjRvDKK6/Qtm1bkpKSeOeddzAYDNSvX5/58+ezatUqqlWrxoYNG3j99dfx9/dn1qxZXL58GYDXXnuNhx56iMzMTF5++WX0ej3t27fHkmtgxo0bR1paGvn5+Tz99NNERkaaHluwYAF79+6lYcOGvPPOO/j4+PDHH38wZ84cMjMzqVWrFnPnziUgIMD2P2Dh2pQQf9G+fXullFKFhYXq73//u/rqq6/UgQMHVLt27dQff/yhlFJq1apV6v3331dKKZWfn68GDx6s/vjjD7V161YVFRWlioqKVFpamnrooYfUd999p5RS6qmnnlKnTp1SGRkZqlu3bqaxMjMzlVJKLVmyRC1btswUx6RJk9Thw4eVUkr9+eefKiwsTCml1Ny5c9V7772nlFLq+++/V4GBgSojI+OWz9GzZ0/T/SXvcePGDRUeHq6uXLmilFIqMDBQJSQkKKWUeu+999ScOXOUUko9/fTTKiUlRSml1IkTJ9SIESNuG6MQpZGZq7hJXl4eAwcOBIpnrkOHDuX48eO0bduWZs2aAbB3717Onj3L1q1bAcjJyeH333/n8OHDhIeHU716dXQ6HZ06dbpl/BMnTtChQwfTWPXq1bttHPv27bupRnvt2jWuX7/O4cOHWbp0KQA9evSgbt26Zj/TF198wfbt2wFITU3l999/p379+lSrVo2+ffsCMHDgQF588UWuX7/O8ePHeemll0yvLygoMPseQvw3Sa7iJiU11/9Wu3Zt038rpZgxYwZdu3a96Tm7d++2WRxGo5HVq1fj7u5ernEOHjzIvn37+Oabb/Dw8GDEiBHk5+ff9rmapqGUwtvb+7Y/AyHKQk5oiTILCgri66+/prCwEICUlBRyc3N5+OGH+e677zAYDKSnp3Pw4MFbXtu+fXuOHDnCxYsXAbh69SoAderU4fr16ze9xxdffGG6/csvvwDw8MMP8+233wLFyTwrK6vUWHNycqhbty4eHh4kJydz4sQJ02NGo9E0+/7222956KGH8PT05K677uK7774Div8hOXPmTNl+QEIgyVVYISIighYtWjBkyBD69evHzJkzMRgMhISE0Lx5c/r27cvUqVNp3779La/18fHhjTfeYPz48QwYMICJEycC0LNnT7Zv387AgQM5cuQI06dP56effqJ///707duXr7/+GoAXXniBI0eOEB4ezvbt22nSpEmpsXbr1o2ioiL69OnDokWLboqpdu3anDp1in79+nHgwAFeeOEFAN566y3WrFnDgAEDCA8PZ8eOHbb60YkqRFNKWg4KIYStycxVCCHsQJKrEELYgSRXIYSwA0muQghhB5JchRDCDiS5CiGEHUhyFUIIO5Dk6iRkubEQrkWSqwP9NaFqmsa1a9c4c+YMc+bMYefOnQ6MTAhRXpJcHUjTNADS0tI4efIkY8eOZfPmzWzfvh03N+mpI0RlJn/BDnD58mV0Oh3Vq1fniy++ICkpCV9fX4YNG8Zdd93FDz/8gL+/v6PDFEKUg/QWqEBKKVJTUxk5ciRfffUVtWvXZvPmzbRr1w5fX1/q16/PW2+9xT333MPQoUMdHa4Qohxk5lqBNE3D09OTRo0a4evrC8DQoUOpVq24OnP9+nXS0tLo06ePI8MUQtiA1FwryIULF4DiZtTVq1e/aaO/ki8Pb7/9NgBt2rSp8PiEELYlM1c7U0pRWFjI+PHjefTRRxk7diyZmZnk5OSYthopERQUxH333Wd6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40bq169P3759UUpVmt0IhBCVmCtfRNCnTx86dOjAxx9/bNoe5fPPP6+ouIQQVVxl30PrjjXXpUuX0qhRI55++mlmzJjB/v37TZfAVgZJSUn07t2bkJAQYmNjHR2OEKIMXLrm+thjj/HYY4+Rm5tLYmIiK1as4MqVK8yaNYuQkBCCgoIqMs4yMRgMvPHGGyxfvhydTsfQoUMJDg6mRYsWjg5NCGEJFygLmF0tULt2bfr3789HH33E7t27uf/++/nkk08qIjarnTp1iubNm9OsWTNq1qxJeHg4iYmJjg5LCFEWNrz81WAwMGjQIJ5//nkALl68SEREBCEhIUyYMIGCggIACgoKmDBhAiEhIURERHDp0iWrwy/TxlJ169YlMjLS6Xu56vV6/Pz8TLd1Op20SRSiktGUmaMMY61cuZKAgADT7YULFxIVFcX27dvx9vZmzZo1AMTFxeHt7c327duJiopi4cKFVscvu/YJIZyPucRahplrWloau3btYujQocVDK8WBAwdMm68OHjzY9M12586dDB48GCjenLU855pcMrnqdDrS0tJMt/V6vWxQKERlY6OywIIFC5gyZYppB+jMzEy8vb1xcys+5eTn52f6ZqvX62ncuDEAbm5ueHl5kZmZaVX4Lplc27Zty4ULF7h48SIFBQVs2rSJ4OBgR4clhLCUhS0Hk5OTGTJkiOn45ptvbhrm+++/x8fHhzZt2lRs/FixtXZl4ObmxsyZMxk9ejQGg4HHH3+ce++919FhCSEsZFFXLAUBAQGsW7fujs85duwYO3fuJCkpifz8fK5du8b8+fPJzs6mqKgINzc30tLSTN9sdTodqamp+Pn5UVRURE5ODvXr17fqM7hkcgXo3r073bt3d3QYQggr2WIngpdffpmXX34ZgIMHD/LZZ5+xaNEioqOj2bp1K+Hh4axfv970zTY4OJj169fz4IMPsnXrVjp16mRqt1pWLlkWEEJUcnbeiWDKlCksX76ckJAQrl69SkREBABDhw7l6tWrhISEsHz5ciZPnmz1e7jszFUIUXnZY/fXjh070rFjRwCaNWtmWn71V+7u7ixZsqRsA9+BJFchhPNRgLnLW538Ci1JrkIIp+TyOxEI4YpW3ne3zcfs87N16yHN+a51PbuM69RcoLeAJFchhNMpXopVevYsa821oklyFUI4JVuf0KpoklyFEM5H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\n", "text/plain": [ "
" ] }, "metadata": {}, "output_type": "display_data" }, { "data": { "image/png": "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\n", "text/plain": [ "
" ] }, "metadata": {}, "output_type": "display_data" }, { "data": { "image/png": "iVBORw0KGgoAAAANSUhEUgAAAVcAAAEYCAYAAADoP7WhAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAALEgAACxIB0t1+/AAAADh0RVh0U29mdHdhcmUAbWF0cGxvdGxpYiB2ZXJzaW9uMy4yLjEsIGh0dHA6Ly9tYXRwbG90bGliLm9yZy+j8jraAAAgAElEQVR4nO3deVzU5dr48c8XUUQBFZVBzfwFhpWa9mS54YaBKO6J1DETy+yoSWqaluaSC50n9ZjZRnZMq5OJG6Xmhim571qZloilCUMPIqDINnP//uBhnjwqMwwzzDBc79fr+3qdmfnOPdd49Oqe63t/r1tTSimEEELYlJujAxBCCFckyVUIIexAkqsQQtiBJFchhLADSa5CCGEHklyFEMIOJLmKWzz44IMMGDCAvn37EhMTw82bN60ea9q0aWzduhWA6dOnc/78+buee+jQIY4fP17mzwgJCeHq1asWP/9XjzzySJk+69133+WTTz4p03tE1SXJVdyiZs2aJCQksGnTJqpXr87q1atveb2oqMiqcefPn0/z5s3v+vrhw4c5ceKEVWML4YzcHR2AcF7t2rXj3LlzHDp0iHfeeQcfHx9SUlLYsmULCxcu5PDhwxQUFDBs2DCeeuoplFLMnTuXffv20ahRI6pXr24aa/jw4bz66qu0bt2apKQk/vnPf2IwGKhXrx7z589n9erVuLm58fXXX/PGG28QEBDArFmzuHLlCgCvv/46jz76KJmZmbzyyivo9Xratm2LJffAjB07lrS0NPLz83n22WeJiooyvbZgwQL27dtHgwYN+Oc//4mvry+///47c+bMITMzk5o1azJ37lwCAwNt/wcsXJsS4i/atm2rlFKqsLBQ/f3vf1dffPGFOnjwoGrTpo36/ffflVJKrV69Wr333ntKKaXy8/PVoEGD1O+//662bdumoqOjVVFRkUpLS1OPPvqo+vbbb5VSSj3zzDPq9OnTKiMjQ3Xt2tU0VmZmplJKqaVLl6rly5eb4pg0aZI6cuSIUkqpP/74Q4WHhyullJo7d6569913lVJKfffddyooKEhlZGTc9j169Ohher7kM27evKkiIiLU1atXlVJKBQUFqYSEBKWUUu+++66aM2eOUkqpZ599VqWkpCillDp58qQaPnz4HWMUojQycxW3yMvLY8CAAUDxzHXIkCGc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\n", "text/plain": [ "
" ] }, "metadata": {}, "output_type": "display_data" }, { "data": { "image/png": "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\n", "text/plain": [ "
" ] }, "metadata": {}, "output_type": "display_data" }, { "data": { "image/png": "iVBORw0KGgoAAAANSUhEUgAAAVcAAAEYCAYAAADoP7WhAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAALEgAACxIB0t1+/AAAADh0RVh0U29mdHdhcmUAbWF0cGxvdGxpYiB2ZXJzaW9uMy4yLjEsIGh0dHA6Ly9tYXRwbG90bGliLm9yZy+j8jraAAAgAElEQVR4nO3deVzU5dr48c8XUUQBFZVBzfwFhpWa9mS54YaBKO6J1DETy+yoSWqaluaSC50n9ZjZRnZMq5OJG6Xmhim571qZloilCUMPIqDINnP//uBhnjwqMwwzzDBc79fr+3qdmfnOPdd49Oqe63t/r1tTSimEEELYlJujAxBCCFckyVUIIexAkqsQQtiBJFchhLADSa5CCGEHklyFEMIOJLmKWzz44IMMGDCAvn37EhMTw82bN60ea9q0aWzduhWA6dOnc/78+buee+jQIY4fP17mzwgJCeHq1asWP/9XjzzySJk+69133+WTTz4p03tE1SXJVdyiZs2aJCQksGnTJqpXr87q1atveb2oqMiqcefPn0/z5s3v+vrhw4c5ceKEVWML4YzcHR2AcF7t2rXj3LlzHDp0iHfeeQcfHx9SUlLYsmULCxcu5PDhwxQUFDBs2DCeeuoplFLMnTuXffv20ahRI6pXr24aa/jw4bz66qu0bt2apKQk/vnPf2IwGKhXrx7z589n9erVuLm58fXXX/PGG28QEBDArFmzuHLlCgCvv/46jz76KJmZmbzyyivo9Xratm2LJffAjB07lrS0NPLz83n22WeJiooyvbZgwQL27dtHgwYN+Oc//4mvry+///47c+bMITMzk5o1azJ37lwCAwNt/wcsXJsS4i/atm2rlFKqsLBQ/f3vf1dffPGFOnjwoGrTpo36/ffflVJKrV69Wr333ntKKaXy8/PVoEGD1O+//662bdumoqOjVVFRkUpLS1OPPvqo+vbbb5VSSj3zzDPq9OnTKiMjQ3Xt2tU0VmZmplJKqaVLl6rly5eb4pg0aZI6cuSIUkqpP/74Q4WHhyullJo7d6569913lVJKfffddyooKEhlZGTc9j169Ohher7kM27evKkiIiLU1atXlVJKBQUFqYSEBKWUUu+++66aM2eOUkqpZ599VqWkpCillDp58qQaPnz4HWMUojQycxW3yMvLY8CAAUDxzHXIkCGc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\n", "text/plain": [ "
" ] }, "metadata": {}, "output_type": "display_data" }, { "data": { "image/png": "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\n", "text/plain": [ "
" ] }, "metadata": {}, "output_type": "display_data" }, { "data": { "image/png": "iVBORw0KGgoAAAANSUhEUgAAAVcAAAEYCAYAAADoP7WhAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAALEgAACxIB0t1+/AAAADh0RVh0U29mdHdhcmUAbWF0cGxvdGxpYiB2ZXJzaW9uMy4yLjEsIGh0dHA6Ly9tYXRwbG90bGliLm9yZy+j8jraAAAgAElEQVR4nO3deVzU5dr48c8XUUQBFZVBzfwFhpWa9mS54YaBKO6J1DETy+yoSWqaluaSC50n9ZjZRnZMq5OJG6Xmhim571qZloilCUMPIqDINnP//uBhnjwqMwwzzDBc79fr+3qdmfnOPdd49Oqe63t/r1tTSimEEELYlJujAxBCCFckyVUIIexAkqsQQtiBJFchhLADSa5CCGEHklyFEMIOJLmKWzz44IMMGDCAvn37EhMTw82bN60ea9q0aWzduhWA6dOnc/78+buee+jQIY4fP17mzwgJCeHq1asWP/9XjzzySJk+69133+WTTz4p03tE1SXJVdyiZs2aJCQksGnTJqpXr87q1atveb2oqMiqcefPn0/z5s3v+vrhw4c5ceKEVWML4YzcHR2AcF7t2rXj3LlzHDp0iHfeeQcfHx9SUlLYsmULCxcu5PDhwxQUFDBs2DCeeuoplFLMnTuXffv20ahRI6pXr24aa/jw4bz66qu0bt2apKQk/vnPf2IwGKhXrx7z589n9erVuLm58fXXX/PGG28QEBDArFmzuHLlCgCvv/46jz76KJmZmbzyyivo9Xratm2LJffAjB07lrS0NPLz83n22WeJiooyvbZgwQL27dtHgwYN+Oc//4mvry+///47c+bMITMzk5o1azJ37lwCAwNt/wcsXJsS4i/atm2rlFKqsLBQ/f3vf1dffPGFOnjwoGrTpo36/ffflVJKrV69Wr333ntKKaXy8/PVoEGD1O+//662bdumoqOjVVFRkUpLS1OPPvqo+vbbb5VSSj3zzDPq9OnTKiMjQ3Xt2tU0VmZmplJKqaVLl6rly5eb4pg0aZI6cuSIUkqpP/74Q4WHhyullJo7d6569913lVJKfffddyooKEhlZGTc9j169Ohher7kM27evKkiIiLU1atXlVJKBQUFqYSEBKWUUu+++66aM2eOUkqpZ599VqWkpCillDp58qQaPnz4HWMUojQycxW3yMvLY8CAAUDxzHXIkCGc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\n", "text/plain": [ "
" ] }, "metadata": {}, "output_type": "display_data" }, { "data": { "image/png": "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\n", "text/plain": [ "
" ] }, "metadata": {}, "output_type": "display_data" }, { "data": { "image/png": "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unUr95h79+5l9uzZJCcns2PHDjp27FjuC3Pffvstr7/+OsePH+fgwYO0aNGCevXqlWvMXbt2MWfOHJKSkjh16hSXL1+mefPm5boDbseOHcycOZMffviBw4cPo9frCQwMpEaNGlaPeeTIERYsWEBUVBQtW7a0epw/S0hIICYmhvz8fI4ePUrr1q2pW7eu1eMZjUZyc3MZP348x48fx83NjTZt2qBpGkajUbq2maNEmRgMBqWUUu+//77atWvXLa+dP39ebdmyRa1atUpdunTJ7Fi7d+9WDz/8sJo8ebLpuaKionLFt3//fvXEE0+on3/+WRUUFKhRo0apI0eOlGvMQ4cOqdDQUHX69GmllFLjxo1T+/fvL9eYx48fV2FhYerHH39USik1e/ZsNX369HKNefXqVfX888+rX375RSmlVFxcnBoyZIhavny5ysnJsWrMgoIC9dJLL6ljx44ppZTatm2bevPNN9WSJUusHlMppf71r3+pFStWKKWUSktLU/v27VOnTp1S2dnZVo139epV9dxzz6nz588rpZSaPn262rp1q/rf//1flZeXZ3WcSikVGxurPv74YzV16lS1cuXKco1VlUhZoIzc3NxISUlh//79t3Sw+vXXX2nRogV9+/bl2WefpVmzZqWOk5uby2effcZrr71G9erVmTJlCgDVqlUr19dOg8HA3//+d+6//35u3rzJfffdxy+//AJYXy9r0KABc+fO5eGHH+aPP/7g9OnTfPbZZ8yaNYtt27ZZPe4LL7zAQw89BEB0dDRZWVnl+irr7u5Obm4uf/zxB1C8y0OTJk3IzMxkz549Vo97/fp1fv31VwBCQkLo2bMnhYWFfP3111Z/9j+3lXzppZdYv349n332GXPnziUrK6vM47m7u5OXl8fFixe5fv06R44cIT4+noULF/Lee++Vq7br7u5OamoqgwcP5syZM8TExLB48WKUUhiNRqvHdXVSFigDpRRFRUUsXbqU4OBgunTpQlJSEjNmzODixYvcf//9eHl5WfR1qXr16nTs2JHWrVvTsWNHEhISSEhIIDQ0tFylgWbNmtGoUSOMRiM1a9ZE0zRiYmIICgqiQYMGVo3p6+vLPffcA8Dq1atp06YNc+bMITMzk927d/P444/j6elZpjH9/Pxo1qwZNWrUwGAwkJOTwxdffEGfPn3w9PQkMzOzzGN6eHhQ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\n", "text/plain": [ "
" ] }, "metadata": {}, "output_type": "display_data" }, { "data": { "image/png": "iVBORw0KGgoAAAANSUhEUgAAAagAAAEYCAYAAAAJeGK1AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAALEgAACxIB0t1+/AAAADh0RVh0U29mdHdhcmUAbWF0cGxvdGxpYiB2ZXJzaW9uMy4yLjEsIGh0dHA6Ly9tYXRwbG90bGliLm9yZy+j8jraAAAgAElEQVR4nOzdeVwVVf/A8c9duayyiqC5K+6FgqC44lZC7rtZueeeW9Jj9Txa+pSWpJWaaaI9mmWlZppaLuC+4ZbiLiigsst2uev8/uDnTQTTUrkXPO/Xy5femTMz3zlc+c6cOXOOTJIkCUEQBEGwMXJrByAIgiAIJREJShAEQbBJIkEJgiAINkkkKEEQBMEmiQQlCIIg2CSRoARBEASbJBJUKfnss8+YNm2atcMoVYmJifj5+WE0Gh97X6GhoRw4cKDEdREREURGRj72MZ4lkZGRBAUFERISUurHPn78OJ07d8bf35/ff//9H+9nxIgRbNiw4QlGVvqSk5Px9/fHZDJZOxSbJBLUE7R582Z69eqFv78/rVq1YsSIERw7dszaYQmlxM/Pj4SEBGuH8VDJycmsXLmSrVu3sn///hLL5ObmMmfOHNq1a4e/vz8dO3Zkzpw5ZGRkPPbxFy1axODBgzlx4gQdO3b8x/tZvnw5PXv2fOx47hcREYGfn1+x5Dl37lz8/Pz46aefHmk/f3VRdZevry8nTpxAoVD843jLM5GgnpCVK1cyd+5c3njjDfbv38/u3bsZNGgQO3futHZo/9iTuPMR/mQr9ZmcnIyrqyseHh4lrtfr9bz22mtcvnyZ5cuXc/z4cb777jtcXV05c+bMEzl+nTp1Hns/T1P16tXZtGmT5bPRaOTXX3+latWqT+wYT/L7IEkSZrP5ie3PVogE9QTk5OSwaNEi3nvvPTp37oyDgwMqlYrQ0FBmzJhR4jYTJ04kJCSEZs2aMXjwYC5dumRZFx0dTdeuXfH396d169asWLECgIyMDEaPHk1AQADNmzdn0KBBli/l7du3mTBhAsHBwYSGhrJ69WrL/k6fPk2vXr1o2rQpLVu25L///W+JMR0+fJg2bdqwbNkyQkJCePvtt7lz5w6jR48mODiYwMBARo8eza1btyzbDBkyhE8//ZQBAwbg7+/PsGHDHniVvX37dkJDQ7l48SJms5lly5bRsWNHgoKCmDRpEllZWZayGzdupH379gQFBbFkyZKH/gwyMzMZOnQo/v7+vPLKKyQlJQEwa9YsPvzwwyJl33jjDaKiokrcz5UrVxg6dCjNmzenS5cubN261bIuIiKCWbNmMWrUKPz9/enbty/Xr18HYPDgwQB0794df39/tm7dWmJ96vV65syZQ6tWrWjVqhVz5sxBr9cXqf+lS5cSFBREaGgoP//8M1D4M2zZsmWRpqAdO3bQrVu3Es8jJyeHt956i+DgYNq3b8/ixYsxm80cOHCAYcOGkZKSgr+/PxEREcW23bRpEzdv3uTzzz+ndu3ayOVyPDw8GDduHG3btrXU05AhQwgICCAsLKzIhdhf1VPHjh25ceMGb7zxBv7+/uj1+mJ3Gvc2h+t0OqZNm0ZQUBABAQH07t2btLQ0oPC7t379egDMZjOLFy+mffv2tGjRgrfeeoucnBzgz6bmDRs20K5du0f6ToWGhnL8+HHu3LkDwN69e/Hz88PT09NS5vr167z66qsEBQURFBTE1KlTyc7OBmD69OkkJydbzvOrr76yxLF+/XratWvHa6+9VqQZPCsrizZt2rBr1y4A8vLy6NSpExs3biwxxiFDhhAZGcmAAQN4/vnnuXHjBrGxsfTu3ZtmzZrRu3dvYmNjATh06BAvv/yyZduhQ4fSu3dvy+dBgwY9VnPrUyMJjy06OlqqX7++ZDAYHlhm0aJF0tSpUy2f169fL+Xk5Eg6nU764IMPpG7dulnWhYSESEePHpUkSZKysrKkP/74Q5IkSfr444+ld999V9Lr9ZJer5eOHj0qmc1myWQyST179pQ+++wzSafTSdevX5dCQ0OlmJgYSZIkqV+/ftKGDRskSZKk3Nxc6cSJEyXGeOjQIal+/frSvHnzJJ1OJ2m1WikjI0Patm2blJ+fL+Xk5EgTJkyQxowZY9nmlVdekTp06CBdvXpV0mq10iuvvCLNnz9fkiRJunHjhlS3bl3JYDBIP/zwg9SxY0cpPj5ekiRJioqKkvr27SvdvHlT0ul00rvvvitNnjxZkiRJunTpkvTCCy9IR44ckXQ6nTR37lypfv360v79+0uMe8aMGUXKv//++9KAAQMkSZKkU6dOSSEhIZLJZJIkSZLS09OlJk2aSKmpqcX2k5eXJ7Vp00b64YcfJIPBIJ09e1Zq3ry5dOnSJctxmjdvLp06dUoyGAzSlClTpDfffNOyfd26dS3n96D6/PTTT6W+fftKaWlpUnp6utS/f38pMjKySPm5c+dKOp1OOnz4sPT8889LV65ckSRJkl566SVpz549lv2PHTtWWrFiRYl1Mn36dOmNN96QcnJypBs3bkidO3eWvv/+e8txWrduXeJ2kiRJb775pvTWW289cL1er5c6duwoLVmyRNLpdNKBAwekF154wRLnw+qpffv2RX6W93++9//Kt99+K40ePVrKz8+XjEajdObMGSknJ0eSpMLv3t1zWr9+vdSxY0fp+vXrUm5urjRu3Dhp2rRpkiT9+T2cOXOmpNVqpbi4OKlhw4bS5cuXSzy/GTNmSAsWLJDeeecdac2aNZIkSdLEiROlzZs3SwMGDJB+/PFHSZIkKT4+Xtq3b5+k0+mk9PR0adCgQdIHH3zwwPO6G8f06dOlvLw8SavVFvk/IkmStHfvXqlly5ZSWlqaNHPmTGnChAkP/Dm88sorUtu2baWLFy9KBoNBSk1NlQICAqQNGzZIBoNB2rx5sxQQECBlZGRIWq1WatSokZSeni7p9XqpRYsWUqtWraScnBxJq9VKjRs3ljIyMh54LGsRd1BPQFZWFm5ubiiVykfepk+fPjg5OaFWq5kwYQLnz5+3XPEplUouX75Mbm4uFSpUoGHDhpblqampJCcno1KpCAgIQCaTcebMGTIyMhg/fjxqtZrnnnuOfv36Wa7+lUol169fJyMjA0dHR1544YUHxiWXy5k4cSJqtRqNRoObmxtdunTB3t4eJycnxowZw9GjR4ts06tXL2rUqIFGo+HFF18kLi6uyPpVq1axYsUKvvnmG6pVqwbAunXrmDx5MpUqVUKtVjN+/Hi2b9+O0Whk27ZttGvXjsDAQNRqNZMmTUIu/+uv6r3lJ0+ezMmTJ7l58yZNmjTB2dmZgwcPArB161aaN29e5Er4rj179lC5cmV69+6NUqmkQYMGdOnShW3btlnKdOzYkSZNmqBUKunWrVuxc31YfW7evJlx48bh4eGBu7s748aNs9wl3TVp0iTUajXNmzenbdu2/PrrrwD06NHDUjYrK4t9+/YRHh5e7Jgmk4mtW7cydepUnJycqFKlCkOHDi12nAfJysrCy8vrgetPnTpFfn4+o0aNQq1W06JFC9q3b8+WLVv+cT09iFKpJCsri4SEBBQKBY0aNcLJyalYuc2bN/P666/z3HPP4ejoyJQpU9i6dWuRZrTx48ej0WioV68e9erV4/z583957O7du7Np0yays7M5evRosedl1apVIyQkBLVajbu7O0OHDi32f6MkEyZMwMHBAY1GU2xdq1atePHFF3n99deJjo5m1qxZf7mvnj17UqdOHZRKJfv27aNatWr06NEDpVJJeHg4NWvWZPfu3Wg0Gho3bsyxY8c4e/Ys9erVo2nTpsTGxnLy5EmqVauGm5vbQ2MvbY/+G1V4IFdXVzIzMzEajY+UpEwmE5GRkWzbto2MjAzLL9/MzEycnZ1ZtGgRS5Ys4ZNPPsHPz4+pU6fi7+/P8OHD+fzzzxk2bBgA/fv3Z9SoUSQlJZGSkkJAQECRY9z9PGfOHBYtWsRLL71ElSpVGD9+PO3bty8xNjc3N+zs7CyftVot//3vf9m7d6+luSMvLw+TyWR5sHvvLzN7e3vy8/OL7HPFihWMGzeOSpUqWZYlJyczbty4IolHLpeTnp5OSkpKkbIODg64urr+ZZ3eW97R0ZEKFSqQkpKCj48PPXv25OeffyYkJISff/6ZV199tcR9JCUlcfr06WL1eG8z2r2JTaPRFDvX+91fnykpKfj6+lo++/r6kpKSYvns4uKCg4NDieu7d+/OSy+9RH5+Pr/++isBAQFUrFix2DEzMzMxGAzFjnP79u2/jPUuV1dXUlNTH7j+7s/n3p/d/fv/u/X0IN27d+fWrVtMmTKF7OxsunXrxuTJk1GpVMViqly5suVz5cqVMRqNpKenlxhTSd/T+wUEBJCRkcGSJUto165dsYSSlpbGnDlzOHbsGHl5eUiShIuLy0PP6d7vakn69evH//73P954442HJg0fHx/Lv+//bkHRn0tgYCBHjhzB29ubwMBAXFxcOHr0qOViyBaJBPUE+Pv7o1ar+f3333nxxRcfWn7z5s3s3LmTlStXUqVKFXJycggMDET6/4HlmzRpwpIlSzAYDKxZs4Y333yT6OhonJyciIiIICIigosXL/Laa6/RuHFjfHx8qFKlCjt27CjxeNWrV2fBggWYzWZ27NjBxIkTOXz4cJFfhHfJZLIin7/++muuXbvG999/j5eXF3FxcfTo0cMS66P4+uuvGTFiBJ6ennTp0gUo/E86d+5cmjVrVqx8xYoVuXLliuWzVqst8nyqJPc+F8vLy+POnTuWX97dunUjPDyc8+fPc+XKlQf2HPPx8SEwMJCVK1c+8rk9zP31WbFixSKdBG7evFkkyWRnZ5Ofn2/52dy8edNS1tvbG39/f3bs2MGmTZsYOHBgicd0c3NDpVKRnJxM7dq1Lfvx9vZ+pJhbtmzJp59+WiSO+8/h1q1bmM1mS5K6efMm1atXf6T938/e3h6tVmv5fG9yVKlUjB8/nvHjx5OYmMioUaOoUaMGffv2LRbT3eeOUHgBpFQq8fDwKPLd+Lu6devGF198UeSZ7l0LFixAJpOxefNmXF1d+f3335k9e/ZD93n/d+JeJpOJ9957jx49erB27Vp69eplaXV42L7ufrfudfPmTVq3bg1A8+bN+fDDD/H19WXkyJFUqFCBd999F5VKZXmGamtEE98T4OzszMSJE5k9eza///47Wq0Wg8FAdHQ08+bNK1Y+Ly8PtVqNm5sbWq2WBQsWWNbp9Xp+/vlncnJyUKlUODo6Wn4J7N69m4SEBCRJwtnZGYVCgUwmo0mTJjg6OrJs2TIKCgowmUxcvHiR06dPA4UPve/eqd29wntYk9m9sdrZ2eHi4kJWVhaff/75366f2rVrs3z5cmbPnm15mD5w4EA+/fRTyy+VjIwMy0PaLl26sGfPHo4dO4Zer2fRokUP7aEUHR1tKb9w4UKef/55y9VlpUqVaNy4MdOnT6dz584lNq1AYTNhfHw8GzduxGAwYDAYOH36dJFk+Vc8PT25cePGX5YJCwtjyZIlZGRkkJGRwRdffFHk4TUUdhLQ6/UcO3aMPXv2FLno6d69OytWrODixYt07ty5xGMoFApefPFFIiMjyc3NJSkpiZUrVz6wQ8X9unfvTqVKlZgwYQJXrlzBbDaTmZnJ0qVLiY6OpkmTJmg0GpYvX47BYODw4cPs2rWLrl27PtL+71evXj22bt2KwWDgzJkzbN++3bLu0KFDXLhwAZPJhJOTE0qlssTvbnh4OKtWreLGjRvk5eURGRnJSy+99Lea3UsyZMgQVq5cSWBgYLF1eXl5ODg44OzszO3bt1m+fHmR9Y/yfbjf0qVLkclkzJ07l+HDhzNjxoxHfkeqbdu2xMfHs3nzZoxGI1u3buXy5cu0a9cOKLyQvnbtGqdPn6ZJkybUqVPH0mpQ0vnZApGgnpBhw4YRERHB4sWLadGiBe3atWPNmjUlXq336NEDX19fWrduTVhYWLFnQps2bSI0NJSmTZuybt065s+fD0BCQoKlp1r//v0ZOHAgwcHBKBQKli5dyvnz5+nQoQPBwcG888475ObmAoU9kMLCwvD392fOnDlERkY+8Jf0/V577TV0Oh3BwcH079/fcjX2d9WrV4+lS5fy7rvvEh0dzauvvkpoaCjDhg3D39+ffv36WRJqnTp1eO+995g2bRqtW7fGxcXloc0i4eHhfPHFFwQFBXH27FlLnd3Vo0cPLl68SPfu3R+4DycnJ1asWMHWrVtp3bo1rVq14uOPP7b0snuY8ePHExERQUBAQJHef/caO3YsjRo1olu3bnTr1o2GDRsyduxYy3pPT09cXFxo3bo106ZN4z//+Q+1atWyrO/UqRNJSUl06tQJe3v7B8by7rvvYm9vT8eOHRk0aBDh4eFFem39FbVaTVRUFDVr1mTYsGE0a9aMvn37kpmZSZMmTVCr1SxdupSYmBiCg4OZNWsW8+bNKxLn3/Hmm29y/fp1mjdvzmeffVYkYaelpTFx4kSaNWtG165dad68eYk/w969e9OtWzdeeeUVOnTogFqt5t133/1H8dzL1dWVFi1alHjXM378eM6dO0dAQACjRo0qdsEwatQolixZQkBAgKUn7l/5448/iIqK4qOPPkKhUDBy5EgAli1b9kixurm5sXTpUlauXElQUBDLly9n6dKluLu7A4VN5Q0bNqR27dqo1WqgMGn5+vo+8JUDa5NJf6etRhDKqKNHjzJ9+nR27979l00s1nT48GGmT59OTEzMX5br2LEjs2fPpmXLlqUUmSBYh7iDEso9g8HA6tWr6dOnj80mp0e1fft2ZDIZwcHB1g5FEJ460UlCKNeuXLlC7969qVev3gNfUC4rhgwZwuXLl5k3b94jP0MUhLJMNPEJgiAINklchgmCIAg2qdw18cXGxv5l7yZbZzAYir2EWNaU9XMQ8VuXiN/6SvscdDpdiSPclLsEpVAoqF+/vrXD+McSEhL+8sW8sqCsn4OI37pE/NZX2ufwoKGwRBOfIAiCYJNEghIEQRBskkhQgiAIgk0SCUoQBEGwSSJBCYIgCDZJJChBEATBJokEJQiCINgkkaAEQRAEmyQSlCAIgmCTyt1gsWfPnqNhwwbWDkMQBKHcKzCY0KgUj72fuLi4EkcAKndDHcnlMqpHbLF2GIIgCOVe/IdhT3X/5S5BCYIglAdmfQGGtASQyVF7VUemfPDgrWa9FrM2B2SgcPYqNjGnKS8LyagHhRKlk/t96zKRjAZkShUKRzfLckmSMOVlgsmITKlG4ej6ZE/wEYgEJQiCYEMks4msfWvJOf4zkl4LgNyhAhWC++Ic0L1I8sk5tZ2coxsxpCcChU9rVB5VqfTaAuQqDZIkkbHtM3JP77Bs41C3JV49/4UkSaT98jH556It6xwbtsczfCqSZCZ1w1y0lw5Z1jm98CIeXcY/5bMvSiQoQRAEG5K5ZyU5RzcyYMAABg8ejF6v56uvvmLbtuUgk+ES0B0AkzabjO1fEBzUnK6TRlKlShWSkpJ49913KUg4hUPtIPLjYsg9vYMxY8YQGBjIwYMH+eqrrzBmp6G9Fkv+uWjefPNNmjRpwp49e1i9ejVuHUaRd3Y32kuHeOutt6hXrx47duxg3Xff4xY6ArlKU2p1YfO9+FatWkV4eDhhYWFERUVZOxxBEISnxpiTRs7xXxg5ciTffvstarUaHx8ffv31V7p06cKdA99h1hcAYM7PBsnMyJEj6dChA0OGDKFHjx4ASEYDptxMMn5bSnBwMJ999hlDhw6ldevWhcfJTCZz11e0b9+eyMhIhg4dSosWLQAwpCWQFR1FWFgYH330EUOHDiUwMBAkM5jNpVofNp2gLl68yPr161m/fj2bNm1iz549JCQkWDssQRCEp6Lg2gkwG5kyZQp5eXmEh4fTq1cvACZPnoxZm40u+TwACmdPZCo7hg8fTkhICFqttsi+0nd8gRoDUVFRxS7u07ctwlGt4Ouvv2bFihVF121dSAUnB5YtW1ZsXWmz6QR15coVmjRpgr29PUqlksDAQHbs2PHwDQVBEMog450UFAoFtWvXJjExEYPBwK1btygoKMDPz89SBkCu1lBp8HwU93V6AMg7twftpUO8//77ZGVlMX/+/KLHybrFvHnziI+P5/PPP79v3U0WLlzIyZMn+frrr4usM2QmP8nTfSibTlB169bl+PHjZGZmotVqiYmJ4datW9YOSxAE4emQzMhksmK98ADk8sJf1xnbFnH7+/dI3/4FcgcXHOq3KVZWe+kQAQEBjB07lpEjR1r2J5fLUSgUtG7dmiFDhjB69GgUisL3mBQKBQqFgi5dutC9e3fGjh1bZJ1cLidl3Uwks+lpnX0xNt1JolatWowYMYLhw4djb29PvXr1LD8kQRCE8kZRoSJGo5Fr165RuXJlFAoF7u7uaDQaLl26BEC1atWoVFHFH3/EkHTy1wfuq0GDBjg4OHD69GnLssGDB6PRaNi5cydOTk5cuHDBsm7kyJHY2dlx5swZKlSoQHx8vGXdpEmTsLOzY8yYMZjzs1E4/dkd/Wk+drHpBAXQt29f+vbtC8CCBQvw9va2ckSCIAhPh331F0Am57PPPmPhwoWsW7eOChUqAFia4iZNmsTkyZNp1qwZsbGxDBs2jDZt2mBvb0+VKlWIiorip59+IiYmhv79+wNQqVIlFi5cyN69e1mwYAEpKSmWddWrV+ejjz7i999/Z/HixWRlZXH9+nUA/Pz8mD17Nlu2bGH58uUAyNT2RWKuVq3aY593XFxcicttPkGlp6fj4eFBcnIyO3bs4Pvvv7d2SIIgCE+FsoI3Ts934bPPPkOn01m6mffv35+NGzcCcOrUKdavX09WVhYAarUaBwcHNmzYAICDgwMqlYr4+HgSs40YMxKpVKkSrVq1IiYmhgMHDgAQn1GAMSORatWqERAQwG+//cbhw4cBuJKahzEjkbp169K4cWN++eUXjh8/jlvH0cjVpdfN3ObH4hs0aBBZWVkolUrefvttS1fIB4mLi+OlVVdLKTpBEIQnSzIayNy9gpxT28FkAArvWlwCe2DISCY/7v9frFWoLOtL4tw0HPdOb6C9doLUnz5AMuoA0FT3x7v/+wDkXz5M6sYPLfuxr9uCij1nApAXF0PalgVgMgJ/vsR7ryc11NGDxuKz+QT1d4kEJQhCeWDSZqO/dRlkcux86iK3cwDAkHULyaBD4eSOXOOEMetW4TBG95Db2aN0qWj5bNblYcxOQ6ZUoXT1KdIJw1SQiyknHZlSjcrNp1gMptxMZCo7VK6VisX4tBOUzTfxCYIgPIsU9i7Y12habPn9ieL+pFISuZ0jai/Hko+jcUKhcXpgDAp7l0eI9ukQXeIEQRAEmyQSlCAIgmCTyl0Tn9ksPfU5SgRBEIQnN2Hhg5S7Oyij8cG9WsqC8jDWYFk/BxG/dYn4re9Rz+FpJicohwlKEARBKB9EghIEQRBskkhQgiAIgk0qdwlKqVRZO4TH8iTGtbK2sn4O5Sn+AkPpjTwtCE9auevFJ5fLqB6xxdphCIJNED1ahbKs3CUoQSgtxuwU8s7uwZiThtLZE8eGoShdPEssq7t5kfyLB5D0WlRe1XGs3xa5nQNmg478iwcwpFxDMupRuHjiULclKjdfTPl30CVfwJB+AyQzCid3HPxCkCnVaK8eR3/zIqa8LORqe5TuVXD0a4n8ASMCCEJZJBKUIPwD2Uc2kLlnJXIZuLu7k5GRQda+Nbi1H45LQDdLOclkJG3LAvLjYrCzs8PR0ZGM2C1kxXyDW/vhZO1fi+nObTQaDfb29mRmZpIVvRpNDX901/+wDPB5l/7WZSSzidwTWy1zBeXm5pKt1ZIVHUXFPv/GztevtKtDEJ6KcvcMShCeNl3yBTJ3r6BP717Ex8eTmprK1atX6dHtZTJ3LkN367KlbPaxn8mPi2HWrFmkpaWRnp7OoUOHqFejCulbI6mgMLBz5060Wi0ZGRkkJyfTo3s3Cq4e5zlfb2JiYrhz5w5arZYXX3wRXdJ5tFeO0bNnT3Jzc0lJSSE/P59jx45Rw9eL9K2fWrFmBOHJsvkEFRUVRVhYGOHh4UyZMgWdTvfwjQThKco+/COenp5ERUWRnp5O+/btyc7OZtWqVbi5uZF9pHBeHslsIvvoT3Tu3Jn33nuPjRs3Eh4eTqNGjVi2bBkAQ4YMITQ0lH//+980btyYChUqMHfuXMuxYmJi2LFjBxqNxjKbtEyhJCEhgQEDBhAYGMiXX35Js2bNmDBhAob0G5jy75R+pQjCU2DTCer27dusXr2aH3/8kV9++QWTycSWLaIDhGBdupuX6Nq1K46Ojqxdu5Y9e/awbt06XFxc6NKlC/qbFwEw5aRhzsuiX79+QOGMqFu2bGH//v2EhITg4+PDtWvXALCzs8PBwQGZTMbVq4XTxVy/fp133nmHkydPFjm+s38YsbGxbNmyhYsXL5KcnAxg2U6mtCuVehCEp82mExSAyWSioKAAo9FIQUEBFStWfPhGgvAUmXLTqVKlCgBpaWkAZGRkAFClShWMOamYCnIxpCdalt1bNj093bL8l19+YcOGDfzrX//i8OHD5OXlERERAUCl10purnMJ7I5MpWH8+PHcuXOHWbNmERsbS1RUFAAylfopnLUglD6b7iTh7e3NsGHDaN++PXZ2doSEhNCqVStrhyU84+R2jpbptu3t7QHQaAqnwc7KygKTkcSFAyzl/6rsW2+9Rc+ePRk1ahQ7d+4kOjqaTZs2UatWLTJ+W1Ls2Ppbl7i1NgLJUMA333xDdHQ0vXv3ZubMmSxatIjXX38d/e2r2FWqbdmmrI0Nl5ubW+ZivldZjx9s5xxsOkHduXOHnTt3snPnTpydnZk0aRKbNm2ie/fu1g5NeIYp3X05dOgQAC1atGDx4sUEBwcDcPjwYWQyGZGRkVy+fJnPP/+cQ4cO0b9/f4KDg7l48SJNmzbl9u3bxMfHU6tWLQCOHTtG/M00bt26xQsvvIBKpUKffKHE4+tu/EGtWrW4evUq6enpqNVqZs6cablTM+vyipQvay8eJyQklLmY71XW44fSP4e4uLgSl9t0E9+BAweoUqUK7u7uqFQqOpSWZZoAACAASURBVHfuzIkTJ6wdlvCMc2rSmdjYWNauXcsrr7zC9evX6d+/P6tWreLMmTPIZDImTZpEr169APjqq6+4cOECixcvtvzHnzlzJgaDgbVr12I0Gtm6dStH9u4iICCA77//HoPBQO3atdHpdMyaNQuATZs2WZoSP/nkE1JTU4mLi2Pv3r0YjUYWL14MChVq71pWqxtBeJJs+g7K19eXU6dOodVq0Wg0HDx4kEaNGlk7LOEZ59SoA3lndjJ48GCWL1/O888/z4kTJ4iOjgbAbDbTq1cvyzOnvLw8mjRpQo8ePfDx8WHbtm1cuHABhaMbu3fvpk6dOoSGhuLo6MiMGTPYtWsXADdv3mTAgAFFjm0yFQ5dNHXqVFq1aoWHhwepqans2rWLpKQk3EJHPHD6bkEoa2SSJEnWDuKvLFq0iK1bt6JUKqlfvz5z5sxBrX7wQ+C4uDheWnW1FCMUnkVmg46c2M3kndn5/yNJeOHYuCNOjdqTuWcV+tuXQSbHsV4rHBuFkn34R/IvHsSsy0ddsTrOzbrh4BdCwbVYso9uxJB2HclQgMLFCwe/EBz9WpG19xsMmcnFji3ptSgqeGPMSMSsy0du71K4T/8w7Gs2K1K2LA51VNabyMp6/GCdJr769esXW27zCervEglKEP4kElTpK+vxg+0kKJt+BiUIgiA8u0SCEgRBEGySSFCCIAiCTbLpXnz/hNkslcl2d0F4GgoMJjQqhbXDEIR/pNzdQRmNBmuH8Fhs4e3tx1XWz6E8xS+Sk1CWlbsEJQiCIJQPIkEJgiAINqncJSilUmXtEB5LWX9/Asr+Odha/AUGk7VDEASrKHedJORyGdUjxJxRQvkhOv0Iz6pydwclCIIglA/l7g5KEB6VZNSTE/sLuWd+x5iTjtLFC6fGnXD274rsvqZiY0462Ud+Iv/iQSR9Piqv6rgEdMNckEfOya1IJfQeVTi4gEyBKS+zyHK5xokKIQNROnty59APFFw7jmQyYufrh0vznmiqNnmq5y0IZYVIUMIzSTIZuf3du+gSz9KmTRuef747J06cYN+ur8i/fBjv/u8jkxd20TZmp3Bz9RQU+jx6dOtGpUqV2L59O5c3zAXA39+f6tWrF9m/Vqtl27ZteHl5FZtk8+TJk1xbNxOZSoO9Ss7AXr1wdHTk559/5ua3/8L9xQk4P9+lVOpBEGyZSFDCMyn3j53oEs+yevVqhgwZws2bN/Hx8WHFihWMGDGCvHPRODUKBSAr5hs0kp6jJ09St25dMjMzWbRoEcOHDycqKooxY8YwcuTIIvuPj4+nRo0aNGrUiJ9++qnIurFjx7JkyRJ8K3pw5MgR3N3d0Wq1LFq0iLCwMHbu/hrHeq2Q2zmWWn0Igi2y+WdQV69epXv37pY/TZs2JSoqytphCWVc7ukd+Pv7M2TIENasWYOvry/r169n+PDhNGzYkNzTOwAw6wvIi4th5MiRNGjQgHHjxvHcc8+RmJjIf//7X1QqFZMmTcLV1RVXV1cGDx4MwPfff1/keGFhYZYyK1asAGD69On4+vrSvXt3ateujclk4sMPP0TS5ZF/YX/pVogg2CCbT1A1a9Zk06ZNbNq0iZ9++gl7e3s6depk7bCEMs6YkWyZpv3AgQNF/g4KCsL4//MwGe/cArPJUnb//v3odDqOHz9OpUqVqFatGlqtFq29F3fu3GHUqFEYDAYWLVpU5HgrV67k1KlTfP7557i6ugIU2WdGRgYXLlygWbNmqNVqDBnF54EShGdNmWriO3jwIM899xyVK1e2dihCGWfW5eHm5gYUPi8CKCgoAMDd3R1TbgY5sb+gS74AYCl7t8y9ZTXV/THeuU1gYCBt27Zl1apVJCUlAZCZmcmHH37IhQsX6NixI6+88gr29vb06dPngcd3dXUlX5f71OtAEGxdmUpQW7ZsITw83NphCOWAwsmDxMREADw8PIr8fXd5xm9LLeXvLXvlypUiZfWZuZi12UybFgnAxx9/jMqrOobUeE6ePMnJkycBWL16Nf369bPcOSUmJlK3bl08PT1JSUnB3d0dnU5HamoqLnU9isT7d8YHzM3NLdPjCYr4rc9WzqHMJCi9Xs+uXbuYOnWqtUMRygG1T21+/fVX8vPzGThwIIcOHaJ///7k5OSwfft25HI5t27dYv/+/fTs2ZMffviB4cOHM3bsWNzc3AgJCeHAgQMkJxc2xdWoUYPevXvz66+/8scff+DeeSwZOxYzfPhwzGYzJ0+epFOnTqhUKs6dOwfADz/8QGhoKJMnT+bEiRPUrVuXtWvXIkkSdpXqFIn374xuUdZndBXxW581ZtQtic0/g7orJiaGhg0b4unpae1QhHKgQvNepKamMmzYMLy9vdm7dy+urq68/vrrZGYWvrfk4uKCg4MDANu2bWPOnDn06dOHbdu2ERcXx6hRoyz7GzduHEajkfnz56Nw8sCxQTsA5HI5kZGRxMbG8tFHH7F//37GjBkDwPLly/nqq6+YOnUq3333HTt37mT69OmovKqjqdm0dCtEEGyQTJIkydpBPIrJkyfTqlUrevfu/Zfl4uLieGnV1VKKSijL7hz+kazoVSjkMjw8PEhPT8dkltBUbUxBwqk/C8oVaJ5rTEHCSTQaDc7OzqSmpiLXOOMWOpz0bZ+D2Wgp7t7pDZybhpO6eT7556KRyWRUrFiR7OxstFotCkc3PMKncmf/t+gSz+Lo6IidnR0ZGRkonL2o2Pc/qL3+vHr9u0MdlfUreBG/9VnjDqp+/frFlpeJJr78/HwOHDjA7NmzrR2KUI5UCOqNg18IeWd3k5+TjpOfJ06NQlE4e1EQfxL9rUsgl+NQKwiV53PokuLIv3gQrT4fd/+aODZoh9zOAZVnVQoSToNkRunmi4NfCACe4dPQ+Yehu36GvOxUVFXtcfCsikO91sjVGjTVnqfgWizaa7HoTQY8WvjhWK81MqXayjUjCLahTCQoBwcHDh8+bO0whHJI5VoJ15CBxZbb1/DHvoZ/kWV2letjV7n4VZ6dT13sfOoWWy6TydBUaYCmSoMSjy2TybCv2Qz7ms3+YfSCUL6VmWdQgiAIwrNFJChBEATBJokEJQiCINikMvEM6u8wmyUxwZtQrhQYTGhUCmuHIQilrtzdQRlLmJenLLGFt7cfV1k/B1uLXyQn4VlV7hKUIAiCUD6IBCUIgiDYpHKXoJT3TdVd1pT1N9Ch7J+DNeIvMJhK/ZiCYOvKXScJuVxG9Ygt1g5DEP4W0bFHEIordwlKEEpi1uWjv3UZCQk771rINU4PLGvMScOQmoBM7YCdT21kChVmXT7G7NRiZeUaR5TOnpgKcjGkXUfS5aNwckflWRWZovC/l0mbgyEtAUlfgMLZo3CdXHR8EISHEQlKKNcks4msfWvJObYJyVA4IaBMZYdz03Bc27xaJFGYtNlk7FhSON26ZAZA4eSOfZ1g8s7uRtJr/+JIMuDPcZcVzp5UaNmf/PP7KUg4WaSkwqUiHp3HYl8r4ImdpyCURyJBCeXanf3fkn3wOwYNGsTQoUORy+WsXr2aVatWgSTh1n4YAJIkkbphLrLUy7wdMYOwsDBSUlKIjIxk796tODs7s+J/3xfb//Lly9mxYwf//vd7BAUF4erqSkJCAp988gnHtn+BTCbjgw8+ICAgABcXF65evcr8+fM5tWEOPq9FovaqXso1Ighlh013krh58yZDhgyha9euhIWFFf5SEYRHZNJmk310AwMHDmTNmjWYTCby8/OJiori9ddfJ/v4Zkx5hXM/FSScQnfjDyIjI5k7dy7nzp2jdu3a7Nq1Cz8/P2QyGU5OTpY/ISEh9O3bFxcXFwBGjx7NtWvXOH78OH369GHnzp24ubmhUCgYMWIEFy9e5NSpUwwcOJCdO3fiYKci99QOa1aPINg8m76DUigURERE0LBhQ3Jzc+nduzchISHUrl3b2qEJZYDuxlkkg46JEydiMpno168fer2eO3fuMHHiRKKioihIOI1jg7ZorxzF0dGRYcOGcfz4cUaNGkW7du3YvXs3Y8aM4c0336Rr164AqNVq4uPjSUxMZOPGjQDUrVuX3NxcAKpWrUq3bt2oXbs2x44do1atWuTl5QFQp04dOnToQPXq1bmWU/yZliAIf7LpO6iKFSvSsGFDAJycnKhZsya3b9+2clRCWWHMTgEKk0dmZibZ2dkUFBSQkpJC3bp1i5QxZqdQvXp11Gq1ZSSJ69evW7YHqNCiPyBj8ODB+Pj48Omnn2I0GnGo1xq9ozcAvr6+BAcHc/XqVU6dOoUkSegdvIDC7utNmzYlLi6OCxcuoPYs293xBeFps+kEda/ExETi4uJ4/vnnrR2KUMbcP2m0TCazLMuKXsWt/72F9uLBYuXu315/+woyGUybNo07d+6wbNkyHBq0xcEvBH3aderVq8f+/fvR6/W89NJL6PV6XNu8iiH9Bo0bN2b//v1kZmbStWtXTCYTzk1F13JB+Cs23cR3V15eHhMnTuRf//oXTk4P7h4sCPdSVii8q7lw4QLBwcG4urqi1+upWLEip0+fBqB69erUquXDCe0t4uPj0el01KhRA8Dy94ULFwDQXj1G165dadCgAfPnzycnJwc3n7qkbfqIkJCW/PzzzyQlJdG1a1cSExMByIpZTWhoKBs2bODSpUuEhYVZWgHyzu/DpdnLlnif1BiAubm5Njee4N8h4rc+WzkHm09QBoOBiRMn8vLLL9O5c2drhyOUIXbPNUKmticyMpL169ezYcMGDAYDKpWKTz/9FIDBgwfzwQcf0LlzZ3777TeWLVvGhAkTWL16NYGBgej1ehYvXmzZ5/Tp0zEYDCxcuBBNtecxF+QBEr///jsajYYbN26wZs0aAN5++21iY2PZtm0bKpUKmUzG998X9gScPHkyZy8eKJKgntQIFgkJCWV6NA8Rv/WV9jnExcWVuNymE5QkScycOZOaNWsydOhQa4cjlDEKjRMuQb354Yf/0atXL0s38379+rF+/XoATp8+TVRUFMnJyUBh892VK1cIDw8nNjaWV199lcuXLyNT2+PmZE98fDybN28mKSmJin1Hob99BYB169YVO35+fj5ms9mSsO6l1WqRyeye4tkLQtknkx7U8G4Djh07xuDBg6lbty5yeeHjsilTptC2bdsHbhMXF8dLq66WVoiCjZMkM9kH15N9dAPmgsJedjI7R1wCuiEZdGQf2QBIyJRq3EJHoEu+SN653WAuHBtPWcEb17avI5kMpP+6CMxGAOzrBOPVcybGrJvcXhuBKTfjb8UlU9rh0fVNHOu3Bp7sUEdl/QpexG991riDql+/frHlNn0HFRAQYGn/F4R/QiaTU6Flf5wDu2NIuYYkgbpiDeRqDQAuQb2RDAXINc7I7Rxw9u+KW/uhGNJvIFNpUFesYRltwqFOMOaCHJArUTp7AKBy86XyG19jyk0vdmy5xgmZQo0pr3jyuns8QRAezKYTlCA8KXKVBrvKxa/QFA4VgArFlhUuv28fdg4lJhWZQmnpkFGSv1onCMKDlZlu5oIgCMKzRSQoQRAEwSaVuyY+s1kSc+sIZU6BwYRGJabgEIR7lbs7KKPRYO0QHostvBz3uMr6OVgjfpGcBKG4cpegBEEQhPJBJChBEATBJokEJQiCINikcpeglEqVtUN4LGX9DXSw7XMoMJisHYIgCI+o3PXik8tlVI/YYu0wBBslengKQtlR7hKUYB2SZEZ75SjaK8eQTAbsfOri2LA9crV9sbJmfQF55/agSz6PTKHEvkZT7GsHYcxOJf9cNGajvkh5tedzOPiFgFyJLvEs+RcPYi7IQWFfAbvnGmHn64f2yhH0KdcwF+SicHBFU+15NDWbIZPJSqsKBEF4wkSCEh6buSCXlPX/QZd8Hjc3NxwcHEg68ztZ+9ZSse9/sKtU21JWn3KVlPX/wZSbga+vLwUFBaSe3IbauxaGrFvIDNpiSSXbZMI1O5WC+BMUJJzGwcEBLy8vbl++TfbRDZZyLi4uVPTw4NbVW2Qf3YB9zQC8es1Epijbzb6C8Kwqd8+ghNKXGb0KKe0qUVFRpKamkpiYyMGDB6la0ZW0zfOR/n9kcEkyk7b5E3xcHdi3bx9JSUmkpKSwZs0a5HeSkHR5LFiwAKPRWOSPr68vWdGrMN88zxdffEFaWhrx8fHk5OTQqlUrAGJiYrhz5w5Xr14lOzubTz/9FO3VY2Qf22TNqhEE4THYfIKKiYmhS5cudOrUiWXLllk7HOE+Jm0Ouad/Y9iwYbz22mt8+OGHhIeHExgYyCeffIIxIwnt5SMAFFyNxZCWwLx58wgJCaFnz57MmjWLQYMGMXr0aMs+8/PzefXVVy1/MjMzAZg5cyZjx45l9uzZ1KhRgw4dOlhmrj158iShoaE0b96cK1euMGnSJIKDg9FeOVb6lSIIwhNh0wnKZDIxe/Zsli9fzpYtW/jll1+4fPmytcMS7mFISwCzkV69egHwxRdfsGXLFs6dO0e3bt1QKBSWSf10ty8jk8no2bMnly5dYuPGjXz++ecAlu0B5HI5ISEhNGzYkDNnzqDVagEYMmQI169fJysriyFDhqDT6YiPjwdg4sSJ7N69m6NHj3Lo0CEA1Go1mIylVRWCIDxhNp2gTp8+TbVq1XjuuedQq9WEhYWxc+dOa4cl3OPuRH2+vr4AZGQUfs7MzESpVOLt7Y0h/QbG7FQM6Tdwc3NDo9FY7oqysrIAqFy5MgAGg4EjR47g4+PDhAkTOHbsGEFBQdjZ2VGjRg2qVq3K2LFjGTJkCIcOHaJnz54A2FVpAECnTp0YMGAA27dvZ9++fWhqNiu9yhAE4Ymy6U4St2/fplKlSpbP3t7enD592ooRCfeTa5yAPxOTg4MDOp0OBwcHy/KC5GTyL+wHQK9UYjKZsLcv7N2n0RROHJieXjjh34wZMzCbzUDhHdPq1asZMGAAhw8fJi8vD3t7e1q2bIm3tzeXL1/m1VdfZcOGDegSzzFkyBBWrFjBb7/9Rp8+fTCbzYW9/+7zsLH2cnNzy/R4giJ+6yrr8YPtnINNJyjB9qncqwCwf/9+WrduTevWrdm/fz8NGjQgNjaWgoICWrRowfDhw1m1ahV79+7l8OHDBAQE4O3tzQsvvADAgQMHAOjcuTO7du1Cr9dbXvjNzS2cqv3QoUN06NABtVqNnZ0d8Ocd2Ntvv83cuXPZvHkz48ePx83NDZlMRuauFXj3m1Uk5oe9SFzWp+wW8VtXWY8frDPle0lsuonP29ubW7duWT7fvn0bb28xO6ktUVaoiKa6PwsWLODatWv8+OOPxMfHI5fLeeuttwCoVasWw4cPx8/PD4CIiAiMRiOXL19m8+bN3Lhxg3nz5gGwcOFCsrKyuHnzJu+//z7x8fEsXrwYgH/961/k5uZy6tQpdu/eTXp6umW7KVOmAPDyyy+TkJBAUlIS/fr1w5B+vbSrRBCEJ8Sm76AaN25MfHw8N27cwNvbmy1btvDJJ59YOyzhPm6hI7i95i3q1atHeHg4Li4ubNmyhdTUVAB27txJ586dOXfuHAB79+6jatWqhIeHk5eXx+bNm9HpdAC0bNmSVq1aUbFiRZKSkti5cyd6lFQIGcSRA+ss2+Xn57Nr1y7Ls6y+ffuiUhV93+ns2bMonT1LsSYEQXiSbDpBKZVK3nvvPUaMGIHJZKJ3797UqVPH2mEJ91F7VcNn2GdkH9nAz7sPIhkLR5Lw7jwdmUJJ1oHviDl7A7m9L5VemQwyGdlHNvC/n7YgU6jQNOiIR/OemHIzyT21ja37TmAuyEHu6IamYUc8g/qgdPHCvlYg2Uc3sHbjNmQqNWqfJvj06E3euWgOXjxbLC65QxXcWvS3Qo0IgvAk2HSCAmjbti1t27a1dhjCQyhdKuLecTQwuti6ir3fLbbMq0dEsWUq10poqtR/4DHsfOrg1e2tYsvVFWv+vWAFQSgTbPoZlCAIgvDsEglKEARBsEkiQQmCIAg2yeafQf1dZrMk5vwRHqjAYEKjUlg7DEEQHkG5u4MyGg3WDuGx2MLb24/Lls9BJCdBKDvKXYISBEEQygeRoARBEASbVO4SlFJZtmdPLatjeBUYTNYOQRCEcqbcdZKQy2VUj9hi7TCeOaJjiiAIT1q5u4MSBEEQyodydwdV3uhTE7hz8Hu0V48hGfXY+dTFpXkvHOoEFSurvXKMO0d+RJ98AeRK7Gs0pULLfhizbpF9dCOGtOuYdfnI7Z0L9xPcF3XFmtw5tJ7883sxZqciU6pRe1XHsVEoBVePo0+5Wuw4cjtHKrQcgEPdFqVRBYIgPKNEgrJhupsXub32bVwcNbzy2mBcXFzYuHEjl396H7f2w3Fp3tNSNufEVjJ2LKZGjRr0mjSBvLw8vvvuO26unAhAw4YNadvtVdzc3EhJSWHLli0kr40AmRyFTOLFF1+kYcOGaLVafvvtN85v+wyFQsHAgQOLxRUbG8uFXctFghIE4akSCcpGSZJE5s6vqFzJi9jYWOzt7cnOzuajjz6iV69e/Lz1fzg2CkXhUAFTQS6Ze1by4osv8ssvv5CamoqjoyNz5syhWbNmxMfH8/bbbxMcHIxOp6NBgwZkZ2fTpEkTEhISWPzll4waNYqDBw9StWpVIiMjadOmDSdPnuSbb74pFtukSZOIW/qVFWpFEIRnSZl4BmUymejRowejRxcfKbu8MmbdQpcUx9SpU/Hy8qJXr174+fmRm5vL3LlzkQw68s/vA0B78SCSXsucOXPQ6/XUr1+fsLAw3N3dmTFjBgBvvPEGtWvXpmHDhixfvhwXFxeaNm0KQPv27cnIyKDLxI+YMGECCoWC1q1bk5+fj0qlsvzZu3cvBoOBH3/8ETvfelarG0EQng1lIkGtXr2aWrVqWTuMUmXMTAYgMDAQgCNHjpCbm8v58+dp0KABDg4OGP6/jCEzGZVKxQsvvMClS5fIysriyJEjAAQEBACF06Z369aNd955h65du3Lo0CF27doFFM5i6+TkROQbL/P2229z7do11q9fD4BDQA+MZgl/f39at27N2rVrSUpKwiWwR6nWhyAIzx6bT1C3bt1iz5499OnTx9qhlCqzLh8AV1dXAMuMs3f/rlChAjnHfubOoR/IProRZ2dn5HK5Zb1ery+yPUCbNm3o378/vr6+KJVK7O3tgcI71IKCAmrWrImHhwc5OTnI5YVfDTufumA2MW3aNAA+/vhjVF7V0dRo+rSrQBCEZ5zNP4OaO3cu06dPJy8vz9qhlCqFswcA169fp2HDhnh6epKYmIinpyd6vZ7bt28DElnRUQBkZGSQl5eHp2fhFOd3/75x44Zln9OmTWPatGnMmDGDDz/8kKFDhzJ//nwWLlzIyZMn6Rjek4a1qnL69GmmT5/O6NGjyfh9GTVq1KB37978+uuv/PHHH3iETUEmkxWL+e4YfLm5uTY9Ht/DiPitS8RvfbZyDjadoHbv3o27uzuNGjXi8OHD1g6nVKm9qiNT2rFu3TpeeuklIiIiOHr0KPXr1+d///sfZrOZgQMHsmLFCsaNG8fKlStZt24dw4cPZ8SIEdSvXzgz7bfffgvAnDlziImJwWAw0LFjR6Dw7tRoNJKTk0PVqlV5oV4t2rRpA0BWVhYAppw0psz9NwqFgo8//hiFsyeO9duUGPPdUTASEhLK7IgYIOK3NhG/9ZX2OcTFxZW43KYTVGxsLLt27SImJgadTkdubi7Tpk3j448/tnZoT53czgFn/66sWbOGxo0bM2bMGMaNG8cvv/zC9OnTgcKmufz8fIxGIwARERF4eHjw5ZdfotPpWLRoEStXrgSgS5cuREREIJfLycjIIDIyktWrVwPw+uuvs3DhQo4fP47RaGT79u3MmzcPABcXF7p3787evXvZtWsXbu2HIVPY9NdGEIRyQiZJkmTtIB7F4cOH+frrr/nyyy//slxcXBwvrSr+cmlZZDYUkLbpI7RXjqJUKlEqlRQUFKB09UFu74z+5kVLWXWlOpj1WowZiWg0GoxGI0ajEYWzJ6acdECy9MbLzy98vqWp0RT76i+QGb0KzCbs7OwwGAyYzWaUrpVQ+9QlPy7GcgyFkzu+I5Yit3MoFuu9Qx2V9StIEb91ifitzxp3UHdbfe4lLoVtmFyloWKff6NLikN79TiSUY+TT10c6gQjmU1oLx3CpM1GYe+MfZ1gZAoV2suH0SWdR61QYV+zKXaVG2DOy0J77TiGjCQwGXFzcsPuuUaoK9VBJpPhUK812suHMWanolGqUXlWs4xUke8Xgik3A5lcgX3NgBKTkyAIwtNQZhJUUFAQQUHFh/d5FthVro9d5aJXFzKFEscGbYuVdajbEoe6LYssUzi54dS44wP3r3TxwrlpeInrHP1C/kHEgiAIj8/mu5kLgiAIzyaRoARBEASbJBKUIAiCYJPKzDOoR2U2S2LyPCsoMJjQqBTWDkMQhHKk3N1BGY0Ga4fwWGzh7e1/QiQnQRCetHKXoARBEITyQSQoQRAEwSaVuwSlVKqsHcJjedjb2wUGUylFIgiCYF3lrpOEXC6jesQWa4fx1IgOIIIgPCvKXYJ6FmVmZnLgwAEMBgOBgYFUrlz5gWXj4+MtU8i3atUKZ2dnAFJTUzl16hSZmZl4eXnRrFkznJ2dkSSJa9euERcXR35+Pp6envxfe3ceV1W1/3/8dQ4IMiYqAuY8oJI5DynkAA4I6k2cbl1v2qBcNc0UM1HMOcdyykopsxyvQ4455qVQLIdQCBFEZFJQQZBB5rN+f/jzfOVqVxPlHPDzfDx8CHutfc577YwPe+919mrbti22trYAJCcnEx4ezu3bt3FycqJt27ZlMmYhRMUnBaoc0+l0BAQEsGzZMv0DYE1MTPjnP//J559/jqXl/z03Lz09nVGjRrFjxw7uPR/Y1taWDz74gLNnz7Jv374Sr21jY8OsWbM4cOAAR44cKdFmUxgpwgAAIABJREFUaWmJr68voaGhBAUFlWirVq0a/v7+TJw48RmMWAjxPJECVY4tXLiQ+fPnM2zYMP71r39hbn53/ahly5ah0+lYv369vu8bb7zBf/7zH6ZPn86AAQPIyMhg+fLlzJo1C4CpU6fSs2dP7O3tuXr1KqtWrdIXmQ8//JDXXnsNGxsbkpOTCQwM5LPPPgPgk08+wdXVFTs7O+Li4li6dCl+fn60a9dOv7aUEEI8CaOfJJGZmcn48ePx9PSkT58+hIaGGjqSUcjOzmbRokW89tprfP/992RlZREREcGSJUuYPHky33//PdHRd5fjCAkJ4eDBg3zyySfMnj2bkydPYmpqyq5du+jc+e6DZX18fAgLC2PHjh106tSJXbt20aRJEwDs7Ow4dOgQO3bsoH379mzdupWGDRsCMHDgQH799Vf27t1Ljx49+PHHH6lRowaBgYGGOTBCiArD6M+g5s2bx6uvvsqKFSsoKCggLy/P0JGMwqlTp8jIyGDMmDEAvPXWW1y/fp1+/foxZswYFixYwJEjR3B2dubw4cNotVp8fX25dOkSY8eO5eWXXyYsLIzRo0cTEhKCm5sb+fn5ANjb2zNmzBiaNWtGVFQUU6dO1b9v+/bt8fLywtzcHICXX35Zv1+jRo0YPHgwjRo1IikpqYyPiBCiojHqM6isrCxOnz7NoEGDADAzM9PfnH/e3XviRKNGjSgsLCQlJQWlFNeuXaN27dpUrlyZhIQEfd+aNWtiaWlJYmIigL6ANGrUCEBfZKpXr46npycpKSkl7i999913XLp0CS8vLz7++GMuXLgAQMuWLQGoXbs2Xbt2JSYmhjNnzui3CyHEkzLqApWUlETVqlWZOnUqr732GtOmTdNPBnjemZjcfbRQUVERWu3//WfUarXodDqKi4tZtGgRrVq14ttvv9UvC6/RaPT97u1/T/369Tlx4gTW1tZ4enqSkZGhX+Xyxx9/5Pvvvyc5OZkpU6bot4eGhtK8eXNCQkLIzc3F09OT/Px8mSQhhCg1o77EV1RUxIULFwgICKBly5bMnTuXNWvWMGHCBENHM6j4+HgsLCyAu0slN27cmPr165OUlETt2rW5fPkyhYWF1K9fn3r16nHz5k2Sk5NJT0/XnzHdu4d08eJFANq2bcv+/fu5ffs2nTp1IjY2Vt+u0WjYsmULAKampgQEBODq6kpkZCRubm788MMPxMbG4u3tTXJyMgCLFi3Cz8+vTI/L05KdnV1un4kIkt/Qynt+MJ4xGHWBcnR0xNHRUX+5yNPTkzVr1hg4leHVrVsXR0dHnJyc+PTTT+nbty87duwgPT0dGxsbJk2aBICXlxerVq1iyJAhbNu2jaVLlzJ37lz27dtHw4YNKSwsZPny5QBs374dBwcHcnJy2LZtGwDz58/nyJEj/P777wQFBWFqasrAgQPJzc0lODgYgL1792JlZYWVlZV+qrqfnx/BwcGsXLnSAEen9OLj4x/5RA9jJvkNq7znh7IfQ2Rk5EO3G3WBsre3x9HRkdjYWBo0aMDJkyf1v/k/78zNzZk9ezYjR46kW7dujBw5EgsLCwYOHMjOnTsBCA8PZ9WqVcTExAB3C058fDw+Pj6EhIQwbNgw/vjjDwA2btzICy+8UOI9rl+/Tm5uLhs3buSll17CxMSEwMBA1q1bR1RUFACBgYH6y4333Lp1Sz+JQgghnpRRFyiAgIAA/Pz8KCwspHbt2nzyySeGjmQ03n33XQBmz57Nm2++CdydEj5r1iwsLS2ZPn06v/zyC1ZWVnz11VfExMTwxRdfsGHDBuDuBIk1a9awYsUKpk+f/qfvM3PmTP2He+HuzL3PP/+czz777KGXW62srPjyyy+f5lCFEM8hjbr/J08FEBkZSZ/1sYaO8cw87Fl8xcXFXLx4kaKiIpo0aULlypUByMnJITc3FysrK/09q5ycHKKjo7GwsMDZ2RmtVotSirS0tAde19LSEktLSzIzM7ly5Qo6nY6aNWtSo0YNNBoNOp2OW7duPbBfWlqa/jNU5VF5v0Qj+Q2rvOcHw1ziuzfx6n5GfwYlHs3ExISXXnrpge337g3997bWrVuX2KbRaKhevfqfvr6tre1Dp41rtdqH7peTk/O40YUQ4k8Z9TRzIYQQzy8pUEIIIYxShbvEp9OpCr1mUl5hMZUrmTy6oxBClHMV7gyqqKjQ0BFK5VEfjpPiJIR4XlS4AiWEEKJikAIlhBDCKEmBEkIIYZQqXIEyNa1k6Ail8mcfjssrLC7jJEIIYVgVbhafVquh3kf7DR3jqavIMxOFEOJhKlyBel6kp6ezbt06fv31V8zMzPD09GTIkCGYmZk90Dc2Npa1a9cSHR1NtWrV+Pvf/0737t2JjIxkz549REREcOfOHV588UX+9re/4e7uTnh4OPv27ePChQvk5uZSu3ZtfHx86NKlC7/99hu7du0iNjaW4uJiatasSd++fenZs6d+vSkhhCgtKVDl0KlTp+jTpw+3bt3C2dmZO3fusHHjRhYuXMjRo0dxcHDQ9127di2jR4/GxMSExo0bc+3aNdauXYudnR3p6eloNBrq1KmDtbU1R44cYeXKlVSrVk3/jL26detiaWnJ4cOHWb58OS1atCAsLAwzMzPq16+PiYkJhw8fZuXKlbz55pt8++23BjoqQoiKpsLdg6roiouL+ec//0mVKlUIDQ0lKiqKxMREdu/eTWxsbIlFAuPi4hgzZgy9evUiLi6OP/74g+TkZObOnUt6ejrW1tbcuHFD35aamspHH31EWloa1atXJzU1lStXrhAREcHNmzeZMGECYWFhAKSmpnLx4kUiIiJITU1lxowZfPfddxw5csRQh0YIUcEYfYHKz89n0KBB9O/fH29vb1asWGHoSAa1f/9+oqOjWbx4Ma1ataJnz574+fnRv39/xo4dy6ZNm/Sr2q5atQoTExPWrl1LYWEhzZs3Z9euXUybNo2OHTui0Wj4+uuvad68OR07diQrK4tPPvmEqlWrArB69WpcXFxwdXWloKCAJUuWYGlpCcDw4cOpV68enTt3pqCgAH9/fzQaDSdPnjTYsRFCVCxGX6DMzMxYv349e/bsYdeuXQQHB3Pu3DlDxzKYc+fOodFo8Pb2JioqiqNHj/L1118D0LdvX3Q6HeHh4fq+rVu35sUXX+TQoUNERESwadMmfd+srCw++ugjIiIiOHXqFHFxceh0OrRaLTdv3iQgIIDIyEhCQkK4evUqOp1Ovzjhrl27MDExwdraGq1WS2hoKEqpEpcXhRCiNIy+QGk0Gv2SEUVFRRQVFT3XN+JTUlKoVq0a5ubmZGRkAHD79m0AatasCUBISAiRkZFER0frt93re+/ve9ttbW0BmDRpEm3btmXRokWkpqbi5+dH06ZNgbuLRjZr1ozZs2eTlZVFq1atqFy5MpcvX+bw4cNotVqWLl0K8D+X7RBCiL+iXEySKC4uxsfHh4SEBN54442Hrk30PIiPj8fU1JSMjAyKi4v1hfveZbfU1FQAZs2axaxZswCoX78+gL6vtbV1ib5ZWVksXbqUiRMnsmDBAqZOnQrA5s2bSUlJYfXq1YwePZoZM2Ywd+5cAP0ZrL29PXXr1mXnzp1s2rSJn3/+ma1bt9KsWbNHPlPQmGVnZ0t+A5L8hmcsYygXBcrExITdu3eTmZnJ2LFjiY6OxtnZ2dCxylzdunXp2LEjK1euJCQkhFdeeYVatWrRpk0bAH755RcARo0ahbu7Ox988AHnzp0jMzOTLl26UKlSJdzd3fV9TUxM2LRpE0OGDGHFihWsW7cOZ2dnEhISuHnzJjt37qR///4sXLiQrVu34uzsTFxcHDVq1MDMzIzY2Fjy8vLIzMykTp06WFlZUalSJaytrcv1iqLlfUVUyW9Y5T0/GGZF3Ycx+kt897O1taVjx44EBwcbOorBDBgwgBo1ajB58mRycnKIjY1l9+7dXLp0iSVLlgDQvn17hg4dirW1NZmZmUydOpWmTZuSlZXFpEmT2L17N/v378fOzo4hQ4YAMH78eKKiooiKiqJFixY4OTnRv39/AKZMmaJvc3Z25uWXX+by5cukp6dz69Ytmjdvzrp164iLi6Nr164GOzZCiIrF6M+gbt26hampKba2tuTl5RESEsLIkSMNHctgLC0tWb16NUOHDqVOnTr06tWLnJwcjhw5QqVKdx/ztGDBAr755hsSExOBu7Px9u7di6urK7GxsZw6dQq4ez+qc+fOD7zHhQsXKCwsfGhbbGwskZGRdOjQgYYNG5KXl8eFCxeIjo7Gw8ODt956Sz+LUAghSsPoz6Bu3LjBm2++Sb9+/Rg0aBCdO3eme/fuho5lUAMHDuT3339n4MCBREREkJyczKRJk/RnUy4uLlhbW/POO++QkpLC/v37adGiBWfPnsXExITly5dz9epVxo0bh7W19QN/evbsSZ8+fR7aNmjQIEaNGoWNjQ1nz54lJiaGxo0b880333DgwIGHPslCCCGehEYppQwd4mmKjIykz/pYQ8d46srTs/jK+zV4yW9Ykt/wDHEPqlmzZg9sN/ozKCGEEM8nKVBCCCGMkhQoIYQQRsnoZ/H9VTqdKlf3ax5XXmExlSuZGDqGEEKUmQp3BlVUVGjoCKXyZ5/eluIkhHjeVLgCJYQQomKQAiWEEMIoVbgCZWpaydARgLv3jIQQQjy5CjdJQqvVUO+j/YaOUSEnagghRFmqcGdQxk6n01FY+HgTOYqKiigufnpnYkop8vLyqGAPDxFCVFBSoMpIeHg4gwYNwtLSEjMzM5o3b87XX3+NTqd7oO++fftwdXXFzMwMMzMzPDw8CAoKIiEhgffff58OHTrg4OCAg4MDvXr1KvFw1qCgIJo1a4aDgwOOjo4MGzaMwMBA3NzcsLa2xsLCAltbW7y9vQkNDS3LQyCEEH9JhbvEZ4zOnTuHq6srlStXxtfXl+rVq7Nnzx7effddLl68yOLFi/V9t27dypQpU2jcuDHTpk2jqKiIjRs34u7ujlIKKysrOnTowIABA9BoNAQGBvLtt98ydepUUlNTGTp0KHZ2dvj4+HD79m02btzIxo0bad26NaNGjaJGjRokJyezdetWOnfuzMmTJ2nVqpUBj44QQjycFKgyMGXKFKytrQkPD6dy5cqkpKQQEBDAqFGj+Oyzz/jXv/5Fw4YNyc7OZsGCBbi7u3Po0CGSk5MxMzNjxowZdO7cmXPnzvHuu++ydOlSioqKMDc357vvvuPWrVsAvPfee2RkZPDTTz/RvHlzoqOj2bx5MwAbNmzAysqKtLQ02rRpQ0BAAC1atGDu3Lls377dkIdHCCEeyugv8U2dOpVOnTrRt29fQ0d5Ijdu3ODw4cOMHz+eGjVq8Oabb9K8eXOuXr3K7NmzUUqxZcsWAA4ePEh6ejozZ85Eo9HQtm1bunXrhoWFBdOmTQPuFhobGxv96rn3bNu2ja1bt/Lxxx9TWFjI7du3S7S//fbb1KtXj7Zt27Ju3Trs7e3p2rWrfvl2IYQwNkZfoHx8fAgMDDR0jCd2+fJlAP2y7KGhoRQWFvLHH3/g6OiIk5OTvk9MTIy+b1JSEjdv3uTixYvk5eXp909LSyM3N7fEe9y4cYMxY8bQrl07JkyYwPDhwykqKirRJyEhQf91jRo10Ol0REZG4uTk9GwGLoQQpWT0l/jat29PUlKSoWM8sezsbABsbGwAKCgoANDP5LOxsWHdunW8+OKLLF68GBMTEywsLPT97u1zb/+hQ4eydevWEu/x3XffYW5uzvr165k3bx7h4eEP5FBKYWJiwooVK/D29mbChAmEhYWxadOmpz9oIYR4Coy+QJVn8fHxaLVa/ddubm7UqFGDlJQUatSoAaBfln3+/PkopVBKcfXqVapXr45Go6Fy5cpYW1tz6dIlgAeK0z316tXDxcWF119/ncGDB1OlShWsra05dOgQvXv3JjMzk507d+Ll5cU777zDN998A8CVK1f+9Pl/Tyo7O/upv2ZZkvyGJfkNz1jGIAXqGapbty41a9akevXqbNiwgX/84x/4+/tz5MgR2rRpw86dO8nJyaFv377s3buXyZMns2TJEjZs2MDUqVPx9/fHzs4OrVbL999/D4CLiwvdunWjdu3aAPj6+hIdHc3p06dZunSp/r2bNm1Kbm4ux48fB2Dnzp307t2b4OBg6tSpw8yZMzl48CCrV6/G39//qY67vK8oKvkNS/IbniFW1H0YKVDPWKVKlfjwww/1fyZNmsTAgQPZt28f48aNAyA3N5f4+HgyMzMBmDdvHvb29vpp5p999hmrV68GoEWLFnz44YfA3X9E77//Pv/5z3/Yv38/fn5++vft0aMH6enpzJkzBwALCwvi4+OpU6cOI0aMAO6evV24cKGsDoUQQvwlUqDKwAcffEBUVBSLFy9m8eLFaDQalFI0btyYPn36cODAAerVqwdAy5YtsbS0ZOTIkYwcOVL/Gr1798bc3JwtW7boZ/3dz87OjqioKGJiYujcufMDn23q2rXrQ7MNGDDg6Q1UCCGeIqMvUBMnTuTUqVOkp6fTpUsXxo0bx+DBgw0d6y8xNTUlMDAQPz8/9u3bR05ODq1bt8bLywuNRsORI0dITk7mhRde4KWXXsLZ2ZlffvmF4OBgtFotHh4edOzYkYKCAg4ePEhaWtoDr+/p6Ym9vT329vZERUVx4sQJNBoN3bp1A+4+YeK/H3FUpUoVvLy8yuowCCHEX2L0BerTTz81dISnpmnTpjRt2vSB7Z6envqv4+Pj0Wg0dO3a9YGzHjMzM/r37//I93F2dsbZ2bnEtnuX9YQQorww+s9BCSGEeD5JgRJCCGGUpEAJIYQwSkZ/D+qv0umUUSwWmFdYTOVKJoaOIYQQ5VaFO4MqKnq8xQCfNSlOQghROhWuQAkhhKgYpEAJIYQwShWuQJmYyKU1IYSoCKRACSGEMEoVbhbfwyQlJREcHIxOp8PV1VX/3LuHCQsLIzQ0FGtrazw8PKhSpYq+7ffffyc8PBxbW1s8PDywtbUF7q61dObMGSIiIrCzs8PDwwNra2t922+//cbFixepVq0aHh4eWFpaPtPxCiFEhaAqmAsXLui/zs/PV76+vsrExEQBClAajUYNGzZMZWdnl9gvKSlJubu76/sBysrKSs2fP19duXJFubm5lWizsbFRn376qYqOjlYdOnQo0ValShX1xRdfqIiICNW6desSbdWqVVPr1q370/xxcXHP6tCUmfI+BslvWJLf8Mp6DPf/3L5fhbvEdz9/f3/WrFnD2LFjOX/+PBEREUyZMoXNmzfrl7qAu2c5Pj4+nDlzhk8//ZRLly5x8uRJPD098ff3p379+kRERLBy5UpiYmI4fvw43bp1Y+LEiTg7O3PlyhW++OILLl++TFBQEB07dmT06NG89NJLpKSkEBgYyOXLl/npp59o0aIFb731FkFBQYY7MEIIUR6UtvJ1795dpaWl6b//9ddf1ahRo5RSSu3YsUM1adJERUZG6tu9vb1VYmLiA/uGh4er7t27q4iIiFLluVeJb9y4oSpXrqzeeustpZRSmzdvVoGBgUoppSZNmqS0Wq2KjY1VSim1f/9+BejPbObNm6eCgoKUUkp/dvTvf/9bFRUVqZkzZ6qQkBBVXFysmjdvrgC1f/9+VVBQoAICAtTp06dVYWGhatiwoQLUzz//rHJzc5W/v786f/68ysvLU7Vq1VLdu3d/aH757cvwJL9hSX7DK9dnUAUFBdy5c+ex+jo6OvLll1/+zz4XL15k/PjxLFu2DBcXF7KystDpdE8STS8kJIS8vDx8fX3R6XSMHDkSX19fcnJyGDVqFDqdjp9//hmAo0ePYmlpybBhwzhz5gzTpk3TLwo4cuRIqlWrxuDBgzlx4gQzZ85k2rRpaLVa3n33XWrVqoWXlxdHjx5lzpw5zJw5E1NTU95++22aNGlCly5d2LdvH/Pnz2fevHmYm5szfPhwfv75ZwoLjeNDxUIIYYz+UoG6fPkyCxYswNPTk7i4uMfap1u3bsTExBAbG/vQ9tjYWMaOHcuiRYto0aIFAGfPnsXT05OVK1dy7dq1vxJRLyEhAYAGDRqQmZlJdnY2xcXFXL9+nfr166PVavV9EhISqFOnDqampvr3u3r1KgANGzbUT6q4t+3+tgYNGpTYdm//R7XpdDr9diGEEA96ZIG6c+cOO3bs4PXXX2f69Ok0bNiQPXv24OLi8nhv8P/PNL766quHto8ZM4YZM2bQrl07/bZu3bqxZcsWbGxsGD16NO+88w4HDhygoKDgMYd1d6l1uHu2d//Uc1NTUwoLC9HpdHz88cc0atSIHTt26F9bq9Xq+wHk5+fr2+69zv9qu/f3o9ruzyiEEOJBj5xm7ubmRpMmTZg7dy4NGzZ8rBfVaDQlvu/bty9ffPEFiYmJD/Tt1KkT27Ztw83NrUQhqVq1KiNGjGDEiBGEhobi7+/P6tWr2bt37yPfPz4+HisrKwAiIiLo1asXTk5OZGVl4eTkxLlz5wBwcXGhdevW5ObmkpiYSGZmJk2aNAHQ/x0REUFsbCy5ubn6bfcWA4yIiCA6OprCwsKH7hcZGYlOp3tom5mZGQUFBcTHx5fInp2d/cC28qa8j0HyG5bkNzyjGcOjbl4FBwer999/X/Xp00etXLlSJSUllWgfMGCAunLliv77Q4cOqY8++kgpdXeSxKxZs5RSSm3ZskUFBAQ8MEkiNTVVjR07VgUEBDzw3pcuXVILFixQPXv2VP7+/urcuXOPfbMtJydH2dvbK3d3d1VcXKzCwsLU6dOnlVJK+fj4KEB9+OGHSimlvLy8FKBmzJihlFLqyJEjKiEhQWVnZ6u6desqQC1cuFAppdSBAwfU1atXVUZGhnJ0dFSAWrVqlVJKqX379qnr16+r1NRUZWdnV2Lixe7du1Vqaqq6du2asra2Vq+//vpD88sNVsOT/IYl+Q2v3EyScHNzY9myZWzcuBEbGxvGjBnDiBEjSEpKAqBjx47s3r0bgOLiYvbs2UPHjh0feJ0BAwZw8uRJbt26VWK7RqNh6dKlxMbGsnz5cuDuGcaQIUOYPn06DRo04IcffmDevHm0bNnysQuvpaUlc+bM4dixY7i6unLy5EnCwsLo3r07O3fuBOC3335jwYIFXL58GYA5c+YwdOhQUlJS2L59O61btyYpKQk7Ozv8/f35xz/+QVpaGps2baJNmzakpaVha2vLhAkTGD58OBkZGaxbt442bdqQk5ODjY0Nvr6+jBw5kuzsbL788kvatWuHUoqZM2c+9liEEOK59CTV7vz58+ratWtKKaUyMzPVxIkTVb9+/VTfvn3VwoULVXFxsVKq5BmUUkqtX79eOTs7P3SaeWZmpurfv7/asGGDiomJUTExMU8S7YFKvG7dOtWgQQP9B2Vr1aqlVq1apZYuXaosLS0VoKpXr67+/e9/K39/f/XCCy/o+7Zr104dPnxY5eTkKD8/P2VjY6Nve+WVV1RQUJDKzMxU48eP178WoLp06aJCQkJUenq6Gj16tLKwsNC3ubu7qzNnzvxpfvnty/Akv2FJfsMzljMojVJKGag2PhORkZE0a9asxDadTkd8fDw6nU4/gw+gsLCQ4uJiKlWqVGICQ3x8PNbW1tSsWbPE6+Tl5REfH4+trS1OTk4l2nJzc0lISKBKlSo4ODiUaLtz5w4JCQlUq1YNe3v7/5k/Pj6eunXrPtHYjUV5H4PkNyzJb3hlPYaH/dyG5+RZfFqtlvr16z+wvVKlSg/MpDM3N9dPgvhvlStX1k90+G8WFhZ/2mZpaUnTpk3/YmohhHi+VehHHQkhhCi/pEAJIYQwShWuQBUXFxs6ghBCiKdACpQQQgijVOEKlBBCiIpBCpQQQgijJAVKCCGEUZICJYQQwihJgRJCCGGUpEAJIYQwSlKghBBCGCUpUEIIIYySFCghhBBGqcItt3Hu3DnMzc0NHUMIIcRjys/Pp1WrVg9sr3AFSgghRMUgl/iEEEIYJSlQQgghjJIUKCGEEEZJCpQQQgijJAVKCCGEUZICJYQQwiiVqwL1yy+/0Lt3b3r27MmaNWseaC8oKGDChAn07NmTwYMHk5SUpG/76quv6NmzJ7179yY4OLgsY+s9af4TJ07g4+NDv3798PHx4eTJk2UdHSjd8Qe4du0arVu35uuvvy6ryCWUJv/FixcZOnQo3t7e9OvXj/z8/LKMrvekYygsLGTKlCn069ePPn368NVXX5V1dODR+U+fPs2AAQNwcXHh4MGDJdp++OEHevXqRa9evfjhhx/KKnIJT5o/MjKyxL+fH3/8sSxj65Xm+ANkZ2fTpUsXZs+eXRZxQZUTRUVFysPDQyUkJKj8/HzVr18/denSpRJ9NmzYoAICApRSSu3bt0+9//77SimlLl26pPr166fy8/NVQkKC8vDwUEVFReUmf0REhEpJSVFKKRUVFaXc3NzKNLtSpct/z7hx49S4ceNUYGBgmeW+pzT5CwsLVd++fVVkZKRSSqlbt26V+b8fpUo3hj179qgJEyYopZS6c+eO6t69u0pMTDS6/ImJiSoyMlJNnjxZHThwQL89PT1dubu7q/T0dJWRkaHc3d1VRkZGuckfGxurrly5opRSKiUlRbm6uqrbt2+XZfxS5b9nzpw5auLEiWrWrFllkrncnEGFhYVRt25dateujZmZGd7e3vz0008l+hw7dowBAwYA0Lt3b06ePIlSip9++glvb2/MzMyoXbs2devWJSwsrNzkd3FxwcHBAYDGjRuTn59PQUFBuckPcPToUV588UUaN25cprnvKU3+EydO0KRJE5o2bQqAnZ0dJiYm5WoMGo2G3NxcioqKyMvLo1KlSlhbWxtd/lq1atG0aVO02pI/mo4fP46rqytVqlThhRdewNXVtcyvhJQmf/369alXrx4ADg4OVK1alVu3bpVVdKB0+QH++OMRun6BAAAD60lEQVQP0tLScHV1LavI5ecS3/Xr13F0dNR/7+DgwPXr1x/o4+TkBICpqSk2Njakp6c/1r7PWmny3+/QoUO4uLhgZmb27EP/V7YnzZ+Tk8PatWt57733yjTzf2d70vxXrlxBo9HwzjvvMGDAANauXVum2e/P96Rj6N27NxYWFri5udG9e3fefvttqlSpYnT5n8W+T8vTyhAWFkZhYSF16tR5mvEeqTT5dTodCxcuZMqUKc8q3kOZlum7iVK5dOkSS5Ys4ZtvvjF0lL9k1apVDB8+HCsrK0NHeSLFxcWcPXuW7du3Y2FhwYgRI2jevDmdOnUydLTHFhYWhlarJTg4mMzMTN544w06d+5M7dq1DR3tuXLjxg0mT57MwoULH3qWYqw2bdpEly5dShS4slBuCpSDgwMpKSn6769fv66/7HV/n+TkZBwdHSkqKiIrKws7O7vH2vdZK01+gJSUFN577z0WLlxY5r953cv2pPnPnz/PoUOHWLJkCZmZmWi1WszNzRk2bFi5yO/o6Ej79u2pWrUqAF26dCEiIqLMC1RpxrBy5UpeffVVKlWqRLVq1WjTpg3h4eFlWqBK8/+hg4MDp06dKrFvhw4dnnrGR2Uozc+R7OxsfH19+eCDDx76YNRnrTT5Q0NDOXv2LJs3byYnJ4fCwkIsLS3x8/N7VnGBcnSJ7+WXXyYuLo7ExEQKCgrYv38/7u7uJfq4u7vrZ/ccOnSIV155BY1Gg7u7O/v376egoIDExETi4uJo0aJFucmfmZnJqFGjmDRpEm3bti3T3PeUJv+mTZs4duwYx44dY/jw4fj6+pZpcSptfjc3N6Kjo/X3cE6fPk2jRo3KNH9px+Dk5MRvv/0GwJ07dzh//jwNGjQwuvx/xs3NjePHj3P79m1u377N8ePHcXNze8aJSypN/oKCAsaOHcvf/vY3PD09n3HShytN/qVLlxIUFMSxY8eYMmUKr7322jMvTkD5mcWnlFJBQUGqV69eysPDQ61evVoppdSyZcvU0aNHlVJK5eXlqXHjxqkePXqogQMHqoSEBP2+q1evVh4eHqpXr14qKCioXOX//PPPVcuWLVX//v31f1JTU8tN/vutWLHCILP4lCpd/l27dikvLy/l7e2tFi5caJD8Sj35GLKzs9W4ceOUl5eX6tOnj1q7dq1R5j9//rx69dVXVcuWLVWHDh2Ul5eXft9t27apHj16qB49eqjt27eXq/y7du1SLi4uJf4fvnDhQrnJf78dO3aU2Sw+WW5DCCGEUSo3l/iEEEI8X6RACSGEMEpSoIQQQhglKVBCCCGMkhQoIYQQRkkKlBBCCKMkBUoIIYRR+n9de09aYys0bgAAAABJRU5ErkJggg==\n", "text/plain": [ "
" ] }, "metadata": {}, "output_type": "display_data" }, { "data": { "image/png": "iVBORw0KGgoAAAANSUhEUgAAAVcAAAEYCAYAAADoP7WhAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAALEgAACxIB0t1+/AAAADh0RVh0U29mdHdhcmUAbWF0cGxvdGxpYiB2ZXJzaW9uMy4yLjEsIGh0dHA6Ly9tYXRwbG90bGliLm9yZy+j8jraAAAgAElEQVR4nO3de1yUZfr48c+DKKKAisqgZv4C00pN+2Z5whMGonhOpNZMLHNXTVLTtDQPeaDd1DWzE9maVpuJJ0rNE6bk+ayVaYlYmDD0RQQUOc3cvz/4MpurMsMwwwzD9X69ntdrZ+bhnmtYvLrneu7nujWllEIIIYRNuTk6ACGEcEWSXIUQwg4kuQohhB1IchVCCDuQ5CqEEHYgyVUIIexAkqu4xYMPPsjAgQPp168f0dHR3Lx50+qxpk+fzrZt2wCYMWMGFy5cuOu5hw8f5sSJE2V+j+DgYK5evWrx83/2yCOPlOm93nnnHT7++OMy/YyouiS5ilvUrFmT+Ph4Nm/eTPXq1VmzZs0trxcVFVk17oIFC2jevPldXz9y5AgnT560amwhnJG7owMQzqt9+/acP3+ew4cP8/bbb+Pj40NycjJbt25l0aJFHDlyhIKCAoYPH85TTz2FUop58+axf/9+GjVqRPXq1U1jjRgxgldeeYU2bdqQmJjIP//5TwwGA/Xq1WPBggWsWbMGNzc3vvrqK15//XUCAgKYPXs2V65cAeC1117j0UcfJTMzk5dffhm9Xk+7du2w5B6YcePGkZaWRn5+Ps8++yyRkZGm1xYuXMj+/ftp0KAB//znP/H19eW3335j7ty5ZGZmUrNmTebNm0dgYKDtf8HCtSkh/qRdu3ZKKaUKCwvV3/72N/X555+rQ4cOqbZt26rffvtNKaXUmjVr1LvvvquUUio/P18NHjxY/fbbb2r79u0qKipKFRUVqbS0NPXoo4+qb775Riml1DPPPKPOnDmjMjIyVLdu3UxjZWZmKqWUWrZsmVqxYoUpjsmTJ6ujR48qpZT6/fffVVhYmFJKqXnz5ql33nlHKaXUt99+q1q0aKEyMjJu+xw9e/Y0PV/yHjdv3lTh4eHq6tWrSimlWrRooeLj45VSSr3zzjtq7ty5Simlnn32WZWcnKyUUurUqVNqxIgRd4xRiNLIzFXcIi8vj4EDBwLFM9ehQ4dy8uRJ2rRpQ9OmTQHYv38/58+fZ/v27QDk5OTw66+/cvToUcLDw6lWrRo6nY6OHTveNv6pU6do3769aay6deveMY4DBw7cUqO9fv06N27c4OjRoyxfvhyAHj16UKdOHbOf6dNPP2Xnzp0ApKam8uuvv1KvXj3c3Nzo27cvAAMHDuTFF1/kxo0bnDx5kpdeesn08wUFBWbfQ4j/JslV3KKk5vrfatWqZfrfSilmzpxJ165dbzln7969NovDaDSydu1aPDw8yjXO4cOHOXDgAF9++SWenp6MGDGC/Pz8O56raRpKKXx8fO74OxCiLOSCliizoKAgvvjiCwoLCwFITk4mNzeXxx57jG+++QaDwUB6ejqHDx++7WfbtWvHsWPHSElJAeDatWsA1K5dmxs3btzyHp9++qnp8U8//QTAY489xtdffw0UJ/OsrKxSY83JyaFOnTp4enqSlJTEqVOnTK8ZjUbT7Pvrr7/m0UcfxcvLi3vuuYdvvvkGKP4Pyblz58r2CxICSa7CChERETRv3pwhQ4bQr18/Zs2ahcFgICQkhGbNmtG3b1+mTZtGu3btbvtZX19f3njjDSZMmMCAAQOYNGkSAD179mTnzp0MHDiQY8eOMWPGDH744Qf69+9P3759+eKLLwAYP348x44dIzw8nJ07d9K4ceNSY+3WrRtFRUX06dOHxYsX3xJTrVq1OHPmDP369ePQoUOMHz8egLfeeot169YxYMAAwsPD2bVrl61+daIK0ZSSloNCCGFrMnMVQgg7kOQqhBB2IMlVCCHsQJKrEELYgSRXIYSwA0muQghhB5JchRDCDiS5OglZbiyEa5Hk6kB/TqiapnH9+nXOnTvH3Llz2b17twMjE0KUlyRXB9I0DYC0tDROnz7N2LFj2bp1Kzt37sTdXXrqCFGZyb9gB7hy5Qo6nY5q1arx6aefkpiYiJ+fH8OGDeOee+7hu+++IyAgwNFhCiHKQXoLVCClFKmpqYwcOZLPP/+cWrVqsXXrVtq2bYufnx/16tXjrbfe4r777mPo0KGODlcIUQ4yc61Amqbh5eVFw4YN8fPzA2Do0KG4uRVXZ27cuEFaWhp9+vRxZJhCCBuQmmsFuXTpElDcjLpatWq3bPRX8uVhyZIlALRu3brC4xNC2JbMXO1MKUVhYSETJkygc+fOjB07lszMTHJyckxbjZQICgrigQceMP1cyQUvIUTlIzVXO9Pr9eh0OlJTUxk7diyPPPIISUlJ9OjRA19fX2rUqIGvry/5+fn4+Pjw8MMPU61aNUeHLYQoJ0mudqKUIisri+HDhzNy5EiGDRuGXq9nypQpHD16lBdeeIHffvuN/Px8NE3j6tWrLFu2DJ1O5+jQhRA2IMnVzvbs2cPbb7/NiBEjGDJkCFevXmXMmDGEhYUxevRo03n5+fnl3oxPCOE8pOZqBz///DP169fHy8uLHj164Onpyfz58zEYDERERPDuu+/yt7/9jZSUFObOnQtA9erVHRy1EMKWZOZqY6mpqYSGhtKwYUNatGjB8OHDCQwMJCsri1deeYWxY8fSt29f0tLSmDx5MsuXL8fX19fRYQshbKzanDlz5jg6CFeRnZ1NgwYNqFWrlqlXgIeHB2+//Tbe3t7k5OSwbds2PDw86NChA4MGDaJ27dqODlsIYQeyztVG9Ho9kydP5ujRozz77LN06tSJ5s2b07JlSz755BN8fX1p1qwZaWlpLF68mKysrFuWYQkhXIuUBWzk2rVrbNmyhX379jFmzBjatGnDunXrOHXqFOHh4XTt2hWApKQkfHx8aNiwoYMjFkLYk5QFbKRmzZo0a9YMg8HA2rVruffeewkODubq1ascPnyYnJwcWrZsia+vr5QChKgC5HtpORw9epT4+HjT4zp16tC7d2+eeOIJPvzwQ3755RcGDBhA8+bN+f7777l+/boDoxVCVCRZilUORUVFxMTE4ObmRv/+/YHiBBsaGkpeXh47d+5kwoQJhIWFUbNmTby8vBwcsRCiosjM1QpKKZRSdOrUiWXLlrF06VK++uor02t169YlMDCQ5ORkjEYjfn5++Pj4VHicf/zxh2wf4+Ryc3MdHUKZyN+T5SS5WkHTNDRN49y5czz++OPMnz+ft99+m82bN5uareTm5pKfn2/xPx6DwWDTGL/77jtefPFFUlNTbTbmL7/8wpEjR8jMzLTZmL/++ivff/89BQUFNhvz0qVLfP/99xiNRpv9Xi9evMjJkycpLCy02Zi7du1i0aJFZGRk2GQ8gFOnTrFp0yZOnTpls9/psWPH2LRpE1D8ty8J1jJyQctK69at49133yU8PJyAgABatmzJu+++y6VLl9i7dy/x8fHMnDmTRo0alTpOcnKyqTuWwWCwyfKsffv2sWjRIjIzM7l27RrdunUr95h79+5l9uzZJCcns2PHDjp27FjuC3Pffvstr7/+OsePH+fgwYO0aNGCevXqlWvMXbt2MWfOHJKSkjh16hSXL1+mefPm5boDbseOHcycOZMffviBw4cPo9frCQwMpEaNGlaPeeTIERYsWEBUVBQtW7a0epw/S0hIICYmhvz8fI4ePUrr1q2pW7eu1eMZjUZyc3MZP348x48fx83NjTZt2qBpGkajUbq2maNEmRgMBqWUUu+//77atWvXLa+dP39ebdmyRa1atUpdunTJ7Fi7d+9WDz/8sJo8ebLpuaKionLFt3//fvXEE0+on3/+WRUUFKhRo0apI0eOlGvMQ4cOqdDQUHX69GmllFLjxo1T+/fvL9eYx48fV2FhYerHH39USik1e/ZsNX369HKNefXqVfX888+rX375RSmlVFxcnBoyZIhavny5ysnJsWrMgoIC9dJLL6ljx44ppZTatm2bevPNN9WSJUusHlMppf71r3+pFStWKKWUSktLU/v27VOnTp1S2dnZVo139epV9dxzz6nz588rpZSaPn262rp1q/rf//1flZeXZ3WcSikVGxurPv74YzV16lS1cuXKco1VlUhZoIzc3NxISUlh//79t3Sw+vXXX2nRogV9+/bl2WefpVmzZqWOk5uby2effcZrr71G9erVmTJlCgDVqlUr19dOg8HA3//+d+6//35u3rzJfffdxy+//AJYXy9r0KABc+fO5eGHH+aPP/7g9OnTfPbZZ8yaNYtt27ZZPe4LL7zAQw89BEB0dDRZWVnl+irr7u5Obm4uf/zxB1C8y0OTJk3IzMxkz549Vo97/fp1fv31VwBCQkLo2bMnhYWFfP3111Z/9j+3lXzppZdYv349n332GXPnziUrK6vM47m7u5OXl8fFixe5fv06R44cIT4+noULF/Lee++Vq7br7u5OamoqgwcP5syZM8TExLB48WKUUhiNRqvHdXVSFigDpRRFRUUsXbqU4OBgunTpQlJSEjNmzODixYvcf//9eHl5WfR1qXr16nTs2JHWrVvTsWNHEhISSEhIIDQ0tFylgWbNmtGoUSOMRiM1a9ZE0zRiYmIICgqiQYMGVo3p6+vLPffcA8Dq1atp06YNc+bMITMzk927d/P444/j6elZpjH9/Pxo1qwZNWrUwGAwkJOTwxdffEGfPn3w9PQkMzOzzGN6eHhQ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\n", "text/plain": [ "
" ] }, "metadata": {}, "output_type": "display_data" }, { "data": { "image/png": "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\n", "text/plain": [ "
" ] }, "metadata": {}, "output_type": "display_data" }, { "data": { "image/png": "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unUr95h79+5l9uzZJCcns2PHDjp27FjuC3Pffvstr7/+OsePH+fgwYO0aNGCevXqlWvMXbt2MWfOHJKSkjh16hSXL1+mefPm5boDbseOHcycOZMffviBw4cPo9frCQwMpEaNGlaPeeTIERYsWEBUVBQtW7a0epw/S0hIICYmhvz8fI4ePUrr1q2pW7eu1eMZjUZyc3MZP348x48fx83NjTZt2qBpGkajUbq2maNEmRgMBqWUUu+//77atWvXLa+dP39ebdmyRa1atUpdunTJ7Fi7d+9WDz/8sJo8ebLpuaKionLFt3//fvXEE0+on3/+WRUUFKhRo0apI0eOlGvMQ4cOqdDQUHX69GmllFLjxo1T+/fvL9eYx48fV2FhYerHH39USik1e/ZsNX369HKNefXqVfX888+rX375RSmlVFxcnBoyZIhavny5ysnJsWrMgoIC9dJLL6ljx44ppZTatm2bevPNN9WSJUusHlMppf71r3+pFStWKKWUSktLU/v27VOnTp1S2dnZVo139epV9dxzz6nz588rpZSaPn262rp1q/rf//1flZeXZ3WcSikVGxurPv74YzV16lS1cuXKco1VlUhZoIzc3NxISUlh//79t3Sw+vXXX2nRogV9+/bl2WefpVmzZqWOk5uby2effcZrr71G9erVmTJlCgDVqlUr19dOg8HA3//+d+6//35u3rzJfffdxy+//AJYXy9r0KABc+fO5eGHH+aPP/7g9OnTfPbZZ8yaNYtt27ZZPe4LL7zAQw89BEB0dDRZWVnl+irr7u5Obm4uf/zxB1C8y0OTJk3IzMxkz549Vo97/fp1fv31VwBCQkLo2bMnhYWFfP3111Z/9j+3lXzppZdYv349n332GXPnziUrK6vM47m7u5OXl8fFixe5fv06R44cIT4+noULF/Lee++Vq7br7u5OamoqgwcP5syZM8TExLB48WKUUhiNRqvHdXVSFigDpRRFRUUsXbqU4OBgunTpQlJSEjNmzODixYvcf//9eHl5WfR1qXr16nTs2JHWrVvTsWNHEhISSEhIIDQ0tFylgWbNmtGoUSOMRiM1a9ZE0zRiYmIICgqiQYMGVo3p6+vLPffcA8Dq1atp06YNc+bMITMzk927d/P444/j6elZpjH9/Pxo1qwZNWrUwGAwkJOTwxdffEGfPn3w9PQkMzOzzGN6eHhQ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\n", "text/plain": [ "
" ] }, "metadata": {}, "output_type": "display_data" }, { "data": { "image/png": "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\n", "text/plain": [ "
" ] }, "metadata": {}, "output_type": "display_data" } ], "source": [ "# Confustion matrix by model\n", "confusion_matrix(\n", " test_stats_list,\n", " train_metadata_json,\n", " 'label',\n", " [10,10,10],\n", " False,\n", " model_names=models_list,\n", " output_directory='./viz2',\n", " file_format='png'\n", ")" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.7.8" } }, "nbformat": 4, "nbformat_minor": 2 } ================================================ FILE: examples/ray/kubernetes/README.md ================================================ ## Running on Kubernetes ### Connect to k8s cluster with a Ray operator You should now be pointing to your cluster with `kubectl`. Check the nodes to make sure you're connected correctly: ``` kubectl get nodes ``` We recommend using the [Kuberay](https://github.com/ray-project/kuberay) implementation of the Ray Operator to launch Ray clusters. ### Configure the Ray cluster First choose your preferred cluster template from `clusters`, for example: ``` export CLUSTER_NAME=ludwig-ray-cpu-cluster ``` ### Start the cluster ``` ./utils/ray_up.sh $CLUSTER_NAME ``` ### Submit a script for execution ``` ./utils/submit.sh $CLUSTER_NAME scripts/train.py ``` ### SSH into the head node ``` ./utils/attach.sh $CLUSTER_NAME ``` ### Run the Ray Dashboard ``` ./utils/dashboard.sh $CLUSTER_NAME ``` Navigate to http://localhost:8267 ### (For Ludwig Developers) Sync local Ludwig repo ``` ./utils/rsync_up.sh $CLUSTER_NAME ~/repos/ludwig ``` ### Shutdown the cluster ``` ./utils/ray_down.sh $CLUSTER_NAME ``` ### Connecting to remote filesystems (S3, GCS, etc.) Build a custom Docker image deriving from `ludwig-ray` or `ludwig-ray-gpu` containing the library needed for your data: - `s3fs` - `adlfs` - `gcsfs` Set environment variables into the cluster YAML definition with your credentials. For example, you can connect to S3 using the environment variables described in the [boto3 documentation](https://boto3.amazonaws.com/v1/documentation/api/latest/guide/configuration.html#using-environment-variables). You could also include the credentials directly into the Docker image if they don't need to be configured at runtime. ================================================ FILE: examples/ray/kubernetes/clusters/ludwig-ray-cpu-cluster.yaml ================================================ apiVersion: ray.io/v1alpha1 kind: RayCluster metadata: labels: controller-tools.k8s.io: "1.0" name: ludwig-ray-cpu-cluster spec: rayVersion: "2.3.1" headGroupSpec: serviceType: ClusterIP replicas: 1 rayStartParams: port: "6379" metrics-export-port: "8080" node-manager-port: "22346" object-manager-port: "22345" object-store-memory: "200000000" redis-password: "LetMeInRay" dashboard-host: "0.0.0.0" node-ip-address: $MY_POD_IP block: "true" template: metadata: labels: rayCluster: ludwig-ray-cpu-cluster rayNodeType: head groupName: headgroup annotations: key: value spec: volumes: - emptyDir: medium: Memory name: dshm containers: - name: ray-head image: ludwigai/ludwig-ray:master lifecycle: preStop: exec: command: - /bin/sh - -c - ray stop env: - name: MY_POD_IP valueFrom: fieldRef: fieldPath: status.podIP ports: - containerPort: 6379 name: redis protocol: TCP - containerPort: 10001 name: client protocol: TCP - containerPort: 8265 name: dashboard protocol: TCP - containerPort: 8000 name: ray-serve protocol: TCP - containerPort: 8080 name: metrics protocol: TCP resources: limits: cpu: "8" memory: 16Gi requests: cpu: "4" memory: 8Gi securityContext: capabilities: add: - SYS_PTRACE workerGroupSpecs: - replicas: 1 minReplicas: 1 maxReplicas: 1 groupName: worker-cpu rayStartParams: redis-password: "LetMeInRay" node-ip-address: $MY_POD_IP block: "true" template: metadata: labels: rayCluster: ludwig-ray-cpu-cluster rayNodeType: worker groupName: worker-cpu annotations: key: value spec: volumes: - emptyDir: medium: Memory name: dshm initContainers: - name: init-myservice image: busybox:1.28 command: [ "sh", "-c", "until nslookup $RAY_IP.$(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace).svc.cluster.local; do echo waiting for myservice; sleep 2; done", ] containers: - name: machine-learning image: ludwigai/ludwig-ray:master lifecycle: preStop: exec: command: - /bin/sh - -c - ray stop env: - name: MY_POD_NAME valueFrom: fieldRef: fieldPath: metadata.name - name: MY_POD_IP valueFrom: fieldRef: fieldPath: status.podIP ports: - containerPort: 80 protocol: TCP resources: limits: cpu: "8" memory: 16Gi requests: cpu: "4" memory: 8Gi ================================================ FILE: examples/ray/kubernetes/clusters/ludwig-ray-gpu-cluster.yaml ================================================ apiVersion: ray.io/v1alpha1 kind: RayCluster metadata: labels: controller-tools.k8s.io: "1.0" name: ludwig-ray-gpu-cluster spec: rayVersion: "2.3.1" headGroupSpec: serviceType: ClusterIP replicas: 1 rayStartParams: port: "6379" metrics-export-port: "8080" node-manager-port: "22346" object-manager-port: "22345" object-store-memory: "200000000" redis-password: "LetMeInRay" dashboard-host: "0.0.0.0" node-ip-address: $MY_POD_IP block: "true" template: metadata: labels: rayCluster: ludwig-ray-gpu-cluster rayNodeType: head groupName: headgroup annotations: key: value spec: volumes: - emptyDir: medium: Memory name: dshm containers: - name: ray-head image: ludwigai/ludwig-ray-gpu:master lifecycle: preStop: exec: command: - /bin/sh - -c - ray stop env: - name: MY_POD_IP valueFrom: fieldRef: fieldPath: status.podIP ports: - containerPort: 6379 name: redis protocol: TCP - containerPort: 10001 name: client protocol: TCP - containerPort: 8265 name: dashboard protocol: TCP - containerPort: 8000 name: ray-serve protocol: TCP - containerPort: 8080 name: metrics protocol: TCP resources: limits: cpu: "8" memory: 16Gi nvidia.com/gpu: "1" requests: cpu: "4" memory: 8Gi securityContext: capabilities: add: - SYS_PTRACE workerGroupSpecs: - replicas: 1 minReplicas: 1 maxReplicas: 1 groupName: worker-gpu rayStartParams: redis-password: "LetMeInRay" node-ip-address: $MY_POD_IP block: "true" template: metadata: labels: rayCluster: ludwig-ray-gpu-cluster rayNodeType: worker groupName: worker-gpu annotations: key: value spec: volumes: - emptyDir: medium: Memory name: dshm initContainers: - name: init-myservice image: busybox:1.28 command: [ "sh", "-c", "until nslookup $RAY_IP.$(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace).svc.cluster.local; do echo waiting for myservice; sleep 2; done", ] containers: - name: machine-learning image: ludwigai/ludwig-ray-gpu:master lifecycle: preStop: exec: command: - /bin/sh - -c - ray stop env: - name: MY_POD_NAME valueFrom: fieldRef: fieldPath: metadata.name - name: MY_POD_IP valueFrom: fieldRef: fieldPath: status.podIP ports: - containerPort: 80 protocol: TCP resources: limits: cpu: "8" memory: 16Gi nvidia.com/gpu: "1" requests: cpu: "4" memory: 8Gi ================================================ FILE: examples/ray/kubernetes/utils/attach.sh ================================================ #!/bin/bash cluster_name="${1:-$CLUSTER_NAME}" head_pod=$(kubectl get pods | grep $cluster_name-head | cut -d' ' -f1) kubectl exec -it $head_pod -- /bin/bash ================================================ FILE: examples/ray/kubernetes/utils/dashboard.sh ================================================ #!/bin/bash cluster_name="${1:-$CLUSTER_NAME}" head_pod=$(kubectl get pods | grep $cluster_name-head | cut -d' ' -f1) kubectl port-forward ${head_pod} 8267:8265 ================================================ FILE: examples/ray/kubernetes/utils/krsync.sh ================================================ #!/bin/bash # https://serverfault.com/a/887402 if [ -z "$KRSYNC_STARTED" ]; then export KRSYNC_STARTED=true exec rsync --blocking-io --rsh "$0" $@ fi # Running as --rsh namespace='' pod=$1 shift # If use uses pod@namespace rsync passes as: {us} -l pod namespace ... if [ "X$pod" = "X-l" ]; then pod=$1 shift namespace="-n $1" shift fi exec kubectl $namespace exec -i $pod -- "$@" ================================================ FILE: examples/ray/kubernetes/utils/ray_down.sh ================================================ #!/bin/bash cluster_name="${1:-$CLUSTER_NAME}" kubectl delete -f clusters/$cluster_name.yaml ================================================ FILE: examples/ray/kubernetes/utils/ray_up.sh ================================================ #!/bin/bash cluster_name="${1:-$CLUSTER_NAME}" kubectl apply -f clusters/$cluster_name.yaml ================================================ FILE: examples/ray/kubernetes/utils/rsync_up.sh ================================================ #!/bin/bash # Example: ./rsync_up.sh cluster-name ~/repos/ludwig cluster_name="${1:-$CLUSTER_NAME}" ludwig_local_dir=$2 pods=$(kubectl get pods --no-headers -o custom-columns=":metadata.name" | grep ${cluster_name}-) script_full_path=$(dirname "$0") echo "Rsync to head and workers..." for pod in $pods do echo "Rsync to pod: $pod" ${script_full_path}/krsync.sh -a --progress --stats ${ludwig_local_dir}/ludwig/ ${pod}:/home/ray/anaconda3/lib/python3.7/site-packages/ludwig done ================================================ FILE: examples/ray/kubernetes/utils/submit.sh ================================================ #!/bin/bash cluster_name="${1:-$CLUSTER_NAME}" py_script=$2 head_pod=$(kubectl get pods | grep $cluster_name-head | cut -d' ' -f1) fname=$(basename $py_script) kubectl cp $py_script $head_pod:/home/ray/. && kubectl exec -it $head_pod -- python /home/ray/$fname ================================================ FILE: examples/ray/kubernetes/utils/upload.sh ================================================ #!/bin/bash cluster_name="${1:-$CLUSTER_NAME}" py_script=$2 head_pod=$(kubectl get pods | grep $cluster_name-head | cut -d' ' -f1) fname=$(basename $py_script) kubectl cp $py_script $head_pod:/home/ray/. echo /home/ray/$fname ================================================ FILE: examples/regex_freezing/ecd_freezing_with_regex_training.py ================================================ import logging import os import shutil import pandas as pd import yaml from datasets import load_dataset from ludwig.api import LudwigModel """ To inspect model layers in the terminal, type: "ludwig collect_summary -pm resnet18" For some models, a HuggingFace Token will be necessary. Once you obtain one, use "export HUGGING_FACE_HUB_TOKEN=""" in the terminal. """ dataset = load_dataset("beans") train_df = pd.DataFrame( {"image_path": [f"train_{i}.jpg" for i in range(len(dataset["train"]))], "label": dataset["train"]["labels"]} ) test_df = pd.DataFrame( {"image_path": [f"test_{i}.jpg" for i in range(len(dataset["test"]))], "label": dataset["test"]["labels"]} ) os.makedirs("train_images", exist_ok=True) os.makedirs("test_images", exist_ok=True) for i, img in enumerate(dataset["train"]["image"]): img.save(f"train_images/train_{i}.jpg") for i, img in enumerate(dataset["test"]["image"]): img.save(f"test_images/test_{i}.jpg") train_df["image_path"] = train_df["image_path"].apply(lambda x: os.path.join("train_images", x)) test_df["image_path"] = test_df["image_path"].apply(lambda x: os.path.join("test_images", x)) train_df.to_csv("beans_train.csv", index=False) test_df.to_csv("beans_test.csv", index=False) config = yaml.safe_load(r""" input_features: - name: image_path type: image encoder: type: resnet use_pretrained: true trainable: true output_features: - name: label type: category trainer: epochs: 1 batch_size: 5 layers_to_freeze_regex: '(layer1\.0\.*|layer2\.0\.*)' """) model = LudwigModel(config, logging_level=logging.INFO) train_stats = model.train(dataset="beans_train.csv", skip_save_model=True) eval_stats, predictions, output_directory = model.evaluate(dataset="beans_test.csv") print("Training Statistics: ", train_stats) print("Evaluation Statistics: ", eval_stats) shutil.rmtree("train_images") shutil.rmtree("test_images") os.remove("beans_train.csv") os.remove("beans_test.csv") ================================================ FILE: examples/regex_freezing/llm_freezing_with_regex_training.py ================================================ import logging import yaml from ludwig.api import LudwigModel """ To inspect model layers in the terminal, type: "ludwig collect_summary -pm resnet18" For some models, a HuggingFace Token will be necessary. Once you obtain one, use "export HUGGING_FACE_HUB_TOKEN=""" in the terminal. """ config_str = yaml.safe_load(r""" model_type: llm base_model: facebook/opt-350m adapter: type: lora prompt: template: | ### Instruction: Generate a concise summary of the following text, capturing the main points and conclusions. ### Input: {input} ### Response: input_features: - name: prompt type: text preprocessing: max_sequence_length: 256 output_features: - name: output type: text preprocessing: max_sequence_length: 256 trainer: type: finetune layers_to_freeze_regex: (decoder\.layers\.22\.final_layer_norm\.*) learning_rate: 0.0001 batch_size: 5 gradient_accumulation_steps: 16 epochs: 1 learning_rate_scheduler: warmup_fraction: 0.01 preprocessing: sample_ratio: 0.1 generation: pad_token_id : 0 """) model = LudwigModel(config=config_str, logging_level=logging.INFO) results = model.train(dataset="ludwig://alpaca") ================================================ FILE: examples/semantic_segmentation/camseq.py ================================================ import logging import os import shutil import pandas as pd import torch import yaml from torchvision.utils import save_image from ludwig.api import LudwigModel from ludwig.datasets import camseq # clean out prior results shutil.rmtree("./results", ignore_errors=True) # set up Python dictionary to hold model training parameters with open("./config_camseq.yaml") as f: config = yaml.safe_load(f.read()) # Define Ludwig model object that drive model training model = LudwigModel(config, logging_level=logging.INFO) # load Camseq dataset df = camseq.load(split=False) pred_set = df[0:1] # prediction hold-out 1 image data_set = df[1:] # train,test,validate on remaining images # initiate model training train_stats, _, output_directory = model.train( # training statistics # location for training results saved to disk dataset=data_set, experiment_name="simple_image_experiment", model_name="single_model", skip_save_processed_input=True, ) # print("{}".format(model.model)) # predict pred_set.reset_index(inplace=True) pred_out_df, results = model.predict(pred_set) if not isinstance(pred_out_df, pd.DataFrame): pred_out_df = pred_out_df.compute() pred_out_df["image_path"] = pred_set["image_path"] pred_out_df["mask_path"] = pred_set["mask_path"] for index, row in pred_out_df.iterrows(): pred_mask = torch.from_numpy(row["mask_path_predictions"]) pred_mask_path = os.path.dirname(os.path.realpath(__file__)) + "/predicted_" + os.path.basename(row["mask_path"]) print(f"\nSaving predicted mask to {pred_mask_path}") if torch.any(pred_mask.gt(1)): pred_mask = pred_mask.float() / 255 save_image(pred_mask, pred_mask_path) print("Input image_path: {}".format(row["image_path"])) print("Label mask_path: {}".format(row["mask_path"])) print(f"Predicted mask_path: {pred_mask_path}") ================================================ FILE: examples/semantic_segmentation/config_camseq.yaml ================================================ input_features: - name: image_path type: image preprocessing: num_processes: 6 infer_image_max_height: 1024 infer_image_max_width: 1024 encoder: unet output_features: - name: mask_path type: image preprocessing: num_processes: 6 infer_image_max_height: 1024 infer_image_max_width: 1024 infer_image_num_classes: true num_classes: 32 decoder: type: unet num_fc_layers: 0 loss: type: softmax_cross_entropy combiner: type: concat num_fc_layers: 0 trainer: epochs: 100 early_stop: -1 batch_size: 1 max_batch_size: 1 ================================================ FILE: examples/serve/README.md ================================================ # Ludwig Model Serve Example This example shows Ludwig's http model serving capability, which is able to load a pre-trained Ludwig model and respond to REST APIs for predictions. A simple client program illustrates how to invoke the REST API to retrieve predictions for provided input features. The two REST APIs covered by this example: | REST API | Description | | ---------------- | ------------------------------- | | `/predict` | Single record prediction | | `/batch_predict` | Prediction for batch of records | ### Preparatory Steps - Run the `simple_model_training.py` example in `examples/titanic`. This should result the following file structures: ``` examples/ titantic/ results/ simple_experiment_simple_model/ model/ description.json training_statistics.json ``` ### Run Model Server Example - Open two terminal windows - In first terminal window: - Ensure current working directory is `examples/serve` - Start ludwig model server with the `titanic` trained model. The following command uses the default host address (`0.0.0.0`) and port number (`8000`). ``` ludwig serve --model_path ../titanic/results/simple_experiment_simple_model/model ``` Sample start up messages for ludwig model server ``` ███████████████████████ █ █ █ █ ▜█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ███ █ █ █ █ █ █ █ █ █ ▌ █ █ █████ █ █ █ █ █ █ █ █ █ █ ▟█ █ █ █ ███████████████████████ ludwig v0.3 - Serve INFO: Started server process [4429] INFO: Waiting for application startup. INFO: Application startup complete. INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit) ``` - In the second terminal window: - Ensure current working director is `examples/serve` - Run the sample client program ``` python client_program.py ``` Output should look like this ```` retrieved 1309 records for predictions single record for prediction: {'PassengerId': 1, 'Survived': 0.0, 'Pclass': 3, 'Name': 'Braund, Mr. Owen Harris', 'Sex': 'male', 'Age': 22.0, 'SibSp': 1, 'Parch': 0, 'Ticket': 'A/5 21171', 'Fare': 7.25, 'Cabin': nan, 'Embarked': 'S', 'split': 0} invoking REST API /predict for single record... Received 1 predictions Sample predictions: Survived_predictions Survived_probabilities_False Survived_probabilities_True Survived_probability 0 False 0.906132 0.093868 0.906132 invoking REST API /batch_predict for entire dataframe... Received 1309 predictions Sample predictions: Survived_predictions Survived_probabilities_False Survived_probabilities_True Survived_probability 0 False 0.906132 0.093868 0.906132 1 True 0.165714 0.834286 0.834286 2 True 0.441169 0.558831 0.558831 3 True 0.228311 0.771689 0.771689 4 False 0.878072 0.121928 0.878072``` ```` ================================================ FILE: examples/serve/client_program.py ================================================ import sys import pandas as pd import requests from ludwig.datasets import titanic # Ludwig model server default values LUDWIG_HOST = "0.0.0.0" LUDWIG_PORT = "8000" # # retrieve data to make predictions # test_df = titanic.load() print(f"retrieved {test_df.shape[0]:d} records for predictions") # # execute REST API /predict for a single record # # get a single record from dataframe and convert to list of dictionaries prediction_request_dict_list = test_df.head(1).to_dict(orient="records") # extract dictionary for the single record only prediction_request_dict = prediction_request_dict_list[0] print("single record for prediction:\n", prediction_request_dict) # construct URL predict_url = "".join(["http://", LUDWIG_HOST, ":", LUDWIG_PORT, "/predict"]) print("\ninvoking REST API /predict for single record...") # connect using the default host address and port number try: response = requests.post(predict_url, data=prediction_request_dict) except requests.exceptions.ConnectionError as e: print(e) print("REST API /predict failed") sys.exit(1) # check if REST API worked if response.status_code == 200: # REST API successful # convert JSON response to panda dataframe pred_df = pd.read_json("[" + response.text + "]", orient="records") print(f"\nReceived {pred_df.shape[0]:d} predictions") print("Sample predictions:") print(pred_df.head()) else: # Error encountered during REST API processing print("\nError during predictions, error code: ", response.status_code, "reason code: ", response.text) # # execute REST API /batch_predict on a pandas dataframe # # create json representation of dataset for REST API prediction_request_json = test_df.to_json(orient="split") print("\ninvoking REST API /batch_predict for entire dataframe...") # construct URL batch_predict_url = "".join(["http://", LUDWIG_HOST, ":", LUDWIG_PORT, "/batch_predict"]) # connect using the default host address and port number response = requests.post(batch_predict_url, data={"dataset": prediction_request_json}) try: response = requests.post(batch_predict_url, data={"dataset": prediction_request_json}) except requests.exceptions.ConnectionError as e: print(e) print("REST API /batch_predict failed") sys.exit(1) # check if REST API worked if response.status_code == 200: # REST API successful # convert JSON response to panda dataframe pred_df = pd.read_json(response.text, orient="split") print(f"\nReceived {pred_df.shape[0]:d} predictions") print("Sample predictions:") print(pred_df.head()) else: # Error encountered during REST API processing print("\nError during predictions, error code: ", response.status_code, "reason code: ", response.text) ================================================ FILE: examples/synthetic/train.py ================================================ """Train a model from entirely synthetic data.""" import logging import tempfile import yaml from ludwig.api import LudwigModel from ludwig.data.dataset_synthesizer import build_synthetic_dataset_df config = yaml.safe_load(""" input_features: - name: Pclass (new) type: category output_features: - name: Survived type: binary """) df = build_synthetic_dataset_df(120, config) model = LudwigModel(config, logging_level=logging.INFO) with tempfile.TemporaryDirectory() as tmpdir: model.train(dataset=df, output_directory=tmpdir) ================================================ FILE: examples/tabnet/higgs/medium_config.yaml ================================================ input_features: - name: lepton_pT type: number - name: lepton_eta type: number - name: lepton_phi type: number - name: missing_energy_magnitude type: number - name: missing_energy_phi type: number - name: jet_1_pt type: number - name: jet_1_eta type: number - name: jet_1_phi type: number - name: jet_1_b-tag type: number - name: jet_2_pt type: number - name: jet_2_eta type: number - name: jet_2_phi type: number - name: jet_2_b-tag type: number - name: jet_3_pt type: number - name: jet_3_eta type: number - name: jet_3_phi type: number - name: jet_3_b-tag type: number - name: jet_4_pt type: number - name: jet_4_eta type: number - name: jet_4_phi type: number - name: jet_4_b-tag type: number - name: m_jj type: number - name: m_jjj type: number - name: m_lv type: number - name: m_jlv type: number - name: m_bb type: number - name: m_wbb type: number - name: m_wwbb type: number output_features: - name: label type: binary weight_regularization: null combiner: type: tabnet size: 32 # N_a output_size: 96 # N_d sparsity: 0.000001 # lambda_sparse bn_virtual_divider: 32 # factor to divide batch_size B to get B_v from the paper bn_momentum: 0.1 # m_B num_steps: 8 # N_steps relaxation_factor: 2 # gamma bn_virtual_bs: 256 # B_v trainer: batch_size: 8192 # B eval_batch_size: 500000 # 65536 131072 262144 524288 epochs: 1000 early_stop: 20 learning_rate: 0.025 optimizer: type: adam learning_rate_scheduler: decay: exponential decay_steps: 10000 decay_rate: 0.9 staircase: true validation_field: label ================================================ FILE: examples/tabnet/higgs/small_config.yaml ================================================ input_features: - name: lepton_pT type: number - name: lepton_eta type: number - name: lepton_phi type: number - name: missing_energy_magnitude type: number - name: missing_energy_phi type: number - name: jet_1_pt type: number - name: jet_1_eta type: number - name: jet_1_phi type: number - name: jet_1_b-tag type: number - name: jet_2_pt type: number - name: jet_2_eta type: number - name: jet_2_phi type: number - name: jet_2_b-tag type: number - name: jet_3_pt type: number - name: jet_3_eta type: number - name: jet_3_phi type: number - name: jet_3_b-tag type: number - name: jet_4_pt type: number - name: jet_4_eta type: number - name: jet_4_phi type: number - name: jet_4_b-tag type: number - name: m_jj type: number - name: m_jjj type: number - name: m_lv type: number - name: m_jlv type: number - name: m_bb type: number - name: m_wbb type: number - name: m_wwbb type: number output_features: - name: label type: binary weight_regularization: null combiner: type: tabnet size: 24 # N_a output_size: 26 # N_d sparsity: 0.000001 # lambda_sparse bn_virtual_divider: 32 # factor to divide batch_size B to get B_v from the paper bn_momentum: 0.4 # m_B num_steps: 5 # N_steps relaxation_factor: 1.5 # gamma bn_virtual_bs: 512 # B_v trainer: batch_size: 16384 # B eval_batch_size: 500000 # 65536 131072 262144 524288 epochs: 1000 early_stop: 20 learning_rate: 0.02 optimizer: type: adam learning_rate_scheduler: decay: exponential decay_steps: 20000 decay_rate: 0.9 staircase: true validation_field: label ================================================ FILE: examples/tabnet/higgs/train_higgs_medium.py ================================================ import logging from ludwig.api import LudwigModel from ludwig.datasets import higgs model = LudwigModel( config="medium_config.yaml", logging_level=logging.INFO, ) higgs_df = higgs.load() model.train(dataset=higgs_df, experiment_name="higgs_medium", model_name="higgs_tabnet_medium") ================================================ FILE: examples/tabnet/higgs/train_higgs_small.py ================================================ import logging from ludwig.api import LudwigModel from ludwig.datasets import higgs model = LudwigModel( config="small_config.yaml", logging_level=logging.INFO, ) higgs_df = higgs.load() model.train(dataset=higgs_df, experiment_name="higgs_small", model_name="higgs_tabnet_small") ================================================ FILE: examples/titanic/README.md ================================================ # Kaggle Titanic Survivor Prediction This API example is based on [Ludwig's Kaggle Titanic example](https://ludwig-ai.github.io/ludwig-docs/examples/#kaggles-titanic-predicting-survivors) for predicting probability of surviving. ### Preparatory Steps Create and download your [Kaggle API Credentials](https://github.com/Kaggle/kaggle-api#api-credentials). The Titanic dataset is hosted by Kaggle, and as such Ludwig will need to authenticate you through the Kaggle API to download the dataset. You will also need to join [the competition](https://www.kaggle.com/c/titanic) to enable downloading of the data. ### Examples | File | Description | | ---------------------------- | ------------------------------------------------------------------------------ | | simple_model_training.py | Demonstrates using Ludwig api for training a model. | | multiple_model_training.py | Trains two models and generates a visualization for results of training. | | model_training_results.ipynb | Example for extracting training statistics and generate custom visualizations. | Enter `python simple_model_training.py` will train a single model. Results of model training will be stored in this location. ``` ./results/ simple_experiment_simple_model/ ``` Enter `python multiple_model_training.py` will train two models and generate standard Ludwig visualizations comparing the two models. Results will in the following directories: ``` ./results/ multiple_model_experiment_model1/ multiple_model_experiment_model2/ ./visualizations/ learning_curves_Survived_accuracy.png learning_curves_Survived_loss.png ``` This is the standard Ludwig learning curve plot from training the two models ![](../images/learning_curves_Survived_accuracy.png) This is the custom visualization created by the Jupyter notebook `model_training_results.ipynb`. ![](../images/custom_learning_curve.png) ================================================ FILE: examples/titanic/model1_config.yaml ================================================ input_features: - name: Pclass type: category - name: Sex type: category - name: Age type: number preprocessing: missing_value_strategy: fill_with_mean - name: SibSp type: number - name: Parch type: number - name: Fare type: number preprocessing: missing_value_strategy: fill_with_mean - name: Embarked type: category output_features: - name: Survived type: binary ================================================ FILE: examples/titanic/model2_config.yaml ================================================ input_features: - name: Pclass type: category - name: Sex type: category - name: Age type: number preprocessing: missing_value_strategy: fill_with_mean normalization: zscore - name: SibSp type: number preprocessing: missing_value_strategy: fill_with_mean normalization: zscore - name: Parch type: number preprocessing: missing_value_strategy: fill_with_mean normalization: zscore - name: Fare type: number preprocessing: missing_value_strategy: fill_with_mean normalization: zscore - name: Embarked type: category output_features: - name: Survived type: binary fc_layers: [{ output_size: 50 }] ================================================ FILE: examples/titanic/model_training_results.ipynb ================================================ { "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# Custom Analysis of Training Results\n", "\n", "Notebook demonstrates two methods for plotting training results. First method uses Ludwig's visualization api. Second method illustrates converting Ludwig training statistics into a pandas dataframe and plotting data with seaborn package.\n", "\n", "This notebook is dependent on running the multiple model training example beforehand. To run the mulitple model training example, enter this command:\n", "``` \n", "python multiple_model_training.py\n", "```" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Import required libraries" ] }, { "cell_type": "code", "execution_count": 1, "metadata": { "pycharm": { "is_executing": false } }, "outputs": [], "source": [ "from ludwig.utils.data_utils import load_json\n", "from ludwig.visualize import learning_curves\n", "import pandas as pd\n", "import numpy as np\n", "import os.path\n", "import matplotlib.pyplot as plt\n", "import seaborn as sns" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Generate Annotated Learning Curves Using Ludwig Visualization API" ] }, { "cell_type": "code", "execution_count": 2, "metadata": {}, "outputs": [ { "data": { "image/png": "iVBORw0KGgoAAAANSUhEUgAAAagAAAEYCAYAAAAJeGK1AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAALEgAACxIB0t1+/AAAADh0RVh0U29mdHdhcmUAbWF0cGxvdGxpYiB2ZXJzaW9uMy4yLjEsIGh0dHA6Ly9tYXRwbG90bGliLm9yZy+j8jraAAAgAElEQVR4nOzdeXxU1d348c+ZLTsJhJ0AIezIqlDRuiBiiywi4to+tq64UXBpK60/bB8fxaVWC1hpre1T20ctakFFsFXcLQoSiAoGBMKWsCUhezL7+f1x7ywJk5CETJbJ9/1yXjNz77n3njnB+c459yxKa40QQgjR3ljaOgNCCCFEJBKghBBCtEsSoIQQQrRLEqCEEEK0SxKghBBCtEsSoIQQQrRLEqBEzFJKna+U2tXW+eislFJvK6V+HIXz/lUp9XBLn1e0PxKgRFQopfYrpaa1ZR601p9orYdH6/xKqe8rpT5WSlUopQqVUh8ppS6L1vVaglJqjlIqRylVrpQqUkq9r5QaFI1raa0v1Vq/EI1zi85BApTosJRS1ja89pXAq8DfgAygF/AgMLsZ51JKqaj/v6iUGoKR3/uAVGAQ8HvA14xz2Vo2d0KcTAKUaFVKKYtSarFSaq9Sqlgp9YpSqlvY/leVUkeVUmVm7eSMsH1/VUqtVEqtV0pVAReZNbWfKqW+Mo9ZpZSKN9NPUUrlhx1fb1pz/8+VUkeUUoeVUrcopbT5pV73MyjgKeB/tNbPa63LtNZ+rfVHWutbzTS/Vkr9X9gxmeb5bOb7D5VSjyil/gNUAz9TSm2pc517lFJvmq/jlFJPKqUOKqWOKaX+oJRKMPd1V0q9pZQqVUqdUEp9Uk/AGw/s01q/pw0VWut/aq0PhpXvw2HXj1R+9yulvgKqzNev1cnzMqXU8rDPeIuZ91Kl1OiwdD2UUjVKqZ7m+1lmza5UKbVRKTU2LO0EpdRWs6a6CohHdAoSoERr+wlwOXAh0BcowfgVH/A2MBToCWwFXqxz/A+AR4AU4FNz29XAdIwawVjghgauHzGtUmo6cC8wDRgCTGngHMOB/sBrDaRpjOuB+Rif5Q/AcKXU0LD9PwBeMl8/BgzDCDJDgH4YNTYwakT5QA+MmtwvgUhzmG0FRiilnlZKXaSUSm5Gnq8DZgJpwD+AGUqpFAjWaK8OyzMAWmsXsNo8NuBq4COt9XGl1ATgL8BtQDrwR+BNM7A5gNeBvwPdMGqt85qRb9EBSYASre124AGtdb75xfVr4MpAzUJr/Rfzl31g3zilVGrY8W9orf9j1lic5rblWuvDWusTwFqML/H61Jf2auB/tdY7tNbV5rXrk24+H2nsh67HX83rebXWZcAbmF/iZqAagfFFrTAC2T1a6xNa6wpgKXCteR4P0AcYqLX2mPfeTgpQWus8jMDbD3gFKDJrTU0JVMu11oe01jVa6wMYQW+uuW8qUK21/jzCcS+F5RdqB9/5wB+11pu01j7zvpULmGw+7MDvzM/2GvBFE/IrOjAJUKK1DQTWmE05pUAuxj2QXkopq1LqMbP5rxzYbx7TPez4QxHOeTTsdTXQ0BdufWn71jl3pOsEFJvPfRpI0xh1r/ESoVrGD4DXzWDZA0gEssPK7V/mdoDfAHuAd5RSeUqpxfVdUGv9udb6aq11D+B84ALggRbM80tE9gGQqJQ6WymVifHDYI25byBwX+CzmZ+vP8bfpC9QUCfgHmhCfkUHJgFKtLZDwKVa67SwR7zWugDjC24ORjNbKpBpHqPCjo/W9PtHMDo7BPRvIO0ujM/RUFNTFUZQCegdIU3dz/Iu0EMpNR7jSz/wZV8E1ABnhJVZqtY6GcCscd6ntc4CLgPuVUpd3EDeMI/7AqPpLXBvqDl5fhWYopTKwKhJRQxQWmsfRq3tOvPxllkTBKMsH6nzbyJRa/0yxt+ln1mLDBhwqs8mYoMEKBFNdqVUfNjDhnGv5RGl1EAI3iyfY6ZPwWjaKcb4olzainl9BbhRKTVSKZUILKkvoflr/l5giVLqRqVUF2V0/jhPKfWcmSwHuEApNcBsovzFqTKgtfZgfOH/BuN+y7vmdj/wJ+DpsE4F/ZRS3zdfz1JKDTG/xMswaqT+uuc383dr2DlGYAS0QJNcDsY9pW5Kqd7A3Y3IcyHwIfC/GB0wchtI/hJwDfBDageyPwG3m7UrpZRKUkrNNO9tfQZ4gYVKKbtS6grgO6fKl4gNEqBENK3H+OUfePwaWAa8idEcVYHx5Xi2mf5vGM03BcA3hL44o05r/TawHKMpak/YtV31pH8N48v2JuAwcAx4GOM+Elrrd4FVwFdANvBWI7PyEkYN8lWttTds+/2BfJnNnxswOmuA0alkA1CJ8YX+rNb6gwjnLsUISF8rpSoxmgnXAE+Y+/8OfInRtPqOmf+m5Lm+5j0AtNabMGppfTE6wwS2bwFuBZ7B6DSzB7PzitbaDVxhvj+BUearG5kv0cEpWbBQiJMppUYC24G4OoFCCNFKpAYlhEkpNdfs2twVeBxYK8FJiLYjAUqIkNuA48BejPs4d7RtdoTo3KSJTwghRLskNSghhBDtUrua8LF79+46MzOzScfU1NSQkJAQnQx1cFI2kUm51E/KJjIpl8haqlyys7OLzMHjtbSrAJWZmcmWLVtOnTBMTk4O48c3NLNN5yVlE5mUS/2kbCKTcomspcpFKRVxdhBp4hNCCNEuSYASQgjRLkmAEkII0S61q3tQQojOw+PxkJ+fj9PpPHXiNqaUIje3oWkGO6emlkt8fDwZGRnY7fZGpZcAJYRoE/n5+aSkpJCZmUntycrbn+rqahITE0+dsJNpSrlorSkuLiY/P59BgwY16hhp4hNCtAmn00l6enq7D06iZSilSE9Pb1KNObYClM8D+qRVBoQQ7ZQEp86lqX/v2GniqzoO2c8DGibdCQld2zpHQgghTkPs1KAKc8FdAe5KOJLd1rkRQnQyU6dO5cSJE41O84tf/IJzzjmHWbNm1Zt+w4YN7Nmzp8l5ee+993juuecaTHPs2DEWLlzY5HO3ptgJUFZH6LWrov50QgjRDlxxxRU8//zzDaZpKEB5vfWvBHPxxRczf/78Bs/dq1cvli9ffuqMtqHYaeJzpIReS4ASQpxCfn4+t9xyC+PHj2fbtm2MHj2aefPmsXz5ck6cOMGTTz7J2LFjKS0t5f777+fw4cMkJCTw0EMPMWLECEpKSrjvvvs4duwY48ePJ3xliDfeeIO///3veDwexo0bx69+9SusVmut60+aNIn8/Px687d161bef/99Nm/ezMqVK1mxYgUPPPAAI0aMIDs7m1mzZpGZmcnKlSvxeDykpaXx5JNP0r17d1avXs327dt58MEHWbx4McnJyWzfvp3CwkJ+9rOfMX36dPLz87n99tt56623WL16Ne+//z41NTUcOnSIadOm8fOf/xyAV199leeff56UlBRGjBiBw+HgwQcfjM4fpY7YCVBxyaHX7sq2y4cQosn+9HEev9vwLVVuX4udM8lh5e5pw7j1gqx60xw8eJBly5axdOlSrrzyStauXcvLL7/Me++9xx/+8AeeffZZVqxYwYgRI/jjH//IZ599xv33388bb7zB73//e84880wWLFjAhx9+yGuvvQbA3r17efvtt3n55Zex2+38+te/Zu3atVx++eVNyv+ZZ57J1KlTmTJlCtOnTw9u93g8rF5trHpfVlbGK6+8glIqGEgWL1580rmOHz/OSy+9RF5eHnfccUet8wXk5uby+uuv43A4mD59Otdffz0Wi4WVK1eyevVqkpKS+PGPf8yIESOa9DlOR9QClFJqOLAqbFMW8KDW+nfRuF6RM47u5mtPTTmNGwYmhGgP/vRJXosGJ4Aqt48/fZLXYIDKyMhg+PDhAAwZMoRzzjkHpRTDhw+noKAAgOzsbJ544gkAzjnnHEpLS6msrOSLL77gmWeeAWDKlCmkpqYC8Nlnn7F9+3auvPJKINSdvqXMmDEj+Pro0aPcc889FBYW4na7ycjIiHjMtGnTsFgsDBkyhKKioohpzjnnHFJSjJaowYMHU1BQQGlpKZMmTSItLQ2A6dOns3///hb7LKcStQCltd4FjAdQSlmBAmBNtK7nUknB1xZPJaWVbtKSHQ0cIYRoL249PysqNahbz68/OAE4HKHvCIvFEnyvlMLna15etNbMnTuX++67r1nHn0r48hYPP/wwN9xwAxdffDGbNm0KBsy6wj9nfcLTWK3WZn/+ltRaTXwXA3u11hGnVG8JvXuk4FV2bNqDFR9bdx3n/HF9sdtipx+IELHq1guyGqzptKWJEyeyfv16hg0bxqZNm+jatSvJyclMmjSJtWvXcuedd/LRRx9RVlYGGDWRO++8kxtuuIH09HRKS0upqqqiX79+Tb52UlISVVVV9e6vqKigV69eALz++uvN+4ANGDNmDEuXLqWsrIykpCTeeecdhg0b1uLXqU9rBahrgZcj7VBKzQfmA/Tp04ecnJwmnbi4uDh4zAgVh017APC5Kvhg60F62EvprGMBw8tGhEi51K81y0YpRXV1datcKxKn04nf7w/mwev14nK5qK6urrXv5ptv5le/+hWzZs0iPj6eX//611RXV3PTTTfxi1/8grVr1zJu3Dh69+5NTU0Nffv25Y477uCGG25Aa43NZmPx4sV07doVv99PTU0N1dXVLF68mOzsbEpLSzn//PO5/fbbmTt3bq08XnzxxTz00EO88MIL/OY3v8Hn8+F0OoN5vvXWW1m4cCFdunRh0qRJ+Hw+qqurcbvdeL1eqqura30uMGp4dT9jeHoAn8+Hy+UiJSWFG2+8kXnz5pGamkpmZibx8fG1yqypf0O3293of2MqvOdJNCilHMBh4Ayt9bGG0k6cOFGf1oKFX/wByoxK2ifx8yiy9WdcZgpZvTvnHFqyyFpkUi71a82yyc3NZeTIka1yrdPVmefiq6qqIikpCa/Xy4IFC5g3bx6XXHIJ0LxyifR3V0pla60n1k3bGjWoS4GtpwpOLaHMH0+q+TpOG1F9+8EKeqXFkRRvrf9AIYQQET3zzDNs3LgRl8vFeeedx7Rp01rt2q0RoK6jnua9lrRxTxG7vynnx/2N92l2JwWAzw85+8o5d0SazPslhBBNdP/997fZtaPag0AplQRcAqyO5nUAissqGODZF3yfYgu1ix4vc1NQ7Ip2FoQQQrSgqAYorXWV1jpda10WzesAfN+yiYt8nwXfb91ziIz0uOD7rw5U4PbKTOdCCNFRxEwfbEfVEfCEak1J1PDnz/cQbzc+osvjZ8dBmWFCCCE6ipgJUCT3BndN8G2PODf/2nGMI2FjCPYfr6G4wt0WuRNCCNFEsROgUnqBO1SD6uEwAtHSf31DckLoY27LK8fvj27XeiFE5xON5TaaasWKFfz5z38GYNmyZWzcuPGkNJs2beK2225r8Dy5ubl89NFHwfeNWb4jGmInQCX3qtXEl+7wYkHj9Wv+d1MeVovRg6+ixsfuI203OFAIIaBxy22cjkWLFnHuuec269i6Aaoxy3dEQ+zMZp7c21ju3eMEezxWpekZ7+Oo08bGvCJmnNGXJHPNqJ35lfRLjyM5PnY+vhCiadr7chsVFRVcdtllvPfee1gsFqqrq7n00kvZsGEDa9asYdWqVXg8HgYOHMgTTzxRa44+gMWLFwdnQv/4449ZunQpCQkJnHXWWcE0X331FY888ggul4v4+HiWLl1KRkYGy5cvx+l0kp2dzW233YbT6Qwu35Gfn88vf/lLSkpKSEtL4/HHH6dv3771LutxOmLnGzqhK1hsRjOfPR6An5zXmwc2GDP3PvrvHTw19yyqXX78Gr7cVyFjo4RoJ3YfrmJnfhXeFmx+t1kUIzKSGNo3qd407Xm5jcD6S5s3b2by5Ml8+OGHnHfeedjtdi655BKuvvpqAJ5++mlee+01rr/++ojncblcLFmyhBdeeIGBAwdy9913B/dlZWXx4osvYrPZ2LhxI08//TQrVqxg4cKFwYAEBJf3AGOC2rlz5zJ37lxeeuklHn74YZ599lmgcct6NEXsNPFZLCc18109No3hvYzp46vcPt76JvRr5XiZm/xiZ6tnUwhxsj1Hqls0OAF4/Zo9p2jODyy3EViKor7lNmbOnAmcvNzGnDlzgPqX25gzZw6fffYZhw4datZnmDFjBuvXrwdg3bp1waU2du/ezQ9+8ANmz57N2rVr2b17d73nyMvLIyMjg8zMTJRSXHbZZcF9FRUVLFq0iFmzZvHoo482eJ6Abdu2Be+bzZw5k+zs7OC+xizr0RSxE6DACFBhHSXs3ioemzcmOFns2q8P47eEppD/ar+MjRKiPRjSJxGbpWVbM2wWxZA+Dc8TF83lNt544w3eeOMN/v3vf/OTn/ykWeeaOnUqn376KaWlpezYsYPJkycDRvPdgw8+yNq1a1mwYAFud/N6Jy9btoyzzz6bt956i5UrVzb7PAGNWdajKWKniQ/MGlTYmGB3BRMyu3LjuYP4y3+MWSYef/cbfvX9sbi9GrdXs+NgJROyurRRhoUQAEP7NtwU15baermN0aNH88gjjzBlypTgfayqqip69OiBx+Nh7dq1wSU3IsnKyqKgoICDBw8yYMAA1q1bF9wXvlzHmjWh5foaWuZjwoQJrFu3jssvv5y3336biRNPmuO1xcRWDapOV3PcFQD89PvDyOhq3EA8XuFiS0FxMMn+4zUUlcvYKCFEZAsWLCA3N5fZs2fz29/+lsceewyAu+66iy1btjBz5kzeffdd+vbtCxgr8959993cdNNNzJ49m5tuuonCwsKTznvvvfdy7bXXsm/fPi644AJeffXViNefMWMGb775Zq2VdBctWsRVV13FddddR1ZWw+toxcXF8dBDDzF//nzmzp1Lt27dgvtuueUWnnrqKS6//HK8Xm9w+9lnn82ePXuYM2dOsIkxYMmSJaxevZrZs2ezbt06HnjggVOUYPNFfbmNpjjt5TY+WAq5r8Owi433vcfB6GsB+PjbQn70l83B41ZcOZHA3yMlwcrUMelYWriJoa3JshKRSbnUT5bbiKwzL7fRkGgvtxFbNag696BwhaY2umBYD644M1TFXvHxLqzmpzfGRtW/aqUQQojWF1sBKqV3xCa+gCUzR5GeZNzE+/Z4BftLQwFsZ34VlTVehBBCtA+xFaDqdDOvG6C6Jjl4cPao4PvffbATu81o1vNryNlfQXtq8hRCiM4sBgOU05hRAsBTA/7ataLLxvXlouE9ACMovZidF9xXKGOjhBCi3YixANUT0EZgCnDXXmJDKcXDc8eQ5DC6a362r5gyd2gxQxkbJYQQ7UNsBShbnDHlUa2OEhUnJeuXlsDPp48Ivn9ywzfYrEZTn9ur2X7g5GOEEEK0rtgKUGCuCxV+HyryIoX/NXkgZw5IA6DS7eOdXQXBfQcKnTI2SgjRJE1ZbuPIkSNcf/31zJgxg5kzZ/LCCy9ETL9hwwb27NnT5Lw0ZnmMY8eOsXDhwiafuzXFYIDqWaejROQAZbUoHp83FrtZc1q7/TBuHbpftS2vHJ+sGyWEiAKr1crixYtZv349q1at4qWXXooYiBoKUOEDa+tqzPIYvXr1Yvny5U3LeCuLramOwOhqXnYg9D5CE1/A0F4p3HXREH63wZgg8en3d/KLS0bj90Ol08fuw1WMyEiOdo6FEG2gLZfb6NmzJz179gQgOTmZrKwsjh07xpAhQ4Jptm7dyvvvv8/mzZtZuXIlK1as4IEHHmDEiBFkZ2cza9YsMjMzWblyJR6Ph7S0NJ588km6d+/O6tWrg7OR17cMRn5+PrfffjtvvfUWq1ev5v3336empoZDhw4xbdo0fv7znwPw6quv8vzzzwdnV3c4HMFZzqMtqgFKKZUGPA+MBjRwk9b6s2hek+ReUJQbeu9u+H7SHVMGs+6rI+w+XsnRCic5h08wtrcxFciugir6pceTkhB7cVyIdmXjCvjwsXpbPJrFkQxTFsO59U/U2h6W28jPzyc3N5dx48bV2n7mmWcyderU4JpOAR6PJ7j8RVlZGa+88gpKqWAgWbx48UnXaMwyGLm5ubz++us4HA6mT5/O9ddfj8ViYeXKlaxevZqkpCR+/OMfM2LEiJOOjZZoN/EtA/6ltR4BjANyT5H+9NWdTeIU/+DjbFYemzc2OOP5Xz/PA2X8EgqsGyVjo4SIso3PtGxwAuN8G59pMElbL7dRVVXFwoUL+eUvf0lycuNaa8Ln5Dt69Cg333wzs2fP5vnnn693uYzGLINxzjnnkJKSQlxcHIMHD6agoICvv/6aSZMmkZaWht1uP+31nZoqagFKKZUKXAD8GUBr7dZal0brekEpvWvfg2qgiS/grIFd+fE5mYBRzfvTZ6E/cmG5m0NFMjZKiKg6d4FR42lJjmTjvA0lacPlNjweDwsXLmT27Nl873vfa/T5w1fOffjhh/nhD3/I2rVreeihh+pdLqMxy2CEp7Farc3+/C0pmm1Xg4BC4H+VUuOAbGCR1rrWpHdKqfnAfIA+ffqQk5PTpIsUFxfXOia5sJwhYTUoV0UxuY045/f7+Hkr0UpRtY9vjpaz53gRQ3p2B2Db3hIK87/FqjpWTapu2QiDlEv9WrNslFJUV5v/r46/2XhEQ3XkRQudTid+vz+YB6/Xi8vlorq6uta+cePG8dZbb3H77bezZcsWUlNTsVgsjB8/ntWrV3Prrbfy6aefUlZWRk1NDePHj+eee+7hmmuuoVu3bpSVlVFVVUXfvn3x+/3U1NRQVVXFkiVLGDBgANdcc02oHOpwOByUlJQE9/t8PpxOZ/B9WVkZqampVFdX89prr+Hz+aiursbtduP1eqmurq71ucAIoHU/Y3j6wHVcLhdDhgzh4Ycf5ujRoyQmJvL2228zdOjQWmVWX97r43a7G/1vLJoBygacCfxEa71JKbUMWAwsCU+ktX4OeA6M2cybOpPySbMvFybC56ECi1PuRs/O/JuU49z41y8A+NOmg/xmTnf8fvBjhS6ZjB+c2qS8tTWZtTsyKZf6tfZs5m05Q3h8fDwWiyWYB5vNRlxcHImJibX23XPPPdx///1cc801JCQk8MQTT5CYmMjdd9/Nfffdx1VXXcWECRPo27cvCQkJjBkzhnvvvZe77roLv9+P3W7nwQcfJDExEYvFQkJCArm5uaxbt45hw4Zx3XXXAcbyGxdeeGGtPM6ZM4clS5awatUqli9fjtVqJT4+PpjnhQsXcv/995OamsrZZ58dDCQOhwObzUZiYmKtzwXGD4O6nzE8PRg1qLi4ODIzM7njjjv40Y9+RGpqKllZWXTt2jWYrjmzmTscjkbPYh+15TaUUr2Bz7XWmeb784HFWuuZ9R1z2sttADjL4LEBcO5tYDF7zVz032Bt3EqPi/6xjTdyDgNw/uAeXDFmYHDf+aO60r1Ly64YGU3yRRyZlEv9ZLmNyDrzchtVVVUkJSXh9XpZsGAB8+bN45JLLgE68HIbWuujwCGl1HBz08XAN9G6XlBcF7DF157uyNP4KuiDs0bRNdEOwCd7Cyl1haZBkrFRQojO5plnnmHOnDnMmjWLjIwMpk2b1mrXjnb/6Z8ALyqlHEAecGOUrwdKGT35vE6IM2+6uqsgPq1Rh6cnx7Fk1ijufeVLAJZ9uJNfTx+H1sbYqG8PVzFSxkYJITqJ+++/v82uHdVu5lrrHK31RK31WK315VrrkmheLyi5V50aVNMWI5w7oR8XDDNmPC+t8fDR3qPBfd8WVFEh60YJIUTUxd5URwAp5rIbAU1o4gPjJuLSuaNJNGc8f/2rfFxml0u/hpx95TI2Sgghoiw2A1Ry79oByt305dwzuiby0+8Zt8808Oynu4L7iso9HJSxUUIIEVUxGqDMe1ABTWziC/jxuZmM72/cuzpYUs2OY6EWyu0HKnB5ZN0oIYSIltgMUCl17kG5m9bEFxCY8dxmMeZB+tvmffjN1XrdXs32g7JulBDCEI3lNppqxYoV/PnPfwZg2bJlbNy48aQ0mzZt4rbbbmvwPLm5uXz00UfB941ZviMaYjNAJde9B9W8GhTA8N4p3DllMABun5//27IvuO9goZPCMlk3SgjRNI1dbuN0LFq0iHPPPbdZx9YNUI1ZviMaYnOa7rq9+JpxDyrcXVOHsO7rI+wtrGJbQQnThlfRt0sSYHSYmDo2HatZyxJCdAztfbmNiooKLrvsMt577z0sFgvV1dVceumlbNiwgTVr1rBq1So8Hg8DBw7kiSeeqDVHH8DixYuDM6F//PHHLF26lISEBM4666xgmq+++opHHnkEl8tFfHw8S5cuJSMjg+XLl+N0OsnOzua2227D6XQGl+/Iz8/nl7/8JSUlJaSlpfH444/Tt2/fepf1OB2xGaBSete5B9W8Jr6AOJuVx+eN5co/GCuF/HHjHv57+lhAGWOjCqoY2V/GRgnRbAc+gbwN4GvBFgmrA7KmwcDz603SnpfbCKy/tHnzZiZPnsyHH37Ieeedh91u55JLLuHqq68G4Omnn+a1117j+uuvj3h+l8vFkiVLeOGFFxg4cCB33313cF9WVhYvvvgiNpuNjRs38vTTT7NixQoWLlwYDEhAcHkPMCaonTt3LnPnzuWll17i4Ycf5tlnnwUat6xHU8RmE19id/CEZoA4nSa+gImZ3bh+sjHtUbnTw9u5h4P7vj0sY6OEOC0HPmnZ4ATG+Q580mCS9r7cxowZM1i/fj0A69atCy61sXv3bn7wgx8we/Zs1q5dW+8yGwB5eXlkZGSQmZmJUorLLrssuK+iooJFixYxa9YsHn300QbPE7Bt2zZmzZoFwMyZM8nOzg7ua8yyHk0RmzUoq82Y7ijAXQVaE1z0qZl+Pn04G3KPcaTMybu7jnBeVg9S4hzBsVHnjeyKOs1rCNEpDTw/OjWoBmpPEN3lNu67774G0zVmuY2pU6fy9NNPU1payo4dO5g8eTJgNN89++yzjBgxgtWrV7N58+Zm5XXZsmWcffbZ/P73vyc/P58f/ehHzTpPQGOW9WiK2AxQAIndwOsGmwPQRpOfPeGUhzUkJd7Ow5eP5uYXtqCBP2zczc8uOgMwxkYdKnIyoMfpXUOITlI3LAAAACAASURBVGng+acMJm1l4sSJrF+/nmHDhrFp0ya6du1KcnIykyZNYu3atdx555189NFHlJWVAUYt68477+SGG24gPT2d0tJSqqqq6NevX/CcWmseeOABsrKyuPHG+meAS0pKYvTo0TzyyCNMmTIleB+rqqqKHj164PF4WLt2Lb169ar3HFlZWRQUFHDw4EEGDBjAunXrgvsqKiqCx65Zs6bWdauqIrc8TZgwgXXr1nH55Zfz9ttvM3HiSXO8tpjYbOIDo5nP2/zpjupz8chezBrbB4DDZTV8cShUjf1axkYJEXMWLFhAbm4us2fP5re//S2PPfYYAHfddRdbtmxh5syZvPvuu/Tt2xeAIUOGcPfdd3PTTTcxe/ZsbrrpJgoLC2udMzs7mzfeeIPPP/+cOXPmMGfOnFq95sLNmDGDN998s9ZKuosWLeKqq67iuuuuIysrq8H8x8XF8dBDDzF//nzmzp1Lt27dgvtuueUWnnrqKS6//HK83tBtirPPPps9e/YwZ86cYBNjwJIlS1i9ejWzZ89m3bp1PPDAA40oxeaJ2nIbzdEiy20ErPovcDiMMVEAE2+HtIEnp2uGwgoX0576iLIaDw6rhf+ZMQ6H+ctmQI94zmon60bJshKRSbnUT5bbiKwzL7fRkA673EabS+x+WvPxNaRHShz/b6ZRwG6fn799kRfcJ2OjhBCiZcRugErq3qJjoeq68qwMzhtiLAm/42gZecWhWSVy9sm6UUIIcbpiN0Aldm+R+fjqY8x4PoZ4u1GEf/siD7/ZXFrp9LH7cMteT4hY1J5uMYjoa+rfO3YDVFL0mvgCBqQnct8lxoznZU4Pb+7ID+7bJetGCdGg+Ph4iouLJUh1ElpriouLiY+PP3ViUwx3M0+PahNfwI3fzeTNLw/zdUEZH+85xvlZPUhPjJexUUKcQkZGBvn5+Sf1cGuP3G53i4/xiQVNLZf4+HgyMjIanT52A9RJNajoBCib1cJj88Zw2TP/wefX/GXTXn560SgUKrhu1EAZGyXESex2O4MGDWrrbDRKTk5Oh+lx2JqiXS6x28QXpXFQkZzRN5X5FxhjEQ6X1bBxX+gXoawbJYQQzRPDASq9zqq6LX8PKtyii4cyqLsxw/mb2/Op8Rj3n2TdKCGEaJ7YDVA2Byh76L27MqqXi7dbefSKMcalIqwbVVQuY6OEEKIpohqglFL7lVJfK6VylFJNmyKiJcSngLkCLj4X+Js3+WNjTc5K57rv9Afgm2Nl7DxeFty3LU/GRgkhRFO0Rg3qIq31+EjTWERdYnfwhi+7Ed1mPoDFl46kZ0ocAP/Yuh+f3wiQMjZKCCGaJnab+ODk+1BR7CgRkJpg56E5owFjbNTrX4fWgZGxUUII0XhRnSxWKbUPKAE08Eet9XMR0swH5gP06dPnrLoz555KcXEx6enpEff13/o46akOSDVmGd7T5SIqHT2bdP7meuzTYj7Pd6KAn100kj6pRgeKeIuL3vYTp7s0VaM0VDadmZRL/aRsIpNyiaylymXChAkRJ4uNdoDqp7UuUEr1BN4FfqK1/ri+9C06mznAu7+C4p3Q3ZyOfswPoNeYJp2/uY6VO5n21EdUOL30TU3gp1NGBQfsnjW4S6usGyWzdkcm5VI/KZvIpFwia6lyaZPZzLXWBebzcWAN8J1oXu8kSa03FqquXl3i+eUMYwDb4bIaPtp7LLhP1o0SQohTi1qAUkolKaVSAq+B7wHbo3W9iOouuRGl6Y7qc83E/pw9yFgc7O3cw1S6PEY2vJodMjZKCCEaFM0aVC/gU6XUl8BmYJ3W+l9RvN7J6i650Qq9+MJZLIrH5o3FYbPg9vl5eev+4L4DMjZKCCEaFLUApbXO01qPMx9naK0fida16nXSbBKt3817UPck7p42FDDGRn19pCS4T8ZGCSFE/WK7m3lSdNeEaqxbz89iZJ8uAPzzy4N4fDI2SgghTiW2A1QUl31vCrvVwuPzxmBR5rpR22VslBBCnEpsByhHojECKyDK8/E1ZGxGGjefZywt8J99hRSUGcEysG6ULNomhBC1xXaAAnAkhV67q6ANA8G9lwxnQLdENPDS1n3BJeKLyj0cKnI2fLAQQnQysR+gEtLAbzahaR/4PW2XFYeVpXONgcKHy2r4aI+MjRJCiPrEfoBq47FQdZ03tDtXnmUsefyvnYcpcxpdzWXdKCGEqC32A9RJS7+3TUeJcP9v5ki6J8fh9vl5ZduB4PaDhU4Ky2RslBBCQGcIUInptQfrtmFHiYC0RAf/fdkZgDE2KqfgRHBfzj4ZGyWEENAZAtRJY6HavgYFMGNMby4Z1QuANV8fwu01FlOsdPr4VsZGCSFEJwhQiXWnO2ofX/5KKf5nzmhS4myUOz28uSM/uO9bGRslhBCdIUC1/XRH9emdGs/iGSMA2LivkIMlRt5kbJQQQnSGAHXShLHtJ0ABXDdpAN8Z1A0NrMrZX2ts1MFCGRslhOi8Yj9AteMaFJgznl8xBofNwuGyGj4MGxu1/aCMjRJCdF6xH6BOWrSwfXSSCJfVI5lFFxsznv9752FKql2AOTbqgIyNEkJ0TrEfoOK6gDesw4GrfX7hz7/AmPHc7fPz6pcHg9sPFsnYKCFE5xT7AUopY9LYgHYwDiqS8BnPc+uMjdomY6OEEJ1Q7AcoAEdK6LXPBX5f2+WlAeEznq/56hBOj5HPKqePXQXt696ZEEJEW+cIUEnp7W66o/oEZjwvd3lYGz426nAV5TI2SgjRiXSOANVOB+tGkuCw8ugVxoznn+0vZP8Jo0lSa8jJk7FRQojOo3MEqLpjodpZV/O6vjukO1dPzEADr+QcCN5/Kq7wcEDGRgkhOomoByillFUptU0p9Va0r1Wvk5Z+b98BCuCBGaPokRLHkfIaPtxzNLh9u6wbJYToJFqjBrUIyG2F69Svg9WgAFIT7Txkznj+711HKK4yxkZ5fJqvZWyUEKITaFSAUkotUkp1UYY/K6W2KqW+14jjMoCZwPOnm9HTktSjw9yDCnfpmD58/4xeeHx+XvsytG7UoSInx0tdbZgzIYSIPlsj092ktV6mlPo+0BW4Hvg78M4pjvsd8HMgpb4ESqn5wHyAPn36kJOT08gsGYqLi095TGJxKcPCltwoPHyAgvKmXaetXDNE8cm3ip3Hy9maX8yZGekAbNpVRD9HIRZV/7GNKZvOSMqlflI2kUm5RBbtcmlsgAp8Dc4A/q613qGUauCrEZRSs4DjWutspdSU+tJprZ8DngOYOHGiHj9+fCOzZMjJyeGUxxSnwM7fBd/2SE2gx5imXactLbEe5Berv2bN14cY0TOVRIcNr7YRlz6YMwbUG/sbVzadkJRL/aRsIpNyiSza5dLYe1DZSql3MALUv5VSKcCp7tR/F7hMKbUf+AcwVSn1f83O6elI6tGuJ4w9lWsn9WdyVjcqXd5aY6N2H6mmrNrThjkTQojoaWyAuhlYDEzSWlcDduDGhg7QWv9Ca52htc4ErgXe11r/1+lkttniUsDX/ufjq49SiseuGEuczcKmA0XkFRn5N8ZGVcjYKCFETGpsgDoH2KW1LlVK/Rfw/4Cy6GWrhXWQ+fgaktk9iXsvGWaMjfryAF6/UYE9Uelh//Gahg8WQogOqLEBaiVQrZQaB9wH7AX+1tiLaK0/1FrPakb+Wk74fHxeJ+iON5bo5vMGMaZfKscqnLy/OzQ2asfBSpzu9jm/oBBCNFdjA5RXG+1Ic4BntNa/p4Geee1SUnfwBpat0ODteN20bVYLj88bi82ieHfXEY5XGvfVPD7NV/s7VrOlEEKcSmMDVIVS6hcY3cvXKaUsGPehOo66Y6E6YDMfwKi+Xbj9wsF4/ZpXc0JjowpOuDha0vGCrhBC1KexAeoawIUxHuookAH8Jmq5ioa6s0l0kMG6kSyYOoSsHknsKapg88Gi4PacfeV4fR2v6VIIISJpVIAyg9KLQKo5vsmptW70Pah2IamHce8poIN1NQ8Xb7fyxLyxKAVvbs+n0mV0Na9x+/nmUMf9XEIIEa6xUx1dDWwGrgKuBjYppa6MZsZaXAdacqMxJmZ240eTB1Ll9vLG9kPB7XuPVlNSKWOjhBAdX2Ob+B7AGAP1Y631j4DvAEuil60o6OCDdSP52fQR9EtLYMuhE+w6Xh7cvi2vHL+MjRJCdHCNDVAWrfXxsPfFTTi2feiAM5qfSnKcjUfmjgbgtS8P4DbvP5VVe9l7tP2uGiyEEI3R2CDzL6XUv5VSNyilbgDWAeujl60o6KAzmp/KlOE9uWJCP4qqXLyz83Bwe+6hSjx+axvmTAghTk9jO0n8DGNC17Hm4zmt9f3RzFiLS+oec018AUtmjaJ7soMP9hzjcJlRc/L5odjbRaZBEkJ0WI1uptNa/1Nrfa/5WBPNTEWFPYHQpOyAq7zepB1N1yQH/33ZaPxasyrnQPD+U40/nvxiWSJeCNExNRiglFIVSqnyCI8KpVTH+4a3d+z5+BoyY0xvvjeqFwdLqvg0L3S78OsDlbi9MjZKCNHxNBigtNYpWusuER4pWusurZXJFhMXPh9fjTEdeIxQSvE/l48mJd7G+twCSqqNaZ1cHr8sES+E6JA6Vk+805WYHlp2Q/vB5244fQfTq0s8S2aOwuX188+vQtMgHSx0UlgWW59VCBH7OleASupeezaJGOnJF+6qiRmcN6Q7O46WkVNwIrh9275yfP7YqTEKIWJfJwtQdSeMjb0ApZTi0SvGkOiwsuarQ9R4jBpjldPHzvzYuu8mhIhtnTtAxWANCqB/t0R+/v3hlLs8rN1eZ4n4KpkGSQjRMXTCABWbY6Hq+tE5mYzs7uDzA0XsDVsiflteuYyNEkJ0CJ0rQCWmd4oaFIDFoljwnTTsNgurcvYHl+EoqZJpkIQQHUPnClCd4B5UuH5d7Nx7yTAKK138e9eR4PZvDlVS5ZQl4oUQ7ZsEqBh3y3mDGJeRyge7j9aaBilnnzT1CSHat6gFKKVUvFJqs1LqS6XUDqXUf0frWo2WmF7nHlTs92qzWS08ceU4LBZqTYN0vMzNoSKZBkkI0X5FswblAqZqrccB44HpSqnJUbzeqVltYLGH3rvK2i4vrWh47xQWTh3KwZIqPqk1DVIFLo9MgySEaJ+iFqC0IVBFsZuPtm9TiuH5+Bpy+5TBnNG3C+u/KeBEtQsAt1fz1X6ZBkkI0T5F9R6UUsqqlMoBjgPvaq03RfN6jRKfGnrt6TxNXHarhd9cOQ6/1ryaE5oGKb/YydESVxvmTAghIrNF8+Raax8wXimVBqxRSo3WWm8PT6OUmg/MB+jTpw85OTlNukZxcXGTjsl0QZrfBxYraC9fbtuCVlEthjYTqWzmjUxm1Y5ythwqZmL/dAA27yomI64Qi2r7Cm5raOq/mc5EyiYyKZfIol0urfLNrLUuVUp9AEwHttfZ9xzGYohMnDhRjx8/vknnzsnJoUnHFAwFVyUkGDWpcUP7Q3KvJl2zo4hUNqNG+/nymU95/etDjOjZheQ4Oz6sWNMGMW5Qx5ugvjma/G+mE5GyiUzKJbJol0s0e/H1MGtOKKUSgEuAndG6XqMl9YCa0tD76qK2y0sbcNgsPHnVOJxeH2u+PhTcnneshqJymfFcCNF+RPMeVB/gA6XUV8AXGPeg3ori9RonqTs4w3rvdbIABTC6Xyp3ThnM1vwT7DgaCtbb8mTGcyFE+xG1Jj6t9VfAhGidv9k6eQ0qYMHUIbyz4xivfXmAwekpxNutVJoznp8xIOXUJxBCiCjrXDNJgBmgOncNCiDOZuU3V42lwuVl7Y6wGc8PV1MqM54LIdqBThqgpAYFMDYjjTsuHMxn+wvZE5jxHNi6txy/NPUJIdpY5wtQielGLz6/OVmqu7L2KrudzE8uHsLw3ims2rYftznjeVm1l91HZMZzIUTb6nwBKj7NGAMlzXyA0dT35FXjKKlx86/cguD2nfmVlNd42zBnQojOrvMFKIsFUvuDU5r5Akb3S+Wui4bw0d5jHCgxZnj3a6OpT2Y8F0K0lc4XoAC6Zcl9qDoWXDSE4b278I+t+/D6zcUNKz3skcUNhRBtpBMHKGniC+ewWfjtVeMornbxTvjihgcrqZSmPiFEG+jEAUpqUHWN6tuFRRcP5b1vj5JfatSc/Bq25klTnxCi9UmAAqguBvkCBuD2Cwczpl8X/rFtX3BWieIKD3nHak5xpBBCtKzOG6A8NeA1557zOsET+8u/N4bNauG3V4+nqMrFe7tDTX07DlZQ6ZSmPiFE6+mcAarrQEBJT756DOmZzM++P5x3dh7hcJnR1OfzS68+IUTr6pwByhZndDUP7yhRJQEq3E3fHcRZmV15eet+aeoTQrSJzhmgALoNqn0fqkYCVDiLRfHbq8ZRUiNNfUKIttGJA1SdjhJSgzpJ/26J/Gr2GbyzS5r6hBCtr5MHKBkLdSpXTczgouE9T2rqkwG8QohokwAVUFMM2t92+WmnlFI8Nm8MNV4v735bewCvzNUnhIimzh2gfC5wmzUBv7f2SrsiqHtyHI9eMYZ3dx0hvzRsrr49ZfilqU8IESWdN0B1zTSeay3/XtwmWekIvndGb66emMFLW/fjNZflKKny8m2BjB8TQkRH5w1QjkRI6VtnRonCtstPB7Bk1igcdsW/dh4ObttZUCUr8AohoqLzBig4+T5U+aG2y0sHkBRn4+lrxvNx3jH2n6gEjBmituwpC3agEEKIltLJA9QgKM0PvT/+jXEvStTrzAFdueuiIbyYvQ+X11iVuKLGx46DlW2cMyFErIlagFJK9VdKfaCU+kYptUMptSha12q2bllQeTxUi/K5oOjbts1TB7DgoiEMSE/gje2hGufeo9UUlrnbMFdCiFgTzRqUF7hPaz0KmAzcpZQaFcXrNV23LOO5aE9o27Ev2yYvHYjNamHZtRP4+kgp3xwN3cPL3luG2ytd9YUQLSNqAUprfURrvdV8XQHkAv2idb1mCQSowt2hbYW54JOawKn075bII3NHs2rbASpdRieJGrefL/dVtHHOhBCxQrXGlDVKqUzgY2C01rq8zr75wHyAPn36nLV+/fomnbu4uJj09PRm5cviqWbsuksB0Gdeh0rsCsD+lHMojRvQrHO2J6dTNo217PMTFLniuOnsIcFtPeylJFvb76SyrVEuHZWUTWRSLpG1VLlMmDAhW2s9se72qAcopVQy8BHwiNZ6dUNpJ06cqLds2dKk8+fk5DB+/PjmZ/A3Q6CqEPqfBQPPNrb1GAXjrm/+OduJ0y6bRqh0eZm5/BMmD+jB5IE9ALBZFFPHppMUb43qtZurNcqlo5KyiUzKJbKWKhelVMQAFdVefEopO/BP4MVTBac2E2zmC7sPVbTLWMRQnFJynI3l107grR35FFYaZeb1a7bILBNCiNMUzV58CvgzkKu1fipa1zltgQDlLANrnPFa++D4jrbLUwczrn8a91wyjL9vycPnNzpJnKj0sCtfZpkQQjRfNGtQ3wWuB6YqpXLMx4woXq95AgEKwBX2hXrsq9bPSwd283mDGNY7+aRZJorKpcOJEKJ5otmL71OttdJaj9VajzcfTesB0Rr6nx16/e2G0OsTe6SZrwmUUjx51Ti+OVbKnqJQT74vdpfh8kjXcyFE03XumSQABn4XEs1eKCfyIC7VeK39ULq/zbLVEXVNcrDsugm8vHVfsOu50+Nna16ZLHAohGgyCVBWG4ycHXpfHTZ5bEle6+eng5uU2Y1bL8ji5a37g9uOlrjJO9p+u50LIdonCVAAo+aEXh/cFHpdsq/18xIDbr8wi77d4vloz7Hgtq8PVFBSKbOeCyEaTwIUQOb5kGAM0uXo9tD28gK5D9UMSil+e/U4vjxygkPmAoca2PRtqUyFJIRoNAlQAFY7jJhlvPa5jW9TADSUHmirXHVoqQl2VvxgAi9v3U+Nx5ghvsbtJ3uP3I8SQjSOBKiAUZeHXheH3XuSZr5mG90vlXsuGVr7flSpm91HqtsuU0KIDkMCVEDWhRCfZrw+viu0XTpKnJarJ/Vn3MAufLDnaHDbjoOVMj5KCHFKEqACrHYYMdN4XX441MxXUQBeV5tlKxY8OOsMjlZWsa84tKjhZ7tKqXH72jBXQoj2TgJUuEAzn9cFTnPSde2HMrkPdTocNgu//+FZrM/Np8IcH+X1aTbuLJGl4oUQ9ZIAFS5rSmigbsn+0Ha5D3XaeqTE8dS143hp675gUCqv9rF1r3SaEEJEJgEqnM0RauYrC80pJwGqZYzNSOOOi7JqLRWfX+xi71HpNCGEOJkEqLrOmGs8hweo8kOyym4LmTO+H2dlpbLpQFFw21f7KzheJvf5hBC1SYCqK2uK0ZvP64SqYmOb9kPpwbbMVUxZdPFQXLg5cMLoNKGU4j+5JZRXe9s4Z0KI9kQCVF02B4w0B+2G16JKpbt5S1FK8egVY9lyuIjSmkDNVPHh9mKc0rNPCGGSABVJoJmv/EhoW1l+2+QlRsXbrSy7djzrd+bj8hpByeeHD7YXS88+IQQgASqyQRcac/NVHA9tq8gH6W3WotKT43jqmnG8uf1QcHl4p1vz8TfF0rNPCCEBKiKr3ViCw1UOHnOyWE8NOEvaNl8xqH+3RB68fCRv5xYEt5VW+vjPzhIJUkJ0chKg6nPGFcZzZWFoW7k080XDiN5duG3qID7JCy3PUVjmYdPu0gaOEkLEOglQ9ck831hptzKsma+8oP704rRMyuzGrDN7kX2oOLjtyAk3W/ZKkBKis5IAVR+rDUZeJjWoVjR1RC+mjk5nx9FQUDpU6CI7T4KUEJ1R1AKUUuovSqnjSqntp07dTo2cXbujRHmBMSZKRM3Ukb2YPCyNvUUVwW0Hj7v4z67iBo4SQsSiaNag/gpMj+L5oy/zPCMguc2peHwuqJYvymibNqoX47NSggN5AY6XeHlve2EDRwkhYk3UApTW+mPgRLTO3ypsccbMEuHNfBVyH6o1TBvVi4lDu7AvLEiVV/pZm31UevcJ0UmoaP7PrpTKBN7SWo9uIM18YD5Anz59zlq/fn2TrlFcXEx6evpp5LJh6fvfpP+Jj2HAJACOxw/jcPKEqF2vJUW7bFpDXomHfVVpZHXvEtx2vLyMs9KrsFlVs84ZC+USLVI2kUm5RNZS5TJhwoRsrfXEutttp33m06S1fg54DmDixIl6/PjxTTo+JyeHph7TJIO6wwuvBd/2dNTQM5rXa0FRL5tWMB44UFTF6s1HyOyWAkDPLqlsK43j8km96NElvsnnjIVyiRYpm8ikXCKLdrlIL75TSc2AuG6h9xXSUaK1DeyexI8u6M++E6GOE92T4lmbfZzP9sg9QSFilQSoxsi6EFzmvRDthyq5Wd/a0pPjWPj9LAprqoPTInVNiOPQMTe/fzePKnOlXiFE7IhmN/OXgc+A4UqpfKXUzdG6VtQN/V6dAbsyHqot2KwW5l80iPgEcPuMWqzdaqFvShJ/2HCAD3Yelw4UQsSQaPbiu05r3UdrbddaZ2it/xyta0Vd/+9ATah5iWNft11eBDPH9+asISmUOkOLSGalp1Bw3MODq7/hP3ukhitELJAmvsaw2o17UQEn9rZdXgQAQ3olc/35/fCr0PpRSQ4bZ/brTu6BGn76j69YtfkgJVWyErIQHVWb9+LrMAZ8F0p2Gq/9btjxTxj8PYhPadt8dWI2q4V5Z/dl5+EKtuWVE28z/jmnJ8Xx3aReVNR4+J83duLVPob2TiarRzKDuidR5fbj92ssluZ1UxdCtA4JUI01/FL44HNISANlgSNbjCB19BuoKQOfG/xe6DYYMiYazYIDzoH0wW2d85g3om8Kw3ons21/GXlHa7BZjIaBlDg752f1AqDG42XXoWo+/qaYoirNs9u+wK81SgEKrBaF3apIjLPSNdFB10Q7aYkOUhPspMTbSIk3npPibCQ5rCTG2UiwW7FKkBMiaiRANVZyT6guA4sN4pKNbd0GGg9PDbirwFUF1Sfg4H/gmzXgLIc+42D8D2H0lZAkA/2ixWJRnJWVxpgBXcjZV8ahIicWFWrBTrDbGJSezKD05AbP4/X7qXB6KHd5KSh0s8tVRZXbG3w4vT6cHh81Hh8urx+tNcoCVqWw2yzE2y3E263E26zEBV7brcTbAq+N5zibhbhAGvM5uM1mMd8brx3mduPZeG+zKJSS4ChimwSoppizEnJehhO7wB4HgS8Ie4LxSOpuBKwAjxPKD8OXL8J/nobU/tClL6T0hZQ+kNzLeKT0htR+ECfNhafLYbPwnaFdmTREU1juZmdBFUXlbhSN+zK3WSx0TYyja2Jck6/t1xq314/H58fj9xuv/eZ7n8bj8+N2+qmq8uL1G++9fo3Xb+z3+v14g9t02OuwZzOdXxtNlBbAalVYlMJmMYJkeCCLs1lxWI3XwYc1tN9uDW1z2CwcO1zFfl1Qa1vdNHarCr63h6WxWyVoipYlAaop4lNh8u3G67JDsGut2eW8nq7N9nhIzzIeddUcMR7HfOB1QU0JVJcCFuM6Sb2MWltyL0jqYQS/xHTjWQLZKSml6JkaR8/UOLTWOD1+Kmq8lFf7OJh/hK7pPXB5fDg9fpwePx6vH6/v1OdtiEWpYI2prfi1Edx8gaBmBjRfIOiZ231ejdPtp9Lvw+v34gum6cKhHcXGMYFz+TReHTqHL/Awt4VvV0qjUCiLUR4WpbBaFBaLxqoUFovCZrFgsyozqFlw2EKvA4HObjXSOKwWbBYLdlvgtRGE7eY5bFYLDmvonA6rBVudc9gsxnub1YLdYjzbrCp0DqmNtlsSoJortT98505ztvNKY3n4mhKoOGwsy1GRbzT9nYrFCo5E45HaL7Td64bS3XAsxzi/u9poRnRXAwocSRCfZgSzuC7GIz7VuEcWnwYJaSQVHYMCH9iTjPPHp4IjBSydq/OmUooEh5UEh5WeqVB5rILxHVL9iAAAC3ZJREFUWZHvDXp9GpfHj9MMXm6PH7fXqBm5PaGakNenQw/zS7w9jMCyKIXDqsDavv/GPjOI+nR4wDPK0h8IeFrj8/hx+TXVfj8+7auV1ufX+HXt95G2NSaNQoMCC8poGDFrp0oZzbcut4uEjRuxYPzvY1FGOpvVgtViBDlbWMCzmcE0ECADaULPobTWOtutFoz9YfsC+y2BdKruPgsWC+a1MGvUxjZrnfSBc9T6AaFol0FaAtTpUpZQgOiSAb3GGNu1hupCKNkPpfugdL9RU/L7jM4U2vy5Xt8/CpsDbN0gsVvk/XX5feA8BpUHjet4XQz1uuDIauO9zwU+r3Fti914WG1gjTOaJ20J4Eg2Ap/DDGiOZLAnmvvjwBZvvHYkhfbZ4kP7rPb6P08HYfwqt5IU3/RakD+8duHX+PzUea/x+8GntZnWOMb4sjRqP36/xqdD5zK2Yb4OTx++3djXkcYoG1+WbVfTbEl1A17g7xj4uwT/3oF9XuNv7NYav/aH/qZaozWhv695fOC9pva/geA+8/zavJbWtffXfR2eTge3A+ha//sqFOZ/xvvAazM4KwXxuHi8XyVZPRq+t9tcEqCiRSlI6mk8Mr5TfzqfBzxVUHoQCr8xApmrnHqbDetjsYLFvBd2OrQfvGXgKjJqcYHeiYHA6veGAp3fZwRav998Dg+6CpTNzJf5rCzGM1bjZ6jVYQZKu/HaFmdus4Kyho61xRmB1BZnvLeaAdYSlkZZw4Kj+X+PMq9jsZkP45iE0r1wzB52jbDrKQuE368KnEdZjHMF9ivzdeCzARavC4vXid3nNjrIOEvBWWb8QEjqbjTVpvQ0gnuUArmuE+yCX0z+sC+6QAA09+mwoHnoUD59+/XD58f8oqvzheiv/ax17WuE11ACwTjwJag7WBBtLKtFYUXRhi27babG4+WfWwr42aXDo3J+CVBtzWoHaxr0ToPeY41tWoOn2ghUzlJwVxivXRXGw11pbPPU1K6NtQRlMWtEcdD0fgItxAfaAz4/eDW4NOAn+A2n/WGPwLeeDuU/0HvP7wPtNQJoWMAfroFP14aOj0SHn0+Fzqn95nlrnxNlNcrMHm/UJlWdJrZAAA8G+kBwNz9b4JzBwGoNBd1AwEfX+czBn7RmHkBpsCkLDJsJZ8xtcsmXHalmSJ+kJh/XFHV/ues6QbTe92EBNXRshGc/Eff56wTL2sE5rLYR4fwejwer1WYUPbEZaJsjwW5jZM+0qJ1fAlR7pFSoqS2lz6nTa23WbFxG0PLWgKeG/Xt2ktmvJ3idxsPnNn7Ne8x7Wd7q0PbAF357ER5oRNPt29CsANUalNlEZGlkz8r2oL5lJQLBVhOqVUJ4jbF2ENZhr8PTBYJh8DcYEbZT+xzh28OvEzg2mIaT0wfOS9g+f4Q8RspL6BjA5+SCkX2jVu4SoGKBUmZNzG40H5lK82ugfxPWatHa+FXv85iBy2XeN/MY24LP3tD7QM0g/LhgQPQaafyesJ+cCvCH7fOG1YDkZ2mLie/a1jnoFALBFuiUg7ZzcnLokjggaueXACVCVOC+ke3072WdjrrNeNR5H3j4fWH7dKgpLtjE5w09wuzevZuhgwebxzXUPKrN81nDmvjCmunCKWV2KDEflsD/WmbTXHhefB6jpuupMn4ABJoRAzUK7TN/wvrC7udZwn4MmOfRgWY/M7AHyio+Dfqd1byyF6IdkQAl2p/AfRiic9e5yl4K3SKMTRNCtCvSyC+EEKJdkgAlhBD/v717jZWrKsM4/n9srVhKqAUk2iJtpUGLgYKkqRZIA34AbQQNXkEIkfCFhIsaBWPiJZpIQgSNBDEULbFBTLk1xhC1kqofKBRaubQaCCocUmiNUEXC/fHDWidMjjMYes5h75n9/JKTc/aa3b3XvHln3u61Z9aKVkqBioiIVkqBioiIVkqBioiIVkqBioiIVkqBioiIVpLdnm/vS9oN/P11/rMDgX9MQ3dGQWLTX+IyWGLTX+LS31TF5VDbB01sbFWB2huSttg+tul+tFFi01/iMlhi01/i0t90xyVDfBER0UopUBER0UqjUKB+3HQHWiyx6S9xGSyx6S9x6W9a4zL096AiImI0jcIVVEREjKAUqIiIaKWhLlCSTpb0F0kPS7qk6f40RdIhku6QtF3Sg5IurO3zJP1G0kP1dyeXWZU0Q9JWSb+s24skba55c6OkWU33sQmS5kpaL+nPknZI+kByBiRdXF9HD0i6QdI+Xc0ZSddJ2iXpgZ62vjmi4gc1RvdJOmay5x/aAiVpBnAVcAqwFPiMpKXN9qoxLwFftL0UWAGcX2NxCbDR9hJgY93uoguBHT3blwFX2D4MeAr4fCO9at73gdttvwc4ihKjTueMpPnABcCxtt9HWTXz03Q3Z34KnDyhbVCOnAIsqT/nAVdP9uRDW6CA5cDDth+x/QLwc+DUhvvUCNs7bd9b//435Y1mPiUea+tua4HTmulhcyQtAD4CXFu3BZwIrK+7dDUu+wMnAGsAbL9g+2mSM1BWGn+rpJnAbGAnHc0Z278H/jmheVCOnApc7+JOYK6kd0zm/MNcoOYDj/Vsj9W2TpO0EDga2AwcbHtnfegJ4OCGutWkK4EvA6/U7QOAp22/VLe7mjeLgN3AT+rw57WS9qXjOWP7ceBy4FFKYdoD3ENyptegHJny9+RhLlAxgaQ5wE3ARbb/1fuYy/cJOvWdAkmrgV2272m6Ly00EzgGuNr20cB/mDCc19GceRvlSmAR8E5gX/53iCuq6c6RYS5QjwOH9GwvqG2dJOnNlOK0zvbNtfnJ8Uvs+ntXU/1ryErgo5L+RhkCPpFy32VuHb6B7ubNGDBme3PdXk8pWF3PmQ8Bf7W92/aLwM2UPErOvGpQjkz5e/IwF6i7gSX10zWzKDcyNzTcp0bU+yprgB22v9fz0Abg7Pr32cBtb3TfmmT7UtsLbC+k5MfvbJ8B3AGcXnfrXFwAbD8BPCbp8Np0ErCdjucMZWhvhaTZ9XU1HpfO50yPQTmyATirfppvBbCnZyhwrwz1TBKSPky5xzADuM72dxruUiMkHQf8AbifV++1fJVyH+oXwLsoy5h80vbEG56dIGkV8CXbqyUtplxRzQO2Amfafr7J/jVB0jLKh0dmAY8A51D+09rpnJH0TeBTlE/HbgXOpdxL6VzOSLoBWEVZVuNJ4OvArfTJkVrQf0gZEn0WOMf2lkmdf5gLVEREjK5hHuKLiIgRlgIVERGtlAIVERGtlAIVERGtlAIVERGtlAIV0WKSVo3Pwh7RNSlQERHRSilQEVNA0pmS7pK0TdI1dQ2qZyRdUdcW2ijpoLrvMkl31jVzbulZT+cwSb+V9CdJ90p6dz38nJ51m9bVL0Qi6bsqa4DdJ+nyhp56xLRJgYqYJEnvpcw8sNL2MuBl4AzKRKNbbB8BbKJ8Cx/geuArto+kzP4x3r4OuMr2UcAHKbNpQ5md/iLKumeLgZWSDgA+BhxRj/Pt6X2WEW+8FKiIyTsJeD9wt6RtdXsxZdqpG+s+PwOOq+swzbW9qbavBU6QtB8w3/YtALafs/1s3ecu22O2XwG2AQspy0A8B6yR9HHK1DIRIyUFKmLyBKy1vaz+HG77G33229t5xXrnfHsZmFnXJlpOmYV8NXD7Xh47orVSoCImbyNwuqS3A0iaJ+lQyutrfAbszwJ/tL0HeErS8bX9c8CmuhLymKTT6jHeImn2oBPWtb/2t/0r4GLKku0RI2Xm/98lIl6L7e2Svgb8WtKbgBeB8ymLAC6vj+2i3KeCskTBj2oBGp9FHEqxukbSt+oxPvEap90PuE3SPpQruC9M8dOKaFxmM4+YJpKesT2n6X5EDKsM8UVERCvlCioiIlopV1AREdFKKVAREdFKKVAREdFKKVAREdFKKVAREdFK/wW79SlUUoo9XAAAAABJRU5ErkJggg==\n", "text/plain": [ "
" ] }, "metadata": { "needs_background": "light" }, "output_type": "display_data" }, { "data": { "image/png": "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N8Gtd0kKuhdCoAReJzY2lqefftrhnnPl448/ZteuXaxfv57evXtjMBgYO3YsnZGEPyYmhsLCQsf++fPnm23bt29f4uLi2LFjB3fccUeTbQICAhxzRAAXLlxo1KahlTNx4kRKS0vJyMggLS2NVatWARAeHo6/vz/btm1Dr9c3uk9wcDArV65k5cqVnDx5kl/96lekpqZy+eWXt/h/HjZsGFdddZVjnq89fZ47dy5/+ctfKCgo4Msvv+T9999v8rVUKhVz5swhLS2NqKgopk6dSnBwMCCL3t13380999zT6Lr9+/dTWFiIJEmO187Pz7+kBwNB10S4+ASdisViwWQyOf6sVis33XQTf/vb3zh37hwgT5bXu4iqqqrQarWEh4dTU1PDiy++2Gl9nTNnDlu2bOH06dPU1NTw2muvNdtWoVCwcuVKXnvtNT788EOMRiN2u50DBw7w+9//HoBBgwbx448/kp+fj8Fg4B//+MdF+1DvwnruueeoqKjgiiuuAGQLc+nSpTz99NOOoILCwkL27t0LyAEGOTk5SJKETqdDpVI16eI7cOAAmzZtctzj9OnT7N69m+HDhzv6/M0331BeXk5xcTH//ve/L9rniIgIxo0bx6pVq+jduzf9+vVrtu3ChQvZvn07W7dudbO0li5dynvvvcehQ4eQJInq6mq+/vprjEYjI0aMQK1Ws2HDBiwWCzt27ODw4cMX7ZfA9xACJehU7rrrLoYNG+b4e/nll7n11luZPn06t99+OyNHjuT6668nPT0dgGuuuYb4+HgmTZrE/Pnzm5zL8BRTpkxh+fLl3HrrrcyaNcvxo63VaptsP2fOHF566SU+/PBDJk2axMSJE1m7dq0jxPyKK65g3rx5XH311SxZsoRp06a1qh8LFy7ku+++Y86cOW5h3I899hiJiYlcf/31jBo1il//+tecOXMGkMPHb7vtNkaOHMkNN9zATTfdxIQJExrdOyQkhN27d7Nw4UJGjhzJnXfeycyZM1mxYgUAixYtYuDAgY73Z968ea3q84IFC/juu++ade/VM3z4cIdb1TU6MDU1lT/96U+sWbOGsWPHctVVVzmCV7RaLS+//DIfffQR48aN47PPPmPWrFmt6pfAt1CIgoUCQes4ffo0CxYs4PDhw2K9j0DQCQgLSiBogS+//BKz2UxFRQV//etfmTZtmhAngaCTEAIlELTAe++9x+WXX86sWbNQqVT88Y9/9HaXBIIeg3DxCQQCgaBLIiwogUAgEHRJfM6Z/ssvvziyBbQFi8WCRqPxQI98EzEejRFj4o4Yj8aIMWlMR4yJyWRqMkLX5wTKz8+PQYMGtfm6nJwcEhMTPdAj30SMR2PEmLgjxqMxYkwa0xFj0lwqMuHiEwgEAkGXRAiUQCAQCLokQqAEAoFA0CURAiUQCASCLokQKIFAIBB0SYRACQQCgaBLIgRKIOgMLDUgkrYIBG3C59ZBCQTtwm6HgxtB7QfDboAmaiN5jKMfw8f3QEgvWPElBIQ7z1VdkPulUEL0QIgeAKGXgVI8OwoEQqAEPYPdf4L/1hU7NBth7ArnObsNjn2MtkYDbVlwuPdF+O7vMPIWuOrPTbcpy4FP7gNLNZScgoNvw8QHnOc/uQ9Ofu5+TVAM3PgOJIxrfL/SLDj2KWR8Chcy4cqHYdIjzvMmA2y+DfJ+hKQrYfAi6D8b/ENb//8SCLoIHn1M27NnD7Nnz2bWrFm8+eabjc7n5+ezfPlyrrnmGhYuXMg333zjye4IugKSXf6xrv+zmjrmvuW58P2r8K8FsH4+FB13nivKkIWknm+ek11u9Wx/Aj64nbhty2H3n2Vr62Kc2gm7/hdqyuC7l+H4Z43b2O2yAJmNQJ3FlrHVed5QACe/kK0nV6qKYNsj7i5BQ6H8f/v7SNj5Bzj3E5gq5D78uE5uY7PC5l9D5pdQWw7H02DLnfDXZNi1RrgYBT6Hxywom83GmjVrWL9+PXq9nuuuu47p06eTnJzsaPP6668zd+5cli1bRmZmJnfddRe7d+/2VJcE3qaqGA7+n/zj6UrsCBiytPEPtSuGAtAGg1+w+/H8g/DZY7LF4MrGxbBiJ4TEw7ZHwW51njMWwoH/g8vvg9wf4ce3nOf2/BUKj8GSf4CfDszVcvuwy0CpktvUlMGn97u/3vYnoO8U0AY5j+1/E7L3QmhvGDQHTJWQ/pH8f9HFwrFPIKY/9JsC1aVQXgC5+8BugYLDspgNvloWlk/vl+/VFJ89JrsPT2yHzJ2Nz9vMsPcFmHAvBEU1P8YCQRfDYxZUeno6iYmJJCQkoNVqmT9/Prt27XJro1AoMBqNABgMBmJiYjzVHYG3sZog/e3G4gRQ8Auc+Upu05T18uM6eGEgvDxKtobqKc+FjUsaixOAIR/+c718bc63jc//9yWorYBtv2187sQ2eO1yWDscno6Hv4+Al4Y4raTtT4DhvPs1FWdlcavnQibs/KO83WciqLWyOESnOK2oI1vgsnGgUoMuBuY9CxPudt7j62fk8TieBqd21B1UQP85sOhViB8pH5Ls8N7N8PO/nddOuBdmrIa44XV9mAx+IY3/rwJBF8Zj9aA+//xz9u7dy1NPPQXAxx9/THp6OqtXr3a0KSoq4o477qCiooKamhrWr1/P0KFDW7xveno6oaFt96cbjUaCg4Mv3rCH0KnjIUlEFewkyHha3kWBXalFIdlQSta6JhIc+wybqYaima9giRwIgKbsFHGf3ojCbgHAGhRLwYK3sfmFE7v91/gVH5avV6ipjR+HKXo4oYfeQiFZG3WjctAyAs/uRl1VAIApchB+JbLg2VX+VFw2i/AzWxtd50pt7Fj8C5yCWNV3LkFZ2x19OL9oE+qKbMJ/WoumMgeCo2HEUucNijOpqayg5Mo/0XvrdTDmFsepmsDeXIicSK/Nc1FaZRfkhSv+l7CDr6GuLgTAMPB6Si//HwCUNSXEpd2M2pjv1kdjvwWUTHrKGQhit4Ky7c4S8Z1pjBiTxnTEmFRXVzeZBNyrQRLbtm1j8eLF3H777Rw8eJDHH3+ctLQ0lC1EMGk0mnZlzhVZiN3p1PHI2Qt14gSgGHwtKv9wWHcV9J0IYb1QKBTQfwbqXz4gfveDsntOFwvbb5FdXnWoqwro/c0j0Gs01IkTChWKX6cRkHg5AQCJQ+W5H1dCehNyzV/h8GZIexjAIU4AyqmPU3nZdYQPnwdbH5LdYiC7HdX+8nwZuIkTw24k6JrXYf1cyP0BhWQlfutNYHOZV4tt8MAV1ouAk7voXbADwhLcTgVU55EwMhEm3OMI6Ij67n9lCwkgMArdoufQOaIAEyHyY/jnVU7LNGkSwTetJ1itbfbtaC3iO9MYMSaN8WQ2c48JlF6vp6CgwLFfWFiIXq93a/PBBx+wbp08wTty5EhMJhNlZWVERkZ6qluClpDscGa3HEDQ7yo5JPtSObwZCn5yPs33Hg+hSfDPWVBdDCd2yBaGXzBo/CH1aqgug6//BEGREJEg/ykU7pP8NUUweL68HdUfSk/If/Vc+SCUOkWRuOFw7ANQSjD8OvcgCW0QBIUTUvozjLgREsZD0TGI6CvPe2V/DYWHobLOUjFXwYVsmPusHA4+/wX4x2SQbO7ipNVB7BDAxW2pCYDAMPj2b9B/euPxyj8gR/ntfwvMBqc4qTQw8R44/omzrUoDl10BN2+WgypCE2DRa1BwECpyoc9UCOzYOSdJkjhTWENhuYmeGHJhN6uJt9nRqJwP0SaLnaO5RmrNtmav04f60Tc2QH4QE7QajwlUamoq2dnZ5Obmotfr2bZtGy+88IJbm7i4OL7//nuWLFnC6dOnMZlMREREeKpLgotReBiy6uYJVVpInn1p90t/H/K+B22AvF9dCmjgvWVO8bDbIWoIGHPlH2P/EPmvnohWPJnZTVByovFx12tN5fIfyPM9DSk9RThAlg76zYTIfnJQw75XZKFQa9zvl3SlHEQBspU04R74/hV5XxME41ZAv2mQtaPhK0FYb6gqgdBejc/l/wR9Zsj32/Oc8/iI66G2RP5zpeQUjLsP7v6vvJ+3D45/LG+XnYZx97sHblwi2UU1HMo2dNj9fA81BzIrmNA/DIVCgd0u8f2JcsqMlhavKiw3o1BA39jATupn98BjQRJqtZrVq1ezYsUK5s2bx9y5c0lJSWHt2rWOYImVK1eyadMmrr76ah555BGeffZZ8YThTcrOOLcvHG++XWtI3wSnv3CKk7kajmyVxSn3h7pGCrj2LRh/Nwy85tJer6M4swuKM+R5m/T/yOLUFLWlcHKbc3/WGpj7V5j9DDx8WN53cSESHOvcDu0tB0xo6sZGEyRbaiCLYslJOcKwfu3SZWMhoJl5V1td8InVJAdqnHCZQ6sthyPvOa2wS6TUaCG9R4uTTEGZmRPnqgBIzzFcVJzqSc8xUGIwe7Jr3Q6PzkFNmTKFKVOmuB176KGHHNvJycm89957nuyCoC0Yzjm3jQXy+h1tGyc/JUlejHpsc517izrX4Q+ya8yV2U/JC0kBeo2VXVS15XDkQzj0vnxc7Qdzn4OQOOf9j6fJC2BTr5PnqdqDzSbPK/nXWUE5/5UtDoCjmyByAFTk1DVWwMBFsmCUnYGcPfLhvB/kPseNkkPQx9/lvL+xQHazAShUMGgx/Pi6vB8aD+GXOdtG9AP/MOd9z/0II26Fmz+UI/OCdM62vcZC9GDZRZmxRRbSqiI48r78/kkN3EylmXD6y0u2hk0WO/tPlmOv8+uFBqoZlBBMT3qcPF9mIrtIdg1n5FVRY7Y79gH6xwcSqWs895eRZ6S8yookwf6TFUxLjcBfq+q0fvsyIpOEQMZuk39UXSk9DbHDnfuG83I0WFB04+slSc6I8M1zYK12n19JmgqX/w62PwZHP5KPjb9bDoV2JThW/pvyJKgC4MRnMPlx6DvNvV1042ifSyakN9bv1qK2GsFaC4WHnOeSZ8tzZyALV00pFB2R9zM+kkVC1WC+rv48yIISehn4h0NtmTx3FD/MeT4iGcKSnAJVckJ29alUEBoDdRF9hPeFAYuc67EkuzyvBnDBxVrTBEBMKpzbL+9nfw0hvSFmSLuGxi5J7D9VTo1ZtsQ0KgXj+4cS5N+zfj5iwrRcKDdiNMvj7ypOvSL9GJwQ3KQHKCRQzVeHSzBbJWotdvadqqCf3juuvkA/FRE6TavbS5JEUYUZi7XpGUelEmwdY6A3Sc/6hAmap6rIfTEruAtU4WE4/B9AAak3gT7V2S7ne9j+OBSky+I1fLHzXOQASJ4jBzks/RdMfFC2zJImNZ8PT6GQ0/e4pvDxNNpgiuOuIu7cp+7jEDMUEie7923wdfJ41Y9Zxkct37vXWPnfiGTIr4sC1Lr8QEX0g4AICOsD5WfchacevxB53JUuT97xo6EyT7bknB2EoTfKr1VbLrsLAY59CFED3a9vJUfPGrlQ6XRjjUnueeIEoFQo6BNm4VS5hlqz81dZF6BiVN+QZqcnAv1UjE0O5dvj8hxoqcFCqaGiU/rcFMOSdPRrxVyYJEn8nFXJ2eLaFtv5qbT0SZI8Mj0jMlIKZAz5jY+VZcr/1hrg0Dt1ByVIfwdy94PJKGcxWD9XFie1Hwya7VxzExgl/6i6fnB7jZIXjXbBuUazfwwMuNp5IDBaFqOGfVX7wbBbGltNTREQKQsQOP91Ox8uixNA7wlN30OhgmE3N+1u7T9fts7q6TcLIvvL4fFDb3AmprWZnZZYGzhXUkvm+WrH/qDeQcSGd0B0p4+iUcH4lDCUdR8JtUrB+P5hqFUt/5TGhMkWVlfgcI6BC5UXnws7U1hzUXECsNgVDtdvR9PzHoMETeM6/1RPTRkc3yZnNEie5DyuUMAv6+HEV2Csz6iggIFznBF4Kj8Y8auOCVXvTHqNla2MynxInNR8/4OiYdTtctRcQ8uzHrUfJFzhTOHUlECFO1N/oU+VgzIqzjqPKVTyHJerCLmiVMvjnP2NPI9V74oE0ATC6N9A7neyC7GN84k1FgUnT1c69mPDtAzo1XERgb5KhE7DFYPCybtQS5I+AF1A635G+8cHolYpvBYoUVFlxVhrk+fCTslzYQHNzIWVGMyk5zgDYsKDNQT6NRZhpUKBv70SldIzD5xCoAQylS4WlMrPuZ7nqz9BcBNh2QFhkDAcMuoEasQNEOyyRGDo9R2+BqfTiBsl/12M0MuaF46m0AbLc2yuc32uoqVQyOuauKL19wRZiFLmNn3OP7T5cy1gsdrJKtNgq3s0DvJTMTo5VETZ1hEVoiUqpG2LoRUKBf1iA1vlXvME1SabYy5MDnqpYNLgcJQNxKXWbGP/yQrHssOwIDWTBoc3K0I5OU2kL+sghEAJ5DkPVxdfr3Fwti4xaUSS+3qdsL5QniVvR/aFK+5unOS1zzQ5MEDQmPB+zQtUK5EkiV/OGDhbXOOxxbLyj5P8vqqUMH5AKFq1mBHwZQL9VIxLCeO/GWWAvGzg0/1FNAzFdF0Pr1XLLkxPWUgXQ3ziBFB9wZlOSKuDuBHOc5F95ESnIM/JjF5R95RfR0NxikyBvjM9219fJtLFpRcc2/YwfuBkfhXZRTXYJfnHxBN/rozsG0JoYOsjvwRdl+hQLUMuc37mJFp+78cmhxLo17Qb0GS1UVRZi4fSuQLCghIAVLrMP4XEyz+cdlvjiK9eY2Q3VPJcqK2sC6V2+XCGJsgRZC2VzejpRPaHqEHyPFO/q9p8eWG5iWO5VRdv2AEokBjQO5iEqIBOeT1B+6i12MgsMpJZZORUkYEqk41+0UEkx+hI0QcTFew+j5oSF0h1rY3souYtcKUCAgMVfHY0n6wLVZgszqjFsmozmUVGckqrsdklxiUE8/69iR5x/wqBErgHSOh6ydF5pTkQ1dd5vH6yHmThGrZMFjFX2hHC3ONQKOVFuJLU5kjGqlobP55yhidHhWiYODDcY4tlz549S1Lvdi6EFnicnJIqXvvqNB8dPIe5hcVIIxLCeHBGMtMGxKBQKFAoFIzoG0K/+AB2Hy9mx9HzfH2imBoXEZIkqdXu44PnqjBZ7fhrOv77LwRK4B4goesl1x4qP+suUNGDGrujhCC1nybEqT4Ra3lV06lzSgwWLDb5Z8Nfq2RcimfnBkQ8RNckq9jIq1+d5uNfzjmCWFril9xybv/XAYb2CuGqwbHklFSTWWQgo8CA2Xppq2wTIgK4ITXcI+IEQqAEDQMkQuLh62ehPM+9Xf1iU4HHOHGuioy8i7vvFAoYnxKKn0a4UnsSpwoNvPJVJlsP5Tdad5QUGUh/vezSC/JTc7qoisxiIxn5lQ7r6si5So6cq2zizjIBGpXjoUSjUtInKoiUmGCSY4IJDXDOQQZoVfSLDqZvdBCBWjU5OTnN3PHSEQLV0zn3szOkXBMESi2c+lLOU1dZACGxEBwnZyYQeIyCMlOrxAlgeJKOiCZyvgm6H7UWG3tOFvPRwXN8frSgURDDFcmRPDA9hQl9my5RVFhZyz++yeI/+3OotTS2lgbG6piXGsfcobGk6HVN3MG7CIHqydSUwxcrIbHOOrKaIesrR3E+zmfAlY9BeJIIfPAgVbVWDmQ655YidRoui246MEEXoGoyIanA95EkiWKDiVNFRk4VGvjpbDm7MwqpaqLO1OT+0Tw4PZkxSS2XJ9KH+LN64WDumdqPTQdyKTaY6BsdRHJMMCkxOqJ1XXshvRCoroi1Vi5PEeih2ljmKvyq8+HL5yHApfbS2R/gkEsOuIHzIEasZ2oOSZKorLaiC1A3WuzYWqw2iX0nK9zmlsb3DxPuu26IyWrju8wSigy1JEUGkaLXERqgYf+ZUrYfOc8XRwsorDS1eI+Zg2K4f3oKIxLC2vTa0To/7pvme14QIVBdjaoi+OktOaHqwEXN52e7lPsf+AexlmoIDIbAAc5zxmL3kPNBCzv2tbsR9YXqiirMhAepuXJwBGpV20RKXnBbSUW1nCpJqZDzvAlx6h7Y7BLnymo4kl/BF0cL2JVRhNHknhZLq1ZeNFChT1QQc4fGcvWIeAbGhrTYtrshBKorYa2FQ2/L4gRyddv4sR0XLWc1yYleLdWNz0kSGAqd+6EJ7gt2BW4cOWukqELOqVZWZeVgViVjkpvPaN0UZwpryL3gTMY5LEnXplIIAu8jSRI/ZJXy9r4cig1O68dYayXrgrHJeR9XGopTkFZFil5HSkwwKfpgJqVEMzBW12NTTAmB6ipIEhz9AKqLncfMRrmybTvr+DS6/7EPZAsKwGYFY6Fc2r3XGIgbCYWZkPmlfH7otSLOuBlyL9RwusBd5PNKagkP1pAc17o8aw2TcV4W7U9SjFgQ2xWRJIlfcsvZmVHI+QtljMyXSI7RUWu18fpXp9mfXdrqeyVGBpLaK7Qu1NtIjcVGtM6PuUNjmTM0lnFJERfNjN6TEALVVcjZA8VHGx8/92PHCFTOXvcieplfQ/FJWLYZ+tdlNLjxHfj+FaitgClPXPprdkMqqiwczHKG6mrVCsx1xdyOnDUQFqS+aBLRppJxjujTNutL4HkuGE28/vVpth8+T36F09Ldcrj1ghQV7EdyTBBjkyKYOzSOQXFOa8hulyivsRAWoGn3HGZ3RwiUJ8n/SS6DYGtFen2Ty/qEmFQoOixvl5yUC8/5h8kl049udk82qlRBwkT3/HgNKT0NmZ+79OuwLE6pS53iBHJ5iEmPtu7/1gOx2uzsO1nhqCAa7K9i8pAIvj9eRlldSQvz7HkAACAASURBVO9vM8ouOodktUmOoAiNWuHxBbeCtlNRY+Ha178jp6QJd3gD1EoFS8f0ZsGweMf7qFUr6RMZRHhQ8w8rSqWCiBbOC4RAeQ67DU5sda4xai1hSXKhuV9qoDQTkCD/ACRNgyPvymLTkJNp4BcK+qGNz9WWw+F3ceTMM16AM9/K26NubVvfejh5JSaqTHLIr1qpYPwAOaBhXP8wRxkDu4SjNHprGJscSpC/yMjRlZAkicc2H3ITp7BADVcN1hOqslBiUZNZZKS82sLk/lHcPaUfvcO9U0KjuyMEylNYa9ouToGRzrLe8WPrBAo4d0C2wpoSp3qObZbrNgW51G6yWeqCIuoWgKq0cCwNJDvWgCjUiW2sO9TDKTM6UxClxAcSUleoLtBPxfj+YXx/vBxrG0qLpiYGow/r2utQeiLr9p5hxzFnwNAzS1K5bnRvNColOTk5JCYmerF3PQshUJ7C7JIVICACRt95kQsU4KdzLoiNGSwXorNUg6lCnkOqJ2ka9B4nC9Av/4KaUlnADr0N4+5zVoE9uRUq61IWKZRQXSmvrwKqk2YRInLptYkKlxx5EcHu0XZRIVrmjYl2y/rcEhqVAo2or9Tl+DG7lGc/P+7Yv+2KJG4a14ailIIORQiUp3AN5dbq5DmktqBUy5F1Z791Px41APrNdArZsFvgx9flek7VxXBog1zl1WyUXYP19JsN7//KsVvdZzY9a0XFpWG3S471SgChQY3DwVVKRbO1cwRdC0mSyK+olUtUFBrqSlUYOZZf6UjAOvKyMFbNHeTlnvZshEB5CouLBaVpp386fqy7QAVEwJAb3NMO6eJg0GI4ukneL8uS/1yJHQ4Wk2xpAejiMcWINU5twVBjdSToDNAqxWLaLkBplZlvMy9wqtAgpwcqMlJZ47RyNSol4/tEMDc1jkkpUVSbbew4WsBnRwr4Kbu0yRRC9YQHanh12ShRRdjLCIHyFGZXCyqoffcI1ssF7kpOglIjW0uaJtbKxI2UXXm53zVxj1gYtAS2/tZ5bMhikVuvjZRXOa2nsCasJ0HnUGI08dmRAj4/cp4fskovWm5iy8FzbDl4jkCtCpPV3qryFAkRAbx4/Qjiw8S6NG8jBMpTuFlQ7RQokCvUFh2VE7YGRjXfrv98COktR+3Vo9JC7Ag5gO/4VufxIYvh0srA9DhcazSFBYmvTWdjt0v8+/ts/vrFCapbsHyao6lrwgI1jnISKXXVZ1NidOhD/MSatC6CR79pe/bs4amnnsJut7N06VLuuusut/NPP/00+/btA6C2tpaSkhIOHDjQ1K18D1eB0l5CCKomQM70cDEUStmScsVqggun4OR2efEtyPNTvcfA2bPt71MPRFhQ3uN0sZEnPkjnQE5Zo3OjE8MZmxThSA0Uo/N3JEApqKjli6MFfHb4PNl1IeNjEsOZMzSW2UNi6R0eIISoi+MxgbLZbKxZs4b169ej1+u57rrrmD59OsnJzoy6Tz75pGN748aNHDt2zFPd6XxcXXyXYkG1h9pK2PYoHPkQpAZPjkOuESmM2ogkSVRUCwvKG3zwUx5PfnTYLWddSkwwt0xIZPaQWGJD/Zu9Vh/iz/CEMB6bPYC8shoCtCqigkVYvy/hsW9aeno6iYmJJCQkADB//nx27drlJlCubNu2jQceeMBT3el8OiJIoj2UnIZ3b4ILJxqfU6phxLLO60s3wVBjc2SP8Nco8deKSL3O4D/7zvLkR4cd+2qlgnunJXPftH74qVv/HigUChIixEJaX8RjAlVYWEhsbKxjX6/Xk56e3mTbc+fOkZeXx4QJHVxawpu4ufg6yYI6vRs23+Y+DxWeBNEDIXoADJgPMSJstq2I+afOZ8P32az+xJmbcmCsjhevH8HgeLE4oifRJb5t27ZtY/bs2ahUF38qslgs5OTktPk1jEZju65rL72qKx2De66oHGt56zMMtAWV4RyBOTsJyv4Sv2LnA4Ck0lJyxf9S1W++s7EE1I1BZ4+HL9DcmORVqnF8VazV5ORUNmrTHenMz4jFZievwkx2mYn081VuCVkHRAfw/LzeBFnKyGliHqozEd+bxnhyTDwmUHq9noICZ1LTwsJC9Hp9k20/++wzVq9e3ar7ajSadqUa6fQUJVnONEe9klKaDg9vLyWnIeNTOPYJ5B9sfF4Xh+LGd4jqNZrm4v56esqWs8U1nCutJSUuyJF9vLkxyTlaCshWVFJ8JHERzc97dCc8/Rkx1FrYfbyIzw6f55uTxU3WThqREMa/bx9HaEDXCEzp6d+bpuiIMcnIyGjyuMcEKjU1lezsbHJzc9Hr9Wzbto0XXnihUbvTp09TWVnJyJEjm7iLj2KzODOYK5SgbsMPWm0FfPogFB2DPlNg8CJInAglmXCsTpQKDzd9rUIF/efAghdBF9t0GwE1Zhs/Z1UiSVBcYWbKkIgmM0NAfYCEiODrKCqqLezMKGT7kfPsOXWhxWqyoxPD+ddtY9H5izHvqXhMoNRqNatXr2bFihXYbDauvfZaUlJSWLt2LUOHDmXGjBmAbD3Nmzeve4V7uqY50gS2PmquphzeXgLnfpL3L5yEH99y5uRrCqUG+k6VhWzgfAiMuJSe9wiKKsyOWkw2O+w7WcHU1KbHrarWhrWuNIZWrcBfKxY4t0RBRS2/5JaTWSRnd8gpqXYsjrXZJU4VGRylRhoSH+pP/1i5muzQXqHMHRonMjn0cDw6BzVlyhSmTJniduyhhx5y2+9WkXv1WFoRYi5Jcg0ovxBZwKpLYeM1cP5Qy/cDUPlB8gxZlPrPgYA25vnr4RRXuNfnqjLZ+CmzgrgmDF3X9U/hQZru9SDVQeSWVrP9yHm2Hyng4Nnyi1/gwuC4EOalxjJnaBzJMcEe6qHAV+kSQRLdjpYi+GwWSH8f9r4ApVlyItno/lBTJu/XM2UlVJfIc03GQlAHQMosWZRSrgJ/Ec3UHiRJaiRQAAXlZhTBKpIaHHeN4GvODdhTOZRbzsu7T7Ezo6hN1w3vHcqcoXHMHRpLUlQnrxEU+BRCoDyBuYGLD+QChgc3ysJU7pLFwWxwuvQAUMDVf3cWE5z7HFTkQlBU54Wrd2OMtTbHZLxGpSAxJoDM8/L7dd6opqDMRGy4vJjTbpcochEzEWIu81NOGS/vPsXXJ4obnVMpFYxNCmdQXAgpMTr6RgcR6LJuLEbn3+LiWoHAFfGN8wQN8/AVZcAn98uFB11RKEFynSRWwDWvuS+mVSohXEQNdRSu1lNUiJYhlwVTXmXhQqUFUHAgU56PCvZXc+Ss0REgoVBApK5nW1D7skp4eXcm/8284HZcoYAp/aOZlxrHrEH6FsucCwRtQQiUJ3AtVnj+EGxeIddrqicgAibeD2PvBEsNFB+X3Xuxw6D36M7vbw/C1SKKDtWiVCgYlyKXbK8x27HYJPadrKBfbACnC5yW8KDewT0yg0RplZkvjxXw4U/n2J9d6nZOoYCFw+K5f3oy/fU6L/VQ0J0RAuUJXC2ozJ1OcVJpYdKjcPn94Fc3IewfAjo99J3S+D6CDkWSJC5UughU3fonP42Scf3D2HOkBAkFldVWDmYZHO3iwv3oH+/bqXIkSeJkoZEdRwsoqTLTJyqIlJhg+sUE46+pE14JCg21nCo0cqrIwN7j+fySf7RRiQqlAhaN6MV905JFYIPAowiB8gSuUXeWGvnfXqNh0asi1ZAXKa+yOkKc/TVKdAFOiygiWENCqJWzFe5uvGB/FaP7hfhE9F5ZlZnMYiOnCo0UVNY6jleZrHx1oois4qoWrr44aqWCJaN6ce/UZBHcIOgUhEB5AlcLylIrl7i440tQ9jwXUVeiuIF7r6HoRAXaUGh15BTLP+4qpYLx/cPQdOG1OFUmK2//kMOG73M4V17jkdcYdVkY81LjmJcaJ4r4CToVIVCewDWKz1IrW01CnLxOcRPuvYYM7xOCzS6Hlw9L0hES2DW/IhXVFt7el8O6vVmUuZQCaYlArYrpA2MYFBdC9oWquoW0VVhdXHihAZq62ko6wlUmrpkwgLhQIUoC79A1v32+jqsFZa2BsMu81xcBIGcxKDG4W1BNoVIqGJsS2lndahP1AQufHS7g28wLbsIC4KdW1lWHDSYhIhCVUrYQFSgYGKdjSv9o53xTK8jJyRHiJPAqQqA6GklqMAdV5+ITeJVSg8VR0ynIX0Wgn29YtMUGE18cLWD7kfP8kFXaKGABoHd4APdOTeba0b3aVCdJIOjqCIHqaGxmsNelx7FZ5O2wBO/2qYcjSRKnzjut2ubce5dCebWZXRlFHMmvILPISGaRkSKDiYTwAJJjdCTHBBPhkomissZKZpEcLXe2tJqwQC0pMcEkxwQToFHVnTOSW1btyBvYkGG9Q7llfCKLR/VCo+q682QCQXsRAtXRNLSeQLj4vMzxc1UUljvdewlRHZPJoMZs4+NfzvHZ4fN8f7qkkcsNILukmuySanZmFLZ4r2KDiWKDie9Ol7TYrj5gYc7QWHqH+3bou0BwMYRAdTRu8091AhUqLChvUVBm4nie8z1JiQ901H+6FIoNJm5Zt48ThYaLN74ElAoYkxjhSKgq0gQJehJCoDoac4MQc4USQuK9158ejLHWyoHMCsd+dIiWwQmXvrC0qLKWm976gdMN1hWNvCyMqf1jGBCrI0UfjD7En7Ml1ZwqMnC6yEiNxeZo66dW0Tc6iJQYHUlRgZRWmesWyBoxWW30iw4mRR9MUmRQmwIbBILuhBCojsZtDVQN6OJB1bNzuHmLX7KctYcCtErGpoSivMQFt+cralj21j7OXJDfZ5VSwSOz+rN4ZK8m1wgNjg9hcPzFM8/r/DUkRgYxc3DTVacFgp6IEKiOxnUNlLVWzD95CUmS3NY9je8fhp+mfYEEkiRx7Hwl2w8XsPmnXAorTYCcWWHtjSOZPyyuQ/osEAjcEQLV0TTMIhHez3t96cFYXaq2qpQKwoOdVmyN2carX2XyY3Ypt13RhzlDYxtdL0kS6XkVfHbkPJ8fKSCnxL1opEal4OWbRjV5rUAg6BiEQHU0DfPwiQAJr+BaVlyjdrr1vjt9gZUfHuZsqfw+7c8u5S9LhnH9WPl9qrXY+Mc3WWw6kNts6qDQAA0vXj+cGYOEO04g8CRCoDqahkESwsXnFSw2Z50tjUqBzS7xx0+PsvGHHLd2kgSPf5iO1S4RSjW3f7C3UfADQLCfmpmDYpibGtfmjAwCgaB9CIHqaFwtKGuNWKTrJSxWFwtKpeTf32W7iVOIv5qYEH8yi4wAPPnRYRSA60qmEH81Vw2JZe7QWK5IjhKiJBB0MkKgOpqmMpkLOp2GLr609HzH/vSBMTyzJBV/tYrl/7eP9Dw5FL3+iiCtipVzB3LD2MvQduFM5gJBd0d8+zqahi6+0N7e60sPxuri4pMkiYO55YC88PXF64ejD/EnNFDDxjvGMyIhzNF2cv9odjwyheWXJwlxEgi8jLCgOhLJ7u7i8w8BjVj57w1cXXxFBpMjn92oy8IJC3RmkggN0PDunRPYcjAPlcnADZOG+ERxQoGgJyAEqiOx1uJwFFlNECKsJ2/h6uLLKXVatdMGxjRqG6BVcfP4RHJycoQ4CQRdCOHD6EhEBF+XwWJ1uvhO1QVCAEwb0FigBAJB10QIVEdiaZhFQkTweQtXC6qsWs4oERviz6A4nbe6JBAI2ogQqI6kUQSfEChv4WpB1SdpnTYwWrjwBAIfwqMCtWfPHmbPns2sWbN48803m2zz2WefMW/ePObPn8+jjz7qye54HnODLBJhid7rSw/H1YKqtcoCNVW49wQCn8JjQRI2m401a9awfv169Ho91113HdOnTyc5OdnRJjs7mzfffJN3332X0NBQSkpaLtbW5bEYXbaFi8+buApUjcWKRqXgiuQoL/ZIIBC0FY9ZUOnp6SQmJpKQkIBWq2X+/Pns2rXLrc2mTZu4+eabCQ0NBSAyMtJT3ekcaiud25Yq4eLzIq4uvlqLjfF9Ign2E0GrAoEv4bFvbGFhIbGxzkzPer2e9PR0tzbZ2dkA3Hjjjdjtdu6//34mT57c4n0tFgs5OTkttmkKo9HYruvaQvSFs9QX4bbbIbegBOiaVmFnjIc3qTX7AfJ8U43FxvAY9UX/v919TNqKGI/GiDFpjCfHxKuPlDabjZycHDZu3EhBQQG33HILW7duJSSk+QJvGo2GxMS2z+3k5OS067o2kefMfq30D/X8610CnTIeXuRQYaFju8ZiY8nlA0iMbrmabncfk7YixqMxYkwa0xFjkpGR0eRxj7n49Ho9BQUFjv3CwkL0en2jNtOnT0ej0ZCQkEBSUpLDqvJJzC5zUIER3utHD8dul6jPdGSzS6iUCvpGBXm3UwKBoM14TKBSU1PJzs4mNzcXs9nMtm3bmD59ulubmTNnsn//fgBKS0vJzs4mIcFH520kCWzOCq7o4r3Xlx5Owwi+2FB/EV4uEPggHnPxqdVqVq9ezYoVK7DZbFx77bWkpKSwdu1ahg4dyowZM5g0aRLffvst8+bNQ6VS8fjjjxMeHu6pLnkWm8ll2wKhopKut3CtBVVjsaIP8fNibwQCQXtplUDdf//9XHfddUyePBmlsvVG15QpU5gyZYrbsYceesixrVAoWLVqFatWrWr1PbssJpcIPnM1hAs/tbdwTRRba7ERGyIS9goEvkir1GbZsmVs3bqVq666iueff56srCxP98v3MBmc2+YqsQbKi7hbUDb0oUKgBAJfpFUW1MSJE5k4cSIGg4G0tDRuu+024uLiWLp0KVdffTUajcbT/ez6NLSgxBoor+FqQdVYbFwWIQRKIPBFWu2vKysrY8uWLWzevJlBgwZx6623cuzYMW6//XZP9s93MJx3btutEBDWfFuBR2kUJCFcfAKBT9IqC+q+++7jzJkzLFq0iDfeeIOYGDmn2bx581iyZIlHO+gzGJwh9ajEpLw3ES4+gaB70CqBWr58ORMmTGjy3JYtWzq0Qz5LjUvGCL/mFxoLPI9VBEkIBN2CVrn4Tp8+TWWlc46loqKCd955x2Od8knMLkESgT6eU9DHqbW4W1DROmHRCgS+SKsEatOmTW7ph0JDQ9m8ebPHOuWTWF3WQQXHNt9O4HEMtRbHtlqlQKMSZc8EAl+kVd9cu92OJDndJjabDYvF0sIVPRHn+Ig6UN6l2mRzbAdohTgJBL5Kq+agrrzySh5++GFuvPFGAN577z0mTZrk0Y75FDYzKFXytt0GEX29258ejuzik1MbBYkSGwKBz9Kqb+9jjz3Ge++9x7vvvgvI66KWLl3q0Y75FK51oMxVEHaZ9/oiwGKTUNfl3gsNEAIlEPgqrfr2KpVKli1bxrJlyzzdH9+k4qxz22oSa6C8jN0uQZ1BGx4kFpELBL5KqwQqOzubF198kczMTEwmZzBAwwq5PZZyF4FCZM32NgqX9yAqWETwCQS+SqtmkFetWsVNN92ESqViw4YNXHPNNVx99dWe7pvvYMh3botFul5FkuT6T/XoQ8X7IRD4Kq0SKJPJxOWXXw5Ar169eOCBB/jmm2882jGfotplka625aqtAs9itUso6+afzFYbcaEBXu6RQCBoL61y8Wm1Wux2O4mJibz99tvo9Xqqqqo83TffwWwAVd2kR4CopOtNLBaR5kgg6C60yoJ68sknqamp4X/+5384evQon376KX/5y1883TffwXWRri7Oe/0QUFbtXJ9nstnRiTBzgcBnuei312azsX37dp544gmCgoJ45plnOqNfPobzqZ1QEWLuTYoqnQ8LNrtdlHoXCHyYi1pQKpWKn376qTP64ptIEihddD4y2Xt9EXDBaHbuCG0SCHyaVvk/Bg0axN13382cOXMIDAx0HL/qqqs81jGfwVAAmrp5DskOIcLF503Kqyyo6z7WrtF8AoHA92iVQJnNZsLDw9m3b5/bcSFQwIVTzm2bBRQi95s3MdRaCK+bd9KqxXshEPgyrRIoMe/UAuXZzm2p2VaCTqLKZCO8bulTgEYIlEDgy7RKoFatWtXkcSFcNFikq/VePwSAXKCwniB/lRd7IhAILpVWCdTUqVMd2yaTiZ07dzrKvvd4qi+Api7fm1ik63UsNqcZGxIg8vAJBL5MqwRq9uzZbvsLFiwQiWPrMRlAU7c4NyDcu30RYHeJ+I8IEhatQODLtMtJn52dTUlJycUb9gSstc5tUUnXq5itdkeaI4AIkclcIPBpWmVBjRw50m3BY3R0NL/73e881imfwnUhqMgi4VWKDLX4q53zTn4aMQclEPgyrRKogwcPtuvme/bs4amnnsJut7N06VLuuusut/NbtmzhueeeQ6/XA3DLLbf4ViFESQK1y1O6sKC8SmFlLQEuoqRRi3VQAoEv0yoX35dffonBYHDsV1ZWsnPnzhavsdlsrFmzhnXr1rFt2zbS0tLIzMxs1G7evHl88sknfPLJJ74lTgBmI6hdkpGKOSivcuZCNf6uAqUSYeYCgS/Tqm/wK6+8gk6nc+yHhITwyiuvtHhNeno6iYmJJCQkoNVqmT9/fvcrcFhTDioXI1Qtag95iyJDLX/5/Li7BaUSFpRA4Mu0ysVndw2NqsNmszXR0klhYSGxsU6Xl16vJz09vVG7HTt28OOPP9KnTx9WrVpFXFzL8zgWi4WcnJzWdNsNo9HYrutaQlN6gnil08WXc64AFL4x7+GJ8fAWVrvE77ZmU2I04+eYg5I4l5dLW3LFdqcx6QjEeDRGjEljPDkmrRKooUOH8swzz3DzzTcD8M477zBkyJBLfvFp06axYMECtFot7733Hk888QQbNmxo8RqNRkNiYmKbXysnJ6dd17WILRtK6n4QJYnEpL4de38P4pHx8BJ//eI4B/OrCNI6P84alZKkpLb9/7rTmHQEYjwaI8akMR0xJhkZGU0eb5WL7/e//z0ajYaHH36Y3/72t/j5+bF69eoWr9Hr9RQUFDj2CwsLHcEQ9YSHh6PVymtVli5dytGjR1vTna5DjUuovdTYyhR4nq9OFPHqV6cB3CL4RICEQOD7tMqCCgwMbHNYeWpqKtnZ2eTm5qLX69m2bRsvvPCCW5uioiJHRordu3fTr1+/Nr2G16kuc9kRP4jeYO1OZ7Leif0iHdsiQEIg8H1a9S2+7bbbqKysdOxXVFRwxx13tHiNWq1m9erVrFixgnnz5jF37lxSUlJYu3atI1hi48aNzJ8/n6uvvpoNGzb4Xm6/2nLntshi3ulYbXaOnXd+Lh+YnuLYFgESAoHv0yoLqqysjJCQEMd+aGhoqzJJTJkyhSlTprgde+ihhxzbjz76KI8++mhr+9r1MDlD730lOKI7kVNajdkqu1ZjQ/wJcinvrhalNgQCn6dV32KlUkl+vjNrd15eniilDWB2ESiVSKvT2ZwqdI5/ij6YMoPFsS8sKIHA92mVBfXwww+zbNkyxo4diyRJ/PTTT6xZs8bTfev6mKugLshDlNrofE4UGB3bo3tHcOp8tWM/UiceGAQCX6dVAjV58mQ+/PBD3n//fQYPHszMmTPx9/e/+IXdHUs1UJc9Qi3Go7M5WSRbUJGBflwW6lxIHhWiITEmwFvdEggEHUSrBGrz5s1s2LCBgoICBg4cyKFDhxgxYsRF1yx1e6wm57ZG/CB2NicLDGhUSm4b3w9FXRRlgFbJuJQwt6zmAoHAN2nVHNSGDRv44IMPiI+PZ+PGjXz00UduQRM9FpurQAV5rx89ELPVzpkLVcwf1IteoYEAKBUwrn8YfqLUu0DQLWjVN1mr1eLnJ+eZM5vN9OvXjzNnzni0Yz6Bzerc9tM1307Q4Zy5UIXVLjEsPsxxbFiSjohgMfckEHQXWuXii42NpbKykpkzZ3LbbbcREhJCfHy8p/vWtbHbQRIC5S1OFhrQqpSEB8oPTgoFJEYLN6tA0J1olUC9+uqrADzwwAOMHz8eg8HApEmTPNqxLo/ZAErXTObix7EzOVloICbYGZgS5KdCqRTzTgJBd6JVAuXKuHHjPNEP36Om3H3tk1qEmXcmJwsNxOicAqULaPNHWSAQdHHEbHJ7qW0gUCpRC6ozOVloRO8mUCKTh0DQ3RAC1V4aWlBioW6nUWuxkVNShT5YWFACQXdGCFR7qS0HpRAob5BZZMQuQYzOOe8XLARKIOh2CIFqLzVl7uXehYuv0zhVZECpgOgg55jr/IWLTyDobgiBai+NXHxi/U1ncaLASESgH+q6mk/+GiUakb1cIOh2iG91e2kYJKEWFlRncarQ0CBAQrj3BILuiBCo9lIj5qC8xYkGa6CCRQSfQNAtEQLVXhqFmQuB6gyqTFbyymrEGiiBoAcgBKq91JbL+XVALvcuSr53CifqihTqg50RfCJAQiDonohf1fZichbLc0t5JPAoe09eABAWlEDQAxAC1V7MLgIl3HudxlcnigjWqgnSyqKkUirw14qPsUDQHRHf7PZiqXFuq0Q13c6gxGjiUF55A+tJhUIUJxQIuiVCoNqD3Q7WWue+qKbbKXxzshhJQqQ4Egh6CEKg2oOpQqyB8gJfnSgGQO+a4kgESAgE3RYhUO1BJIrtdKw2O9+cKAJEgIRA0FMQAtUeasvdI/eEQHmcg7nlVNbKFYzjQlxCzIVACQTdFiFQ7UFYUJ3O7uOy9aRRKQn1d459kHDxCQTdFvH42R5EscJO56s6gdIH+zui9oL8VahEmXefxWKxkJeXR21t7cUbdxGsVisZGRne7kaXoi1j4u/vT+/evdFoWpdc26MCtWfPHp566insdjtLly7lrrvuarLdF198wYMPPsgHH3xAamqqJ7vUMdSUCQuqE8kvr+F4gZxBYnRChON4WKB4vvJl8vLy0Ol0JCUl+cxSAZPJhJ+feCB1pbVjIkkSJSUl5OXl0adPn1bd22MuPpvNxpo1a1i3bh3btm0jLS2NzMzMRu2MRiMbNmxg+PDhnupKx9PQxacWAuUp1qLw4QAAIABJREFU7HaJ93/MBUCpUDAhKdpxLiFahPf7MrW1tURGRvqMOAkuDYVCQWRkZJssZo8JVHp6OomJiSQkJKDVapk/fz67du1q1G7t2rXceeedvvVU0qiarg/13UNIktSh97PZJbYeymfO2j2s3XUKgCGxofir5Tknf40SfZh4MPB1hDj1LNr6fnvMR1JYWEhsbKxjX6/Xk56e7tbm6NGjFBQUMHXqVP75z3+26r4Wi4WcnJw298doNLbruqaIKM5D52JBXSirpMrWMffuLDpyPMprleSUawjQSPQLN6Nq52OPzS5xpKCab7IqiAgKJyU6lN4hwZwslNNKTerrtJ7C/Mzknj3bEd130JFj0h3w9HhYrVZMJpPH7u8J7Ha7z/XZ07R1TKxWa6s/V15z4tvtdp599lmeeeaZNl2n0WhITExs8+vl5OSQmJjIqV/2UpKx13HcqgogN3oKJm14q+81tbwCXaRz6NLLVJyttLe5T96ktMxERHggID/VxIX6k6LXcVlE4EUDD+x2iYO55ew7U8L50lqG6SNRqxQYzQryTcFcOTCyzf05XlDJXRt+4mxpNYtTExgcK78fC4f0xmiyMK5vOP3CQh3thyXHdngEX/1nRCDj6fHIyMjwLc8JLc+3TJ8+nQ8++ICIiIgmzzdss2rVKr7++msiIyNJS0trsv3OnTtJSkoiOTm5Tf3ctWsXp0+fbnbeH2Qj4qmnnuLvf/97m+7dkLbOy6nV6kafq+aCLDwmUHq9noKCAsd+YWEher3esV9VVcXJkye59dZbASguLuaee+7h9ddf91igxMmfv6b/p4tIaXD8ghTCPeaH+VEa2Kr7vKM5T1JMX8f+//2Qz39La1q4oqtyvtERrVpJ36ggUvQ6UmKC6Rsd5HCrWe0SP2SV8PmRAgoqawnQqHhk6iBH6XWA9LMGfsi5wAMzkvFTt05AKqot3LnhALmlNYzqHcHkfnq38zeP6UN8uD95JbLvOiZUK8LLBT7PkiVLuOWWW3jiiSeabbNz506mTp3apEBZrVbU6qZ/wmfMmMGMGTNafH29Xn/J4uRpPCZQqampZGdnk5ubi16vZ9u2bbzwwguO8zqdjn379jn2ly9fzuOPP+7RKD6TsazJ41GKSt7RPsVq6228b5vKSEUmc1Q/0ktRzGvWRRyV3CNOQhVVbnNQ1bbu82Nptto5XmBwRM01hwK4ZXQfooLcE+XGhQTwt2+y+ezweaYOiCFFH0xKTDDDE8LQNOH7kySJRzcfIre0hriQAG4YmdSojd2OQ5wAEmNEcER34609Wfxt50mqzLYOu2eQVsXDM/tz5+S+TZ7Py8tjxYoVjBgxgoMHDzJ06FCuvfZa/v73v1NaWsrzzz/PsGHDKC8v58knnyQ3Nxc/Pz/+/Oc/M3DgQMrKynj00UcpLCxkxIgRbvOwn3zyCRs3bsRisTB8+HD+8Ic/oFK5/06MHTuWvLy8Zvv/888/s3v3bvbv38/rr7/Oyy+/zP/7f/+PgQMH8tNPP7FgwQKSkpJ4/fXXsVgshIWF8fzzzxMVFcWWLVs4cuQIq1evZuXKlQQHB3PkyBGKi4t57LHHmDNnDnl5edx9992kpaWxZcsWdu/eTU1NDbm5ucycOZPHH38cgM2bN7Nu3Tp0Oh0DBw5Eq9WyevXqDniHLo7HBEqtVrN69WpWrFiBzWbj2muvJSUlhbVr1zJ06NCLqrsnGDRhHrmG3xNqdVoOQaZCVHYLWuBZ8vhT7T/Q5HwLkuyyGxdWTdrod93uk/iDxS2K79YJA1moaLtby5u4Pn1JSFSbbBhNVkzW1rkqQ/w1JEUEO/a1GgVmi/wFnZAYxfu/5JB14YzjfK+wAJ5Zksrk/tFu93lzTxY7Mwrx16i4bVw/tHUiFuyvYlS/EL7NKMPm0iWtWkFcuG+5hQQX5629WR0qTgBVZhtv7c1qVqAAzp49y9q1a3n66ae57rrr2Lp1K++++y67du3ijTfe4LXXXuPll19m8ODBvPbaa+zZs4cnnniCTz75hFdffZVRo0Zx//338/XXX/PBBx8AcPr0abZv3867776LRqPhj3/8I1u3buWaa65pU/9HjRrF9OnTmTp1KnPmzHEct1gsbNmyBYCKigo2bdqEQqFwCMnKlSsb3auoqIj//Oc/ZGVlcc8997jdr56MjAw+/vhjtFotc+bMYfny5SiVSl5//XW2bNlCUFAQv/rVrxg4sHWepo7Ao3NQU6ZMYcqUKW7HHnrooSbbbty40ZNdAaAy7zgJfiXg5xL9FZTg1kYDoJQg678ARBoziNBI2F1KavhZK90EKsAviP/f3v1HRVXnjx9/zgwzgPwMRVjFVCR/rJqpGbJLZfzwB4hg6jlrnjrqabMfhqZlhqttrtJm5u+0/LjbVlutZQgSWCZu4nclUbL1o/FpdU0TUjQEggGHmWG+f4xcGWH4JcMgvB7neM7cO3fufd/LdV7zft/3+/2yqG+3L03b8t5xCxlD7urVjV/d4Ur2KWsNdWSQH6knL9gEu8LSKh77ay7TRwfxwoRBXCk3cPxCKWu++B4V8Ojo/vhfn6Vco1YROtAX724ujAz25tiZX5T93OnvLoNzO6Hf3x/skBrU7++3H5wAgoKCGDRoEAAhISGEhYWhUqkYNGgQhYWFAOTl5bF582YAQkNDKS0tpaKigqNHj7JlyxYAxo0bh4+P9RlpTk4OJ0+eZPr06cCN7vRtJSYmRnl96dIlnnvuOa5cuUJ1dTVBQUENfiYqKgq1Wk1ISAg///xzg9uEhYXh5eUFwIABAygsLKS0tJQxY8bg6+sLwMSJEzl37lybnUtTutRIR513D2pQoaaJLtG97sZUWYbLpf9FbTHjU/49Jb7Xx2lZzOhM5TYBykTzRkV3Rj19dPy6jycqrLmZyqvMuLpoeGPaPfxwtYLTlyvIPn2F0kojALvyCtiVZ9usMWHQr/h1oK+yPHqAN97XB+H26eFOmd7E6YuVaDUqggO6tdu5ifbz+weCG63pOIpOd+PHqlqtVpZVKhVmc+uCpcViYerUqSxevLhNyngzd/cbTdyrVq1i9uzZREZGcuTIESVg3qzuedpTdxuNRtPq829LXSpAefboTdV9z1N5paEujhY8C/+Ja7X114VmQDiU/wT6Yu5x+y/6gQ8AoK66at28zjOokQN7YFHfXkHqyuUr+Pf0b3rDRrioVfh761BfH9vQ19+dkz9au4R7uuh4NtLaHeVKuYE/7jlFxv/W75QxJMCHCYN7Kct3/aobvbvbVueG9fUiqIcbrlo17rrO87xP3B7uvfde9uzZwzPPPMPRo0e544478PT0ZMyYMaSnp/P0009z8OBBysrKAGtN5Omnn2b27Nl0796d0tJS9Ho9vXv3bvGxPTw80Ov1dt8vLy9XOp+lpqa27gQbMXz4cJKTkykrK8PDw4N9+/YxcODANj+OPV0qQAG4e/vh7m2nG2jfX0PuVqi8Yh1QNngifLsL37JT+Ppd/9IsrgJUoKm9dCp6dfeE22zAoam8ht5+bZsJ+E5/d767UEGNBUr0Jn4oqsRVa32mtGzSr4kZ2ouU44VcKb+Gv5cbvX3duTvwDmXwXg9vLb++07PBfft63F4/AETnMX/+fJKSkoiLi8PV1ZU///nPADzzzDMsXryY2NhYRo4cSa9e1h9aISEhLFy4kLlz51JTU4NWq2XFihX1AtSiRYvIzc2lpKSEBx54gGeffZYZM2bYbBMTE8Py5ct5//33G+xxN3/+fBYsWICPjw+hoaGNdrpojYCAAObNm8eMGTPw8fEhODhYaQZsDypLW08B4GD5+fkMGTKkxZ9r9pgO/WXIfRPM1dblS99BxVV48vrYqcI8+OtECHvcuqxxhYf+2OLyOJujxrgcPV1KQXHLBzK669Q8NLy7EtCcQcZB2WqPcVCt+b/sTF1xLj69Xo+Hhwcmk4n58+czbdo0oqOjlfdbek0a+rvbuxck3cbNPHrCkGk3ln2D4HI+mK5/6eqLZaLYRvRvxTMitQruG+jr1OAkhGjYli1biI+PZ/LkyQQFBREVFdVux+5yTXzN0r3OUF4XN6gxwuXvoNdIOJctyQob0cNbx8hgL4pKq2lO3Vytso5r8vOUJjwhOqLGBhI7mgSohri4gUptHQvlogOVBn761hqgvt8rNagm9OvZjX49pbedEOLWSJtKQ1Qq0HrcWNa6wcV/w8+nofjMTak2ulZ7tBBCtBcJUPZo69QAagPU/2VYl6UGJYQQDidNfPboPKB2+IGLOxSdutGVXAKUEEI4nNSg7Lm5BmU2WLuYg21QkmSFQgisqTSuXr3a7G1eeuklwsLCmDx5cpuVYfPmzUpuvY0bN3L48OF62xw5coR58+Y1up/8/HwOHjyoLGdlZbF9+/Y2K2dzSYCy5+ZnUHV1rzP1vUZ6nwkhWu7hhx9mx44dDtv/ggUL+M1vftOqz94coCIjIxvNLeUo0sRnj65uDeqm9A49h4DJOqWP1KCEuHWnf9LzfwV6TDVtN2+Ai1rF4CAP7url0eD7HT3dRnl5OVOmTCErKwu1Wk1lZSWTJk1i//797N69m507d2I0Gunbty9r1qyxmaMPYOnSpcpM6NnZ2SQnJ+Pu7s7o0aOVbU6cOMHq1asxGAy4ubmRnJxMUFAQmzZt4tq1a+Tl5TFv3jyuXbumpO8oKCggKSmJkpIS/Pz8eOWVV+jXr5/dtB63QmpQ9tStQbncXIOqM6mlPIMS4paduVjZpsEJrAk2z1ysbHSbH3/8kTlz5rB3715++OEHJd3GkiVLeOuttwCUdBvp6ekkJiYq44Jq021kZGQQHR3NTz/9BNim20hLS0OtVpOent7i8tfmX8rNzQXgq6++Ijw8HK1WS3R0NJ9++il79uwhODhYSfXREIPBwPLly3nrrbdISUnhypUrynvBwcF88MEHpKamkpiYyPr169HpdCQmJhITE0NaWprN7OlgnaB26tSppKenExcXp0z9BDfSerz99ts2+f9aS2pQ9ujsNPF1vwt0deaLc5EAJcStCvlVN4fUoEJ+1fh4vI6ebiMmJobMzEzGjh1LRkYGjzzyCACnT59mw4YNlJeXo9frCQ8Pt7uPs2fPEhQURL9+/QCYMmUKH3/8MWCtpb344oucP38elUqF0WhsskzHjx9Xrkd8fDyvv/668l5z0nq0hAQoe+p2knC7kQqCQZNuzNMH0sQnRBu4q5f9pjhH6ujpNiIiIli/fj2lpaWcOnWKsWPHAtbmu61btzJ48GBSUlKUWlZLbdy4kdDQUN58800KCgp47LHHbqm8zUnr0RLSxGdP3Sa+bnfceD00wdqjr5Y08QnRqdWm2wAaTLcB1Eu38cUXX1BcXAxAaWmpUhtrKQ8PD4YNG8bq1asZN26c8hxLr9fj7++P0WhssvkwODiYwsJCfvzxRwAyMjKU9+qm69i9e7fNce2l+Rg5cqSyj/T0dEaNGtWqc2sOCVD21G3ic/OB3zwLD/8P9B59Uw1KApQQndn8+fM5deoUcXFxbNiwwSbdxrFjx4iNjeXLL79sMN1GXFwcc+fOtXnuU2vRokX87ne/44cffuCBBx7gk08+afD4MTEx7Nmzx+ZZ0IIFC5gxYwYzZ84kOLjxRI+urq6sXLmSJ554gqlTp+LndyPd0OOPP866detISEjAZDIp60NDQzlz5gzx8fFkZmba7G/58uWkpKQQFxdHWlqaQ+fqk3Qb9pir4Z8vW1+rNBDxpxsDdY9th9IfrK9HPQ5+A1pcHmeT1BL1yTWxJek26uuK6TaaIuk2nEGju5E112K2rTVJDUoIIRxOAlRj6naUMNZpj60boGSyWCGEcAgJUI2p+xyqum6Akk4SQgjhaBKgGmNTg6oz4E+6mQshhMNJgGpM3RpUbROfxXJTgJK5+IQQwhEkQDWm7lio6us1KIvZmmkXrL371DLWWQghHEECVGMa6iRhkudPQoj6WpJu4+LFizz66KPExMQQGxvLu+++2+D2+/fv58yZMy0uS3PSYxQVFZGYmNjifbcnhwao7OxsJkyYQHR0dIMX66OPPiIuLo74+HhmzpzZqj+EQ9k08V2vQUkXcyHELdJoNCxdupTMzEx27tzJhx9+2OD3X2MBqu7A2ps1Jz1GQEAAmzZtalnB25nD2qfMZjMrV67knXfeISAggOnTpxMREUFIyI1cSnFxccycOROwRvxXX31VSbbVIWgb6MUnXcyFaHuHN8NXf4bqirbbp84Txi21zgLTAGem2+jZsyc9e/YEwNPTk+DgYIqKimy+H7/55hsOHDhAbm4u27ZtY/PmzSxbtozBgweTl5fH5MmT6devH9u2bcNoNOLr68vatWvp0aMHKSkpSnoMe2kwCgoKePLJJ/nss89ISUnhwIEDVFVVceHCBaKioliyZAkAn3zyCTt27FBmV9fpdKxYsaLt/k6NcFgN6sSJE/Tt25c+ffqg0+mIjY0lKyvLZhtPzxuzgldVVaGqnamho2ioic+mi7l0kBCiTRze0rbBCaz7O7yl0U06QrqNgoIC8vPzGTFihM36UaNGERERwZIlS0hLS+POO+8EwGg0kpKSwty5cxk9ejQff/wxqampxMbG2k2A2Jw0GPn5+WzYsIH09HT27t3LxYsXKSoqYtu2bezcuZOPPvqIs2fPNno925rDalBFRUUEBgYqywEBAZw4caLedh988AHvvPMORqPRbjtsXUajkfPnz7e4PBUVFS3+nNZQRq/rr6sry7h4/jxulYUEXF93zWihqBVl6Qhacz06O7kmthx9PUwmEwaD9Qef5r4n0fy/11FVNzxBaWtYdB6Y73sSs8HQ4PvV1dX07t2bfv36YTQa6d+/P2PGjKG6upr+/ftTUFCAwWDg2LFjrFu3DoPBwJgxYygpKaG4uJjc3FzWr1+PwWAgLCwMb29vqqurOXToECdPnmTatGmANd2Gj48PBoMBi8VCdXW1ct6VlZXMnz+fF154Aa1Wq6yvZTabMRqNyvqamhqio6OV5R9//JE33niDK1euYDQa6d27NwaDAZPJhNlsxmAwYDabGTduHEajkT59+vDzzz9jMBiorq6mpqZG2f6+++5TZiPv378/586do7S0lFGjRuHu7k5NTQ1RUVGcP3/eppy1+2guk8nU7PvK6V3QZs2axaxZs0hPT2fbtm289tprjW6v1WpbNT9Yq+YVM9wB1gmA0VmqrZ+/eBWuT0zs5ul7287dJvPO1SfXxFZ7zMWnzOH2wHPWf21IhfULzt6XnE6nw9XVVSmDVqulW7duyrqamhpcXV1RqVTKtgaDAZVKVW89oCxrNBq76TbqfsZoNPL8888THx9PbGxsg2XUaDRotVrlGGq1Gm9vb2V5zZo1zJ49m8jISI4cOcKWLVtwdXXFxcUFjUaDq6srGo1GOa9arq6u6HQ61Gq1sr27u7vNtVCr1Wi1WmU/gM1+a7V0Lj4XF5d691V+fn6D2zqsiS8gIIBLly4py0VFRcq07g2JjY1l//79jipO62hv6iRhqYHyn26s8wys/xkhRKfiiHQbFouFZcuWERwczJw5c+weu7G0F2CbLiM1NbX1J2nH8OHDOXr0KGVlZZhMJvbt29fmx2iMwwLU8OHDOXfuHBcuXKC6upqMjAwiIiJstjl37pzy+quvvup4v17Vmjrp3i1gumYboLx6NfgxIUTn4Yh0G3l5eaSlpfH1118THx9PfHw8Bw8erHfsmJgY/vKXv5CQkKDkc7q5bAsWLODhhx/G19e33vu3KiAggHnz5impPXr37o2Xl1ebH8ceh6bbOHjwIMnJyZjNZqZNm8ZTTz3Fxo0bGTZsGJGRkaxatYqcnBxcXFzw9vZmxYoV3HXXXY3us93SbdT61+tQdX1sQ9hzkLv1RkeJ8Bdts+3eRqQ5qz65JrYk3UZ9XTHdhl6vx8PDA5PJxPz585k2bRrR0dHK+45Mt+HQZ1APPvggDz74oM26BQsWKK//8Ic/OPLwbUPrcSNAlRXcCE7abuDq47xyCSFEO9iyZQuHDx/GYDAQHh5OVFRUux3b6Z0kOjxdna7mV0/feO3V+0YCQyGE6KQcmTG3KTLVUVPqdpQorhOgvOX5kxBCOJIEqKZoG5jRHKw1KCGEEA4jAaopdZv46pIAJYQQDiUBqil1a1C1XNzA/Y72L4sQQnQhEqCa0lCA8uolHSSEEDYckW6jpTZv3qxMuL1x40YOHz5cb5sjR44wb968RveTn59vMy6rOek7HEF68TWloSY+b2neE0K0Xm26jaFDh1JRUcG0adP47W9/azOb+a2qO6SnpfLz8zl58qQyTCgyMpLIyMi2KlqzSYBqir0alBCi7Zw/BGf326azuVUaHQRHQd/7G3y7o6fbKC8vZ8qUKWRlZaFWq6msrGTSpEns37+f3bt3s3PnToxGI3379mXNmjW4u7vbnN/SpUsZN24cEydOJDs7m+TkZNzd3Rk9erSyzYkTJ1i9ejUGgwE3NzeSk5MJCgpi06ZNXLt2jby8PObNm8e1a9eU9B0FBQUkJSVRUlKCn58fr7zyCv369bOb1uNWSBNfU7QN1KCkg4QQbev8obYNTmDd3/lDjW7SkdNt1OZfys3NBazTwYWHh6PVaomOjubTTz9lz549BAcHs2vXLrv7NxgMLF++nLfeeouUlBSbaZeCg4P54IMPSE1NJTExkfXr16PT6UhMTCQmJoa0tDRiYmJs9rdq1SqmTp1Keno6cXFxytRP0Ly0Hi0hNaimaN2xzot8/deRRgfdujuzREJ0Pn3vd0wNyk7tqVZQUBCDBg0CrHPohYWFoVKpGDRokDLBa15eHps3bwYgNDSU0tJSKioqOHr0KFu2WPNNjRs3Dh8f68wyOTk5nDx5kunTpwPWdBvduzf8naHX60lMTCQpKckmP16tmJgYMjMzGTt2LBkZGTzyyCMAnD59mg0bNlBeXo5eryc8PNzuOZ49e5agoCD69esHwJQpU/j4448Bay3txRdf5Pz586hUKoxGY6PXC+D48ePK9YiPj+f1119X3ouKikKtVhMSEsLPP//c5L6aIgGqKSq1tRZVOwbKq5d1nRCi7fS9v8lg4gi1+Y/AmsqidlmlUmE2m1u1T4vFYjfdRl1Go5HExETi4uIYP358g9tERESwfv16SktLOXXqFGPHjgWszXdbt25l8ODBpKSkKLWsltq4cSOhoaG8+eabFBQU8Nhjj7VqP7XqXs+2IN+0zVG3mU+ePwnRpTg73cawYcNYvXo148aNU55j6fV6/P39MRqNjTYfgrUZr7CwUJkNPSMjQ3mvbrqO3bt32xzXXpqPkSNHKvtIT09n1KhRjR7/VkiAag5dnY4S8vxJiC7Fmek2wNrMt2fPHptnQQsWLFBSYAQHBzdafldXV1auXMkTTzzB1KlT8fPzU957/PHHWbduHQkJCZhMJmV9aGgoZ86cIT4+nszMTJv9LV++nJSUFOLi4khLS3PoXH0OTbfhCO2ebgPgXDac2Wt9HhW22DZg3aYktUR9ck1sSbqN+rpiuo2m3LbpNjqNvvfDHf2ts0d0guAkhBC3AwlQzaFSgU8fZ5dCCCG6FHkGJYRwmtvsCYO4RS39e0uAEkI4hZubG8XFxRKkugiLxUJxcTFubm7N/ow08QkhnCIoKIiCgoJ6Pdw6MpPJhIuLfG3W1ZJr4ubmRlBQULP3LVdaCOEUWq2W/v37O7sYLSI9Petz5DWRJj4hhBAdkgQoIYQQHZIEKCGEEB3SbTeTxLfffisjuYUQohMxGAzcc8899dbfdgFKCCFE1yBNfEIIITokCVBCCCE6JAlQQgghOiQJUEIIITokCVBCCCE6JAlQQgghOqROH6Cys7OZMGEC0dHRbN++3dnFcYqLFy/y6KOPEhMTQ2xsLO+++y4ApaWlzJkzh/HjxzNnzhzKysqcXNL2ZTabSUhIYN68eQBcuHCBGTNmEB0dzcKFC6murnZyCdvXL7/8QmJiIhMnTmTSpEkcP368S98jf/vb34iNjWXy5MksWrQIg8HQ5e6Rl156ibCwMCZPnqyss3dPWCwWVq1aRXR0NHFxcZw6deqWj9+pA5TZbGblypXs2LGDjIwMPvvsM86cOePsYrU7jUbD0qVLyczMZOfOnXz44YecOXOG7du3ExYWxr59+wgLC+tyAfy9995jwIAByvLatWuZPXs2X375Jd7e3uzatcuJpWt/q1ev5v777+fzzz8nLS2NAQMGdNl7pKioiPfee49PP/2Uzz77DLPZTEZGRpe7Rx5++GF27Nhhs87ePZGdnc25c+fYt28ff/rTn/jjH/94y8fv1AHqxIkT9O3blz59+qDT6YiNjSUrK8vZxWp3PXv2ZOjQoQB4enoSHBxMUVERWVlZJCQkAJCQkMD+/fudWcx2denSJb766iumT58OWH/9ff3110yYMAGAqVOndql7pby8nKNHjyrXQ6fT4e3t3aXvEbPZzLVr1zCZTFy7dg1/f/8ud4+MGTMGHx8fm3X27ona9SqVinvuuYdffvmFy5cv39LxO3WAKioqIjAwUFkOCAigqKjIiSVyvoKCAvLz8xkxYgTFxcX07NkTAH9/f4qLi51cuvaTnJzMCy+8gFpt/S9QUlKCt7e3ktcmMDCwS90rBQUF+Pn58dJLL5GQkMCyZcuorKzssvdIQEAAc+fO5aGHHiI8PBxPT0+GDh3ape+RWvbuiZu/b9vi+nTqACVs6fV6EhMTSUpKwtPT0+Y9lUqFSqVyUsna1z//+U/8/PwYNmyYs4vSYZhMJr777jtmzpxJamoq7u7u9ZrzutI9UlZWRlZWFllZWRw6dIiqqioOHTrk7GJ1OI6+Jzp1wsKAgAAuXbqkLBcVFREQEODEEjmP0WgkMTGRuLg4xo8fD0D37t25fPkyPXv25PLly/j5+Tm5lO3jm2++4cCBA2RnZ2MwGKioqGD16tX88ssvSnawtf31AAAElUlEQVTQS5cudal7JTAwkMDAQEaMGAHAxIkT2b59e5e9Rw4fPkxQUJByvuPHj+ebb77p0vdILXv3xM3ft21xfTp1DWr48OGcO3eOCxcuUF1dTUZGBhEREc4uVruzWCwsW7aM4OBg5syZo6yPiIggNTUVgNTUVCIjI51VxHa1ePFisrOzOXDgAOvWrWPs2LG88cYbhIaG8sUXXwCwe/fuLnWv+Pv7ExgYyNmzZwHIyclhwIABXfYe6dWrF//+97+pqqrCYrGQk5NDSEhIl75Hatm7J2rXWywWvv32W7y8vJSmwNbq9LOZHzx4kOTkZMxmM9OmTeOpp55ydpHa3bFjx5g1axYDBw5UnrksWrSIu+++m4ULF3Lx4kV69erFhg0b8PX1dXJp29eRI0f461//yttvv82FCxd47rnnKCsrY8iQIaxduxadTufsIrab/Px8li1bhtFopE+fPrz66qvU1NR02Xtk06ZNZGZm4uLiwpAhQ1i9ejVFRUVd6h5ZtGgRubm5lJSU0L17d5599lmioqIavCcsFgsrV67k0KFDuLu7k5yczPDhw2/p+J0+QAkhhLg9deomPiGEELcvCVBCCCE6JAlQQgghOiQJUEIIITokCVBCCCE6JAlQQtxmjhw5oszALkRnJgFKCCFEh9SppzoSwpnS0tJ4//33MRqNjBgxgpdffpl7772XGTNm8K9//YsePXqwfv16/Pz8yM/P5+WXX6aqqoo777yT5ORkfHx8OH/+PC+//DJXr15Fo9GwceNGACorK0lMTOQ///kPQ4cOZe3atahUKtauXcuBAwfQaDSEh4fz4osvOvkqCNF6UoMSwgH++9//snfvXj766CPS0tJQq9Wkp6dTWVnJsGHDyMjIYMyYMWzZsgWAJUuW8Pzzz5Oens7AgQOV9c8//zyzZs1iz549/OMf/8Df3x+A7777jqSkJDIzMykoKCAvL4+SkhK+/PJLMjIySE9P75KzpojORQKUEA6Qk5PDyZMnmT59OvHx8eTk5HDhwgXUajUxMTEAxMfHk5eXR3l5OeXl5dx3332ANc/QsWPHqKiooKioiOjoaABcXV1xd3cH4O677yYwMBC1Ws3gwYMpLCzEy8sLV1dXkpKS2LdvH25ubs45eSHaiDTxCeEAFouFqVOnsnjxYpv1W7dutVlubaqCuvO/aTQazGYzLi4u7Nq1i5ycHD7//HP+/ve/895777Vq/0J0BFKDEsIBwsLC+OKLL5RkbqWlpRQWFlJTU6PMhp2ens7o0aPx8vLC29ubY8eOAdZnV2PGjMHT05PAwEAlY2l1dTVVVVV2j6nX6ykvL+fBBx8kKSmJ77//3sFnKYRjSQ1KCAcICQlh4cKFzJ07l5qaGrRaLStWrKBbt26cOHGCbdu24efnx4YNGwB47bXXlE4StTOJA6xZs4YVK1awceNGtFqt0kmiIXq9nqeffhqDwQDA0qVLHX+iQjiQzGYuRDsaOXIkx48fd3YxhLgtSBOfEEKIDklqUEIIITokqUEJIYTokCRACSGE6JAkQAkhhOiQJEAJIYTokCRACSGE6JD+PxtzubQWLUATAAAAAElFTkSuQmCC\n", "text/plain": [ "
" ] }, "metadata": {}, "output_type": "display_data" } ], "source": [ "# retrieve training statistics\n", "list_of_stats = []\n", "list_of_models = []\n", "\n", "for model in ['model1', 'model2']:\n", " experiment_model_dir = './results/multiple_experiment_' + model \n", " train_stats = load_json(os.path.join(experiment_model_dir,'training_statistics.json'))\n", " list_of_stats.append(train_stats)\n", " list_of_models.append(model)\n", " \n", "\n", "# generating learning curves from training\n", "learning_curves(list_of_stats, 'Survived',\n", " model_names=list_of_models,\n", " output_directory='./visualizations',\n", " file_format='png')\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Generate Annotated Learning Curves Using seaborn package\n", "\n", "### Helper function to collect training statistics" ] }, { "cell_type": "code", "execution_count": 3, "metadata": {}, "outputs": [], "source": [ "# function to generate pandas data frame from training statistcs\n", "# Parameter:\n", "# experiment_model_dir: directory containing the training statistics for a specific model training experiment\n", "#\n", "# Returns: pandas dataframe containing the performance metric and loss\n", "#\n", "\n", "def extract_training_stats(experiment_model_dir):\n", " list_of_splits = ['training', 'validation', 'test']\n", " list_of_df = []\n", " for split in list_of_splits:\n", " train_stats = load_json(os.path.join(experiment_model_dir,'training_statistics.json'))\n", " df = pd.DataFrame(train_stats[split]['combined'])\n", " df.columns = [split + '_' + c for c in df.columns]\n", " list_of_df.append(df)\n", " \n", " df = pd.concat(list_of_df, axis=1)\n", " df['epoch'] = df.index + 1\n", " \n", " return df" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Retrieve training results" ] }, { "cell_type": "code", "execution_count": 4, "metadata": { "pycharm": { "is_executing": false } }, "outputs": [], "source": [ "model1 = extract_training_stats('./results/multiple_experiment_model1')\n", "model1.name = 'model1'\n", "model2 = extract_training_stats('./results/multiple_experiment_model2')\n", "model2.name = 'model2'" ] }, { "cell_type": "code", "execution_count": 5, "metadata": {}, "outputs": [ { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
training_lossvalidation_losstest_lossepoch
06.6467167.0696277.5412131
16.4955526.9090967.3704742
26.3157076.7180517.1673513
36.1354156.5265966.9636944
45.9549756.3349486.7598505
\n", "
" ], "text/plain": [ " training_loss validation_loss test_loss epoch\n", "0 6.646716 7.069627 7.541213 1\n", "1 6.495552 6.909096 7.370474 2\n", "2 6.315707 6.718051 7.167351 3\n", "3 6.135415 6.526596 6.963694 4\n", "4 5.954975 6.334948 6.759850 5" ] }, "execution_count": 5, "metadata": {}, "output_type": "execute_result" } ], "source": [ "model1.head()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Helper function to generate plot ready data" ] }, { "cell_type": "code", "execution_count": 6, "metadata": {}, "outputs": [], "source": [ "# create pandas dataframe suitable for plotting learning curves\n", "# Parameters\n", "# train_df_list: list of pandas datatframe containing training statistics\n", "#\n", "# Returns: plot ready pandas dataframe\n", "\n", "def create_plot_ready_data(list_of_train_stats_df):\n", " # holding ready for plot ready data\n", " plot_ready_list = []\n", " \n", " # consolidate the multiple training statistics dataframes\n", " for df in list_of_train_stats_df:\n", " for col in ['training', 'validation']:\n", " df2 = df[['epoch', col + '_loss']].copy()\n", " df2.columns = ['epoch', 'loss']\n", " df2['type'] = col\n", " df2['model'] = df.name\n", " plot_ready_list.append(df2)\n", "\n", " return pd.concat(plot_ready_list, axis=0, ignore_index=True)\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Plot learning curves" ] }, { "cell_type": "code", "execution_count": 7, "metadata": {}, "outputs": [], "source": [ "# create plot ready data\n", "learning_curves = create_plot_ready_data([model1, model2])" ] }, { "cell_type": "code", "execution_count": 8, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "Text(0.5, 1.0, 'Learning Curves')" ] }, "execution_count": 8, "metadata": {}, "output_type": "execute_result" }, { "data": { "image/png": "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\n", "text/plain": [ "
" ] }, "metadata": {}, "output_type": "display_data" } ], "source": [ "# Plot learning curves for the different models\n", "fig = plt.figure(figsize=(10,6))\n", "sns.set_style(style='dark')\n", "ax = sns.lineplot(x='epoch', y='loss',\n", " style='type',\n", " hue='model',\n", " data=learning_curves)\n", "ax.set_title('Learning Curves', fontdict={'fontsize': 16})" ] }, { "cell_type": "code", "execution_count": 9, "metadata": {}, "outputs": [], "source": [ "fig.savefig('./visualizations/custom_learning_curve.png')" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [] } ], "metadata": { "kernelspec": { "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.9.7" } }, "nbformat": 4, "nbformat_minor": 4 } ================================================ FILE: examples/titanic/multiple_model_training.py ================================================ #!/usr/bin/env python # # Multiple Model Training Example # # This example trains multiple models and extracts training statistics import logging import shutil # ## Import required libraries from ludwig.api import LudwigModel from ludwig.datasets import titanic from ludwig.visualize import learning_curves # clean out old results shutil.rmtree("./results", ignore_errors=True) shutil.rmtree("./visualizations", ignore_errors=True) # list models to train list_of_model_ids = ["model1", "model2"] list_of_train_stats = [] training_set, _, _ = titanic.load(split=True) # ## Train models for model_id in list_of_model_ids: print(">>>> training: ", model_id) # Define Ludwig model object that drive model training model = LudwigModel(config="./" + model_id + "_config.yaml", logging_level=logging.WARN) # initiate model training train_stats, _, _ = model.train( dataset=training_set, experiment_name="multiple_model_experiment", model_name=model_id ) # save training stats for later use list_of_train_stats.append(train_stats) print(">>>>>>> completed: ", model_id, "\n") # generating learning curves from training learning_curves( list_of_train_stats, "Survived", model_names=list_of_model_ids, output_directory="./visualizations", file_format="png", ) ================================================ FILE: examples/titanic/simple_model_training.py ================================================ #!/usr/bin/env python # # Simple Model Training Example # # This example is the API example for this Ludwig command line example # (https://ludwig-ai.github.io/ludwig-docs/latest/examples/titanic/). # Import required libraries import logging import os import shutil import yaml from ludwig.api import LudwigModel from ludwig.datasets import titanic # clean out prior results shutil.rmtree("./results", ignore_errors=True) # Download and prepare the dataset training_set, test_set, _ = titanic.load(split=True) config = yaml.safe_load(""" input_features: - name: Pclass type: category - name: Sex type: category - name: Age type: number preprocessing: missing_value_strategy: fill_with_mean - name: SibSp type: number - name: Parch type: number - name: Fare type: number preprocessing: missing_value_strategy: fill_with_mean - name: Embarked type: category output_features: - name: Survived type: binary """) # Define Ludwig model object that drive model training model = LudwigModel(config=config, logging_level=logging.INFO) # initiate model training ( train_stats, # dictionary containing training statistics preprocessed_data, # tuple Ludwig Dataset objects of pre-processed training data output_directory, # location of training results stored on disk ) = model.train( dataset=training_set, experiment_name="simple_experiment", model_name="simple_model", skip_save_processed_input=True ) # list contents of output directory print("contents of output directory:", output_directory) for item in os.listdir(output_directory): print("\t", item) # batch prediction model.predict(test_set, skip_save_predictions=False) ================================================ FILE: examples/twitter_bots/README.md ================================================ # Twitter Bots Example We'll be using the twitter human-bots dataset which is composed of 37438 rows each corresponding to a Twitter user account. Each row contains 20 feature columns collected via the Twitter API. These features contain multiple data modalities, including the account description and the profile image. The target column account_type has two unique values: bot or human. 25013 user accounts were annotated as human accounts, the remaining 12425 are bots. ### Preparatory Steps Create and download your [Kaggle API Credentials](https://github.com/Kaggle/kaggle-api#api-credentials). The Twitter Bots dataset is hosted by Kaggle, Ludwig will need to authenticate you through the Kaggle API to download the dataset. ### Examples Run `python train_twitter_bots.py` to train a single model. For a faster, more lightweight model run `python train_twitter_bots_text_only.py`, which does not use image features. This will download the Twitter Bots dataset into the current directory, train a model, and write results into the following directories: ``` ./outputs/results/ api_experiment_run/ ./outputs/visualizations/ confusion_matrix__account_type_top2.png confusion_matrix_entropy__account_type_top2.png learning_curves_account_type_accuracy.png learning_curves_account_type_loss.png ``` After training, the script will generate the following plots: ![Account Type Accuracy](images/learning_curves_account_type_loss.png) ![Account Type Loss](images/learning_curves_account_type_accuracy.png) ![Account Type Confusion Matrix](images/confusion_matrix__account_type_top2.png) ================================================ FILE: examples/twitter_bots/train_twitter_bots.py ================================================ #!/usr/bin/env python """Trains model on Twitter Bots dataset using default settings.""" import logging import os import shutil import yaml from ludwig import datasets from ludwig.api import LudwigModel from ludwig.utils.fs_utils import rename from ludwig.visualize import confusion_matrix, learning_curves if __name__ == "__main__": # Cleans out prior results results_dir = os.path.join("outputs", "results") visualizations_dir = os.path.join("outputs", "visualizations") shutil.rmtree(results_dir, ignore_errors=True) shutil.rmtree(visualizations_dir, ignore_errors=True) # Loads the dataset twitter_bots_dataset = datasets.get_dataset("twitter_bots", cache_dir="downloads") training_set, val_set, test_set = twitter_bots_dataset.load(split=True) # Moves profile images into local directory, so relative paths in the dataset will be resolved. if not os.path.exists("profile_images"): rename(os.path.join(twitter_bots_dataset.processed_dataset_dir, "profile_images"), "profile_images") config = yaml.safe_load(""" input_features: - name: default_profile type: binary - name: default_profile_image type: binary - name: description type: text - name: favourites_count type: number - name: followers_count type: number - name: friends_count type: number - name: geo_enabled type: binary - name: lang type: category - name: location type: category - name: profile_background_image_path type: category - name: profile_image_path type: image preprocessing: num_channels: 3 - name: statuses_count type: number - name: verified type: binary - name: average_tweets_per_day type: number - name: account_age_days type: number output_features: - name: account_type type: binary """) model = LudwigModel(config, logging_level=logging.INFO) train_stats, preprocessed_data, output_directory = model.train(dataset=training_set, output_directory=results_dir) # Generates predictions and performance statistics for the test set. test_stats, predictions, output_directory = model.evaluate( test_set, collect_predictions=True, collect_overall_stats=True, output_directory=results_dir ) confusion_matrix( [test_stats], model.training_set_metadata, "account_type", top_n_classes=[2], model_names=[""], normalize=True, output_directory=visualizations_dir, file_format="png", ) # Visualizes learning curves, which show how performance metrics changed over time during training. learning_curves( train_stats, output_feature_name="account_type", output_directory=visualizations_dir, file_format="png" ) ================================================ FILE: examples/twitter_bots/train_twitter_bots_text_only.py ================================================ #!/usr/bin/env python """Trains twitter bots using tabular and text features only, no images.""" import logging import os import shutil import yaml from ludwig.api import LudwigModel from ludwig.datasets import twitter_bots from ludwig.visualize import confusion_matrix, learning_curves if __name__ == "__main__": # Cleans out prior results results_dir = os.path.join("outputs", "results") visualizations_dir = os.path.join("outputs", "visualizations") shutil.rmtree(results_dir, ignore_errors=True) shutil.rmtree(visualizations_dir, ignore_errors=True) # Loads the dataset training_set, val_set, test_set = twitter_bots.load(split=True) config = yaml.safe_load(""" input_features: - name: created_at type: date column: created_at - name: default_profile type: binary column: default_profile - name: description type: text column: description - name: favourites_count type: number column: favourites_count - name: followers_count type: number column: followers_count - name: friends_count type: number column: friends_count - name: geo_enabled type: binary column: geo_enabled - name: lang type: category column: lang - name: location type: text column: location - name: screen_name type: text column: screen_name - name: statuses_count type: number column: statuses_count - name: verified type: binary column: verified - name: average_tweets_per_day type: number column: average_tweets_per_day - name: account_age_days type: number column: account_age_days output_features: - name: account_type type: category column: account_type trainer: batch_size: 16 defaults: text: preprocessing: tokenizer: space_punct max_sequence_length: 16 model_type: ecd """) model = LudwigModel(config, logging_level=logging.INFO) train_stats, preprocessed_data, output_directory = model.train(dataset=training_set, output_directory=results_dir) # Generates predictions and performance statistics for the test set. test_stats, predictions, output_directory = model.evaluate( test_set, collect_predictions=True, collect_overall_stats=True, output_directory=results_dir ) confusion_matrix( [test_stats], model.training_set_metadata, "account_type", top_n_classes=[2], model_names=[""], normalize=True, output_directory=visualizations_dir, file_format="png", ) # Visualizes learning curves, which show how performance metrics changed over time during training. learning_curves( train_stats, output_feature_name="account_type", output_directory=visualizations_dir, file_format="png" ) ================================================ FILE: examples/wine_quality/README.md ================================================ # Ludwig Defaults Config Section Example Demonstrates how to use Ludwig's defaults section introduced in v0.6. ### Preparatory Steps - Create `data` directory - Download [Kaggle wine quality data set](https://www.kaggle.com/rajyellow46/wine-quality) into the `data` directory. Directory should appear as follows: ``` wine_quality/ data/ winequalityN.csv ``` ### Description Jupyter notebook `model_defaults_example.ipynb` demonstrates how to use the defaults section of Ludwig. Key features demonstrated in the notebook: - Training data is prepared for use - Programmatically create Ludwig config dictionary from the training data dataframe - How to define preprocessing, encoder, decoder and loss sub-sections under the defaults section ================================================ FILE: examples/wine_quality/model_defaults_example.ipynb ================================================ { "cells": [ { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "import pandas as pd \n", "import numpy as np\n", "\n", "import os\n", "\n", "import shutil\n", "from pprint import pprint\n", "import logging\n", "\n", "from ludwig.api import LudwigModel" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Receive data for training" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "train_df = pd.read_csv('./data/winequalityN.csv')\n", "train_df['quality'] = train_df['quality'].apply(str)\n", "train_df.shape" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# Replace white space in column names with underscore\n", "new_col = []\n", "for i in range(len(train_df.columns)):\n", " new_col.append(train_df.columns[i].replace(' ', '_'))\n", " \n", "train_df.columns = new_col" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "train_df.head()" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "train_df.describe().T" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "train_df.dtypes" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "train_df['quality'].value_counts().sort_index()" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "cols = list(set(train_df.columns) - set(['quality']))\n", "features = train_df[cols]\n", "\n", "#extract categorical features\n", "categorical_features = []\n", "for p in features:\n", " if train_df[p].dtype == 'object':\n", " categorical_features.append(p)\n", " \n", "print(\"categorical features:\", categorical_features, '\\n')\n", "\n", "# get numerical features\n", "numerical_features = list(set(features) - set(categorical_features))\n", "\n", "print(\"numerical features:\", numerical_features, \"\\n\")" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "for feature in categorical_features:\n", " print(f\"# of distinct values in categorical feature '{feature}' : {train_df[feature].nunique()}\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Create Ludwig Config" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# template for config\n", "config = {'input_features':[], 'output_features': [], 'trainer':{}}\n", "\n", "# setup input features for categorical features\n", "for p in categorical_features:\n", " a_feature = {\n", " 'name': p.replace(' ','_'), \n", " 'type': 'category'\n", " }\n", " config['input_features'].append(a_feature)\n", "\n", "# setup input features for numerical features\n", "for p in numerical_features:\n", " a_feature = {\n", " 'name': p.replace(' ', '_'), \n", " 'type': 'number'\n", " }\n", " config['input_features'].append(a_feature)\n", "\n", "# set up output variable\n", "config['output_features'].append({'name': 'quality', 'type':'category'})\n", "\n", "# set default preprocessing and encoder for numerical features\n", "config['defaults'] = {\n", " 'number': {\n", " 'preprocessing': {\n", " 'missing_value_strategy': 'fill_with_mean', \n", " 'normalization': 'zscore'\n", " },\n", " 'encoder': {\n", " 'type': 'dense',\n", " 'num_layers': 2\n", " },\n", " },\n", " 'category': {\n", " 'encoder': {\n", " 'type': 'sparse'\n", " },\n", " 'decoder': {\n", " 'top_k': 2\n", " },\n", " 'loss': {\n", " 'confidence_penalty': 0.1 \n", " }\n", " }\n", "}\n", "\n", "# set up trainer\n", "config['trainer'] = {'epochs': 5}" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "pprint(config, indent=2)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Initialize and Train LudwigModel" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "model = LudwigModel(config, backend = 'local', logging_level = logging.INFO)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Inspecting Config After Model Initialization" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "pprint(model.config['input_features'], indent=2)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "pprint(model.config['output_features'], indent=2)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "eval_stats, train_stats, _, _ = model.experiment(\n", " dataset = train_df,\n", " experiment_name = 'wine_quality'\n", ")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Cleanup" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "try:\n", " shutil.rmtree('./results')\n", " items = os.listdir('./')\n", " for item in items:\n", " if item.endswith(\".hdf5\") or item.endswith(\".json\") or item == '.lock_preprocessing':\n", " os.remove(os.path.join('./', item))\n", "except Exception as e:\n", " pass " ] } ], "metadata": { "kernelspec": { "display_name": "Python 3.8.13 64-bit", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.8.13" }, "orig_nbformat": 4, "vscode": { "interpreter": { "hash": "949777d72b0d2535278d3dc13498b2535136f6dfe0678499012e853ee9abcab1" } } }, "nbformat": 4, "nbformat_minor": 2 } ================================================ FILE: examples/wmt15/config_large.yaml ================================================ input_features: - name: en type: text encoder: bert pretrained_model_name_or_path: bert-base-uncased output_features: - name: fr type: text tokenizer: french_tokenize ================================================ FILE: examples/wmt15/config_small.yaml ================================================ input_features: - name: en type: text encoder: embed output_features: - name: fr type: text ================================================ FILE: examples/wmt15/train_nmt.py ================================================ """Sample ludwig training code for training an NMT model (en -> fr) on WMT15 (https://www.statmt.org/wmt15/). The dataset is rather large (8GB), which can take several minutes to preprocess. """ import logging import shutil from ludwig.api import LudwigModel from ludwig.datasets import wmt15 # clean out prior results shutil.rmtree("./results", ignore_errors=True) # Download and prepare the dataset training_set = wmt15.load() model = LudwigModel(config="./config_small.yaml", logging_level=logging.INFO) ( train_stats, # dictionary containing training statistics preprocessed_data, # tuple Ludwig Dataset objects of pre-processed training data output_directory, # location of training results stored on disk ) = model.train(dataset=training_set, experiment_name="simple_experiment", model_name="simple_model") ================================================ FILE: ludwig/__init__.py ================================================ # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import logging import sys from ludwig.globals import LUDWIG_VERSION as __version__ # noqa logging.basicConfig(level=logging.INFO, stream=sys.stdout, format="%(message)s") # Disable annoying message about NUMEXPR_MAX_THREADS logging.getLogger("numexpr").setLevel(logging.WARNING) ================================================ FILE: ludwig/accounting/__init__.py ================================================ ================================================ FILE: ludwig/accounting/used_tokens.py ================================================ import torch def get_used_tokens_for_ecd(inputs: dict[str, torch.Tensor], targets: dict[str, torch.Tensor]) -> int: """Returns the number of used tokens for an ECD model. The number of used tokens is the total size of the input and output tensors, which corresponds to 1 token for binary, category, and number features, and variable number of tokens for text features, for each example in the batch. Args: inputs: The input tensors for one forward pass through ecd. targets: The target tensors for one forward pass through ecd. """ used_tokens = 0 for input_feature_tensor in inputs.values(): used_tokens += torch.flatten(input_feature_tensor).shape[0] if targets is not None: # targets may be None for evaluation. for output_feature_tensor in targets.values(): used_tokens += torch.flatten(output_feature_tensor).shape[0] return used_tokens def get_used_tokens_for_llm(model_inputs: torch.Tensor, tokenizer) -> int: """Returns the number of used tokens for an LLM model. Args: model_inputs: torch.Tensor with the merged input and target IDs. tokenizer: The tokenizer used to encode the inputs. Returns: The total number of non-pad tokens, for all examples in the batch. """ return torch.sum(model_inputs != tokenizer.pad_token_id).item() ================================================ FILE: ludwig/api.py ================================================ # !/usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== """ File name: LudwigModel.py Author: Piero Molino Date created: 5/21/2019 Python Version: 3+ """ import copy import dataclasses import logging import os import sys import tempfile import time import traceback from collections import OrderedDict from dataclasses import dataclass from pprint import pformat from typing import Any, ClassVar import numpy as np import pandas as pd import torch from tabulate import tabulate from ludwig.api_annotations import PublicAPI from ludwig.backend import Backend, initialize_backend, provision_preprocessing_workers from ludwig.callbacks import Callback from ludwig.constants import ( AUTO, BATCH_SIZE, EVAL_BATCH_SIZE, FALLBACK_BATCH_SIZE, FULL, HYPEROPT, HYPEROPT_WARNING, MIN_DATASET_SPLIT_ROWS, MODEL_ECD, MODEL_LLM, TEST, TIMESERIES, TRAINING, VALIDATION, ) from ludwig.data.cache.types import CacheableDataset from ludwig.data.dataset.base import Dataset from ludwig.data.postprocessing import convert_predictions, postprocess from ludwig.data.preprocessing import load_metadata, preprocess_for_prediction, preprocess_for_training from ludwig.datasets import load_dataset_uris from ludwig.features.feature_registries import update_config_with_metadata, update_config_with_model from ludwig.globals import ( LUDWIG_VERSION, MODEL_FILE_NAME, MODEL_HYPERPARAMETERS_FILE_NAME, MODEL_WEIGHTS_FILE_NAME, set_disable_progressbar, TRAIN_SET_METADATA_FILE_NAME, TRAINING_CHECKPOINTS_DIR_PATH, ) from ludwig.models.base import BaseModel from ludwig.models.calibrator import Calibrator from ludwig.models.inference import InferenceModule, save_ludwig_model_for_inference from ludwig.models.predictor import ( calculate_overall_stats, print_evaluation_stats, save_evaluation_stats, save_prediction_outputs, ) from ludwig.models.registry import model_type_registry from ludwig.schema.model_config import ModelConfig from ludwig.types import ModelConfigDict, TrainingSetMetadataDict from ludwig.upload import get_upload_registry from ludwig.utils import metric_utils from ludwig.utils.backward_compatibility import upgrade_config_dict_to_latest_version from ludwig.utils.config_utils import get_preprocessing_params from ludwig.utils.data_utils import ( figure_data_format, generate_kfold_splits, load_dataset, load_json, load_yaml, save_json, ) from ludwig.utils.dataset_utils import generate_dataset_statistics from ludwig.utils.defaults import default_random_seed from ludwig.utils.fs_utils import makedirs, path_exists, upload_output_directory from ludwig.utils.heuristics import get_auto_learning_rate from ludwig.utils.llm_utils import create_text_streamer, TextStreamer from ludwig.utils.misc_utils import ( get_commit_hash, get_file_names, get_from_registry, get_output_directory, set_saved_weights_in_checkpoint_flag, ) from ludwig.utils.print_utils import print_boxed from ludwig.utils.tokenizers import HFTokenizer from ludwig.utils.torch_utils import DEVICE from ludwig.utils.trainer_utils import get_training_report from ludwig.utils.types import DataFrame, TorchDevice from ludwig.utils.upload_utils import HuggingFaceHub logger = logging.getLogger(__name__) @PublicAPI @dataclass class EvaluationFrequency: # noqa F821 """Represents the frequency of periodic evaluation of a metric during training. For example: "every epoch" frequency: 1, period: EPOCH "every 50 steps". frequency: 50, period: STEP """ frequency: float = 1.0 period: str = "epoch" # One of "epoch" or "step". EPOCH: ClassVar[str] = "epoch" # One epoch is a single pass through the training set. STEP: ClassVar[str] = "step" # One step is training on one mini-batch. @PublicAPI @dataclass class TrainingStats: # noqa F821 """Training stats were previously represented as a tuple or a dict. This class replaces those while preserving dict and tuple-like behavior (unpacking, [] access). """ training: dict[str, Any] validation: dict[str, Any] test: dict[str, Any] evaluation_frequency: EvaluationFrequency = dataclasses.field(default_factory=EvaluationFrequency) # TODO(daniel): deprecate multiple return value unpacking and dictionary-style element access def __iter__(self): return iter((self.training, self.test, self.validation)) def __contains__(self, key): return ( (key == TRAINING and self.training) or (key == VALIDATION and self.validation) or (key == TEST and self.test) ) def __getitem__(self, key): # Supports dict-style [] element access for compatibility. return {TRAINING: self.training, VALIDATION: self.validation, TEST: self.test}[key] @PublicAPI @dataclass class PreprocessedDataset: # noqa F821 training_set: Dataset validation_set: Dataset test_set: Dataset training_set_metadata: TrainingSetMetadataDict # TODO(daniel): deprecate multiple return value unpacking and indexed access def __iter__(self): return iter((self.training_set, self.validation_set, self.test_set, self.training_set_metadata)) def __getitem__(self, index): return (self.training_set, self.validation_set, self.test_set, self.training_set_metadata)[index] @PublicAPI @dataclass class TrainingResults: # noqa F821 train_stats: TrainingStats preprocessed_data: PreprocessedDataset output_directory: str def __iter__(self): """Supports tuple-style return value unpacking ex. train_stats, training_set, output_dir = model.train(...) """ return iter((self.train_stats, self.preprocessed_data, self.output_directory)) def __getitem__(self, index): """Provides indexed getter ex. train_stats = model.train(...)[0] """ return (self.train_stats, self.preprocessed_data, self.output_directory)[index] @PublicAPI class LudwigModel: """Class that allows access to high level Ludwig functionalities. # Inputs :param config: (Union[str, dict]) in-memory representation of config or string path to a YAML config file. :param logging_level: (int) Log level that will be sent to stderr. :param backend: (Union[Backend, str]) `Backend` or string name of backend to use to execute preprocessing / training steps. :param gpus: (Union[str, int, List[int]], default: `None`) GPUs to use (it uses the same syntax of CUDA_VISIBLE_DEVICES) :param gpu_memory_limit: (float: default: `None`) maximum memory fraction [0, 1] allowed to allocate per GPU device. :param allow_parallel_threads: (bool, default: `True`) allow Torch to use multithreading parallelism to improve performance at the cost of determinism. # Example usage: ```python from ludwig.api import LudwigModel ``` Train a model: ```python config = {...} ludwig_model = LudwigModel(config) train_stats, _, _ = ludwig_model.train(dataset=file_path) ``` or ```python train_stats, _, _ = ludwig_model.train(dataset=dataframe) ``` If you have already trained a model you can load it and use it to predict ```python ludwig_model = LudwigModel.load(model_dir) ``` Predict: ```python predictions, _ = ludwig_model.predict(dataset=file_path) ``` or ```python predictions, _ = ludwig_model.predict(dataset=dataframe) ``` Evaluation: ```python eval_stats, _, _ = ludwig_model.evaluate(dataset=file_path) ``` or ```python eval_stats, _, _ = ludwig_model.evaluate(dataset=dataframe) ``` """ def __init__( self, config: str | dict, logging_level: int = logging.ERROR, backend: Backend | str | None = None, gpus: str | int | list[int] | None = None, gpu_memory_limit: float | None = None, allow_parallel_threads: bool = True, callbacks: list[Callback] | None = None, ) -> None: """Constructor for the Ludwig Model class. # Inputs :param config: (Union[str, dict]) in-memory representation of config or string path to a YAML config file. :param logging_level: (int) Log level that will be sent to stderr. :param backend: (Union[Backend, str]) `Backend` or string name of backend to use to execute preprocessing / training steps. :param gpus: (Union[str, int, List[int]], default: `None`) GPUs to use (it uses the same syntax of CUDA_VISIBLE_DEVICES) :param gpu_memory_limit: (float: default: `None`) maximum memory fraction [0, 1] allowed to allocate per GPU device. :param allow_parallel_threads: (bool, default: `True`) allow Torch to use multithreading parallelism to improve performance at the cost of determinism. :param callbacks: (list, default: `None`) a list of `ludwig.callbacks.Callback` objects that provide hooks into the Ludwig pipeline. # Return :return: (None) `None` """ # check if config is a path or a dict if isinstance(config, str): # assume path config_dict = load_yaml(config) self.config_fp = config else: config_dict = copy.deepcopy(config) self.config_fp = None # type: ignore [assignment] self._user_config = upgrade_config_dict_to_latest_version(config_dict) # Initialize the config object self.config_obj = ModelConfig.from_dict(self._user_config) # setup logging self.set_logging_level(logging_level) # setup Backend self.backend = initialize_backend(backend or self._user_config.get("backend")) self.callbacks = callbacks if callbacks is not None else [] # setup PyTorch env (GPU allocation, etc.) self.backend.initialize_pytorch( gpus=gpus, gpu_memory_limit=gpu_memory_limit, allow_parallel_threads=allow_parallel_threads ) # setup model self.model = None self.training_set_metadata: dict[str, dict] | None = None # online training state self._online_trainer = None # Zero-shot LLM usage. if ( self.config_obj.model_type == MODEL_LLM and self.config_obj.trainer.type == "none" # Category output features require a vocabulary. The LLM LudwigModel should be initialized with # model.train(dataset). and self.config_obj.output_features[0].type == "text" ): self._initialize_llm() def _initialize_llm(self, random_seed: int = default_random_seed): """Initialize the LLM model. Should only be used in a zero-shot (NoneTrainer) setting. """ self.model = LudwigModel.create_model(self.config_obj, random_seed=random_seed) if self.model.model.device.type == "cpu" and torch.cuda.is_available(): logger.warning(f"LLM was initialized on {self.model.model.device}. Moving to GPU for inference.") self.model.model.to(torch.device("cuda")) def train( self, dataset: str | dict | pd.DataFrame | None = None, training_set: str | dict | pd.DataFrame | Dataset | None = None, validation_set: str | dict | pd.DataFrame | Dataset | None = None, test_set: str | dict | pd.DataFrame | Dataset | None = None, training_set_metadata: str | dict | None = None, data_format: str | None = None, experiment_name: str = "api_experiment", model_name: str = "run", model_resume_path: str | None = None, skip_save_training_description: bool = False, skip_save_training_statistics: bool = False, skip_save_model: bool = False, skip_save_progress: bool = False, skip_save_log: bool = False, skip_save_processed_input: bool = False, output_directory: str | None = "results", random_seed: int = default_random_seed, **kwargs, ) -> TrainingResults: """This function is used to perform a full training of the model on the specified dataset. During training if the skip parameters are False the model and statistics will be saved in a directory `[output_dir]/[experiment_name]_[model_name]_n` where all variables are resolved to user specified ones and `n` is an increasing number starting from 0 used to differentiate among repeated runs. # Inputs :param dataset: (Union[str, dict, pandas.DataFrame], default: `None`) source containing the entire dataset to be used in the experiment. If it has a split column, it will be used for splitting (0 for train, 1 for validation, 2 for test), otherwise the dataset will be randomly split. :param training_set: (Union[str, dict, pandas.DataFrame], default: `None`) source containing training data. :param validation_set: (Union[str, dict, pandas.DataFrame], default: `None`) source containing validation data. :param test_set: (Union[str, dict, pandas.DataFrame], default: `None`) source containing test data. :param training_set_metadata: (Union[str, dict], default: `None`) metadata JSON file or loaded metadata. Intermediate preprocessed structure containing the mappings of the input dataset created the first time an input file is used in the same directory with the same name and a '.meta.json' extension. :param data_format: (str, default: `None`) format to interpret data sources. Will be inferred automatically if not specified. Valid formats are `'auto'`, `'csv'`, `'df'`, `'dict'`, `'excel'`, `'feather'`, `'fwf'`, `'hdf5'` (cache file produced during previous training), `'html'` (file containing a single HTML ``), `'json'`, `'jsonl'`, `'parquet'`, `'pickle'` (pickled Pandas DataFrame), `'sas'`, `'spss'`, `'stata'`, `'tsv'`. :param experiment_name: (str, default: `'experiment'`) name for the experiment. :param model_name: (str, default: `'run'`) name of the model that is being used. :param model_resume_path: (str, default: `None`) resumes training of the model from the path specified. The config is restored. In addition to config, training statistics, loss for each epoch and the state of the optimizer are restored such that training can be effectively continued from a previously interrupted training process. :param skip_save_training_description: (bool, default: `False`) disables saving the description JSON file. :param skip_save_training_statistics: (bool, default: `False`) disables saving training statistics JSON file. :param skip_save_model: (bool, default: `False`) disables saving model weights and hyperparameters each time the model improves. By default Ludwig saves model weights after each epoch the validation metric improves, but if the model is really big that can be time consuming. If you do not want to keep the weights and just find out what performance a model can get with a set of hyperparameters, use this parameter to skip it, but the model will not be loadable later on and the returned model will have the weights obtained at the end of training, instead of the weights of the epoch with the best validation performance. :param skip_save_progress: (bool, default: `False`) disables saving progress each epoch. By default Ludwig saves weights and stats after each epoch for enabling resuming of training, but if the model is really big that can be time consuming and will uses twice as much space, use this parameter to skip it, but training cannot be resumed later on. :param skip_save_log: (bool, default: `False`) disables saving TensorBoard logs. By default Ludwig saves logs for the TensorBoard, but if it is not needed turning it off can slightly increase the overall speed. :param skip_save_processed_input: (bool, default: `False`) if input dataset is provided it is preprocessed and cached by saving an HDF5 and JSON files to avoid running the preprocessing again. If this parameter is `False`, the HDF5 and JSON file are not saved. :param output_directory: (str, default: `'results'`) the directory that will contain the training statistics, TensorBoard logs, the saved model and the training progress files. :param random_seed: (int, default: `42`) a random seed that will be used anywhere there is a call to a random number generator: data splitting, parameter initialization and training set shuffling :param kwargs: (dict, default: {}) a dictionary of optional parameters. # Return :return: (Tuple[Dict, Union[Dict, pd.DataFrame], str]) tuple containing `(training_statistics, preprocessed_data, output_directory)`. `training_statistics` is a nested dictionary of dataset -> feature_name -> metric_name -> List of metrics. Each metric corresponds to each training checkpoint. `preprocessed_data` is the tuple containing these three data sets `(training_set, validation_set, test_set)`. `output_directory` filepath to where training results are stored. """ # Only reset the metadata if the model has not been trained before if self.training_set_metadata: logger.warning( "This model has been trained before. Its architecture has been defined by the original training set " "(for example, the number of possible categorical outputs). The current training data will be mapped " "to this architecture. If you want to change the architecture of the model, please concatenate your " "new training data with the original and train a new model from scratch." ) training_set_metadata = self.training_set_metadata if self._user_config.get(HYPEROPT): print_boxed("WARNING") logger.warning(HYPEROPT_WARNING) # setup directories and file names if model_resume_path is not None: if path_exists(model_resume_path): output_directory = model_resume_path if self.backend.is_coordinator(): logger.info(f"Model resume path '{model_resume_path}' exists, trying to resume training.") else: if self.backend.is_coordinator(): logger.info( f"Model resume path '{model_resume_path}' does not exist, starting training from scratch" ) model_resume_path = None if model_resume_path is None: if self.backend.is_coordinator(): output_directory = get_output_directory(output_directory, experiment_name, model_name) else: output_directory = None # if we are skipping all saving, # there is no need to create a directory that will remain empty should_create_output_directory = not ( skip_save_training_description and skip_save_training_statistics and skip_save_model and skip_save_progress and skip_save_log and skip_save_processed_input ) output_url = output_directory with upload_output_directory(output_directory) as (output_directory, upload_fn): train_callbacks = self.callbacks if upload_fn is not None: # Upload output files (checkpoints, etc.) to remote storage at the end of # each epoch and evaluation, in case of failure in the middle of training. class UploadOnEpochEndCallback(Callback): def on_eval_end(self, trainer, progress_tracker, save_path): upload_fn() def on_epoch_end(self, trainer, progress_tracker, save_path): upload_fn() train_callbacks = train_callbacks + [UploadOnEpochEndCallback()] description_fn = training_stats_fn = model_dir = None if self.backend.is_coordinator(): if should_create_output_directory: makedirs(output_directory, exist_ok=True) description_fn, training_stats_fn, model_dir = get_file_names(output_directory) if isinstance(training_set, Dataset) and training_set_metadata is not None: preprocessed_data = (training_set, validation_set, test_set, training_set_metadata) else: # save description if self.backend.is_coordinator(): description = get_experiment_description( self.config_obj.to_dict(), dataset=dataset, training_set=training_set, validation_set=validation_set, test_set=test_set, training_set_metadata=training_set_metadata, data_format=data_format, backend=self.backend, random_seed=random_seed, ) if not skip_save_training_description: save_json(description_fn, description) # print description experiment_description = [ ["Experiment name", experiment_name], ["Model name", model_name], ["Output directory", output_directory], ] for key, value in description.items(): if key != "config": # Config is printed separately. experiment_description.append([key, pformat(value, indent=4)]) if self.backend.is_coordinator(): print_boxed("EXPERIMENT DESCRIPTION") logger.info(tabulate(experiment_description, tablefmt="fancy_grid")) print_boxed("LUDWIG CONFIG") logger.info("User-specified config (with upgrades):\n") logger.info(pformat(self._user_config, indent=4)) logger.info( "\nFull config saved to:\n" f"{output_directory}/{experiment_name}/model/model_hyperparameters.json" ) preprocessed_data = self.preprocess( # type: ignore[assignment] dataset=dataset, training_set=training_set, validation_set=validation_set, test_set=test_set, training_set_metadata=training_set_metadata, data_format=data_format, experiment_name=experiment_name, model_name=model_name, model_resume_path=model_resume_path, skip_save_training_description=skip_save_training_description, skip_save_training_statistics=skip_save_training_statistics, skip_save_model=skip_save_model, skip_save_progress=skip_save_progress, skip_save_log=skip_save_log, skip_save_processed_input=skip_save_processed_input, output_directory=output_directory, random_seed=random_seed, **kwargs, ) training_set, validation_set, test_set, training_set_metadata = preprocessed_data self.training_set_metadata = training_set_metadata if self.backend.is_coordinator(): dataset_statistics = generate_dataset_statistics(training_set, validation_set, test_set) if not skip_save_model: # save train set metadata os.makedirs(model_dir, exist_ok=True) # type: ignore[arg-type] save_json( # type: ignore[arg-type] os.path.join(model_dir, TRAIN_SET_METADATA_FILE_NAME), training_set_metadata ) logger.info("\nDataset Statistics") logger.info(tabulate(dataset_statistics, headers="firstrow", tablefmt="fancy_grid")) for callback in self.callbacks: callback.on_train_init( base_config=self._user_config, experiment_directory=output_directory, experiment_name=experiment_name, model_name=model_name, output_directory=output_directory, resume_directory=model_resume_path, ) # Build model if not provided # if it was provided it means it was already loaded if not self.model: if self.backend.is_coordinator(): print_boxed("MODEL") # update model config with metadata properties derived from training set update_config_with_metadata(self.config_obj, training_set_metadata) logger.info("Warnings and other logs:") self.model = LudwigModel.create_model(self.config_obj, random_seed=random_seed) # update config with properties determined during model instantiation update_config_with_model(self.config_obj, self.model) set_saved_weights_in_checkpoint_flag(self.config_obj) # auto tune learning rate if hasattr(self.config_obj.trainer, "learning_rate") and self.config_obj.trainer.learning_rate == AUTO: detected_learning_rate = get_auto_learning_rate(self.config_obj) self.config_obj.trainer.learning_rate = detected_learning_rate with self.backend.create_trainer( model=self.model, config=self.config_obj.trainer, resume=model_resume_path is not None, skip_save_model=skip_save_model, skip_save_progress=skip_save_progress, skip_save_log=skip_save_log, callbacks=train_callbacks, random_seed=random_seed, ) as trainer: # auto tune batch size self._tune_batch_size(trainer, training_set, random_seed=random_seed) if ( self.config_obj.model_type == "LLM" and trainer.config.type == "none" and self.config_obj.adapter is not None and self.config_obj.adapter.pretrained_adapter_weights is not None ): trainer.model.initialize_adapter() # Load pre-trained adapter weights for inference only # train model if self.backend.is_coordinator(): print_boxed("TRAINING") if not skip_save_model: self.save_config(model_dir) for callback in self.callbacks: callback.on_train_start( model=self.model, config=self.config_obj.to_dict(), config_fp=self.config_fp, ) try: train_stats = trainer.train( training_set, validation_set=validation_set, test_set=test_set, save_path=model_dir, ) self.model, train_trainset_stats, train_valiset_stats, train_testset_stats = train_stats # Calibrates output feature probabilities on validation set if calibration is enabled. # Must be done after training, and before final model parameters are saved. if self.backend.is_coordinator(): calibrator = Calibrator( self.model, self.backend, batch_size=trainer.eval_batch_size, ) if calibrator.calibration_enabled(): if validation_set is None: logger.warning( "Calibration uses validation set, but no validation split specified." "Will use training set for calibration." "Recommend providing a validation set when using calibration." ) calibrator.train_calibration(training_set, TRAINING) elif len(validation_set) < MIN_DATASET_SPLIT_ROWS: logger.warning( f"Validation set size ({len(validation_set)} rows) is too small for calibration." "Will use training set for calibration." f"Validation set much have at least {MIN_DATASET_SPLIT_ROWS} rows." ) calibrator.train_calibration(training_set, TRAINING) else: calibrator.train_calibration(validation_set, VALIDATION) if not skip_save_model: self.model.save(model_dir) # Evaluation Frequency if self.config_obj.model_type == MODEL_ECD and self.config_obj.trainer.steps_per_checkpoint: evaluation_frequency = EvaluationFrequency( self.config_obj.trainer.steps_per_checkpoint, EvaluationFrequency.STEP ) elif self.config_obj.model_type == MODEL_ECD and self.config_obj.trainer.checkpoints_per_epoch: evaluation_frequency = EvaluationFrequency( 1.0 / self.config_obj.trainer.checkpoints_per_epoch, EvaluationFrequency.EPOCH ) else: evaluation_frequency = EvaluationFrequency(1, EvaluationFrequency.EPOCH) # Unpack train()'s return. # The statistics are all nested dictionaries of TrainerMetrics: feature_name -> metric_name -> # List[TrainerMetric], with one entry per training checkpoint, according to steps_per_checkpoint. # We reduce the dictionary of TrainerMetrics to a simple list of floats for interfacing with Ray # Tune. train_stats = TrainingStats( metric_utils.reduce_trainer_metrics_dict(train_trainset_stats), metric_utils.reduce_trainer_metrics_dict(train_valiset_stats), metric_utils.reduce_trainer_metrics_dict(train_testset_stats), evaluation_frequency, ) # save training statistics if self.backend.is_coordinator(): if not skip_save_training_statistics: save_json(training_stats_fn, train_stats) # results of the model with highest validation test performance if ( self.backend.is_coordinator() and validation_set is not None and not self.config_obj.trainer.skip_all_evaluation ): print_boxed("TRAINING REPORT") training_report = get_training_report( trainer.validation_field, trainer.validation_metric, test_set is not None, train_valiset_stats, train_testset_stats, ) logger.info(tabulate(training_report, tablefmt="fancy_grid")) logger.info(f"\nFinished: {experiment_name}_{model_name}") logger.info(f"Saved to: {output_directory}") finally: for callback in self.callbacks: callback.on_train_end(output_directory) self.training_set_metadata = training_set_metadata if self.is_merge_and_unload_set(): # For an LLM model trained with a LoRA adapter, merge first, then save the full model. self.model.merge_and_unload(progressbar=self.config_obj.adapter.postprocessor.progressbar) if self.backend.is_coordinator() and not skip_save_model: self.model.save_base_model(model_dir) elif self.backend.is_coordinator() and not skip_save_model: self.model.save(model_dir) # Synchronize model weights between workers self.backend.sync_model(self.model) print_boxed("FINISHED") return TrainingResults(train_stats, preprocessed_data, output_url) def train_online( self, dataset: str | dict | pd.DataFrame, training_set_metadata: str | dict | None = None, data_format: str = "auto", random_seed: int = default_random_seed, ) -> None: """Performs one epoch of training of the model on `dataset`. # Inputs :param dataset: (Union[str, dict, pandas.DataFrame], default: `None`) source containing the entire dataset to be used in the experiment. If it has a split column, it will be used for splitting (0 for train, 1 for validation, 2 for test), otherwise the dataset will be randomly split. :param training_set_metadata: (Union[str, dict], default: `None`) metadata JSON file or loaded metadata. Intermediate preprocessed structure containing the mappings of the input dataset created the first time an input file is used in the same directory with the same name and a '.meta.json' extension. :param data_format: (str, default: `None`) format to interpret data sources. Will be inferred automatically if not specified. Valid formats are `'auto'`, `'csv'`, `'df'`, `'dict'`, `'excel'`, `'feather'`, `'fwf'`, `'hdf5'` (cache file produced during previous training), `'html'` (file containing a single HTML `
`), `'json'`, `'jsonl'`, `'parquet'`, `'pickle'` (pickled Pandas DataFrame), `'sas'`, `'spss'`, `'stata'`, `'tsv'`. :param random_seed: (int, default: `42`) a random seed that is going to be used anywhere there is a call to a random number generator: data splitting, parameter initialization and training set shuffling # Return :return: (None) `None` """ training_set_metadata = training_set_metadata or self.training_set_metadata preprocessing_params = get_preprocessing_params(self.config_obj) with provision_preprocessing_workers(self.backend): # TODO (Connor): Refactor to use self.config_obj training_dataset, _, _, training_set_metadata = preprocess_for_training( self.config_obj.to_dict(), training_set=dataset, training_set_metadata=training_set_metadata, data_format=data_format, skip_save_processed_input=True, preprocessing_params=preprocessing_params, backend=self.backend, random_seed=random_seed, callbacks=self.callbacks, ) if not self.training_set_metadata: self.training_set_metadata = training_set_metadata if not self.model: update_config_with_metadata(self.config_obj, training_set_metadata) self.model = LudwigModel.create_model(self.config_obj, random_seed=random_seed) # update config with properties determined during model instantiation update_config_with_model(self.config_obj, self.model) set_saved_weights_in_checkpoint_flag(self.config_obj) if not self._online_trainer: self._online_trainer = self.backend.create_trainer( config=self.config_obj.trainer, model=self.model, random_seed=random_seed ) self._tune_batch_size(self._online_trainer, dataset, random_seed=random_seed) self.model = self._online_trainer.train_online(training_dataset) def _tune_batch_size(self, trainer, dataset, random_seed: int = default_random_seed): """Sets AUTO batch-size-related parameters based on the trainer, backend type, and number of workers. Batch-size related parameters that are set: - trainer.batch_size - trainer.eval_batch_size - trainer.gradient_accumulation_steps - trainer.effective_batch_size The final batch size selected may be non-deterministic even with a fixed random seed since throughput-based heuristics may be affected by resources used by other processes running on the machine. """ if not self.config_obj.trainer.can_tune_batch_size(): # Some model types don't have batch sizes to be tuned return # Render the batch size and gradient accumulation steps prior to batch size tuning. This is needed in the event # the effective_batch_size and gradient_accumulation_steps are set explicitly, but batch_size is AUTO. In this # case, we can infer the batch_size directly without tuning. num_workers = self.backend.num_training_workers self.config_obj.trainer.update_batch_size_grad_accum(num_workers) # TODO (ASN): add support for substitute_with_max parameter # TODO(travis): detect train and eval batch sizes separately (enable / disable gradients) if self.config_obj.trainer.batch_size == AUTO: if self.backend.supports_batch_size_tuning(): tuned_batch_size = trainer.tune_batch_size( self.config_obj.to_dict(), dataset, random_seed=random_seed, tune_for_training=True ) else: logger.warning( f"Backend {self.backend.BACKEND_TYPE} does not support batch size tuning, " f"using fallback training batch size {FALLBACK_BATCH_SIZE}." ) tuned_batch_size = FALLBACK_BATCH_SIZE # TODO(travis): pass these in as args to trainer when we call train, # to avoid setting state on possibly remote trainer self.config_obj.trainer.batch_size = tuned_batch_size # Re-render the gradient_accumulation_steps to account for the explicit batch size. self.config_obj.trainer.update_batch_size_grad_accum(num_workers) if self.config_obj.trainer.eval_batch_size in {AUTO, None}: if self.backend.supports_batch_size_tuning(): tuned_batch_size = trainer.tune_batch_size( self.config_obj.to_dict(), dataset, random_seed=random_seed, tune_for_training=False ) else: logger.warning( f"Backend {self.backend.BACKEND_TYPE} does not support batch size tuning, " f"using fallback eval batch size {FALLBACK_BATCH_SIZE}." ) tuned_batch_size = FALLBACK_BATCH_SIZE self.config_obj.trainer.eval_batch_size = tuned_batch_size # Update trainer params separate to config params for backends with stateful trainers trainer.batch_size = self.config_obj.trainer.batch_size trainer.eval_batch_size = self.config_obj.trainer.eval_batch_size trainer.gradient_accumulation_steps = self.config_obj.trainer.gradient_accumulation_steps def save_dequantized_base_model(self, save_path: str) -> None: """Upscales quantized weights of a model to fp16 and saves the result in a specified folder. Args: save_path (str): The path to the folder where the upscaled model weights will be saved. Raises: ValueError: If the model type is not 'llm' or if quantization is not enabled or the number of bits is not 4 or 8. RuntimeError: If no GPU is available, as GPU is required for quantized models. Returns: None """ if self.config_obj.model_type != MODEL_LLM: raise ValueError( f"Model type {self.config_obj.model_type} is not supported by this method. Only `llm` model type is " "supported." ) if not self.config_obj.quantization: raise ValueError( "Quantization is not enabled in your Ludwig model config. " "To enable quantization, set `quantization` to `{'bits': 4}` or `{'bits': 8}` in your model config." ) if self.config_obj.quantization.bits != 4: raise ValueError( "This method only works with quantized models with 4 bits. " "Support for 8-bit quantized models will be added in a future release." ) if not torch.cuda.is_available(): raise RuntimeError("GPU is required for quantized models but no GPU found.") # Create the LLM model class instance with the loaded LLM if it hasn't been initialized yet. if not self.model: self.model = LudwigModel.create_model(self.config_obj) self.model.save_dequantized_base_model(save_path) logger.info( "If you want to upload this model to huggingface.co, run the following Python commands: \n" "from ludwig.utils.hf_utils import upload_folder_to_hfhub; \n" f"upload_folder_to_hfhub(repo_id='desired/huggingface/repo/name', folder_path='{save_path}')" ) def generate( self, input_strings: str | list[str], generation_config: dict | None = None, streaming: bool | None = False, ) -> str | list[str]: """A simple generate() method that directly uses the underlying transformers library to generate text. Args: input_strings (Union[str, List[str]]): Input text or list of texts to generate from. generation_config (Optional[dict]): Configuration for text generation. streaming (Optional[bool]): If True, enable streaming output. Returns: Union[str, List[str]]: Generated text or list of generated texts. """ if self.config_obj.model_type != MODEL_LLM: raise ValueError( f"Model type {self.config_obj.model_type} is not supported by this method. Only `llm` model type is " "supported." ) if not torch.cuda.is_available(): # GPU is generally well-advised for working with LLMs and is required for loading quantized models, see # https://github.com/ludwig-ai/ludwig/issues/3695. raise ValueError("GPU is not available.") # TODO(Justin): Decide if it's worth folding padding_side handling into llm.py's tokenizer initialization. # For batch inference with models like facebook/opt-350m, if the tokenizer padding side is off, HF prints a # warning, e.g.: # "A decoder-only architecture is being used, but right-padding was detected! For correct generation results, " # "please set `padding_side='left'` when initializing the tokenizer. padding_side = "left" if not self.model.model.config.is_encoder_decoder else "right" tokenizer = HFTokenizer(self.config_obj.base_model, padding_side=padding_side) with self.model.use_generation_config(generation_config): start_time = time.time() tokenized_inputs = tokenizer.tokenizer(input_strings, return_tensors="pt", padding=True) input_ids = tokenized_inputs["input_ids"].to("cuda") attention_mask = tokenized_inputs["attention_mask"].to("cuda") if streaming: streamer = create_text_streamer(tokenizer.tokenizer) outputs = self._generate_streaming_outputs(input_strings, input_ids, attention_mask, streamer) else: outputs = self._generate_non_streaming_outputs(input_strings, input_ids, attention_mask) decoded_outputs = tokenizer.tokenizer.batch_decode(outputs, skip_special_tokens=True) logger.info(f"Finished generating in: {(time.time() - start_time):.2f}s.") return decoded_outputs[0] if len(decoded_outputs) == 1 else decoded_outputs def _generate_streaming_outputs( self, input_strings: str | list[str], input_ids: torch.Tensor, attention_mask: torch.Tensor, streamer: TextStreamer, ) -> torch.Tensor: """Generate streaming outputs for the given input. Args: input_strings (Union[str, List[str]]): Input text or list of texts to generate from. input_ids (torch.Tensor): Tensor containing input IDs. attention_mask (torch.Tensor): Tensor containing attention masks. streamer (Union[TextStreamer, None]): Text streamer instance for streaming output. Returns: torch.Tensor: Concatenated tensor of generated outputs. """ outputs = [] input_strings = input_strings if isinstance(input_strings, list) else [input_strings] for i in range(len(input_ids)): with torch.no_grad(): logger.info(f"Input: {input_strings[i]}\n") # NOTE: self.model.model.generation_config is not used here because it is the default # generation config that the CausalLM was initialized with, rather than the one set within the # context manager. generated_output = self.model.model.generate( input_ids=input_ids[i].unsqueeze(0), attention_mask=attention_mask[i].unsqueeze(0), generation_config=self.model.generation, streamer=streamer, ) logger.info("----------------------") outputs.append(generated_output) return torch.cat(outputs, dim=0) def _generate_non_streaming_outputs( self, _input_strings: str | list[str], input_ids: torch.Tensor, attention_mask: torch.Tensor, ) -> torch.Tensor: """Generate non-streaming outputs for the given input. Args: _input_strings (Union[str, List[str]]): Unused input parameter. input_ids (torch.Tensor): Tensor containing input IDs. attention_mask (torch.Tensor): Tensor containing attention masks. streamer (Union[TextStreamer, None]): Text streamer instance for streaming output. Returns: torch.Tensor: Tensor of generated outputs. """ with torch.no_grad(): # NOTE: self.model.model.generation_config is not used here because it is the default # generation config that the CausalLM was initialized with, rather than the one set within the # context manager. return self.model.model.generate( input_ids=input_ids, attention_mask=attention_mask, generation_config=self.model.generation, ) def predict( self, dataset: str | dict | pd.DataFrame | None = None, data_format: str = None, split: str = FULL, batch_size: int = 128, generation_config: dict | None = None, skip_save_unprocessed_output: bool = True, skip_save_predictions: bool = True, output_directory: str = "results", return_type: str | dict | pd.DataFrame = pd.DataFrame, callbacks: list[Callback] | None = None, **kwargs, ) -> tuple[dict | pd.DataFrame, str]: """Using a trained model, make predictions from the provided dataset. # Inputs :param dataset: (Union[str, dict, pandas.DataFrame]): source containing the entire dataset to be evaluated. :param data_format: (str, default: `None`) format to interpret data sources. Will be inferred automatically if not specified. Valid formats are `'auto'`, `'csv'`, `'df'`, `'dict'`, `'excel'`, `'feather'`, `'fwf'`, `'hdf5'` (cache file produced during previous training), `'html'` (file containing a single HTML `
`), `'json'`, `'jsonl'`, `'parquet'`, `'pickle'` (pickled Pandas DataFrame), `'sas'`, `'spss'`, `'stata'`, `'tsv'`. :param split: (str, default= `'full'`): if the input dataset contains a split column, this parameter indicates which split of the data to use. Possible values are `'full'`, `'training'`, `'validation'`, `'test'`. :param batch_size: (int, default: 128) size of batch to use when making predictions. :param generation_config: (Dict, default: `None`) config for the generation of the predictions. If `None`, the config that was used during model training is used. This is only used if the model type is LLM. Otherwise, this parameter is ignored. See [Large Language Models](https://ludwig.ai/latest/configuration/large_language_model/#generation) under "Generation" for an example generation config. :param skip_save_unprocessed_output: (bool, default: `True`) if this parameter is `False`, predictions and their probabilities are saved in both raw unprocessed numpy files containing tensors and as postprocessed CSV files (one for each output feature). If this parameter is `True`, only the CSV ones are saved and the numpy ones are skipped. :param skip_save_predictions: (bool, default: `True`) skips saving test predictions CSV files. :param output_directory: (str, default: `'results'`) the directory that will contain the training statistics, TensorBoard logs, the saved model and the training progress files. :param return_type: (Union[str, dict, pandas.DataFrame], default: pd.DataFrame) indicates the format of the returned predictions. :param callbacks: (Optional[List[Callback]], default: None) optional list of callbacks to use during this predict operation. Any callbacks already registered to the model will be preserved. # Return :return `(predictions, output_directory)`: (Tuple[Union[dict, pd.DataFrame], str]) `predictions` predictions from the provided dataset, `output_directory` filepath string to where data was stored. """ self._check_initialization() # preprocessing start_time = time.time() logger.debug("Preprocessing") dataset, _ = preprocess_for_prediction( # TODO (Connor): Refactor to use self.config_obj self.config_obj.to_dict(), dataset=dataset, training_set_metadata=self.training_set_metadata, data_format=data_format, split=split, include_outputs=False, backend=self.backend, callbacks=self.callbacks + (callbacks or []), ) logger.debug("Predicting") with self.backend.create_predictor(self.model, batch_size=batch_size) as predictor: with self.model.use_generation_config(generation_config): predictions = predictor.batch_predict( dataset, ) if self.backend.is_coordinator(): # if we are skipping all saving, # there is no need to create a directory that will remain empty should_create_exp_dir = not (skip_save_unprocessed_output and skip_save_predictions) if should_create_exp_dir: makedirs(output_directory, exist_ok=True) logger.debug("Postprocessing") postproc_predictions = postprocess( predictions, self.model.output_features, self.training_set_metadata, output_directory=output_directory, backend=self.backend, skip_save_unprocessed_output=skip_save_unprocessed_output or not self.backend.is_coordinator(), ) converted_postproc_predictions = convert_predictions( postproc_predictions, self.model.output_features, return_type=return_type, backend=self.backend ) if self.backend.is_coordinator(): if not skip_save_predictions: save_prediction_outputs( postproc_predictions, self.model.output_features, output_directory, self.backend ) logger.info(f"Saved to: {output_directory}") logger.info(f"Finished predicting in: {(time.time() - start_time):.2f}s.") return converted_postproc_predictions, output_directory def evaluate( self, dataset: str | dict | pd.DataFrame | None = None, data_format: str | None = None, split: str = FULL, batch_size: int | None = None, skip_save_unprocessed_output: bool = True, skip_save_predictions: bool = True, skip_save_eval_stats: bool = True, collect_predictions: bool = False, collect_overall_stats: bool = False, output_directory: str = "results", return_type: str | dict | pd.DataFrame = pd.DataFrame, **kwargs, ) -> tuple[dict, dict | pd.DataFrame, str]: """This function is used to predict the output variables given the input variables using the trained model and compute test statistics like performance measures, confusion matrices and the like. # Inputs :param dataset: (Union[str, dict, pandas.DataFrame]) source containing the entire dataset to be evaluated. :param data_format: (str, default: `None`) format to interpret data sources. Will be inferred automatically if not specified. Valid formats are `'auto'`, `'csv'`, `'df'`, `'dict'`, `'excel'`, `'feather'`, `'fwf'`, `'hdf5'` (cache file produced during previous training), `'html'` (file containing a single HTML `
`), `'json'`, `'jsonl'`, `'parquet'`, `'pickle'` (pickled Pandas DataFrame), `'sas'`, `'spss'`, `'stata'`, `'tsv'`. :param split: (str, default=`'full'`): if the input dataset contains a split column, this parameter indicates which split of the data to use. Possible values are `'full'`, `'training'`, `'validation'`, `'test'`. :param batch_size: (int, default: None) size of batch to use when making predictions. Defaults to model config eval_batch_size :param skip_save_unprocessed_output: (bool, default: `True`) if this parameter is `False`, predictions and their probabilities are saved in both raw unprocessed numpy files containing tensors and as postprocessed CSV files (one for each output feature). If this parameter is `True`, only the CSV ones are saved and the numpy ones are skipped. :param skip_save_predictions: (bool, default: `True`) skips saving test predictions CSV files. :param skip_save_eval_stats: (bool, default: `True`) skips saving test statistics JSON file. :param collect_predictions: (bool, default: `False`) if `True` collects post-processed predictions during eval. :param collect_overall_stats: (bool, default: False) if `True` collects overall stats during eval. :param output_directory: (str, default: `'results'`) the directory that will contain the training statistics, TensorBoard logs, the saved model and the training progress files. :param return_type: (Union[str, dict, pd.DataFrame], default: pandas.DataFrame) indicates the format to of the returned predictions. # Return :return: (`evaluation_statistics`, `predictions`, `output_directory`) `evaluation_statistics` dictionary containing evaluation performance statistics, `postprocess_predictions` contains predicted values, `output_directory` is location where results are stored. """ self._check_initialization() for callback in self.callbacks: callback.on_evaluation_start() # preprocessing logger.debug("Preprocessing") dataset, training_set_metadata = preprocess_for_prediction( # TODO (Connor): Refactor to use self.config_obj self.config_obj.to_dict(), dataset=dataset, training_set_metadata=self.training_set_metadata, data_format=data_format, split=split, include_outputs=True, backend=self.backend, callbacks=self.callbacks, ) # Fallback to use eval_batch_size or batch_size if not provided if batch_size is None: # Requires dictionary getter since some trainer configs may not have a batch_size param batch_size = self.config_obj.trainer.to_dict().get( EVAL_BATCH_SIZE, None ) or self.config_obj.trainer.to_dict().get(BATCH_SIZE, None) logger.debug("Predicting") with self.backend.create_predictor(self.model, batch_size=batch_size) as predictor: eval_stats, predictions = predictor.batch_evaluation( dataset, collect_predictions=collect_predictions or collect_overall_stats, ) # calculate the overall metrics if collect_overall_stats: dataset = dataset.to_df() overall_stats = calculate_overall_stats( self.model.output_features, predictions, dataset, training_set_metadata ) eval_stats = { of_name: ( {**eval_stats[of_name], **overall_stats[of_name]} # account for presence of 'combined' key if of_name in overall_stats else {**eval_stats[of_name]} ) for of_name in eval_stats } if self.backend.is_coordinator(): # if we are skipping all saving, # there is no need to create a directory that will remain empty should_create_exp_dir = not ( skip_save_unprocessed_output and skip_save_predictions and skip_save_eval_stats ) if should_create_exp_dir: makedirs(output_directory, exist_ok=True) if collect_predictions: logger.debug("Postprocessing") postproc_predictions = postprocess( predictions, self.model.output_features, self.training_set_metadata, output_directory=output_directory, backend=self.backend, skip_save_unprocessed_output=skip_save_unprocessed_output or not self.backend.is_coordinator(), ) else: postproc_predictions = predictions # = {} if self.backend.is_coordinator(): should_save_predictions = ( collect_predictions and postproc_predictions is not None and not skip_save_predictions ) if should_save_predictions: save_prediction_outputs( postproc_predictions, self.model.output_features, output_directory, self.backend ) print_evaluation_stats(eval_stats) if not skip_save_eval_stats: save_evaluation_stats(eval_stats, output_directory) if should_save_predictions or not skip_save_eval_stats: logger.info(f"Saved to: {output_directory}") if collect_predictions: postproc_predictions = convert_predictions( postproc_predictions, self.model.output_features, return_type=return_type, backend=self.backend ) for callback in self.callbacks: callback.on_evaluation_end() return eval_stats, postproc_predictions, output_directory def forecast( self, dataset: DataFrame, data_format: str | None = None, horizon: int = 1, output_directory: str | None = None, output_format: str = "parquet", ) -> DataFrame: # TODO(travis): WIP dataset, _, _, _ = load_dataset_uris(dataset, None, None, None, self.backend) if isinstance(dataset, CacheableDataset): dataset = dataset.unwrap() dataset = load_dataset(dataset, data_format=data_format, df_lib=self.backend.df_engine.df_lib) window_sizes = [ feature.preprocessing.window_size for feature in self.config_obj.input_features if feature.type == TIMESERIES ] if not window_sizes: raise ValueError("Forecasting requires at least one input feature of type `timeseries`.") # TODO(travis): there's a lot of redundancy in this approach, since we are preprocessing the same DataFrame # multiple times with only a small number of features (the horizon) being appended each time. # A much better approach would be to only preprocess a single row, but incorporating the row-level embedding # over the window_size of rows precending it, then performing the model forward pass on only that row of # data. max_lookback_window_size = max(window_sizes) total_forecasted = 0 while total_forecasted < horizon: # We only need the last `window_size` worth of rows to forecast the next value dataset = dataset.tail(max_lookback_window_size) # Run through preprocessing and prediction to obtain row-wise next values # TODO(travis): can optimize the preprocessing part here, since we only need to preprocess / predict # the last row, not the last `window_size` rows. preds, _ = self.predict(dataset, skip_save_predictions=True, skip_save_unprocessed_output=True) next_series = {} for feature in self.config_obj.output_features: if feature.type == TIMESERIES: key = f"{feature.name}_predictions" next_series[feature.column] = pd.Series(preds[key].iloc[-1]) next_preds = pd.DataFrame(next_series) dataset = pd.concat([dataset, next_preds], axis=0).reset_index(drop=True) total_forecasted += len(next_preds) horizon_df = dataset.tail(total_forecasted).head(horizon) return_cols = [feature.column for feature in self.config_obj.output_features if feature.type == TIMESERIES] results_df = horizon_df[return_cols] if output_directory is not None: if self.backend.is_coordinator(): # TODO(travis): generalize this to support any pandas output format if output_format == "parquet": output_path = os.path.join(output_directory, "forecast.parquet") results_df.to_parquet(output_path) elif output_format == "csv": output_path = os.path.join(output_directory, "forecast.csv") results_df.to_csv(output_path) else: raise ValueError(f"`output_format` {output_format} not supported. Must be one of [parquet, csv]") logger.info(f"Saved to: {output_path}") return results_df def experiment( self, dataset: str | dict | pd.DataFrame | None = None, training_set: str | dict | pd.DataFrame | None = None, validation_set: str | dict | pd.DataFrame | None = None, test_set: str | dict | pd.DataFrame | None = None, training_set_metadata: str | dict | None = None, data_format: str | None = None, experiment_name: str = "experiment", model_name: str = "run", model_resume_path: str | None = None, eval_split: str = TEST, skip_save_training_description: bool = False, skip_save_training_statistics: bool = False, skip_save_model: bool = False, skip_save_progress: bool = False, skip_save_log: bool = False, skip_save_processed_input: bool = False, skip_save_unprocessed_output: bool = False, skip_save_predictions: bool = False, skip_save_eval_stats: bool = False, skip_collect_predictions: bool = False, skip_collect_overall_stats: bool = False, output_directory: str = "results", random_seed: int = default_random_seed, **kwargs, ) -> tuple[dict | None, TrainingStats, PreprocessedDataset, str]: """Trains a model on a dataset's training and validation splits and uses it to predict on the test split. It saves the trained model and the statistics of training and testing. # Inputs :param dataset: (Union[str, dict, pandas.DataFrame], default: `None`) source containing the entire dataset to be used in the experiment. If it has a split column, it will be used for splitting (0 for train, 1 for validation, 2 for test), otherwise the dataset will be randomly split. :param training_set: (Union[str, dict, pandas.DataFrame], default: `None`) source containing training data. :param validation_set: (Union[str, dict, pandas.DataFrame], default: `None`) source containing validation data. :param test_set: (Union[str, dict, pandas.DataFrame], default: `None`) source containing test data. :param training_set_metadata: (Union[str, dict], default: `None`) metadata JSON file or loaded metadata. Intermediate preprocessed structure containing the mappings of the input dataset created the first time an input file is used in the same directory with the same name and a '.meta.json' extension. :param data_format: (str, default: `None`) format to interpret data sources. Will be inferred automatically if not specified. Valid formats are `'auto'`, `'csv'`, `'df'`, `'dict'`, `'excel'`, `'feather'`, `'fwf'`, `'hdf5'` (cache file produced during previous training), `'html'` (file containing a single HTML `
`), `'json'`, `'jsonl'`, `'parquet'`, `'pickle'` (pickled Pandas DataFrame), `'sas'`, `'spss'`, `'stata'`, `'tsv'`. :param experiment_name: (str, default: `'experiment'`) name for the experiment. :param model_name: (str, default: `'run'`) name of the model that is being used. :param model_resume_path: (str, default: `None`) resumes training of the model from the path specified. The config is restored. In addition to config, training statistics and loss for epoch and the state of the optimizer are restored such that training can be effectively continued from a previously interrupted training process. :param eval_split: (str, default: `test`) split on which to perform evaluation. Valid values are `training`, `validation` and `test`. :param skip_save_training_description: (bool, default: `False`) disables saving the description JSON file. :param skip_save_training_statistics: (bool, default: `False`) disables saving training statistics JSON file. :param skip_save_model: (bool, default: `False`) disables saving model weights and hyperparameters each time the model improves. By default Ludwig saves model weights after each epoch the validation metric improves, but if the model is really big that can be time consuming. If you do not want to keep the weights and just find out what performance a model can get with a set of hyperparameters, use this parameter to skip it, but the model will not be loadable later on and the returned model will have the weights obtained at the end of training, instead of the weights of the epoch with the best validation performance. :param skip_save_progress: (bool, default: `False`) disables saving progress each epoch. By default Ludwig saves weights and stats after each epoch for enabling resuming of training, but if the model is really big that can be time consuming and will uses twice as much space, use this parameter to skip it, but training cannot be resumed later on. :param skip_save_log: (bool, default: `False`) disables saving TensorBoard logs. By default Ludwig saves logs for the TensorBoard, but if it is not needed turning it off can slightly increase the overall speed. :param skip_save_processed_input: (bool, default: `False`) if input dataset is provided it is preprocessed and cached by saving an HDF5 and JSON files to avoid running the preprocessing again. If this parameter is `False`, the HDF5 and JSON file are not saved. :param skip_save_unprocessed_output: (bool, default: `False`) by default predictions and their probabilities are saved in both raw unprocessed numpy files containing tensors and as postprocessed CSV files (one for each output feature). If this parameter is True, only the CSV ones are saved and the numpy ones are skipped. :param skip_save_predictions: (bool, default: `False`) skips saving test predictions CSV files :param skip_save_eval_stats: (bool, default: `False`) skips saving test statistics JSON file :param skip_collect_predictions: (bool, default: `False`) skips collecting post-processed predictions during eval. :param skip_collect_overall_stats: (bool, default: `False`) skips collecting overall stats during eval. :param output_directory: (str, default: `'results'`) the directory that will contain the training statistics, TensorBoard logs, the saved model and the training progress files. :param random_seed: (int: default: 42) random seed used for weights initialization, splits and any other random function. # Return :return: (Tuple[dict, dict, tuple, str)) `(evaluation_statistics, training_statistics, preprocessed_data, output_directory)` `evaluation_statistics` dictionary with evaluation performance statistics on the test_set, `training_statistics` is a nested dictionary of dataset -> feature_name -> metric_name -> List of metrics. Each metric corresponds to each training checkpoint. `preprocessed_data` tuple containing preprocessed `(training_set, validation_set, test_set)`, `output_directory` filepath string to where results are stored. """ if self._user_config.get(HYPEROPT): print_boxed("WARNING") logger.warning(HYPEROPT_WARNING) train_stats, preprocessed_data, output_directory = self.train( dataset=dataset, training_set=training_set, validation_set=validation_set, test_set=test_set, training_set_metadata=training_set_metadata, data_format=data_format, experiment_name=experiment_name, model_name=model_name, model_resume_path=model_resume_path, skip_save_training_description=skip_save_training_description, skip_save_training_statistics=skip_save_training_statistics, skip_save_model=skip_save_model, skip_save_progress=skip_save_progress, skip_save_log=skip_save_log, skip_save_processed_input=skip_save_processed_input, skip_save_unprocessed_output=skip_save_unprocessed_output, output_directory=output_directory, random_seed=random_seed, ) training_set, validation_set, test_set, training_set_metadata = preprocessed_data eval_set = validation_set if eval_split == TRAINING: eval_set = training_set elif eval_split == VALIDATION: eval_set = validation_set elif eval_split == TEST: eval_set = test_set else: logger.warning(f"Eval split {eval_split} not supported. " f"Using validation set instead") if eval_set is not None: trainer_dict = self.config_obj.trainer.to_dict() batch_size = trainer_dict.get(EVAL_BATCH_SIZE, trainer_dict.get(BATCH_SIZE, None)) # predict try: eval_stats, _, _ = self.evaluate( eval_set, data_format=data_format, batch_size=batch_size, output_directory=output_directory, skip_save_unprocessed_output=skip_save_unprocessed_output, skip_save_predictions=skip_save_predictions, skip_save_eval_stats=skip_save_eval_stats, collect_predictions=not skip_collect_predictions, collect_overall_stats=not skip_collect_overall_stats, return_type="dict", ) except NotImplementedError: logger.warning( "Skipping evaluation as the necessary methods are not " "supported. Full exception below:\n" f"{traceback.format_exc()}" ) eval_stats = None else: logger.warning(f"The evaluation set {eval_set} was not provided. " f"Skipping evaluation") eval_stats = None return eval_stats, train_stats, preprocessed_data, output_directory def collect_weights(self, tensor_names: list[str] = None, **kwargs) -> list: """Load a pre-trained model and collect the tensors with a specific name. # Inputs :param tensor_names: (list, default: `None`) List of tensor names to collect weights # Return :return: (list) List of tensors """ self._check_initialization() collected_tensors = self.model.collect_weights(tensor_names) return collected_tensors def collect_activations( self, layer_names: list[str], dataset: str | dict[str, list] | pd.DataFrame, data_format: str | None = None, split: str = FULL, batch_size: int = 128, **kwargs, ) -> list: """Loads a pre-trained model model and input data to collect the values of the activations contained in the tensors. # Inputs :param layer_names: (list) list of strings for layer names in the model to collect activations. :param dataset: (Union[str, Dict[str, list], pandas.DataFrame]) source containing the data to make predictions. :param data_format: (str, default: `None`) format to interpret data sources. Will be inferred automatically if not specified. Valid formats are `'auto'`, `'csv'`, `'df'`, `'dict'`, `'excel'`, `'feather'`, `'fwf'`, `'hdf5'` (cache file produced during previous training), `'html'` (file containing a single HTML `
`), `'json'`, `'jsonl'`, `'parquet'`, `'pickle'` (pickled Pandas DataFrame), `'sas'`, `'spss'`, `'stata'`, `'tsv'`. :param split: (str, default= `'full'`): if the input dataset contains a split column, this parameter indicates which split of the data to use. Possible values are `'full'`, `'training'`, `'validation'`, `'test'`. :param batch_size: (int, default: 128) size of batch to use when making predictions. # Return :return: (list) list of collected tensors. """ self._check_initialization() # preprocessing logger.debug("Preprocessing") dataset, training_set_metadata = preprocess_for_prediction( # TODO (Connor): Refactor to use self.config_obj self.config_obj.to_dict(), dataset=dataset, training_set_metadata=self.training_set_metadata, data_format=data_format, split=split, include_outputs=False, ) logger.debug("Predicting") with self.backend.create_predictor(self.model, batch_size=batch_size) as predictor: activations = predictor.batch_collect_activations( layer_names, dataset, ) return activations def preprocess( self, dataset: str | dict | pd.DataFrame | None = None, training_set: str | dict | pd.DataFrame | None = None, validation_set: str | dict | pd.DataFrame | None = None, test_set: str | dict | pd.DataFrame | None = None, training_set_metadata: str | dict | None = None, data_format: str | None = None, skip_save_processed_input: bool = True, random_seed: int = default_random_seed, **kwargs, ) -> PreprocessedDataset: """This function is used to preprocess data. # Args: :param dataset: (Union[str, dict, pandas.DataFrame], default: `None`) source containing the entire dataset to be used in the experiment. If it has a split column, it will be used for splitting (0 for train, 1 for validation, 2 for test), otherwise the dataset will be randomly split. :param training_set: (Union[str, dict, pandas.DataFrame], default: `None`) source containing training data. :param validation_set: (Union[str, dict, pandas.DataFrame], default: `None`) source containing validation data. :param test_set: (Union[str, dict, pandas.DataFrame], default: `None`) source containing test data. :param training_set_metadata: (Union[str, dict], default: `None`) metadata JSON file or loaded metadata. Intermediate preprocessed structure containing the mappings of the input dataset created the first time an input file is used in the same directory with the same name and a '.meta.json' extension. :param data_format: (str, default: `None`) format to interpret data sources. Will be inferred automatically if not specified. Valid formats are `'auto'`, `'csv'`, `'df'`, `'dict'`, `'excel'`, `'feather'`, `'fwf'`, `'hdf5'` (cache file produced during previous training), `'html'` (file containing a single HTML `
`), `'json'`, `'jsonl'`, `'parquet'`, `'pickle'` (pickled Pandas DataFrame), `'sas'`, `'spss'`, `'stata'`, `'tsv'`. :param skip_save_processed_input: (bool, default: `False`) if input dataset is provided it is preprocessed and cached by saving an HDF5 and JSON files to avoid running the preprocessing again. If this parameter is `False`, the HDF5 and JSON file are not saved. :param random_seed: (int, default: `42`) a random seed that will be used anywhere there is a call to a random number generator: data splitting, parameter initialization and training set shuffling # Returns: :return: (PreprocessedDataset) data structure containing `(proc_training_set, proc_validation_set, proc_test_set, training_set_metadata)`. # Raises: RuntimeError: An error occurred while preprocessing the data. Examples include training dataset being empty after preprocessing, lazy loading not being supported with RayBackend, etc. """ print_boxed("PREPROCESSING") for callback in self.callbacks: callback.on_preprocess_start(self.config_obj.to_dict()) preprocessing_params = get_preprocessing_params(self.config_obj) proc_training_set = proc_validation_set = proc_test_set = None try: with provision_preprocessing_workers(self.backend): # TODO (Connor): Refactor to use self.config_obj preprocessed_data = preprocess_for_training( self.config_obj.to_dict(), dataset=dataset, training_set=training_set, validation_set=validation_set, test_set=test_set, training_set_metadata=training_set_metadata, data_format=data_format, skip_save_processed_input=skip_save_processed_input, preprocessing_params=preprocessing_params, backend=self.backend, random_seed=random_seed, callbacks=self.callbacks, ) proc_training_set, proc_validation_set, proc_test_set, training_set_metadata = preprocessed_data return PreprocessedDataset(proc_training_set, proc_validation_set, proc_test_set, training_set_metadata) except Exception as e: raise RuntimeError(f"Caught exception during model preprocessing: {str(e)}") from e finally: for callback in self.callbacks: callback.on_preprocess_end(proc_training_set, proc_validation_set, proc_test_set, training_set_metadata) @staticmethod def load( model_dir: str, logging_level: int = logging.ERROR, backend: Backend | str | None = None, gpus: str | int | list[int] | None = None, gpu_memory_limit: float | None = None, allow_parallel_threads: bool = True, callbacks: list[Callback] = None, from_checkpoint: bool = False, ) -> "LudwigModel": # return is an instance of ludwig.api.LudwigModel class """This function allows for loading pretrained models. # Inputs :param model_dir: (str) path to the directory containing the model. If the model was trained by the `train` or `experiment` command, the model is in `results_dir/experiment_dir/model`. :param logging_level: (int, default: 40) log level that will be sent to stderr. :param backend: (Union[Backend, str]) `Backend` or string name of backend to use to execute preprocessing / training steps. :param gpus: (Union[str, int, List[int]], default: `None`) GPUs to use (it uses the same syntax of CUDA_VISIBLE_DEVICES) :param gpu_memory_limit: (float: default: `None`) maximum memory fraction [0, 1] allowed to allocate per GPU device. :param allow_parallel_threads: (bool, default: `True`) allow Torch to use multithreading parallelism to improve performance at the cost of determinism. :param callbacks: (list, default: `None`) a list of `ludwig.callbacks.Callback` objects that provide hooks into the Ludwig pipeline. :param from_checkpoint: (bool, default: `False`) if `True`, the model will be loaded from the latest checkpoint (training_checkpoints/) instead of the final model weights. # Return :return: (LudwigModel) a LudwigModel object # Example usage ```python ludwig_model = LudwigModel.load(model_dir) ``` """ # Initialize PyTorch before calling `broadcast()` to prevent initializing # Torch with default parameters backend_param = backend backend = initialize_backend(backend) backend.initialize_pytorch( gpus=gpus, gpu_memory_limit=gpu_memory_limit, allow_parallel_threads=allow_parallel_threads ) config = backend.broadcast_return(lambda: load_json(os.path.join(model_dir, MODEL_HYPERPARAMETERS_FILE_NAME))) # Upgrades deprecated fields and adds new required fields in case the config loaded from disk is old. config_obj = ModelConfig.from_dict(config) # Ensure that the original backend is used if it was specified in the config and user requests it if backend_param is None and "backend" in config: # Reset backend from config backend = initialize_backend(config.get("backend")) # initialize model ludwig_model = LudwigModel( config_obj.to_dict(), logging_level=logging_level, backend=backend, gpus=gpus, gpu_memory_limit=gpu_memory_limit, allow_parallel_threads=allow_parallel_threads, callbacks=callbacks, ) # generate model from config set_saved_weights_in_checkpoint_flag(config_obj) ludwig_model.model = LudwigModel.create_model(config_obj) # load model weights ludwig_model.load_weights(model_dir, from_checkpoint) # If merge_and_unload was NOT performed before saving (i.e., adapter weights exist), # we need to merge them now for inference. if ludwig_model.is_merge_and_unload_set(): weights_save_path = os.path.join(model_dir, MODEL_WEIGHTS_FILE_NAME) adapter_config_path = os.path.join(weights_save_path, "adapter_config.json") if os.path.exists(adapter_config_path): ludwig_model.model.merge_and_unload(progressbar=config_obj.adapter.postprocessor.progressbar) # load train set metadata ludwig_model.training_set_metadata = backend.broadcast_return( lambda: load_metadata(os.path.join(model_dir, TRAIN_SET_METADATA_FILE_NAME)) ) return ludwig_model def load_weights( self, model_dir: str, from_checkpoint: bool = False, ) -> None: """Loads weights from a pre-trained model. # Inputs :param model_dir: (str) filepath string to location of a pre-trained model :param from_checkpoint: (bool, default: `False`) if `True`, the model will be loaded from the latest checkpoint (training_checkpoints/) instead of the final model weights. # Return :return: `None` # Example usage ```python ludwig_model.load_weights(model_dir) ``` """ if self.backend.is_coordinator(): if from_checkpoint: with self.backend.create_trainer( model=self.model, config=self.config_obj.trainer, ) as trainer: checkpoint = trainer.create_checkpoint_handle() training_checkpoints_path = os.path.join(model_dir, TRAINING_CHECKPOINTS_DIR_PATH) trainer.resume_weights_and_optimizer(training_checkpoints_path, checkpoint) else: self.model.load(model_dir) self.backend.sync_model(self.model) def save(self, save_path: str) -> None: """This function allows to save models on disk. # Inputs :param save_path: (str) path to the directory where the model is going to be saved. Both a JSON file containing the model architecture hyperparameters and checkpoints files containing model weights will be saved. # Return :return: (None) `None` # Example usage ```python ludwig_model.save(save_path) ``` """ self._check_initialization() # save config self.save_config(save_path) # save model weights self.model.save(save_path) # save training set metadata training_set_metadata_path = os.path.join(save_path, TRAIN_SET_METADATA_FILE_NAME) save_json(training_set_metadata_path, self.training_set_metadata) @staticmethod def upload_to_hf_hub( repo_id: str, model_path: str, repo_type: str = "model", private: bool = False, commit_message: str = "Upload trained [Ludwig](https://ludwig.ai/latest/) model weights", commit_description: str | None = None, ) -> bool: """Uploads trained model artifacts to the HuggingFace Hub. # Inputs :param repo_id: (`str`) A namespace (user or an organization) and a repo name separated by a `/`. :param model_path: (`str`) The path of the saved model. This is either (a) the folder where the 'model_weights' folder and the 'model_hyperparameters.json' file are stored, or (b) the parent of that folder. :param private: (`bool`, *optional*, defaults to `False`) Whether the model repo should be private. :param repo_type: (`str`, *optional*) Set to `"dataset"` or `"space"` if uploading to a dataset or space, `None` or `"model"` if uploading to a model. Default is `None`. :param commit_message: (`str`, *optional*) The summary / title / first line of the generated commit. Defaults to: `f"Upload {path_in_repo} with huggingface_hub"` :param commit_description: (`str` *optional*) The description of the generated commit # Returns :return: (bool) True for success, False for failure. """ if os.path.exists(os.path.join(model_path, MODEL_FILE_NAME, MODEL_WEIGHTS_FILE_NAME)) and os.path.exists( os.path.join(model_path, MODEL_FILE_NAME, MODEL_HYPERPARAMETERS_FILE_NAME) ): experiment_path = model_path elif os.path.exists(os.path.join(model_path, MODEL_WEIGHTS_FILE_NAME)) and os.path.exists( os.path.join(model_path, MODEL_HYPERPARAMETERS_FILE_NAME) ): experiment_path = os.path.dirname(model_path) else: raise ValueError( f"Can't find 'model_weights' and '{MODEL_HYPERPARAMETERS_FILE_NAME}' either at " f"'{model_path}' or at '{model_path}/model'" ) model_service = get_upload_registry()["hf_hub"] hub: HuggingFaceHub = model_service() hub.login() upload_status: bool = hub.upload( repo_id=repo_id, model_path=experiment_path, repo_type=repo_type, private=private, commit_message=commit_message, commit_description=commit_description, ) return upload_status def save_config(self, save_path: str) -> None: """Save config to specified location. # Inputs :param save_path: (str) filepath string to save config as a JSON file. # Return :return: `None` """ os.makedirs(save_path, exist_ok=True) model_hyperparameters_path = os.path.join(save_path, MODEL_HYPERPARAMETERS_FILE_NAME) save_json(model_hyperparameters_path, self.config_obj.to_dict()) def to_torchscript( self, model_only: bool = False, device: TorchDevice | None = None, ): """Converts the trained model to Torchscript. # Inputs :param model_only (bool, optional): If True, only the ECD model will be converted to Torchscript. Else, preprocessing and postprocessing steps will also be converted to Torchscript. :param device (TorchDevice, optional): If None, the model will be converted to Torchscript on the same device to ensure maximum model parity. # Returns :return: A torch.jit.ScriptModule that can be used to predict on a dictionary of inputs. """ if device is None: device = DEVICE self._check_initialization() if model_only: return self.model.to_torchscript(device) else: inference_module = InferenceModule.from_ludwig_model( self.model, self.config_obj.to_dict(), self.training_set_metadata, device=device ) return torch.jit.script(inference_module) def save_torchscript( self, save_path: str, model_only: bool = False, device: TorchDevice | None = None, ): """Saves the Torchscript model to disk. # Inputs :param save_path (str): The path to the directory where the model will be saved. :param model_only (bool, optional): If True, only the ECD model will be converted to Torchscript. Else, the preprocessing and postprocessing steps will also be converted to Torchscript. :param device (TorchDevice, optional): If None, the model will be converted to Torchscript on the same device to ensure maximum model parity. # Return :return: `None` """ if device is None: device = DEVICE save_ludwig_model_for_inference( save_path, self.model, self.config_obj.to_dict(), self.training_set_metadata, model_only=model_only, device=device, ) def _check_initialization(self): if self.model is None or self._user_config is None or self.training_set_metadata is None: raise ValueError("Model has not been trained or loaded") def free_gpu_memory(self): """Manually moves the model to CPU to force GPU memory to be freed. For more context: https://discuss.pytorch.org/t/how-can-we-release-gpu-memory-cache/14530/35 """ if torch.cuda.is_available(): self.model.model.to(torch.device("cpu")) torch.cuda.empty_cache() @staticmethod def create_model(config_obj: ModelConfig | dict, random_seed: int = default_random_seed) -> BaseModel: """Instantiates BaseModel object. # Inputs :param config_obj: (Union[Config, dict]) Ludwig config object :param random_seed: (int, default: ludwig default random seed) Random seed used for weights initialization, splits and any other random function. # Return :return: (ludwig.models.BaseModel) Instance of the Ludwig model object. """ if isinstance(config_obj, dict): config_obj = ModelConfig.from_dict(config_obj) model_type = get_from_registry(config_obj.model_type, model_type_registry) return model_type(config_obj, random_seed=random_seed) @staticmethod def set_logging_level(logging_level: int) -> None: """Sets level for log messages. # Inputs :param logging_level: (int) Set/Update the logging level. Use logging constants like `logging.DEBUG` , `logging.INFO` and `logging.ERROR`. # Return :return: `None` """ logging.getLogger("ludwig").setLevel(logging_level) if logging_level in {logging.WARNING, logging.ERROR, logging.CRITICAL}: set_disable_progressbar(True) else: set_disable_progressbar(False) @property def config(self) -> ModelConfigDict: """Returns the fully-rendered config of this model including default values.""" return self.config_obj.to_dict() @config.setter def config(self, user_config: ModelConfigDict): """Updates the config of this model. WARNING: this can have unexpected results on an already trained model. """ self._user_config = user_config self.config_obj = ModelConfig.from_dict(self._user_config) def is_merge_and_unload_set(self) -> bool: """Check whether the encapsulated model is of type LLM and is configured to merge_and_unload QLoRA weights. # Return :return (bool): whether merge_and_unload should be done. """ # TODO: In the future, it may be possible to move up the model type check into the BaseModel class. return self.config_obj.model_type == MODEL_LLM and self.model.is_merge_and_unload_set() @PublicAPI def kfold_cross_validate( num_folds: int, config: dict | str, dataset: str = None, data_format: str = None, skip_save_training_description: bool = False, skip_save_training_statistics: bool = False, skip_save_model: bool = False, skip_save_progress: bool = False, skip_save_log: bool = False, skip_save_processed_input: bool = False, skip_save_predictions: bool = False, skip_save_eval_stats: bool = False, skip_collect_predictions: bool = False, skip_collect_overall_stats: bool = False, output_directory: str = "results", random_seed: int = default_random_seed, gpus: str | int | list[int] | None = None, gpu_memory_limit: float | None = None, allow_parallel_threads: bool = True, backend: Backend | str | None = None, logging_level: int = logging.INFO, **kwargs, ) -> tuple[dict, dict]: """Performs k-fold cross validation and returns result data structures. # Inputs :param num_folds: (int) number of folds to create for the cross-validation :param config: (Union[dict, str]) model specification required to build a model. Parameter may be a dictionary or string specifying the file path to a yaml configuration file. Refer to the [User Guide](http://ludwig.ai/user_guide/#model-config) for details. :param dataset: (Union[str, dict, pandas.DataFrame], default: `None`) source containing the entire dataset to be used for k_fold processing. :param data_format: (str, default: `None`) format to interpret data sources. Will be inferred automatically if not specified. Valid formats are `'auto'`, `'csv'`, `'df'`, `'dict'`, `'excel'`, `'feather'`, `'fwf'`, `'html'` (file containing a single HTML `
`), `'json'`, `'jsonl'`, `'parquet'`, `'pickle'` (pickled Pandas DataFrame), `'sas'`, `'spss'`, `'stata'`, `'tsv'`. Currently `hdf5` format is not supported for k_fold cross validation. :param skip_save_training_description: (bool, default: `False`) disables saving the description JSON file. :param skip_save_training_statistics: (bool, default: `False`) disables saving training statistics JSON file. :param skip_save_model: (bool, default: `False`) disables saving model weights and hyperparameters each time the model improves. By default Ludwig saves model weights after each epoch the validation metric improves, but if the model is really big that can be time consuming. If you do not want to keep the weights and just find out what performance a model can get with a set of hyperparameters, use this parameter to skip it, but the model will not be loadable later on and the returned model will have the weights obtained at the end of training, instead of the weights of the epoch with the best validation performance. :param skip_save_progress: (bool, default: `False`) disables saving progress each epoch. By default Ludwig saves weights and stats after each epoch for enabling resuming of training, but if the model is really big that can be time consuming and will uses twice as much space, use this parameter to skip it, but training cannot be resumed later on. :param skip_save_log: (bool, default: `False`) disables saving TensorBoard logs. By default Ludwig saves logs for the TensorBoard, but if it is not needed turning it off can slightly increase the overall speed. :param skip_save_processed_input: (bool, default: `False`) if input dataset is provided it is preprocessed and cached by saving an HDF5 and JSON files to avoid running the preprocessing again. If this parameter is `False`, the HDF5 and JSON file are not saved. :param skip_save_predictions: (bool, default: `False`) skips saving test predictions CSV files. :param skip_save_eval_stats: (bool, default: `False`) skips saving test statistics JSON file. :param skip_collect_predictions: (bool, default: `False`) skips collecting post-processed predictions during eval. :param skip_collect_overall_stats: (bool, default: `False`) skips collecting overall stats during eval. :param output_directory: (str, default: `'results'`) the directory that will contain the training statistics, TensorBoard logs, the saved model and the training progress files. :param random_seed: (int, default: `42`) Random seed used for weights initialization, splits and any other random function. :param gpus: (list, default: `None`) list of GPUs that are available for training. :param gpu_memory_limit: (float: default: `None`) maximum memory fraction [0, 1] allowed to allocate per GPU device. :param allow_parallel_threads: (bool, default: `True`) allow Torch to use multithreading parallelism to improve performance at the cost of determinism. :param backend: (Union[Backend, str]) `Backend` or string name of backend to use to execute preprocessing / training steps. :param logging_level: (int, default: INFO) log level to send to stderr. # Return :return: (tuple(kfold_cv_statistics, kfold_split_indices), dict) a tuple of dictionaries `kfold_cv_statistics`: contains metrics from cv run. `kfold_split_indices`: indices to split training data into training fold and test fold. """ # if config is a path, convert to dictionary if isinstance(config, str): # assume path config = load_yaml(config) backend = initialize_backend(backend or config.get("backend")) # check for k_fold if num_folds is None: raise ValueError("k_fold parameter must be specified") logger.info(f"starting {num_folds:d}-fold cross validation") # create output_directory if not available if not os.path.isdir(output_directory): os.mkdir(output_directory) # prepare data for k-fold processing # use Ludwig's utility to facilitate creating a dataframe # that is used as the basis for creating folds dataset, _, _, _ = load_dataset_uris(dataset, None, None, None, backend) # determine data format of provided dataset if not data_format or data_format == "auto": data_format = figure_data_format(dataset) data_df = load_dataset(dataset, data_format=data_format, df_lib=backend.df_engine.df_lib) kfold_cv_stats = {} kfold_split_indices = {} for train_indices, test_indices, fold_num in generate_kfold_splits(data_df, num_folds, random_seed): with tempfile.TemporaryDirectory() as temp_dir_name: curr_train_df = data_df.iloc[train_indices] curr_test_df = data_df.iloc[test_indices] kfold_split_indices["fold_" + str(fold_num)] = { "training_indices": train_indices, "test_indices": test_indices, } # train and validate model on this fold logger.info(f"training on fold {fold_num:d}") model = LudwigModel( config=config, logging_level=logging_level, backend=backend, gpus=gpus, gpu_memory_limit=gpu_memory_limit, allow_parallel_threads=allow_parallel_threads, ) eval_stats, train_stats, preprocessed_data, output_directory = model.experiment( training_set=curr_train_df, test_set=curr_test_df, experiment_name="cross_validation", model_name="fold_" + str(fold_num), skip_save_training_description=skip_save_training_description, skip_save_training_statistics=skip_save_training_statistics, skip_save_model=skip_save_model, skip_save_progress=skip_save_progress, skip_save_log=skip_save_log, skip_save_processed_input=skip_save_processed_input, skip_save_predictions=skip_save_predictions, skip_save_eval_stats=skip_save_eval_stats, skip_collect_predictions=skip_collect_predictions, skip_collect_overall_stats=skip_collect_overall_stats, output_directory=os.path.join(temp_dir_name, "results"), random_seed=random_seed, ) # augment the training statistics with scoring metric from # the hold out fold if dataclasses.is_dataclass(train_stats): train_stats_dict = dataclasses.asdict(train_stats) elif hasattr(train_stats, "to_dict"): train_stats_dict = train_stats.to_dict() else: train_stats_dict = vars(train_stats) train_stats_dict["fold_eval_stats"] = eval_stats # collect training statistics for this fold kfold_cv_stats["fold_" + str(fold_num)] = train_stats_dict # consolidate raw fold metrics across all folds raw_kfold_stats = {} for fold_name in kfold_cv_stats: curr_fold_eval_stats = kfold_cv_stats[fold_name]["fold_eval_stats"] for of_name in curr_fold_eval_stats: if of_name not in raw_kfold_stats: raw_kfold_stats[of_name] = {} fold_eval_stats_of = curr_fold_eval_stats[of_name] for metric in fold_eval_stats_of: if metric not in { "predictions", "probabilities", "confusion_matrix", "overall_stats", "per_class_stats", "roc_curve", "precision_recall_curve", }: if metric not in raw_kfold_stats[of_name]: raw_kfold_stats[of_name][metric] = [] raw_kfold_stats[of_name][metric].append(fold_eval_stats_of[metric]) # calculate overall kfold statistics overall_kfold_stats = {} for of_name in raw_kfold_stats: overall_kfold_stats[of_name] = {} for metric in raw_kfold_stats[of_name]: mean = np.mean(raw_kfold_stats[of_name][metric]) std = np.std(raw_kfold_stats[of_name][metric]) overall_kfold_stats[of_name][metric + "_mean"] = mean overall_kfold_stats[of_name][metric + "_std"] = std kfold_cv_stats["overall"] = overall_kfold_stats logger.info(f"completed {num_folds:d}-fold cross validation") return kfold_cv_stats, kfold_split_indices def _get_compute_description(backend) -> dict: """Returns the compute description for the backend.""" compute_description = {"num_nodes": backend.num_nodes} if torch.cuda.is_available(): # Assumption: All nodes are of the same instance type. # TODO: fix for Ray where workers may be of different skus compute_description.update( { "gpus_per_node": torch.cuda.device_count(), "arch_list": torch.cuda.get_arch_list(), "gencode_flags": torch.cuda.get_gencode_flags(), "devices": {}, } ) for i in range(torch.cuda.device_count()): compute_description["devices"][i] = { "gpu_type": torch.cuda.get_device_name(i), "device_capability": torch.cuda.get_device_capability(i), "device_properties": str(torch.cuda.get_device_properties(i)), } return compute_description @PublicAPI def get_experiment_description( config, dataset=None, training_set=None, validation_set=None, test_set=None, training_set_metadata=None, data_format=None, backend=None, random_seed=None, ): description = OrderedDict() description["ludwig_version"] = LUDWIG_VERSION description["command"] = " ".join(sys.argv) commit_hash = get_commit_hash() if commit_hash is not None: description["commit_hash"] = commit_hash[:12] if random_seed is not None: description["random_seed"] = random_seed if isinstance(dataset, str): description["dataset"] = dataset if isinstance(training_set, str): description["training_set"] = training_set if isinstance(validation_set, str): description["validation_set"] = validation_set if isinstance(test_set, str): description["test_set"] = test_set if training_set_metadata is not None: description["training_set_metadata"] = training_set_metadata # determine data format if not provided or auto if not data_format or data_format == "auto": data_format = figure_data_format(dataset, training_set, validation_set, test_set) if data_format: description["data_format"] = str(data_format) description["config"] = config description["torch_version"] = torch.__version__ description["compute"] = _get_compute_description(backend) return description ================================================ FILE: ludwig/api_annotations.py ================================================ def PublicAPI(*args, **kwargs): """Annotation for documenting public APIs. Public APIs are classes and methods exposed to end users of Ludwig. If stability="stable", the APIs will remain backwards compatible across minor Ludwig releases (e.g., Ludwig 0.6 -> Ludwig 0.7). If stability="experimental", the APIs can be used by advanced users who are tolerant to and expect breaking changes. This will likely be seen in the case of incremental new feature development. Args: stability: One of {"stable", "experimental"} Examples: >>> from api_annotations import PublicAPI >>> @PublicAPI ... def func1(x): ... return x >>> @PublicAPI(stability="experimental") ... def func2(y): ... return y """ if len(args) == 1 and len(kwargs) == 0 and callable(args[0]): return PublicAPI(stability="stable")(args[0]) if "stability" in kwargs: stability = kwargs["stability"] assert stability in ["stable", "experimental"], stability elif kwargs: raise ValueError(f"Unknown kwargs: {kwargs.keys()}") else: stability = "stable" def wrap(obj): if stability == "experimental": message = f"PublicAPI ({stability}): This API is {stability} and may change before becoming stable." else: message = "PublicAPI: This API is stable across Ludwig releases." _append_doc(obj, message=message) _mark_annotated(obj) return obj return wrap def DeveloperAPI(*args, **kwargs): """Annotation for documenting developer APIs. Developer APIs are lower-level methods explicitly exposed to advanced Ludwig users and library developers. Their interfaces may change across minor Ludwig releases (for e.g., Ludwig 0.6.1 and Ludwig 0.6.2). Examples: >>> from api_annotations import DeveloperAPI >>> @DeveloperAPI ... def func(x): ... return x """ if len(args) == 1 and len(kwargs) == 0 and callable(args[0]): return DeveloperAPI()(args[0]) def wrap(obj): _append_doc(obj, message="DeveloperAPI: This API may change across minor Ludwig releases.") _mark_annotated(obj) return obj return wrap def Deprecated(*args, **kwargs): """Annotation for documenting a deprecated API. Deprecated APIs may be removed in future releases of Ludwig (e.g., Ludwig 0.7 to Ludwig 0.8). Args: message: A message to help users understand the reason for the deprecation, and provide a migration path. Examples: >>> from api_annotations import Deprecated >>> @Deprecated ... def func(x): ... return x >>> @Deprecated(message="g() is deprecated because the API is error prone. Please call h() instead.") ... def g(y): ... return y """ if len(args) == 1 and len(kwargs) == 0 and callable(args[0]): return Deprecated()(args[0]) message = "**DEPRECATED:** This API is deprecated and may be removed in a future Ludwig release." if "message" in kwargs: message += " " + kwargs["message"] del kwargs["message"] if kwargs: raise ValueError(f"Unknown kwargs: {kwargs.keys()}") def inner(obj): _append_doc(obj, message=message, directive="warning") _mark_annotated(obj) return obj return inner def _append_doc(obj, message: str, directive: str | None = None) -> str: """ Args: message: An additional message to append to the end of docstring for a class or method that uses one of the API annotations directive: A shorter message that provides contexts for the message and indents it. For example, this could be something like 'warning' or 'info'. """ if not obj.__doc__: obj.__doc__ = "" obj.__doc__ = obj.__doc__.rstrip() indent = _get_indent(obj.__doc__) obj.__doc__ += "\n\n" if directive is not None: obj.__doc__ += f"{' ' * indent}.. {directive}::\n" obj.__doc__ += f"{' ' * (indent + 4)}{message}" else: obj.__doc__ += f"{' ' * indent}{message}" obj.__doc__ += f"\n{' ' * indent}" def _mark_annotated(obj) -> None: # Set magic token for check_api_annotations linter. if hasattr(obj, "__name__"): obj._annotated = obj.__name__ def _is_annotated(obj) -> bool: # Check the magic token exists and applies to this class (not a subclass). return hasattr(obj, "_annotated") and obj._annotated == obj.__name__ def _get_indent(docstring: str) -> int: """ Example: >>> def f(): ... '''Docstring summary.''' >>> f.__doc__ 'Docstring summary.' >>> _get_indent(f.__doc__) 0 >>> def g(foo): ... '''Docstring summary. ... ... Args: ... foo: Does bar. ... ''' >>> g.__doc__ 'Docstring summary.\\n\\n Args:\\n foo: Does bar.\\n ' >>> _get_indent(g.__doc__) 4 >>> class A: ... def h(): ... '''Docstring summary. ... ... Returns: ... None. ... ''' >>> A.h.__doc__ 'Docstring summary.\\n\\n Returns:\\n None.\\n ' >>> _get_indent(A.h.__doc__) 8 """ if not docstring: return 0 non_empty_lines = list(filter(bool, docstring.splitlines())) if len(non_empty_lines) == 1: # Docstring contains summary only. return 0 # The docstring summary isn't indented, so check the indentation of the second non-empty line. return len(non_empty_lines[1]) - len(non_empty_lines[1].lstrip()) ================================================ FILE: ludwig/automl/__init__.py ================================================ from ludwig.automl.automl import auto_train # noqa from ludwig.automl.automl import cli_init_config # noqa from ludwig.automl.automl import create_auto_config # noqa from ludwig.automl.automl import train_with_config # noqa; noqa ================================================ FILE: ludwig/automl/auto_tune_config.py ================================================ import copy import logging import math from collections import OrderedDict import psutil try: import GPUtil except ImportError: raise ImportError("GPUtil is not installed. In order to use auto_train please run pip install ludwig[ray]") from ludwig.api import LudwigModel from ludwig.backend import initialize_backend from ludwig.constants import ( AUTO, AUTOML_DEFAULT_TEXT_ENCODER, AUTOML_LARGE_TEXT_DATASET, AUTOML_MAX_ROWS_PER_CHECKPOINT, AUTOML_SMALLER_TEXT_ENCODER, AUTOML_SMALLER_TEXT_LENGTH, AUTOML_TEXT_ENCODER_MAX_TOKEN_LEN, HYPEROPT, MINIMUM_BATCH_SIZE, PREPROCESSING, SPACE, TEXT, TRAINER, ) from ludwig.data.preprocessing import preprocess_for_training from ludwig.features.feature_registries import update_config_with_metadata from ludwig.schema.model_config import ModelConfig from ludwig.utils.automl.utils import get_model_type from ludwig.utils.torch_utils import initialize_pytorch logger = logging.getLogger(__name__) # maps variable search space that can be modified to minimum permissible value for the range RANKED_MODIFIABLE_PARAM_LIST = { "tabnet": OrderedDict( { "trainer.batch_size": 32, "combiner.size": 8, "combiner.output_size": 8, } ), "concat": OrderedDict( { "trainer.batch_size": 32, "combiner.output_size": 64, "combiner.num_fc_layers": 1, } ), "tabtransformer": OrderedDict( { "trainer.batch_size": 32, "combiner.num_heads:": 4, "combiner.output_size": 8, "combiner.num_layers": 4, "combiner.num_fc_layers": 1, } ), "text": OrderedDict( # for single input feature text models e.g. bert and its variants { "trainer.batch_size": 16, } ), } BYTES_PER_MiB = 1048576 BYTES_PER_WEIGHT = 4 # assumes 32-bit precision = 4 bytes BYTES_OPTIMIZER_PER_WEIGHT = 8 # for optimizer m and v vectors def get_trainingset_metadata(config, dataset, backend): _, _, _, training_set_metadata = preprocess_for_training( config, dataset=dataset, preprocessing_params=config[PREPROCESSING], backend=backend ) return training_set_metadata # Note: if run in Ray Cluster, this method is run remote with gpu resources requested if available def _get_machine_memory(): if GPUtil.getGPUs(): machine_mem = GPUtil.getGPUs()[0].memoryTotal * BYTES_PER_MiB else: machine_mem = psutil.virtual_memory().total return machine_mem def _get_text_feature_max_length(config, training_set_metadata) -> int: """Returns max sequence length over text features, subject to preprocessing limit.""" max_length = 0 for feature in config["input_features"]: if feature["type"] == TEXT: feature_max_len = training_set_metadata[feature["name"]]["max_sequence_length"] if feature_max_len > max_length: max_length = feature_max_len if ( ("preprocessing" in config) and (TEXT in config["preprocessing"]) and ("max_sequence_length" in config["preprocessing"][TEXT]) ): limit = config["preprocessing"][TEXT]["max_sequence_length"] else: limit = 256 # Preprocessing default max_sequence_length = 256 if max_length > limit + 2: # For start and stop symbols. max_length = limit + 2 return max_length def _get_text_model_memory_usage(config, training_set_metadata, memory_usage) -> int: max_feature_token_length = _get_text_feature_max_length(config, training_set_metadata) memory_usage = (memory_usage / AUTOML_TEXT_ENCODER_MAX_TOKEN_LEN) * max_feature_token_length return memory_usage def compute_memory_usage(config_obj, training_set_metadata, model_category) -> int: update_config_with_metadata(config_obj, training_set_metadata) lm = LudwigModel.create_model(config_obj) model_size = lm.get_model_size() # number of parameters in model batch_size = config_obj.trainer.batch_size if batch_size == AUTO: # Smallest valid batch size that will allow training to complete batch_size = MINIMUM_BATCH_SIZE memory_usage = model_size * (BYTES_PER_WEIGHT + BYTES_OPTIMIZER_PER_WEIGHT) * batch_size if model_category == TEXT: return _get_text_model_memory_usage(config_obj.to_dict(), training_set_metadata, memory_usage) else: return memory_usage def sub_new_params(config: dict, new_param_vals: dict): new_config = copy.deepcopy(config) for param, val in new_param_vals.items(): config_section = param.split(".")[0] param_name = param.split(".")[1] new_config[config_section][param_name] = val return new_config def get_new_params(current_param_values, hyperparam_search_space, params_to_modify): for param, _ in params_to_modify.items(): if param in hyperparam_search_space: if hyperparam_search_space[param][SPACE] == "choice": current_param_values[param] = hyperparam_search_space[param]["categories"][-1] else: current_param_values[param] = hyperparam_search_space[param]["upper"] return current_param_values def _update_text_encoder(input_features: list, old_text_encoder: str, new_text_encoder: str) -> None: for feature in input_features: if feature["type"] == TEXT and feature["encoder"] == old_text_encoder: feature["encoder"] = new_text_encoder def _get_text_feature_min_usable_length(input_features: list, training_set_metadata) -> int: """Returns min of AUTOML_SMALLER_TEXT_LENGTH and lowest 99th percentile sequence length over text features.""" min_usable_length = AUTOML_SMALLER_TEXT_LENGTH for feature in input_features: if feature["type"] == TEXT: feature_99ptile_len = training_set_metadata[feature["name"]]["max_sequence_length_99ptile"] if feature_99ptile_len < min_usable_length: min_usable_length = feature_99ptile_len return round(min_usable_length) def reduce_text_feature_max_length(config, training_set_metadata) -> bool: """Reduce max sequence length, when viable, to control its quadratic impact.""" input_features = config["input_features"] min_usable_length = _get_text_feature_min_usable_length(input_features, training_set_metadata) seq_len_limit = {"max_sequence_length": min_usable_length} if "preprocessing" not in config: config["preprocessing"] = {TEXT: seq_len_limit} elif ( (TEXT not in config["preprocessing"]) or ("max_sequence_length" not in config["preprocessing"][TEXT]) or (min_usable_length < float(config["preprocessing"][TEXT]["max_sequence_length"])) ): config["preprocessing"][TEXT] = seq_len_limit else: return False return True # For hyperparam_search_space comprised solely of choice spaces, compute maximum number of # combinations and return that value if it is less than num_samples; else return num_samples. def _update_num_samples(num_samples, hyperparam_search_space): max_num_samples = 1 for param in hyperparam_search_space.keys(): if hyperparam_search_space[param][SPACE] == "choice": max_num_samples *= len(hyperparam_search_space[param]["categories"]) else: return num_samples if max_num_samples < num_samples: return max_num_samples return num_samples # Note: if run in Ray Cluster, this method is run remote with gpu resources requested if available def memory_tune_config(config, dataset, model_category, row_count, backend): backend = initialize_backend(backend) fits_in_memory = False tried_reduce_seq_len = False config_obj = ModelConfig.from_dict(config) raw_config = config_obj.to_dict() training_set_metadata = get_trainingset_metadata(raw_config, dataset, backend) modified_hyperparam_search_space = copy.deepcopy(raw_config[HYPEROPT]["parameters"]) current_param_values = {} param_list = [] model_type = get_model_type(raw_config) if model_type in RANKED_MODIFIABLE_PARAM_LIST: params_to_modify = RANKED_MODIFIABLE_PARAM_LIST[model_type] if len(params_to_modify.keys()) > 0: param_list = list(params_to_modify.keys()) max_memory = _get_machine_memory() initialize_pytorch() while param_list: # compute memory utilization current_param_values = get_new_params(current_param_values, modified_hyperparam_search_space, params_to_modify) temp_config = sub_new_params(raw_config, current_param_values) config_obj = ModelConfig.from_dict(temp_config) mem_use = compute_memory_usage(config_obj, training_set_metadata, model_category) if mem_use > max_memory and model_category == TEXT and not tried_reduce_seq_len: tried_reduce_seq_len = True if reduce_text_feature_max_length(config, training_set_metadata): reduce_text_feature_max_length(temp_config, training_set_metadata) config_obj = ModelConfig.from_dict(temp_config) mem_use = compute_memory_usage(config_obj, training_set_metadata, model_category) logger.info(f"Checking model estimated mem use {mem_use} against memory size {max_memory}") if mem_use <= max_memory: fits_in_memory = True break # check if we have exhausted tuning of current param (e.g. we can no longer reduce the param value) param, min_value = param_list[0], params_to_modify[param_list[0]] if param in modified_hyperparam_search_space.keys(): param_space = modified_hyperparam_search_space[param]["space"] if param_space == "choice": if ( len(modified_hyperparam_search_space[param]["categories"]) >= 2 and modified_hyperparam_search_space[param]["categories"][-2] >= min_value ): modified_hyperparam_search_space[param]["categories"] = modified_hyperparam_search_space[param][ "categories" ][:-1] else: param_list.pop(0) # exhausted reduction of this parameter else: # reduce by 10% upper_bound, lower_bound = ( modified_hyperparam_search_space[param]["upper"], modified_hyperparam_search_space[param]["lower"], ) reduction_val = (upper_bound - lower_bound) * 0.1 new_upper_bound = upper_bound - reduction_val if (new_upper_bound) > lower_bound and new_upper_bound > min_value: modified_hyperparam_search_space[param]["upper"] = new_upper_bound else: param_list.pop(0) # exhausted reduction of this parameter else: param_list.pop(0) # param not in hyperopt search space if model_category == TEXT and row_count > AUTOML_LARGE_TEXT_DATASET: if "checkpoints_per_epoch" not in config[TRAINER] and "steps_per_checkpoint" not in config[TRAINER]: checkpoints_per_epoch = max(2, math.floor(row_count / AUTOML_MAX_ROWS_PER_CHECKPOINT)) config[TRAINER][ "checkpoints_per_epoch" ] = checkpoints_per_epoch # decrease latency to get model accuracy signal if "evaluate_training_set" not in config[TRAINER]: config[TRAINER]["evaluate_training_set"] = False # reduce overhead for increased evaluation frequency if not fits_in_memory: # Switch to smaller pre-trained model encoder for large datasets. _update_text_encoder(config["input_features"], AUTOML_DEFAULT_TEXT_ENCODER, AUTOML_SMALLER_TEXT_ENCODER) modified_config = copy.deepcopy(config) modified_config[HYPEROPT]["parameters"] = modified_hyperparam_search_space modified_config[HYPEROPT]["executor"]["num_samples"] = _update_num_samples( modified_config[HYPEROPT]["executor"]["num_samples"], modified_hyperparam_search_space ) return modified_config, fits_in_memory ================================================ FILE: ludwig/automl/automl.py ================================================ """automl.py. Driver script which: (1) Builds a base config by performing type inference and populating config w/default combiner parameters, training parameters, and hyperopt search space (2) Tunes config based on resource constraints (3) Runs hyperparameter optimization experiment """ import argparse import copy import logging import os import warnings from typing import Any import numpy as np import pandas as pd import yaml from ludwig.api import LudwigModel from ludwig.api_annotations import PublicAPI from ludwig.automl.base_config import ( create_default_config, DatasetInfo, get_dataset_info, get_features_config, get_reference_configs, ) from ludwig.backend import Backend, initialize_backend from ludwig.constants import ( AUTO, AUTOML_DEFAULT_IMAGE_ENCODER, AUTOML_DEFAULT_TABULAR_MODEL, AUTOML_DEFAULT_TEXT_ENCODER, BINARY, CATEGORY, ENCODER, HYPEROPT, IMAGE, INPUT_FEATURES, NAME, NUMBER, OUTPUT_FEATURES, TABULAR, TEXT, TRAINER, TYPE, ) from ludwig.contrib import add_contrib_callback_args from ludwig.data.cache.types import CacheableDataset from ludwig.datasets import load_dataset_uris from ludwig.globals import LUDWIG_VERSION, MODEL_FILE_NAME from ludwig.hyperopt.run import hyperopt from ludwig.schema.model_config import ModelConfig from ludwig.types import ModelConfigDict from ludwig.utils.automl.ray_utils import _ray_init from ludwig.utils.automl.utils import _add_transfer_config, get_model_type, set_output_feature_metric from ludwig.utils.data_utils import load_dataset, use_credentials from ludwig.utils.defaults import default_random_seed from ludwig.utils.fs_utils import open_file from ludwig.utils.heuristics import get_auto_learning_rate from ludwig.utils.misc_utils import merge_dict from ludwig.utils.print_utils import print_ludwig try: import dask.dataframe as dd from ray.tune import ExperimentAnalysis except ImportError as e: raise RuntimeError("ray is not installed. In order to use auto_train please run pip install ludwig[ray]") from e logger = logging.getLogger(__name__) OUTPUT_DIR = "." TABULAR_TYPES = {CATEGORY, NUMBER, BINARY} class AutoTrainResults: def __init__(self, experiment_analysis: ExperimentAnalysis, creds: dict[str, Any] = None): self._experiment_analysis = experiment_analysis self._creds = creds @property def experiment_analysis(self): return self._experiment_analysis @property def best_trial_id(self) -> str: return self._experiment_analysis.best_trial.trial_id @property def best_model(self) -> LudwigModel | None: checkpoint = self._experiment_analysis.best_checkpoint if checkpoint is None: logger.warning("No best model found") return None # Use credentials context for remote checkpoints that may need custom auth with use_credentials(self._creds): with checkpoint.as_directory() as ckpt_path: model_dir = os.path.join(ckpt_path, MODEL_FILE_NAME) if not os.path.isdir(model_dir): logger.warning( f"Best checkpoint does not contain model files at {model_dir}. " "The trial may not have completed a full training epoch." ) return None # Ray Tune checkpoints contain training_checkpoints/ (from # mid-training saves) but not model_weights (only saved after # training completes). Load from the training checkpoint. return LudwigModel.load(model_dir, from_checkpoint=True) @PublicAPI def auto_train( dataset: str | pd.DataFrame | dd.DataFrame, target: str, time_limit_s: int | float, output_directory: str = OUTPUT_DIR, tune_for_memory: bool = False, user_config: dict = None, random_seed: int = default_random_seed, use_reference_config: bool = False, **kwargs, ) -> AutoTrainResults: """Main auto train API that first builds configs for each model type (e.g. concat, tabnet, transformer). Then selects model based on dataset attributes. And finally runs a hyperparameter optimization experiment. All batch and learning rate tuning is done @ training time. # Inputs :param dataset: (str, pd.DataFrame, dd.DataFrame) data source to train over. :param target: (str) name of target feature :param time_limit_s: (int, float) total time allocated to auto_train. acts as the stopping parameter :param output_directory: (str) directory into which to write results, defaults to current working directory. :param tune_for_memory: (bool) refine hyperopt search space for available host / GPU memory :param user_config: (dict) override automatic selection of specified config items :param random_seed: (int, default: `42`) a random seed that will be used anywhere there is a call to a random number generator, including hyperparameter search sampling, as well as data splitting, parameter initialization and training set shuffling :param use_reference_config: (bool) refine hyperopt search space by setting first search point from reference model config, if any :param kwargs: additional keyword args passed down to `ludwig.hyperopt.run.hyperopt`. # Returns :return: (AutoTrainResults) results containing hyperopt experiments and best model """ config = create_auto_config( dataset, target, time_limit_s, tune_for_memory, user_config, random_seed, use_reference_config=use_reference_config, ) return train_with_config(dataset, config, output_directory=output_directory, random_seed=random_seed, **kwargs) @PublicAPI def create_auto_config( dataset: str | pd.DataFrame | dd.DataFrame | DatasetInfo, target: str | list[str], time_limit_s: int | float, tune_for_memory: bool = False, user_config: dict = None, random_seed: int = default_random_seed, imbalance_threshold: float = 0.9, use_reference_config: bool = False, backend: Backend | str = None, ) -> ModelConfigDict: """Returns an auto-generated Ludwig config with the intent of training the best model on given given dataset / target in the given time limit. # Inputs :param dataset: (str, pd.DataFrame, dd.DataFrame, DatasetInfo) data source to train over. :param target: (str, List[str]) name of target feature :param time_limit_s: (int, float) total time allocated to auto_train. acts as the stopping parameter :param tune_for_memory: (bool) DEPRECATED refine hyperopt search space for available host / GPU memory :param user_config: (dict) override automatic selection of specified config items :param random_seed: (int, default: `42`) a random seed that will be used anywhere there is a call to a random number generator, including hyperparameter search sampling, as well as data splitting, parameter initialization and training set shuffling :param imbalance_threshold: (float) maximum imbalance ratio (minority / majority) to perform stratified sampling :param use_reference_config: (bool) refine hyperopt search space by setting first search point from reference model config, if any # Return :return: (dict) selected model configuration """ backend = initialize_backend(backend) if not isinstance(dataset, DatasetInfo): # preload ludwig datasets dataset, _, _, _ = load_dataset_uris(dataset, None, None, None, backend) if isinstance(dataset, CacheableDataset): dataset = dataset.unwrap() dataset = load_dataset(dataset, df_lib=backend.df_engine.df_lib) dataset_info = get_dataset_info(dataset) if not isinstance(dataset, DatasetInfo) else dataset features_config = create_features_config(dataset_info, target) return create_automl_config_for_features( features_config, dataset_info, target, time_limit_s=time_limit_s, user_config=user_config, random_seed=random_seed, imbalance_threshold=imbalance_threshold, use_reference_config=use_reference_config, backend=backend, ) @PublicAPI def create_automl_config_for_features( features_config: ModelConfigDict, dataset_info: DatasetInfo, target: str | list[str], time_limit_s: int | float, tune_for_memory: bool = False, user_config: dict = None, random_seed: int = default_random_seed, imbalance_threshold: float = 0.9, use_reference_config: bool = False, backend: Backend | str = None, ) -> ModelConfigDict: default_configs = create_default_config( features_config, dataset_info, target, time_limit_s, random_seed, imbalance_threshold, backend ) model_config, _, _ = _model_select(dataset_info, default_configs, user_config, use_reference_config) if tune_for_memory: warnings.warn("`tune_for_memory=True` is deprecated, `batch_size=auto` will be used instead") return model_config @PublicAPI def create_features_config( dataset_info: DatasetInfo, target_name: str | list[str] = None, ) -> ModelConfigDict: return get_features_config(dataset_info.fields, dataset_info.row_count, target_name) @PublicAPI def train_with_config( dataset: str | pd.DataFrame | dd.DataFrame, config: dict, output_directory: str = OUTPUT_DIR, random_seed: int = default_random_seed, **kwargs, ) -> AutoTrainResults: """Performs hyperparameter optimization with respect to the given config and selects the best model. # Inputs :param dataset: (str) filepath to dataset. :param config: (dict) optional Ludwig configuration to use for training, defaults to `create_auto_config`. :param output_directory: (str) directory into which to write results, defaults to current working directory. :param random_seed: (int, default: `42`) a random seed that will be used anywhere there is a call to a random number generator, including hyperparameter search sampling, as well as data splitting, parameter initialization and training set shuffling :param kwargs: additional keyword args passed down to `ludwig.hyperopt.run.hyperopt`. # Returns :return: (AutoTrainResults) results containing hyperopt experiments and best model """ _ray_init() model_type = get_model_type(config) hyperopt_results = _train( config, dataset, output_directory=output_directory, model_name=model_type, random_seed=random_seed, **kwargs ) # catch edge case where metric_score is nan # TODO (ASN): Decide how we want to proceed if at least one trial has # completed for trial in hyperopt_results.ordered_trials: if isinstance(trial.metric_score, str) or np.isnan(trial.metric_score): warnings.warn( "There was an error running the experiment. " "A trial failed to start. " "Consider increasing the time budget for experiment. " ) # Extract credentials needed to pull artifacts, if provided creds = None backend: Backend = initialize_backend(kwargs.get("backend")) if backend is not None: creds = backend.storage.artifacts.credentials experiment_analysis = hyperopt_results.experiment_analysis return AutoTrainResults(experiment_analysis, creds) def _model_select( dataset_info: DatasetInfo, default_configs, user_config, use_reference_config: bool, ): """Performs model selection based on dataset or user specified model. Note: Current implementation returns tabnet by default for tabular datasets. """ fields = dataset_info.fields base_config = copy.deepcopy(default_configs["base_config"]) model_category = None input_features = default_configs["base_config"]["input_features"] # tabular dataset heuristics if len(fields) > 3 and all(f[TYPE] in TABULAR_TYPES for f in input_features): model_category = TABULAR base_config = merge_dict(base_config, default_configs["combiner"][AUTOML_DEFAULT_TABULAR_MODEL]) # override combiner heuristic if explicitly provided by user if user_config is not None: if "combiner" in user_config.keys(): model_type = user_config["combiner"]["type"] base_config = merge_dict(base_config, default_configs["combiner"][model_type]) else: # text heuristics for i, input_feature in enumerate(input_features): base_config_input_feature = base_config["input_features"][i] # default text encoder is bert if input_feature[TYPE] == TEXT: model_category = TEXT if ENCODER in input_feature: base_config_input_feature[ENCODER][TYPE] = AUTOML_DEFAULT_TEXT_ENCODER else: base_config_input_feature[ENCODER] = {TYPE: AUTOML_DEFAULT_TEXT_ENCODER} # TODO(shreya): Should this hyperopt config param be set here? base_config[HYPEROPT]["executor"]["num_samples"] = 5 # set for small hyperparameter search space base_config = merge_dict(base_config, default_configs[TEXT][AUTOML_DEFAULT_TEXT_ENCODER]) # TODO (ASN): add image heuristics if input_feature[TYPE] == IMAGE: model_category = IMAGE if ENCODER in input_feature: base_config_input_feature[ENCODER][TYPE] = AUTOML_DEFAULT_IMAGE_ENCODER else: base_config_input_feature[ENCODER] = {TYPE: AUTOML_DEFAULT_IMAGE_ENCODER} # Merge combiner config base_config = merge_dict(base_config, default_configs["combiner"]["concat"]) # Adjust learning rate based on other config settings if base_config[TRAINER]["learning_rate"] == AUTO: # Add a fake output feature to ensure we can load the ModelConfig, as we expect there to be at least # one output feature in all cases # TODO(travis): less hacky way to do this, we should probably allow ModelConfig to be created without output # features load_config = copy.deepcopy(base_config) if not load_config.get(OUTPUT_FEATURES): load_config[OUTPUT_FEATURES] = [{"name": "fake", "type": "binary"}] base_config[TRAINER]["learning_rate"] = get_auto_learning_rate(ModelConfig.from_dict(load_config)) # override and constrain automl config based on user specified values if user_config is not None: base_config = merge_dict(base_config, user_config) # remove all parameters from hyperparameter search that user has # provided explicit values for hyperopt_params = copy.deepcopy(base_config["hyperopt"]["parameters"]) for hyperopt_params in hyperopt_params.keys(): config_section, param = hyperopt_params.split(".")[0], hyperopt_params.split(".")[1] if config_section in user_config.keys(): if param in user_config[config_section]: del base_config["hyperopt"]["parameters"][hyperopt_params] # if single output feature, set relevant metric and goal if not already set base_config = set_output_feature_metric(base_config) # add as initial trial in the automl search the hyperparameter settings from # the best model for a similar dataset and matching model type, if any. if use_reference_config: ref_configs = get_reference_configs() base_config = _add_transfer_config(base_config, ref_configs) return base_config, model_category, dataset_info.row_count def _train( config: dict, dataset: str | pd.DataFrame | dd.DataFrame, output_directory: str, model_name: str, random_seed: int, **kwargs, ): hyperopt_results = hyperopt( config, dataset=dataset, output_directory=output_directory, model_name=model_name, random_seed=random_seed, skip_save_log=True, # avoid per-step log overhead by default **kwargs, ) return hyperopt_results def init_config( dataset: str, target: str | list[str], time_limit_s: int | float, tune_for_memory: bool = False, suggested: bool = False, hyperopt: bool = False, output: str = None, random_seed: int = default_random_seed, use_reference_config: bool = False, **kwargs, ): config = create_auto_config( dataset=dataset, target=target, time_limit_s=time_limit_s, random_seed=random_seed, use_reference_config=use_reference_config, tune_for_memory=tune_for_memory, ) if HYPEROPT in config and not hyperopt: del config[HYPEROPT] if not suggested: # Only use inputs and outputs minimal_config = { INPUT_FEATURES: [{"name": f[NAME], "type": f[TYPE]} for f in config[INPUT_FEATURES]], OUTPUT_FEATURES: [{"name": f[NAME], "type": f[TYPE]} for f in config[OUTPUT_FEATURES]], } if hyperopt: minimal_config[HYPEROPT] = config[HYPEROPT] config = minimal_config if output is None: print(yaml.safe_dump(config, None, sort_keys=False)) else: with open_file(output, "w") as f: yaml.safe_dump(config, f, sort_keys=False) def cli_init_config(sys_argv): parser = argparse.ArgumentParser( description="This script initializes a valid config from a dataset.", prog="ludwig init_config", usage="%(prog)s [options]", ) parser.add_argument( "-d", "--dataset", type=str, help="input data file path", ) parser.add_argument( "-t", "--target", type=str, help="target(s) to predict as output features of the model", action="append", required=False, ) parser.add_argument( "--time_limit_s", type=int, help="time limit to train the model in seconds when using hyperopt", required=False, ) parser.add_argument( "--suggested", type=bool, help="use suggested config from automl, otherwise only use inferred types and return a minimal config", default=False, required=False, ) parser.add_argument( "--hyperopt", type=bool, help="include automl hyperopt config", default=False, required=False, ) parser.add_argument( "--random_seed", type=int, help="seed for random number generators used in hyperopt to improve repeatability", required=False, ) parser.add_argument( "--use_reference_config", type=bool, help="refine hyperopt search space by setting first search point from stored reference model config", default=False, required=False, ) parser.add_argument( "-o", "--output", type=str, help="output initialized YAML config path", required=False, ) add_contrib_callback_args(parser) args = parser.parse_args(sys_argv) args.callbacks = args.callbacks or [] for callback in args.callbacks: callback.on_cmdline("init_config", *sys_argv) print_ludwig("Init Config", LUDWIG_VERSION) init_config(**vars(args)) ================================================ FILE: ludwig/automl/base_config.py ================================================ """Uses heuristics to build ludwig configuration file: (1) infer types based on dataset (2) populate with - default combiner parameters, - preprocessing parameters, - combiner specific default training parameters, - combiner specific hyperopt space - feature parameters (3) add machineresources (base implementation -- # CPU, # GPU) """ import logging import os from dataclasses import dataclass from typing import Any import dask.dataframe as dd import numpy as np import pandas as pd import yaml from dataclasses_json import dataclass_json, LetterCase from tqdm import tqdm from ludwig.api_annotations import DeveloperAPI from ludwig.backend import Backend from ludwig.constants import ( COLUMN, COMBINER, ENCODER, EXECUTOR, HYPEROPT, INPUT_FEATURES, PREPROCESSING, SCHEDULER, SEARCH_ALG, SPLIT, TEXT, TYPE, ) from ludwig.types import ModelConfigDict from ludwig.utils.automl.data_source import DataSource, wrap_data_source from ludwig.utils.automl.field_info import FieldConfig, FieldInfo, FieldMetadata from ludwig.utils.automl.type_inference import infer_type, should_exclude from ludwig.utils.data_utils import load_yaml from ludwig.utils.misc_utils import merge_dict from ludwig.utils.system_utils import Resources logger = logging.getLogger(__name__) PATH_HERE = os.path.abspath(os.path.dirname(__file__)) CONFIG_DIR = os.path.join(PATH_HERE, "defaults") BASE_AUTOML_CONFIG = os.path.join(CONFIG_DIR, "base_automl_config.yaml") REFERENCE_CONFIGS = os.path.join(CONFIG_DIR, "reference_configs.yaml") combiner_defaults = { "concat": os.path.join(CONFIG_DIR, "combiner/concat_config.yaml"), "tabnet": os.path.join(CONFIG_DIR, "combiner/tabnet_config.yaml"), "transformer": os.path.join(CONFIG_DIR, "combiner/transformer_config.yaml"), } encoder_defaults = {"text": {"bert": os.path.join(CONFIG_DIR, "text/bert_config.yaml")}} # Cap for number of distinct values to return. MAX_DISTINCT_VALUES_TO_RETURN = 10 @DeveloperAPI @dataclass_json(letter_case=LetterCase.CAMEL) @dataclass class DatasetInfo: fields: list[FieldInfo] row_count: int size_bytes: int = -1 def allocate_experiment_resources(resources: Resources) -> dict: """Allocates ray trial resources based on available resources. # Inputs :param resources (dict) specifies all available GPUs, CPUs and associated metadata of the machines (i.e. memory) # Return :return: (dict) gpu and cpu resources per trial """ # TODO (ASN): # (1) expand logic to support multiple GPUs per trial (multi-gpu training) # (2) add support for kubernetes namespace (if applicable) # (3) add support for smarter allocation based on size of GPU memory experiment_resources = {"cpu_resources_per_trial": 1} gpu_count, cpu_count = resources.gpus, resources.cpus if gpu_count > 0: experiment_resources.update({"gpu_resources_per_trial": 1}) if cpu_count > 1: cpus_per_trial = max(int(cpu_count / gpu_count), 1) experiment_resources["cpu_resources_per_trial"] = cpus_per_trial return experiment_resources def get_resource_aware_hyperopt_config( experiment_resources: dict[str, Any], time_limit_s: int | float, random_seed: int ) -> dict[str, Any]: """Returns a Ludwig config with the hyperopt section populated with appropriate parameters. Hyperopt parameters are intended to be appropriate for the given resources and time limit. """ executor = experiment_resources executor.update({"time_budget_s": time_limit_s}) if time_limit_s is not None: executor.update({SCHEDULER: {"max_t": time_limit_s}}) return { HYPEROPT: { SEARCH_ALG: {"random_state_seed": random_seed}, EXECUTOR: executor, }, } def _get_stratify_split_config(field_meta: FieldMetadata) -> dict: return { PREPROCESSING: { SPLIT: { TYPE: "stratify", COLUMN: field_meta.name, } } } def get_default_automl_hyperopt() -> dict[str, Any]: """Returns general, default settings for hyperopt. For example: - We set a random_state_seed for sample sequence repeatability - We use an increased reduction_factor to get more pruning/exploration. TODO: If settings seem reasonable, consider building this into the hyperopt schema, directly. """ return yaml.safe_load(""" search_alg: type: variant_generator executor: type: ray num_samples: 10 time_budget_s: 3600 scheduler: type: async_hyperband time_attr: time_total_s max_t: 3600 grace_period: 72 reduction_factor: 5 """) def create_default_config( features_config: ModelConfigDict, dataset_info: DatasetInfo, target_name: str | list[str], time_limit_s: int | float, random_seed: int, imbalance_threshold: float = 0.9, backend: Backend = None, ) -> dict: """Returns auto_train configs for three available combiner models. Coordinates the following tasks: - extracts fields and generates list of FieldInfo objects - gets field metadata (i.e avg. words, total non-null entries) - builds input_features and output_features section of config - for imbalanced datasets, a preprocessing section is added to perform stratified sampling if the imbalance ratio is smaller than imbalance_threshold - for each combiner, adds default training, hyperopt - infers resource constraints and adds gpu and cpu resource allocation per trial # Inputs :param dataset_info: (str) filepath Dataset Info object. :param target_name: (str, List[str]) name of target feature :param time_limit_s: (int, float) total time allocated to auto_train. acts as the stopping parameter :param random_seed: (int, default: `42`) a random seed that will be used anywhere there is a call to a random number generator, including hyperparameter search sampling, as well as data splitting, parameter initialization and training set shuffling :param imbalance_threshold: (float) maximum imbalance ratio (minority / majority) to perform stratified sampling :param backend: (Backend) backend to use for training. # Return :return: (dict) dictionaries contain auto train config files for all available combiner types """ base_automl_config = load_yaml(BASE_AUTOML_CONFIG) base_automl_config.update(features_config) targets = convert_targets(target_name) features_metadata = get_field_metadata(dataset_info.fields, dataset_info.row_count, targets) # Handle expensive features for CPU resources = backend.get_available_resources() for ifeature in base_automl_config[INPUT_FEATURES]: if resources.gpus == 0: if ifeature[TYPE] == TEXT: # When no GPUs are available, default to the embed encoder, which is fast enough for CPU ifeature[ENCODER] = {"type": "embed"} # create set of all feature types appearing in the dataset feature_types = [[feat[TYPE] for feat in features] for features in features_config.values()] feature_types = set(sum(feature_types, [])) model_configs = {} # update hyperopt config experiment_resources = allocate_experiment_resources(resources) base_automl_config = merge_dict( base_automl_config, get_resource_aware_hyperopt_config(experiment_resources, time_limit_s, random_seed) ) # add preprocessing section if single output feature is imbalanced outputs_metadata = [f for f in features_metadata if f.mode == "output"] if len(outputs_metadata) == 1: of_meta = outputs_metadata[0] is_categorical = of_meta.config.type in ["category", "binary"] is_imbalanced = of_meta.imbalance_ratio < imbalance_threshold if is_categorical and is_imbalanced: base_automl_config.update(_get_stratify_split_config(of_meta)) model_configs["base_config"] = base_automl_config # read in all encoder configs for feat_type, default_configs in encoder_defaults.items(): if feat_type in feature_types: if feat_type not in model_configs.keys(): model_configs[feat_type] = {} for encoder_name, encoder_config_path in default_configs.items(): model_configs[feat_type][encoder_name] = load_yaml(encoder_config_path) # read in all combiner configs model_configs[COMBINER] = {} for combiner_type, default_config in combiner_defaults.items(): combiner_config = load_yaml(default_config) model_configs[COMBINER][combiner_type] = combiner_config return model_configs # Read in the score and configuration of a reference model trained by Ludwig for each dataset in a list. def get_reference_configs() -> dict: reference_configs = load_yaml(REFERENCE_CONFIGS) return reference_configs def get_dataset_info(df: pd.DataFrame | dd.DataFrame) -> DatasetInfo: """Constructs FieldInfo objects for each feature in dataset. These objects are used for downstream type inference. # Inputs :param df: (Union[pd.DataFrame, dd.DataFrame]) Pandas or Dask dataframe. # Return :return: (DatasetInfo) Structure containing list of FieldInfo objects. """ source = wrap_data_source(df) return get_dataset_info_from_source(source) def is_field_boolean(source: DataSource, field: str) -> bool: """Returns a boolean indicating whether the object field should have a bool dtype. Columns with object dtype that have 3 distinct values of which one is Nan/None is a bool type column. """ unique_values = source.df[field].unique() if len(unique_values) <= 3: for entry in unique_values: try: if np.isnan(entry): continue except TypeError: # For some field types such as object arrays, np.isnan throws a TypeError # In this case, do nothing and proceed to checking if the entry is a bool object pass if isinstance(entry, bool): continue return False return True return False @DeveloperAPI def get_dataset_info_from_source(source: DataSource) -> DatasetInfo: """Constructs FieldInfo objects for each feature in dataset. These objects are used for downstream type inference. # Inputs :param source: (DataSource) A wrapper around a data source, which may represent a pandas or Dask dataframe. # Return :return: (DatasetInfo) Structure containing list of FieldInfo objects. """ row_count = len(source) fields = [] for field in tqdm(source.columns, desc="Analyzing fields", total=len(source.columns)): logger.info(f"Analyzing field: {field}") dtype = source.get_dtype(field) num_distinct_values, distinct_values, distinct_values_balance = source.get_distinct_values( field, MAX_DISTINCT_VALUES_TO_RETURN ) nonnull_values = source.get_nonnull_values(field) image_values = source.get_image_values(field) audio_values = source.get_audio_values(field) if dtype == "object": # Check if it is a nullboolean field. We do this since if you read a csv with # pandas that has a column of booleans and some missing values, the column is # interpreted as object dtype instead of bool if is_field_boolean(source, field): dtype = "bool" avg_words = None if source.is_string_type(dtype): try: avg_words = source.get_avg_num_tokens(field) except AttributeError: # Series is not actually a string type despite being an object, e.g., Decimal, Datetime, etc. avg_words = None fields.append( FieldInfo( name=field, dtype=dtype, distinct_values=distinct_values, num_distinct_values=num_distinct_values, distinct_values_balance=distinct_values_balance, nonnull_values=nonnull_values, image_values=image_values, audio_values=audio_values, avg_words=avg_words, ) ) return DatasetInfo(fields=fields, row_count=row_count, size_bytes=source.size_bytes()) def get_features_config( fields: list[FieldInfo], row_count: int, target_name: str | list[str] = None, ) -> dict: """Constructs FieldInfo objects for each feature in dataset. These objects are used for downstream type inference. # Inputs :param fields: (List[FieldInfo]) FieldInfo objects for all fields in dataset :param row_count: (int) total number of entries in original dataset :param target_name (str, List[str]) name of target feature # Return :return: (dict) section of auto_train config for input_features and output_features """ targets = convert_targets(target_name) metadata = get_field_metadata(fields, row_count, targets) return get_config_from_metadata(metadata, targets) def convert_targets(target_name: str | list[str] = None) -> set[str]: targets = target_name if isinstance(targets, str): targets = [targets] if targets is None: targets = [] return set(targets) def get_config_from_metadata(metadata: list[FieldMetadata], targets: set[str] = None) -> dict: """Builds input/output feature sections of auto-train config using field metadata. # Inputs :param metadata: (List[FieldMetadata]) field descriptions :param targets (Set[str]) names of target features # Return :return: (dict) section of auto_train config for input_features and output_features """ config = { "input_features": [], "output_features": [], } for field_meta in metadata: if field_meta.name in targets: config["output_features"].append(field_meta.config.to_dict()) elif not field_meta.excluded and field_meta.mode == "input": config["input_features"].append(field_meta.config.to_dict()) return config @DeveloperAPI def get_field_metadata(fields: list[FieldInfo], row_count: int, targets: set[str] = None) -> list[FieldMetadata]: """Computes metadata for each field in dataset. # Inputs :param fields: (List[FieldInfo]) FieldInfo objects for all fields in dataset :param row_count: (int) total number of entries in original dataset :param targets (Set[str]) names of target features # Return :return: (List[FieldMetadata]) list of objects containing metadata for each field """ metadata = [] column_count = len(fields) for idx, field in enumerate(fields): missing_value_percent = 1 - float(field.nonnull_values) / row_count dtype = infer_type(field, missing_value_percent, row_count) metadata.append( FieldMetadata( name=field.name, config=FieldConfig( name=field.name, column=field.name, type=dtype, ), excluded=should_exclude(idx, field, dtype, column_count, row_count, targets), mode=infer_mode(field, targets), missing_values=missing_value_percent, imbalance_ratio=field.distinct_values_balance, ) ) return metadata def infer_mode(field: FieldInfo, targets: set[str] = None) -> str: if field.name in targets: return "output" if field.name.lower() == "split": return "split" return "input" ================================================ FILE: ludwig/automl/defaults/base_automl_config.yaml ================================================ trainer: batch_size: auto #256 learning_rate: auto #.001 # validation_metric: accuracy hyperopt: search_alg: # Gives results like default + supports random_state_seed for sample sequence repeatability type: variant_generator executor: type: ray num_samples: 10 time_budget_s: 7200 scheduler: type: async_hyperband time_attr: time_total_s max_t: 7200 grace_period: 72 # Increased over default to get more pruning/exploration reduction_factor: 5 ================================================ FILE: ludwig/automl/defaults/combiner/concat_config.yaml ================================================ combiner: type: concat hyperopt: # goal: maximize parameters: combiner.num_fc_layers: space: randint lower: 1 upper: 4 combiner.output_size: space: choice categories: [128, 256] combiner.dropout: space: uniform lower: 0.0 upper: 0.1 # This needs to be loguniform due to invalid schemas created by merging with a choice parameter space. See the # comment in ludwig/automl/defaults/text/bert_config.yaml for more information. trainer.learning_rate: space: loguniform lower: 0.00002 upper: 0.001 trainer.batch_size: space: choice categories: [64, 128, 256, 512, 1024] ================================================ FILE: ludwig/automl/defaults/combiner/tabnet_config.yaml ================================================ combiner: type: tabnet trainer: batch_size: auto learning_rate_scaling: sqrt learning_rate_scheduler: decay: exponential decay_steps: 20000 decay_rate: 0.8 optimizer: type: adam hyperopt: parameters: trainer.learning_rate: space: loguniform lower: 0.00002 upper: 0.001 trainer.learning_rate_scheduler.decay_rate: space: choice categories: [0.8, 0.9, 0.95] trainer.learning_rate_scheduler.decay_steps: space: choice categories: [500, 2000, 8000, 10000, 20000] combiner.size: space: choice categories: [8, 16, 24, 32, 64] combiner.output_size: space: choice categories: [8, 16, 24, 32, 64, 128] combiner.num_steps: space: choice categories: [3, 4, 5, 6, 7, 8, 9, 10] combiner.relaxation_factor: space: choice categories: [1.0, 1.2, 1.5, 2.0] combiner.sparsity: space: choice categories: [0.0, 0.000001, 0.0001, 0.001, 0.01, 0.1] combiner.bn_virtual_bs: space: choice categories: [256, 512, 1024, 2048, 4096] combiner.bn_momentum: space: choice categories: [0.4, 0.3, 0.2, 0.1, 0.05, 0.02] ================================================ FILE: ludwig/automl/defaults/combiner/transformer_config.yaml ================================================ combiner: type: transformer trainer: batch_size: auto #256 learning_rate: auto #0.0001 # validation_metric: accuracy hyperopt: # goal: maximize parameters: trainer.learning_rate: space: loguniform lower: 0.00002 upper: 0.001 trainer.batch_size: space: choice categories: [64, 128, 256] combiner.num_heads: space: choice categories: [4] combiner.dropout: space: uniform lower: 0.1 upper: 0.3 combiner.num_layers: space: randint lower: 3 upper: 4 combiner.num_fc_layers: space: choice categories: [1, 2] combiner.fc_dropout: space: uniform lower: 0.1 upper: 0.5 ================================================ FILE: ludwig/automl/defaults/reference_configs.yaml ================================================ # Record the score and configuration of a reference model trained by Ludwig for specified datasets. # This information is useful for Ludwig AutoML hyperparameter transfer learning or for manual experimentation. datasets: - name: adult_census_income goal: maximize metric: accuracy validation_metric_score: 0.8682432174682617 training_rows: 29305 test_rows: 16281 validation_rows: 3256 config: output_features: - name: income type: category input_features: - name: age type: number - name: workclass type: category - name: fnlwgt type: number - name: education type: category - name: education-num type: number - name: marital-status type: category - name: occupation type: category - name: relationship type: category - name: race type: category - name: sex type: category - name: capital-gain type: number - name: capital-loss type: number - name: hours-per-week type: number - name: native-country type: category combiner: type: tabnet size: 8 # N_a output_size: 128 # N_d sparsity: 0.0 # lambda_sparse bn_momentum: 0.4 # m_B num_steps: 3 # N_steps relaxation_factor: 1.0 # gamma bn_virtual_bs: 4096 # B_v trainer: batch_size: 256 # B eval_batch_size: null # 65536 131072 262144 524288 epochs: 300 early_stop: 30 learning_rate: 0.01 optimizer: type: adam learning_rate_scheduler: decay: exponential decay_steps: 500 decay_rate: 0.95 validation_metric: accuracy - name: allstate_claims_severity goal: minimize metric: root_mean_squared_error validation_metric_score: 1915.5531005859375 training_rows: 131726 test_rows: 37750 validation_rows: 18842 config: output_features: - name: loss type: number input_features: - column: cat1 name: cat1 type: category - column: cat2 name: cat2 type: category - column: cat3 name: cat3 type: category - column: cat4 name: cat4 type: category - column: cat5 name: cat5 type: category - column: cat6 name: cat6 type: category - column: cat7 name: cat7 type: category - column: cat8 name: cat8 type: category - column: cat9 name: cat9 type: category - column: cat10 name: cat10 type: category - column: cat11 name: cat11 type: category - column: cat12 name: cat12 type: category - column: cat13 name: cat13 type: category - column: cat14 name: cat14 type: category - column: cat15 name: cat15 type: category - column: cat16 name: cat16 type: category - column: cat17 name: cat17 type: category - column: cat18 name: cat18 type: category - column: cat19 name: cat19 type: category - column: cat20 name: cat20 type: category - column: cat21 name: cat21 type: category - column: cat22 name: cat22 type: category - column: cat23 name: cat23 type: category - column: cat24 name: cat24 type: category - column: cat25 name: cat25 type: category - column: cat26 name: cat26 type: category - column: cat27 name: cat27 type: category - column: cat28 name: cat28 type: category - column: cat29 name: cat29 type: category - column: cat30 name: cat30 type: category - column: cat31 name: cat31 type: category - column: cat32 name: cat32 type: category - column: cat33 name: cat33 type: category - column: cat34 name: cat34 type: category - column: cat35 name: cat35 type: category - column: cat36 name: cat36 type: category - column: cat37 name: cat37 type: category - column: cat38 name: cat38 type: category - column: cat39 name: cat39 type: category - column: cat40 name: cat40 type: category - column: cat41 name: cat41 type: category - column: cat42 name: cat42 type: category - column: cat43 name: cat43 type: category - column: cat44 name: cat44 type: category - column: cat45 name: cat45 type: category - column: cat46 name: cat46 type: category - column: cat47 name: cat47 type: category - column: cat48 name: cat48 type: category - column: cat49 name: cat49 type: category - column: cat50 name: cat50 type: category - column: cat51 name: cat51 type: category - column: cat52 name: cat52 type: category - column: cat53 name: cat53 type: category - column: cat54 name: cat54 type: category - column: cat55 name: cat55 type: category - column: cat56 name: cat56 type: category - column: cat57 name: cat57 type: category - column: cat58 name: cat58 type: category - column: cat59 name: cat59 type: category - column: cat60 name: cat60 type: category - column: cat61 name: cat61 type: category - column: cat62 name: cat62 type: category - column: cat63 name: cat63 type: category - column: cat64 name: cat64 type: category - column: cat65 name: cat65 type: category - column: cat66 name: cat66 type: category - column: cat67 name: cat67 type: category - column: cat68 name: cat68 type: category - column: cat69 name: cat69 type: category - column: cat70 name: cat70 type: category - column: cat71 name: cat71 type: category - column: cat72 name: cat72 type: category - column: cat73 name: cat73 type: category - column: cat74 name: cat74 type: category - column: cat75 name: cat75 type: category - column: cat76 name: cat76 type: category - column: cat77 name: cat77 type: category - column: cat78 name: cat78 type: category - column: cat79 name: cat79 type: category - column: cat80 name: cat80 type: category - column: cat81 name: cat81 type: category - column: cat82 name: cat82 type: category - column: cat83 name: cat83 type: category - column: cat84 name: cat84 type: category - column: cat85 name: cat85 type: category - column: cat86 name: cat86 type: category - column: cat87 name: cat87 type: category - column: cat88 name: cat88 type: category - column: cat89 name: cat89 type: category - column: cat90 name: cat90 type: category - column: cat91 name: cat91 type: category - column: cat92 name: cat92 type: category - column: cat93 name: cat93 type: category - column: cat94 name: cat94 type: category - column: cat95 name: cat95 type: category - column: cat96 name: cat96 type: category - column: cat97 name: cat97 type: category - column: cat98 name: cat98 type: category - column: cat99 name: cat99 type: category - column: cat100 name: cat100 type: category - column: cat101 name: cat101 type: category - column: cat102 name: cat102 type: category - column: cat103 name: cat103 type: category - column: cat104 name: cat104 type: category - column: cat105 name: cat105 type: category - column: cat106 name: cat106 type: category - column: cat107 name: cat107 type: category - column: cat108 name: cat108 type: category - column: cat109 name: cat109 type: category - column: cat110 name: cat110 type: category - column: cat111 name: cat111 type: category - column: cat112 name: cat112 type: category - column: cat113 name: cat113 type: category - column: cat114 name: cat114 type: category - column: cat115 name: cat115 type: category - column: cat116 name: cat116 type: category - column: cont1 name: cont1 type: number - column: cont2 name: cont2 type: number - column: cont3 name: cont3 type: number - column: cont4 name: cont4 type: number - column: cont5 name: cont5 type: number - column: cont6 name: cont6 type: number - column: cont7 name: cont7 type: number - column: cont8 name: cont8 type: number - column: cont9 name: cont9 type: number - column: cont10 name: cont10 type: number - column: cont11 name: cont11 type: number - column: cont12 name: cont12 type: number - column: cont13 name: cont13 type: number - column: cont14 name: cont14 type: number combiner: type: tabnet size: 128 # N_a output_size: 8 # N_d sparsity: 0.0 # lambda_sparse bn_momentum: 0.02 # m_B num_steps: 10 # N_steps relaxation_factor: 1.0 # gamma bn_virtual_bs: 4096 # B_v trainer: batch_size: 256 # B eval_batch_size: null # 65536 131072 262144 524288 epochs: 300 early_stop: 30 learning_rate: 0.01 optimizer: type: adam learning_rate_scheduler: decay: exponential decay_steps: 10000 decay_rate: 0.9 validation_metric: root_mean_squared_error - name: bnp_claims_management goal: maximize metric: accuracy validation_metric_score: 0.7761691808700562 training_rows: 80101 test_rows: 22823 validation_rows: 11397 config: output_features: - name: target type: binary input_features: - name: v1 type: number - name: v2 type: number - name: v3 type: category - name: v4 type: number - name: v5 type: number - name: v6 type: number - name: v7 type: number - name: v8 type: number - name: v9 type: number - name: v10 type: number - name: v11 type: number - name: v12 type: number - name: v13 type: number - name: v14 type: number - name: v15 type: number - name: v16 type: number - name: v17 type: number - name: v18 type: number - name: v19 type: number - name: v20 type: number - name: v21 type: number - name: v22 type: category - name: v23 type: number - name: v24 type: category - name: v25 type: number - name: v26 type: number - name: v27 type: number - name: v28 type: number - name: v29 type: number - name: v30 type: category - name: v31 type: category - name: v32 type: number - name: v33 type: number - name: v34 type: number - name: v35 type: number - name: v36 type: number - name: v37 type: number - name: v38 type: number - name: v39 type: number - name: v40 type: number - name: v41 type: number - name: v42 type: number - name: v43 type: number - name: v44 type: number - name: v45 type: number - name: v46 type: number - name: v47 type: category - name: v48 type: number - name: v49 type: number - name: v50 type: number - name: v51 type: number - name: v52 type: category - name: v53 type: number - name: v54 type: number - name: v55 type: number - name: v56 type: category - name: v57 type: number - name: v58 type: number - name: v59 type: number - name: v60 type: number - name: v61 type: number - name: v62 type: category - name: v63 type: number - name: v64 type: number - name: v65 type: number - name: v66 type: category - name: v67 type: number - name: v68 type: number - name: v69 type: number - name: v70 type: number - name: v71 type: category - name: v72 type: category - name: v73 type: number - name: v74 type: category - name: v75 type: category - name: v76 type: number - name: v77 type: number - name: v78 type: number - name: v79 type: category - name: v80 type: number - name: v81 type: number - name: v82 type: number - name: v83 type: number - name: v84 type: number - name: v85 type: number - name: v86 type: number - name: v87 type: number - name: v88 type: number - name: v89 type: number - name: v90 type: number - name: v91 type: category - name: v92 type: number - name: v93 type: number - name: v94 type: number - name: v95 type: number - name: v96 type: number - name: v97 type: number - name: v98 type: number - name: v99 type: number - name: v100 type: number - name: v101 type: number - name: v102 type: number - name: v103 type: number - name: v104 type: number - name: v105 type: number - name: v106 type: number - name: v107 type: category - name: v108 type: number - name: v109 type: number - name: v110 type: category - name: v111 type: number - name: v112 type: category - name: v113 type: category - name: v114 type: number - name: v115 type: number - name: v116 type: number - name: v117 type: number - name: v118 type: number - name: v119 type: number - name: v120 type: number - name: v121 type: number - name: v122 type: number - name: v123 type: number - name: v124 type: number - name: v125 type: category - name: v126 type: number - name: v127 type: number - name: v128 type: number - name: v129 type: number - name: v130 type: number - name: v131 type: number combiner: type: tabnet size: 32 # N_a output_size: 8 # N_d sparsity: 0.0 # lambda_sparse bn_momentum: 0.02 # m_B num_steps: 3 # N_steps relaxation_factor: 1.0 # gamma bn_virtual_bs: 256 # B_v trainer: batch_size: 256 # B eval_batch_size: null # 65536 131072 262144 524288 epochs: 300 early_stop: 30 learning_rate: 0.01 optimizer: type: adam learning_rate_scheduler: decay: exponential decay_steps: 2000 decay_rate: 0.4 validation_metric: accuracy - name: ieee_fraud goal: maximize metric: accuracy validation_metric_score: 0.9836957454681396 training_rows: 413498 test_rows: 118039 validation_rows: 59003 config: output_features: - name: isFraud type: binary input_features: - name: TransactionDT type: number - name: TransactionAmt type: number - name: ProductCD type: category - name: card1 type: number - name: card2 type: number - name: card3 type: number - name: card4 type: category - name: card5 type: number - name: card6 type: category - name: addr1 type: number - name: addr2 type: number - name: dist1 type: number - name: dist2 type: number - name: P_emaildomain type: category - name: R_emaildomain type: category - name: C1 type: number - name: C2 type: number - name: C3 type: number - name: C4 type: number - name: C5 type: number - name: C6 type: number - name: C7 type: number - name: C8 type: number - name: C9 type: number - name: C10 type: number - name: C11 type: number - name: C12 type: number - name: C13 type: number - name: C14 type: number - name: D1 type: number - name: D2 type: number - name: D3 type: number - name: D4 type: number - name: D5 type: number - name: D6 type: number - name: D7 type: number - name: D8 type: number - name: D9 type: number - name: D10 type: number - name: D11 type: number - name: D12 type: number - name: D13 type: number - name: D14 type: number - name: D15 type: number - name: M1 type: category - name: M2 type: category - name: M3 type: category - name: M4 type: category - name: M5 type: category - name: M6 type: category - name: M7 type: category - name: M8 type: category - name: M9 type: category - name: V1 type: number - name: V2 type: number - name: V3 type: number - name: V4 type: number - name: V5 type: number - name: V6 type: number - name: V7 type: number - name: V8 type: number - name: V9 type: number - name: V10 type: number - name: V11 type: number - name: V12 type: number - name: V13 type: number - name: V14 type: number - name: V15 type: number - name: V16 type: number - name: V17 type: number - name: V18 type: number - name: V19 type: number - name: V20 type: number - name: V21 type: number - name: V22 type: number - name: V23 type: number - name: V24 type: number - name: V25 type: number - name: V26 type: number - name: V27 type: number - name: V28 type: number - name: V29 type: number - name: V30 type: number - name: V31 type: number - name: V32 type: number - name: V33 type: number - name: V34 type: number - name: V35 type: number - name: V36 type: number - name: V37 type: number - name: V38 type: number - name: V39 type: number - name: V40 type: number - name: V41 type: number - name: V42 type: number - name: V43 type: number - name: V44 type: number - name: V45 type: number - name: V46 type: number - name: V47 type: number - name: V48 type: number - name: V49 type: number - name: V50 type: number - name: V51 type: number - name: V52 type: number - name: V53 type: number - name: V54 type: number - name: V55 type: number - name: V56 type: number - name: V57 type: number - name: V58 type: number - name: V59 type: number - name: V60 type: number - name: V61 type: number - name: V62 type: number - name: V63 type: number - name: V64 type: number - name: V65 type: number - name: V66 type: number - name: V67 type: number - name: V68 type: number - name: V69 type: number - name: V70 type: number - name: V71 type: number - name: V72 type: number - name: V73 type: number - name: V74 type: number - name: V75 type: number - name: V76 type: number - name: V77 type: number - name: V78 type: number - name: V79 type: number - name: V80 type: number - name: V81 type: number - name: V82 type: number - name: V83 type: number - name: V84 type: number - name: V85 type: number - name: V86 type: number - name: V87 type: number - name: V88 type: number - name: V89 type: number - name: V90 type: number - name: V91 type: number - name: V92 type: number - name: V93 type: number - name: V94 type: number - name: V95 type: number - name: V96 type: number - name: V97 type: number - name: V98 type: number - name: V99 type: number - name: V100 type: number - name: V101 type: number - name: V102 type: number - name: V103 type: number - name: V104 type: number - name: V105 type: number - name: V106 type: number - name: V107 type: number - name: V108 type: number - name: V109 type: number - name: V110 type: number - name: V111 type: number - name: V112 type: number - name: V113 type: number - name: V114 type: number - name: V115 type: number - name: V116 type: number - name: V117 type: number - name: V118 type: number - name: V119 type: number - name: V120 type: number - name: V121 type: number - name: V122 type: number - name: V123 type: number - name: V124 type: number - name: V125 type: number - name: V126 type: number - name: V127 type: number - name: V128 type: number - name: V129 type: number - name: V130 type: number - name: V131 type: number - name: V132 type: number - name: V133 type: number - name: V134 type: number - name: V135 type: number - name: V136 type: number - name: V137 type: number - name: V138 type: number - name: V139 type: number - name: V140 type: number - name: V141 type: number - name: V142 type: number - name: V143 type: number - name: V144 type: number - name: V145 type: number - name: V146 type: number - name: V147 type: number - name: V148 type: number - name: V149 type: number - name: V150 type: number - name: V151 type: number - name: V152 type: number - name: V153 type: number - name: V154 type: number - name: V155 type: number - name: V156 type: number - name: V157 type: number - name: V158 type: number - name: V159 type: number - name: V160 type: number - name: V161 type: number - name: V162 type: number - name: V163 type: number - name: V164 type: number - name: V165 type: number - name: V166 type: number - name: V167 type: number - name: V168 type: number - name: V169 type: number - name: V170 type: number - name: V171 type: number - name: V172 type: number - name: V173 type: number - name: V174 type: number - name: V175 type: number - name: V176 type: number - name: V177 type: number - name: V178 type: number - name: V179 type: number - name: V180 type: number - name: V181 type: number - name: V182 type: number - name: V183 type: number - name: V184 type: number - name: V185 type: number - name: V186 type: number - name: V187 type: number - name: V188 type: number - name: V189 type: number - name: V190 type: number - name: V191 type: number - name: V192 type: number - name: V193 type: number - name: V194 type: number - name: V195 type: number - name: V196 type: number - name: V197 type: number - name: V198 type: number - name: V199 type: number - name: V200 type: number - name: V201 type: number - name: V202 type: number - name: V203 type: number - name: V204 type: number - name: V205 type: number - name: V206 type: number - name: V207 type: number - name: V208 type: number - name: V209 type: number - name: V210 type: number - name: V211 type: number - name: V212 type: number - name: V213 type: number - name: V214 type: number - name: V215 type: number - name: V216 type: number - name: V217 type: number - name: V218 type: number - name: V219 type: number - name: V220 type: number - name: V221 type: number - name: V222 type: number - name: V223 type: number - name: V224 type: number - name: V225 type: number - name: V226 type: number - name: V227 type: number - name: V228 type: number - name: V229 type: number - name: V230 type: number - name: V231 type: number - name: V232 type: number - name: V233 type: number - name: V234 type: number - name: V235 type: number - name: V236 type: number - name: V237 type: number - name: V238 type: number - name: V239 type: number - name: V240 type: number - name: V241 type: number - name: V242 type: number - name: V243 type: number - name: V244 type: number - name: V245 type: number - name: V246 type: number - name: V247 type: number - name: V248 type: number - name: V249 type: number - name: V250 type: number - name: V251 type: number - name: V252 type: number - name: V253 type: number - name: V254 type: number - name: V255 type: number - name: V256 type: number - name: V257 type: number - name: V258 type: number - name: V259 type: number - name: V260 type: number - name: V261 type: number - name: V262 type: number - name: V263 type: number - name: V264 type: number - name: V265 type: number - name: V266 type: number - name: V267 type: number - name: V268 type: number - name: V269 type: number - name: V270 type: number - name: V271 type: number - name: V272 type: number - name: V273 type: number - name: V274 type: number - name: V275 type: number - name: V276 type: number - name: V277 type: number - name: V278 type: number - name: V279 type: number - name: V280 type: number - name: V281 type: number - name: V282 type: number - name: V283 type: number - name: V284 type: number - name: V285 type: number - name: V286 type: number - name: V287 type: number - name: V288 type: number - name: V289 type: number - name: V290 type: number - name: V291 type: number - name: V292 type: number - name: V293 type: number - name: V294 type: number - name: V295 type: number - name: V296 type: number - name: V297 type: number - name: V298 type: number - name: V299 type: number - name: V300 type: number - name: V301 type: number - name: V302 type: number - name: V303 type: number - name: V304 type: number - name: V305 type: number - name: V306 type: number - name: V307 type: number - name: V308 type: number - name: V309 type: number - name: V310 type: number - name: V311 type: number - name: V312 type: number - name: V313 type: number - name: V314 type: number - name: V315 type: number - name: V316 type: number - name: V317 type: number - name: V318 type: number - name: V319 type: number - name: V320 type: number - name: V321 type: number - name: V322 type: number - name: V323 type: number - name: V324 type: number - name: V325 type: number - name: V326 type: number - name: V327 type: number - name: V328 type: number - name: V329 type: number - name: V330 type: number - name: V331 type: number - name: V332 type: number - name: V333 type: number - name: V334 type: number - name: V335 type: number - name: V336 type: number - name: V337 type: number - name: V338 type: number - name: V339 type: number - name: id_01 type: number - name: id_02 type: number - name: id_03 type: number - name: id_04 type: number - name: id_05 type: number - name: id_06 type: number - name: id_07 type: number - name: id_08 type: number - name: id_09 type: number - name: id_10 type: number - name: id_11 type: number - name: id_12 type: category - name: id_13 type: number - name: id_14 type: number - name: id_15 type: category - name: id_16 type: category - name: id_17 type: number - name: id_18 type: number - name: id_19 type: number - name: id_20 type: number - name: id_21 type: number - name: id_22 type: number - name: id_23 type: category - name: id_24 type: number - name: id_25 type: number - name: id_26 type: number - name: id_27 type: category - name: id_28 type: category - name: id_29 type: category - name: id_30 type: category - name: id_31 type: text - name: id_32 type: number - name: id_33 type: category - name: id_34 type: category - name: id_35 type: category - name: id_36 type: category - name: id_37 type: category - name: id_38 type: category - name: DeviceType type: category - name: DeviceInfo type: category combiner: type: tabnet size: 128 # N_a output_size: 24 # N_d sparsity: 0.000001 # lambda_sparse bn_momentum: 0.02 # m_B num_steps: 10 # N_steps relaxation_factor: 1.0 # gamma bn_virtual_bs: 2048 # B_v trainer: batch_size: 256 # B eval_batch_size: null # 65536 131072 262144 524288 epochs: 300 early_stop: 30 learning_rate: 0.01 optimizer: type: adam learning_rate_scheduler: decay: exponential decay_steps: 10000 decay_rate: 0.95 validation_metric: accuracy - name: mercedes_benz_greener goal: minimize metric: root_mean_squared_error validation_metric_score: 7.685836315155029 training_rows: 2969 test_rows: 840 validation_rows: 400 config: output_features: - name: y type: number input_features: - name: X0 type: category - name: X1 type: category - name: X2 type: category - name: X3 type: category - name: X4 type: category - name: X5 type: category - name: X6 type: category - name: X8 type: category - name: X10 type: binary - name: X11 type: binary - name: X12 type: binary - name: X13 type: binary - name: X14 type: binary - name: X15 type: binary - name: X16 type: binary - name: X17 type: binary - name: X18 type: binary - name: X19 type: binary - name: X20 type: binary - name: X21 type: binary - name: X22 type: binary - name: X23 type: binary - name: X24 type: binary - name: X26 type: binary - name: X27 type: binary - name: X28 type: binary - name: X29 type: binary - name: X30 type: binary - name: X31 type: binary - name: X32 type: binary - name: X33 type: binary - name: X34 type: binary - name: X35 type: binary - name: X36 type: binary - name: X37 type: binary - name: X38 type: binary - name: X39 type: binary - name: X40 type: binary - name: X41 type: binary - name: X42 type: binary - name: X43 type: binary - name: X44 type: binary - name: X45 type: binary - name: X46 type: binary - name: X47 type: binary - name: X48 type: binary - name: X49 type: binary - name: X50 type: binary - name: X51 type: binary - name: X52 type: binary - name: X53 type: binary - name: X54 type: binary - name: X55 type: binary - name: X56 type: binary - name: X57 type: binary - name: X58 type: binary - name: X59 type: binary - name: X60 type: binary - name: X61 type: binary - name: X62 type: binary - name: X63 type: binary - name: X64 type: binary - name: X65 type: binary - name: X66 type: binary - name: X67 type: binary - name: X68 type: binary - name: X69 type: binary - name: X70 type: binary - name: X71 type: binary - name: X73 type: binary - name: X74 type: binary - name: X75 type: binary - name: X76 type: binary - name: X77 type: binary - name: X78 type: binary - name: X79 type: binary - name: X80 type: binary - name: X81 type: binary - name: X82 type: binary - name: X83 type: binary - name: X84 type: binary - name: X85 type: binary - name: X86 type: binary - name: X87 type: binary - name: X88 type: binary - name: X89 type: binary - name: X90 type: binary - name: X91 type: binary - name: X92 type: binary - name: X93 type: binary - name: X94 type: binary - name: X95 type: binary - name: X96 type: binary - name: X97 type: binary - name: X98 type: binary - name: X99 type: binary - name: X100 type: binary - name: X101 type: binary - name: X102 type: binary - name: X103 type: binary - name: X104 type: binary - name: X105 type: binary - name: X106 type: binary - name: X107 type: binary - name: X108 type: binary - name: X109 type: binary - name: X110 type: binary - name: X111 type: binary - name: X112 type: binary - name: X113 type: binary - name: X114 type: binary - name: X115 type: binary - name: X116 type: binary - name: X117 type: binary - name: X118 type: binary - name: X119 type: binary - name: X120 type: binary - name: X122 type: binary - name: X123 type: binary - name: X124 type: binary - name: X125 type: binary - name: X126 type: binary - name: X127 type: binary - name: X128 type: binary - name: X129 type: binary - name: X130 type: binary - name: X131 type: binary - name: X132 type: binary - name: X133 type: binary - name: X134 type: binary - name: X135 type: binary - name: X136 type: binary - name: X137 type: binary - name: X138 type: binary - name: X139 type: binary - name: X140 type: binary - name: X141 type: binary - name: X142 type: binary - name: X143 type: binary - name: X144 type: binary - name: X145 type: binary - name: X146 type: binary - name: X147 type: binary - name: X148 type: binary - name: X150 type: binary - name: X151 type: binary - name: X152 type: binary - name: X153 type: binary - name: X154 type: binary - name: X155 type: binary - name: X156 type: binary - name: X157 type: binary - name: X158 type: binary - name: X159 type: binary - name: X160 type: binary - name: X161 type: binary - name: X162 type: binary - name: X163 type: binary - name: X164 type: binary - name: X165 type: binary - name: X166 type: binary - name: X167 type: binary - name: X168 type: binary - name: X169 type: binary - name: X170 type: binary - name: X171 type: binary - name: X172 type: binary - name: X173 type: binary - name: X174 type: binary - name: X175 type: binary - name: X176 type: binary - name: X177 type: binary - name: X178 type: binary - name: X179 type: binary - name: X180 type: binary - name: X181 type: binary - name: X182 type: binary - name: X183 type: binary - name: X184 type: binary - name: X185 type: binary - name: X186 type: binary - name: X187 type: binary - name: X189 type: binary - name: X190 type: binary - name: X191 type: binary - name: X192 type: binary - name: X194 type: binary - name: X195 type: binary - name: X196 type: binary - name: X197 type: binary - name: X198 type: binary - name: X199 type: binary - name: X200 type: binary - name: X201 type: binary - name: X202 type: binary - name: X203 type: binary - name: X204 type: binary - name: X205 type: binary - name: X206 type: binary - name: X207 type: binary - name: X208 type: binary - name: X209 type: binary - name: X210 type: binary - name: X211 type: binary - name: X212 type: binary - name: X213 type: binary - name: X214 type: binary - name: X215 type: binary - name: X216 type: binary - name: X217 type: binary - name: X218 type: binary - name: X219 type: binary - name: X220 type: binary - name: X221 type: binary - name: X222 type: binary - name: X223 type: binary - name: X224 type: binary - name: X225 type: binary - name: X226 type: binary - name: X227 type: binary - name: X228 type: binary - name: X229 type: binary - name: X230 type: binary - name: X231 type: binary - name: X232 type: binary - name: X233 type: binary - name: X234 type: binary - name: X235 type: binary - name: X236 type: binary - name: X237 type: binary - name: X238 type: binary - name: X239 type: binary - name: X240 type: binary - name: X241 type: binary - name: X242 type: binary - name: X243 type: binary - name: X244 type: binary - name: X245 type: binary - name: X246 type: binary - name: X247 type: binary - name: X248 type: binary - name: X249 type: binary - name: X250 type: binary - name: X251 type: binary - name: X252 type: binary - name: X253 type: binary - name: X254 type: binary - name: X255 type: binary - name: X256 type: binary - name: X257 type: binary - name: X258 type: binary - name: X259 type: binary - name: X260 type: binary - name: X261 type: binary - name: X262 type: binary - name: X263 type: binary - name: X264 type: binary - name: X265 type: binary - name: X266 type: binary - name: X267 type: binary - name: X268 type: binary - name: X269 type: binary - name: X270 type: binary - name: X271 type: binary - name: X272 type: binary - name: X273 type: binary - name: X274 type: binary - name: X275 type: binary - name: X276 type: binary - name: X277 type: binary - name: X278 type: binary - name: X279 type: binary - name: X280 type: binary - name: X281 type: binary - name: X282 type: binary - name: X283 type: binary - name: X284 type: binary - name: X285 type: binary - name: X286 type: binary - name: X287 type: binary - name: X288 type: binary - name: X289 type: binary - name: X290 type: binary - name: X291 type: binary - name: X292 type: binary - name: X293 type: binary - name: X294 type: binary - name: X295 type: binary - name: X296 type: binary - name: X297 type: binary - name: X298 type: binary - name: X299 type: binary - name: X300 type: binary - name: X301 type: binary - name: X302 type: binary - name: X304 type: binary - name: X305 type: binary - name: X306 type: binary - name: X307 type: binary - name: X308 type: binary - name: X309 type: binary - name: X310 type: binary - name: X311 type: binary - name: X312 type: binary - name: X313 type: binary - name: X314 type: binary - name: X315 type: binary - name: X316 type: binary - name: X317 type: binary - name: X318 type: binary - name: X319 type: binary - name: X320 type: binary - name: X321 type: binary - name: X322 type: binary - name: X323 type: binary - name: X324 type: binary - name: X325 type: binary - name: X326 type: binary - name: X327 type: binary - name: X328 type: binary - name: X329 type: binary - name: X330 type: binary - name: X331 type: binary - name: X332 type: binary - name: X333 type: binary - name: X334 type: binary - name: X335 type: binary - name: X336 type: binary - name: X337 type: binary - name: X338 type: binary - name: X339 type: binary - name: X340 type: binary - name: X341 type: binary - name: X342 type: binary - name: X343 type: binary - name: X344 type: binary - name: X345 type: binary - name: X346 type: binary - name: X347 type: binary - name: X348 type: binary - name: X349 type: binary - name: X350 type: binary - name: X351 type: binary - name: X352 type: binary - name: X353 type: binary - name: X354 type: binary - name: X355 type: binary - name: X356 type: binary - name: X357 type: binary - name: X358 type: binary - name: X359 type: binary - name: X360 type: binary - name: X361 type: binary - name: X362 type: binary - name: X363 type: binary - name: X364 type: binary - name: X365 type: binary - name: X366 type: binary - name: X367 type: binary - name: X368 type: binary - name: X369 type: binary - name: X370 type: binary - name: X371 type: binary - name: X372 type: binary - name: X373 type: binary - name: X374 type: binary - name: X375 type: binary - name: X376 type: binary - name: X377 type: binary - name: X378 type: binary - name: X379 type: binary - name: X380 type: binary - name: X382 type: binary - name: X383 type: binary - name: X384 type: binary - name: X385 type: binary combiner: type: tabnet size: 128 # N_a output_size: 8 # N_d sparsity: 0.1 # lambda_sparse bn_momentum: 0.1 # m_B num_steps: 9 # N_steps relaxation_factor: 1.0 # gamma bn_virtual_bs: 256 # B_v trainer: batch_size: 256 # B eval_batch_size: null # 65536 131072 262144 524288 epochs: 300 early_stop: 30 learning_rate: 0.005 optimizer: type: adam learning_rate_scheduler: decay: exponential decay_steps: 500 decay_rate: 0.95 validation_metric: root_mean_squared_error - name: otto_group_product goal: maximize metric: accuracy validation_metric_score: 0.7956883907318115 training_rows: 43459 test_rows: 12296 validation_rows: 6123 config: output_features: - name: target type: category input_features: - name: feat_1 type: number - name: feat_2 type: number - name: feat_3 type: number - name: feat_4 type: number - name: feat_5 type: number - name: feat_6 type: number - name: feat_7 type: number - name: feat_8 type: number - name: feat_9 type: number - name: feat_10 type: number - name: feat_11 type: number - name: feat_12 type: number - name: feat_13 type: number - name: feat_14 type: number - name: feat_15 type: number - name: feat_16 type: number - name: feat_17 type: number - name: feat_18 type: number - name: feat_19 type: number - name: feat_20 type: number - name: feat_21 type: category - name: feat_22 type: number - name: feat_23 type: number - name: feat_24 type: number - name: feat_25 type: number - name: feat_26 type: number - name: feat_27 type: number - name: feat_28 type: number - name: feat_29 type: number - name: feat_30 type: number - name: feat_31 type: number - name: feat_32 type: number - name: feat_33 type: number - name: feat_34 type: number - name: feat_35 type: number - name: feat_36 type: number - name: feat_37 type: number - name: feat_38 type: number - name: feat_39 type: number - name: feat_40 type: number - name: feat_41 type: number - name: feat_42 type: number - name: feat_43 type: number - name: feat_44 type: number - name: feat_45 type: number - name: feat_46 type: number - name: feat_47 type: number - name: feat_48 type: number - name: feat_49 type: number - name: feat_50 type: number - name: feat_51 type: number - name: feat_52 type: number - name: feat_53 type: number - name: feat_54 type: number - name: feat_55 type: number - name: feat_56 type: number - name: feat_57 type: number - name: feat_58 type: number - name: feat_59 type: number - name: feat_60 type: number - name: feat_61 type: number - name: feat_62 type: number - name: feat_63 type: number - name: feat_64 type: number - name: feat_65 type: number - name: feat_66 type: number - name: feat_67 type: number - name: feat_68 type: number - name: feat_69 type: number - name: feat_70 type: number - name: feat_71 type: number - name: feat_72 type: number - name: feat_73 type: number - name: feat_74 type: number - name: feat_75 type: number - name: feat_76 type: number - name: feat_77 type: number - name: feat_78 type: number - name: feat_79 type: number - name: feat_80 type: number - name: feat_81 type: number - name: feat_82 type: number - name: feat_83 type: number - name: feat_84 type: number - name: feat_85 type: number - name: feat_86 type: number - name: feat_87 type: number - name: feat_88 type: number - name: feat_89 type: number - name: feat_90 type: number - name: feat_91 type: number - name: feat_92 type: number - name: feat_93 type: number combiner: type: tabnet size: 128 # N_a output_size: 128 # N_d sparsity: 0.0 # lambda_sparse bn_momentum: 0.2 # m_B num_steps: 3 # N_steps relaxation_factor: 1.0 # gamma bn_virtual_bs: 512 # B_v trainer: batch_size: 256 # B eval_batch_size: null # 65536 131072 262144 524288 epochs: 300 early_stop: 30 learning_rate: 0.005 optimizer: type: adam learning_rate_scheduler: decay: exponential decay_steps: 20000 decay_rate: 0.4 validation_metric: accuracy - name: poker_hand goal: maximize metric: accuracy validation_metric_score: 0.9804078340530396 training_rows: 22509 test_rows: 0 validation_rows: 2501 config: output_features: - name: hand type: category input_features: - name: S1 type: number - name: C1 type: number - name: S2 type: number - name: C2 type: number - name: S3 type: number - name: C3 type: number - name: S4 type: number - name: C4 type: number - name: S5 type: number - name: C5 type: number combiner: type: tabnet size: 16 # N_a output_size: 128 # N_d sparsity: 0.0 # lambda_sparse bn_momentum: 0.02 # m_B num_steps: 6 # N_steps relaxation_factor: 1.0 # gamma bn_virtual_bs: 512 # B_v trainer: batch_size: 256 # B eval_batch_size: null # 65536 131072 262144 524288 epochs: 300 early_stop: 30 learning_rate: 0.01 optimizer: type: adam learning_rate_scheduler: decay: exponential decay_steps: 8000 decay_rate: 0.8 validation_metric: accuracy - name: porto_seguro_safe_driver goal: maximize metric: accuracy validation_metric_score: 0.9630663394927979 training_rows: 416779 test_rows: 118948 validation_rows: 59485 config: output_features: - name: target type: binary input_features: - name: ps_ind_01 type: category - name: ps_ind_02_cat type: number - name: ps_ind_03 type: category - name: ps_ind_04_cat type: category - name: ps_ind_05_cat type: category - name: ps_ind_06_bin type: binary - name: ps_ind_07_bin type: binary - name: ps_ind_08_bin type: binary - name: ps_ind_09_bin type: binary - name: ps_ind_10_bin type: binary - name: ps_ind_11_bin type: binary - name: ps_ind_12_bin type: binary - name: ps_ind_13_bin type: binary - name: ps_ind_14 type: category - name: ps_ind_15 type: category - name: ps_ind_16_bin type: binary - name: ps_ind_17_bin type: binary - name: ps_ind_18_bin type: binary - name: ps_reg_01 type: number - name: ps_reg_02 type: number - name: ps_reg_03 type: number - name: ps_car_01_cat type: category - name: ps_car_02_cat type: category - name: ps_car_03_cat type: category - name: ps_car_04_cat type: category - name: ps_car_05_cat type: category - name: ps_car_06_cat type: category - name: ps_car_07_cat type: category - name: ps_car_08_cat type: binary - name: ps_car_09_cat type: category - name: ps_car_10_cat type: category - name: ps_car_11_cat type: number - name: ps_car_11 type: category - name: ps_car_12 type: number - name: ps_car_13 type: number - name: ps_car_14 type: number - name: ps_car_15 type: number - name: ps_calc_01 type: number - name: ps_calc_02 type: number - name: ps_calc_03 type: number - name: ps_calc_04 type: category - name: ps_calc_05 type: category - name: ps_calc_06 type: category - name: ps_calc_07 type: category - name: ps_calc_08 type: category - name: ps_calc_09 type: category - name: ps_calc_10 type: number - name: ps_calc_11 type: number - name: ps_calc_12 type: category - name: ps_calc_13 type: category - name: ps_calc_14 type: number - name: ps_calc_15_bin type: binary - name: ps_calc_16_bin type: binary - name: ps_calc_17_bin type: binary - name: ps_calc_18_bin type: binary - name: ps_calc_19_bin type: binary - name: ps_calc_20_bin type: binary combiner: type: tabnet size: 32 # N_a output_size: 32 # N_d sparsity: 0.0001 # lambda_sparse bn_momentum: 0.4 # m_B num_steps: 5 # N_steps relaxation_factor: 1.2 # gamma bn_virtual_bs: 1024 # B_v trainer: batch_size: 1024 # B eval_batch_size: null # 65536 131072 262144 524288 epochs: 300 early_stop: 30 learning_rate: 0.005 optimizer: type: adam learning_rate_scheduler: decay: exponential decay_steps: 10000 decay_rate: 0.9 validation_metric: accuracy - name: santander_customer_satisfaction goal: maximize metric: accuracy validation_metric_score: 0.9611535668373108 training_rows: 53298 test_rows: 15128 validation_rows: 7594 config: output_features: - name: TARGET type: binary input_features: - name: var3 type: number - name: var15 type: number - name: imp_ent_var16_ult1 type: number - name: imp_op_var39_comer_ult1 type: number - name: imp_op_var39_comer_ult3 type: number - name: imp_op_var40_comer_ult1 type: number - name: imp_op_var40_comer_ult3 type: number - name: imp_op_var40_efect_ult1 type: number - name: imp_op_var40_efect_ult3 type: number - name: imp_op_var40_ult1 type: number - name: imp_op_var41_comer_ult1 type: number - name: imp_op_var41_comer_ult3 type: number - name: imp_op_var41_efect_ult1 type: number - name: imp_op_var41_efect_ult3 type: number - name: imp_op_var41_ult1 type: number - name: imp_op_var39_efect_ult1 type: number - name: imp_op_var39_efect_ult3 type: number - name: imp_op_var39_ult1 type: number - name: imp_sal_var16_ult1 type: number - name: ind_var1_0 type: binary - name: ind_var1 type: binary - name: ind_var2_0 type: binary - name: ind_var2 type: binary - name: ind_var5_0 type: binary - name: ind_var5 type: binary - name: ind_var6_0 type: binary - name: ind_var6 type: binary - name: ind_var8_0 type: binary - name: ind_var8 type: binary - name: ind_var12_0 type: binary - name: ind_var12 type: binary - name: ind_var13_0 type: binary - name: ind_var13_corto_0 type: binary - name: ind_var13_corto type: binary - name: ind_var13_largo_0 type: binary - name: ind_var13_largo type: binary - name: ind_var13_medio_0 type: binary - name: ind_var13_medio type: binary - name: ind_var13 type: binary - name: ind_var14_0 type: binary - name: ind_var14 type: binary - name: ind_var17_0 type: binary - name: ind_var17 type: binary - name: ind_var18_0 type: binary - name: ind_var18 type: binary - name: ind_var19 type: binary - name: ind_var20_0 type: binary - name: ind_var20 type: binary - name: ind_var24_0 type: binary - name: ind_var24 type: binary - name: ind_var25_cte type: binary - name: ind_var26_0 type: binary - name: ind_var26_cte type: binary - name: ind_var26 type: binary - name: ind_var25_0 type: binary - name: ind_var25 type: binary - name: ind_var27_0 type: binary - name: ind_var28_0 type: binary - name: ind_var28 type: binary - name: ind_var27 type: binary - name: ind_var29_0 type: binary - name: ind_var29 type: binary - name: ind_var30_0 type: binary - name: ind_var30 type: binary - name: ind_var31_0 type: binary - name: ind_var31 type: binary - name: ind_var32_cte type: binary - name: ind_var32_0 type: binary - name: ind_var32 type: binary - name: ind_var33_0 type: binary - name: ind_var33 type: binary - name: ind_var34_0 type: binary - name: ind_var34 type: binary - name: ind_var37_cte type: binary - name: ind_var37_0 type: binary - name: ind_var37 type: binary - name: ind_var39_0 type: binary - name: ind_var40_0 type: binary - name: ind_var40 type: binary - name: ind_var41_0 type: binary - name: ind_var41 type: binary - name: ind_var39 type: binary - name: ind_var44_0 type: binary - name: ind_var44 type: binary - name: ind_var46_0 type: binary - name: ind_var46 type: binary - name: num_var1_0 type: number - name: num_var1 type: number - name: num_var4 type: category - name: num_var5_0 type: number - name: num_var5 type: number - name: num_var6_0 type: number - name: num_var6 type: number - name: num_var8_0 type: number - name: num_var8 type: number - name: num_var12_0 type: number - name: num_var12 type: number - name: num_var13_0 type: number - name: num_var13_corto_0 type: number - name: num_var13_corto type: number - name: num_var13_largo_0 type: number - name: num_var13_largo type: number - name: num_var13_medio_0 type: number - name: num_var13_medio type: number - name: num_var13 type: number - name: num_var14_0 type: number - name: num_var14 type: number - name: num_var17_0 type: number - name: num_var17 type: number - name: num_var18_0 type: number - name: num_var18 type: number - name: num_var20_0 type: number - name: num_var20 type: number - name: num_var24_0 type: number - name: num_var24 type: number - name: num_var26_0 type: number - name: num_var26 type: number - name: num_var25_0 type: number - name: num_var25 type: number - name: num_op_var40_hace2 type: number - name: num_op_var40_hace3 type: number - name: num_op_var40_ult1 type: number - name: num_op_var40_ult3 type: number - name: num_op_var41_hace2 type: number - name: num_op_var41_hace3 type: number - name: num_op_var41_ult1 type: number - name: num_op_var41_ult3 type: number - name: num_op_var39_hace2 type: number - name: num_op_var39_hace3 type: number - name: num_op_var39_ult1 type: number - name: num_op_var39_ult3 type: number - name: num_var27_0 type: binary - name: num_var28_0 type: binary - name: num_var28 type: binary - name: num_var27 type: binary - name: num_var29_0 type: number - name: num_var29 type: number - name: num_var30_0 type: number - name: num_var30 type: number - name: num_var31_0 type: number - name: num_var31 type: number - name: num_var32_0 type: number - name: num_var32 type: number - name: num_var33_0 type: number - name: num_var33 type: number - name: num_var34_0 type: number - name: num_var34 type: number - name: num_var35 type: number - name: num_var37_med_ult2 type: number - name: num_var37_0 type: number - name: num_var37 type: number - name: num_var39_0 type: number - name: num_var40_0 type: number - name: num_var40 type: number - name: num_var41_0 type: number - name: num_var41 type: binary - name: num_var39 type: number - name: num_var42_0 type: number - name: num_var42 type: number - name: num_var44_0 type: number - name: num_var44 type: number - name: num_var46_0 type: binary - name: num_var46 type: binary - name: saldo_var1 type: number - name: saldo_var5 type: number - name: saldo_var6 type: number - name: saldo_var8 type: number - name: saldo_var12 type: number - name: saldo_var13_corto type: number - name: saldo_var13_largo type: number - name: saldo_var13_medio type: number - name: saldo_var13 type: number - name: saldo_var14 type: number - name: saldo_var17 type: number - name: saldo_var18 type: number - name: saldo_var20 type: number - name: saldo_var24 type: number - name: saldo_var26 type: number - name: saldo_var25 type: number - name: saldo_var28 type: binary - name: saldo_var27 type: binary - name: saldo_var29 type: number - name: saldo_var30 type: number - name: saldo_var31 type: number - name: saldo_var32 type: number - name: saldo_var33 type: number - name: saldo_var34 type: number - name: saldo_var37 type: number - name: saldo_var40 type: number - name: saldo_var41 type: binary - name: saldo_var42 type: number - name: saldo_var44 type: number - name: saldo_var46 type: binary - name: var36 type: number - name: delta_imp_amort_var18_1y3 type: number - name: delta_imp_amort_var34_1y3 type: number - name: delta_imp_aport_var13_1y3 type: number - name: delta_imp_aport_var17_1y3 type: number - name: delta_imp_aport_var33_1y3 type: number - name: delta_imp_compra_var44_1y3 type: number - name: delta_imp_reemb_var13_1y3 type: number - name: delta_imp_reemb_var17_1y3 type: number - name: delta_imp_reemb_var33_1y3 type: number - name: delta_imp_trasp_var17_in_1y3 type: number - name: delta_imp_trasp_var17_out_1y3 type: number - name: delta_imp_trasp_var33_in_1y3 type: number - name: delta_imp_trasp_var33_out_1y3 type: number - name: delta_imp_venta_var44_1y3 type: number - name: delta_num_aport_var13_1y3 type: number - name: delta_num_aport_var17_1y3 type: number - name: delta_num_aport_var33_1y3 type: number - name: delta_num_compra_var44_1y3 type: number - name: delta_num_reemb_var13_1y3 type: number - name: delta_num_reemb_var17_1y3 type: number - name: delta_num_reemb_var33_1y3 type: number - name: delta_num_trasp_var17_in_1y3 type: number - name: delta_num_trasp_var17_out_1y3 type: number - name: delta_num_trasp_var33_in_1y3 type: number - name: delta_num_trasp_var33_out_1y3 type: number - name: delta_num_venta_var44_1y3 type: number - name: imp_amort_var18_hace3 type: binary - name: imp_amort_var18_ult1 type: number - name: imp_amort_var34_hace3 type: binary - name: imp_amort_var34_ult1 type: number - name: imp_aport_var13_hace3 type: number - name: imp_aport_var13_ult1 type: number - name: imp_aport_var17_hace3 type: number - name: imp_aport_var17_ult1 type: number - name: imp_aport_var33_hace3 type: number - name: imp_aport_var33_ult1 type: number - name: imp_var7_emit_ult1 type: number - name: imp_var7_recib_ult1 type: number - name: imp_compra_var44_hace3 type: number - name: imp_compra_var44_ult1 type: number - name: imp_reemb_var13_hace3 type: binary - name: imp_reemb_var13_ult1 type: number - name: imp_reemb_var17_hace3 type: number - name: imp_reemb_var17_ult1 type: number - name: imp_reemb_var33_hace3 type: binary - name: imp_reemb_var33_ult1 type: number - name: imp_var43_emit_ult1 type: number - name: imp_trans_var37_ult1 type: number - name: imp_trasp_var17_in_hace3 type: number - name: imp_trasp_var17_in_ult1 type: number - name: imp_trasp_var17_out_hace3 type: binary - name: imp_trasp_var17_out_ult1 type: number - name: imp_trasp_var33_in_hace3 type: number - name: imp_trasp_var33_in_ult1 type: number - name: imp_trasp_var33_out_hace3 type: binary - name: imp_trasp_var33_out_ult1 type: number - name: imp_venta_var44_hace3 type: number - name: imp_venta_var44_ult1 type: number - name: ind_var7_emit_ult1 type: binary - name: ind_var7_recib_ult1 type: binary - name: ind_var10_ult1 type: binary - name: ind_var10cte_ult1 type: binary - name: ind_var9_cte_ult1 type: binary - name: ind_var9_ult1 type: binary - name: ind_var43_emit_ult1 type: binary - name: ind_var43_recib_ult1 type: binary - name: var21 type: number - name: num_var2_0_ult1 type: binary - name: num_var2_ult1 type: binary - name: num_aport_var13_hace3 type: number - name: num_aport_var13_ult1 type: number - name: num_aport_var17_hace3 type: number - name: num_aport_var17_ult1 type: number - name: num_aport_var33_hace3 type: number - name: num_aport_var33_ult1 type: number - name: num_var7_emit_ult1 type: number - name: num_var7_recib_ult1 type: number - name: num_compra_var44_hace3 type: number - name: num_compra_var44_ult1 type: number - name: num_ent_var16_ult1 type: number - name: num_var22_hace2 type: number - name: num_var22_hace3 type: number - name: num_var22_ult1 type: number - name: num_var22_ult3 type: number - name: num_med_var22_ult3 type: number - name: num_med_var45_ult3 type: number - name: num_meses_var5_ult3 type: category - name: num_meses_var8_ult3 type: category - name: num_meses_var12_ult3 type: category - name: num_meses_var13_corto_ult3 type: category - name: num_meses_var13_largo_ult3 type: category - name: num_meses_var13_medio_ult3 type: number - name: num_meses_var17_ult3 type: category - name: num_meses_var29_ult3 type: category - name: num_meses_var33_ult3 type: category - name: num_meses_var39_vig_ult3 type: category - name: num_meses_var44_ult3 type: category - name: num_op_var39_comer_ult1 type: number - name: num_op_var39_comer_ult3 type: number - name: num_op_var40_comer_ult1 type: number - name: num_op_var40_comer_ult3 type: number - name: num_op_var40_efect_ult1 type: number - name: num_op_var40_efect_ult3 type: number - name: num_op_var41_comer_ult1 type: number - name: num_op_var41_comer_ult3 type: number - name: num_op_var41_efect_ult1 type: number - name: num_op_var41_efect_ult3 type: number - name: num_op_var39_efect_ult1 type: number - name: num_op_var39_efect_ult3 type: number - name: num_reemb_var13_hace3 type: binary - name: num_reemb_var13_ult1 type: number - name: num_reemb_var17_hace3 type: number - name: num_reemb_var17_ult1 type: number - name: num_reemb_var33_hace3 type: binary - name: num_reemb_var33_ult1 type: number - name: num_sal_var16_ult1 type: number - name: num_var43_emit_ult1 type: number - name: num_var43_recib_ult1 type: number - name: num_trasp_var11_ult1 type: number - name: num_trasp_var17_in_hace3 type: number - name: num_trasp_var17_in_ult1 type: number - name: num_trasp_var17_out_hace3 type: binary - name: num_trasp_var17_out_ult1 type: number - name: num_trasp_var33_in_hace3 type: number - name: num_trasp_var33_in_ult1 type: number - name: num_trasp_var33_out_hace3 type: binary - name: num_trasp_var33_out_ult1 type: number - name: num_venta_var44_hace3 type: number - name: num_venta_var44_ult1 type: number - name: num_var45_hace2 type: number - name: num_var45_hace3 type: number - name: num_var45_ult1 type: number - name: num_var45_ult3 type: number - name: saldo_var2_ult1 type: binary - name: saldo_medio_var5_hace2 type: number - name: saldo_medio_var5_hace3 type: number - name: saldo_medio_var5_ult1 type: number - name: saldo_medio_var5_ult3 type: number - name: saldo_medio_var8_hace2 type: number - name: saldo_medio_var8_hace3 type: number - name: saldo_medio_var8_ult1 type: number - name: saldo_medio_var8_ult3 type: number - name: saldo_medio_var12_hace2 type: number - name: saldo_medio_var12_hace3 type: number - name: saldo_medio_var12_ult1 type: number - name: saldo_medio_var12_ult3 type: number - name: saldo_medio_var13_corto_hace2 type: number - name: saldo_medio_var13_corto_hace3 type: number - name: saldo_medio_var13_corto_ult1 type: number - name: saldo_medio_var13_corto_ult3 type: number - name: saldo_medio_var13_largo_hace2 type: number - name: saldo_medio_var13_largo_hace3 type: number - name: saldo_medio_var13_largo_ult1 type: number - name: saldo_medio_var13_largo_ult3 type: number - name: saldo_medio_var13_medio_hace2 type: number - name: saldo_medio_var13_medio_hace3 type: binary - name: saldo_medio_var13_medio_ult1 type: number - name: saldo_medio_var13_medio_ult3 type: number - name: saldo_medio_var17_hace2 type: number - name: saldo_medio_var17_hace3 type: number - name: saldo_medio_var17_ult1 type: number - name: saldo_medio_var17_ult3 type: number - name: saldo_medio_var29_hace2 type: number - name: saldo_medio_var29_hace3 type: number - name: saldo_medio_var29_ult1 type: number - name: saldo_medio_var29_ult3 type: number - name: saldo_medio_var33_hace2 type: number - name: saldo_medio_var33_hace3 type: number - name: saldo_medio_var33_ult1 type: number - name: saldo_medio_var33_ult3 type: number - name: saldo_medio_var44_hace2 type: number - name: saldo_medio_var44_hace3 type: number - name: saldo_medio_var44_ult1 type: number - name: saldo_medio_var44_ult3 type: number - name: var38 type: number combiner: type: tabnet size: 24 # N_a output_size: 128 # N_d sparsity: 0.001 # lambda_sparse bn_momentum: 0.2 # m_B num_steps: 7 # N_steps relaxation_factor: 1.2 # gamma bn_virtual_bs: 256 # B_v trainer: batch_size: 4096 # B eval_batch_size: null # 65536 131072 262144 524288 epochs: 300 early_stop: 30 learning_rate: 0.005 optimizer: type: adam learning_rate_scheduler: decay: exponential decay_steps: 10000 decay_rate: 0.8 validation_metric: accuracy - name: santander_customer_transaction goal: maximize metric: accuracy validation_metric_score: 0.9150915145874023 training_rows: 139904 test_rows: 40098 validation_rows: 19998 config: output_features: - name: target type: binary input_features: - name: var_0 type: number - name: var_1 type: number - name: var_2 type: number - name: var_3 type: number - name: var_4 type: number - name: var_5 type: number - name: var_6 type: number - name: var_7 type: number - name: var_8 type: number - name: var_9 type: number - name: var_10 type: number - name: var_11 type: number - name: var_12 type: number - name: var_13 type: number - name: var_14 type: number - name: var_15 type: number - name: var_16 type: number - name: var_17 type: number - name: var_18 type: number - name: var_19 type: number - name: var_20 type: number - name: var_21 type: number - name: var_22 type: number - name: var_23 type: number - name: var_24 type: number - name: var_25 type: number - name: var_26 type: number - name: var_27 type: number - name: var_28 type: number - name: var_29 type: number - name: var_30 type: number - name: var_31 type: number - name: var_32 type: number - name: var_33 type: number - name: var_34 type: number - name: var_35 type: number - name: var_36 type: number - name: var_37 type: number - name: var_38 type: number - name: var_39 type: number - name: var_40 type: number - name: var_41 type: number - name: var_42 type: number - name: var_43 type: number - name: var_44 type: number - name: var_45 type: number - name: var_46 type: number - name: var_47 type: number - name: var_48 type: number - name: var_49 type: number - name: var_50 type: number - name: var_51 type: number - name: var_52 type: number - name: var_53 type: number - name: var_54 type: number - name: var_55 type: number - name: var_56 type: number - name: var_57 type: number - name: var_58 type: number - name: var_59 type: number - name: var_60 type: number - name: var_61 type: number - name: var_62 type: number - name: var_63 type: number - name: var_64 type: number - name: var_65 type: number - name: var_66 type: number - name: var_67 type: number - name: var_68 type: number - name: var_69 type: number - name: var_70 type: number - name: var_71 type: number - name: var_72 type: number - name: var_73 type: number - name: var_74 type: number - name: var_75 type: number - name: var_76 type: number - name: var_77 type: number - name: var_78 type: number - name: var_79 type: number - name: var_80 type: number - name: var_81 type: number - name: var_82 type: number - name: var_83 type: number - name: var_84 type: number - name: var_85 type: number - name: var_86 type: number - name: var_87 type: number - name: var_88 type: number - name: var_89 type: number - name: var_90 type: number - name: var_91 type: number - name: var_92 type: number - name: var_93 type: number - name: var_94 type: number - name: var_95 type: number - name: var_96 type: number - name: var_97 type: number - name: var_98 type: number - name: var_99 type: number - name: var_100 type: number - name: var_101 type: number - name: var_102 type: number - name: var_103 type: number - name: var_104 type: number - name: var_105 type: number - name: var_106 type: number - name: var_107 type: number - name: var_108 type: number - name: var_109 type: number - name: var_110 type: number - name: var_111 type: number - name: var_112 type: number - name: var_113 type: number - name: var_114 type: number - name: var_115 type: number - name: var_116 type: number - name: var_117 type: number - name: var_118 type: number - name: var_119 type: number - name: var_120 type: number - name: var_121 type: number - name: var_122 type: number - name: var_123 type: number - name: var_124 type: number - name: var_125 type: number - name: var_126 type: number - name: var_127 type: number - name: var_128 type: number - name: var_129 type: number - name: var_130 type: number - name: var_131 type: number - name: var_132 type: number - name: var_133 type: number - name: var_134 type: number - name: var_135 type: number - name: var_136 type: number - name: var_137 type: number - name: var_138 type: number - name: var_139 type: number - name: var_140 type: number - name: var_141 type: number - name: var_142 type: number - name: var_143 type: number - name: var_144 type: number - name: var_145 type: number - name: var_146 type: number - name: var_147 type: number - name: var_148 type: number - name: var_149 type: number - name: var_150 type: number - name: var_151 type: number - name: var_152 type: number - name: var_153 type: number - name: var_154 type: number - name: var_155 type: number - name: var_156 type: number - name: var_157 type: number - name: var_158 type: number - name: var_159 type: number - name: var_160 type: number - name: var_161 type: number - name: var_162 type: number - name: var_163 type: number - name: var_164 type: number - name: var_165 type: number - name: var_166 type: number - name: var_167 type: number - name: var_168 type: number - name: var_169 type: number - name: var_170 type: number - name: var_171 type: number - name: var_172 type: number - name: var_173 type: number - name: var_174 type: number - name: var_175 type: number - name: var_176 type: number - name: var_177 type: number - name: var_178 type: number - name: var_179 type: number - name: var_180 type: number - name: var_181 type: number - name: var_182 type: number - name: var_183 type: number - name: var_184 type: number - name: var_185 type: number - name: var_186 type: number - name: var_187 type: number - name: var_188 type: number - name: var_189 type: number - name: var_190 type: number - name: var_191 type: number - name: var_192 type: number - name: var_193 type: number - name: var_194 type: number - name: var_195 type: number - name: var_196 type: number - name: var_197 type: number - name: var_198 type: number - name: var_199 type: number combiner: type: tabnet size: 8 # N_a output_size: 8 # N_d sparsity: 0.0 # lambda_sparse bn_momentum: 0.4 # m_B num_steps: 3 # N_steps relaxation_factor: 2.0 # gamma bn_virtual_bs: 256 # B_v trainer: batch_size: 256 # B eval_batch_size: null # 65536 131072 262144 524288 epochs: 300 early_stop: 30 learning_rate: 0.005 optimizer: type: adam learning_rate_scheduler: decay: exponential decay_steps: 20000 decay_rate: 0.95 validation_metric: accuracy - name: sarcos goal: minimize metric: root_mean_squared_error validation_metric_score: 2.0124664306640625 training_rows: 40036 test_rows: 0 validation_rows: 4448 config: output_features: - name: torque_1 type: number input_features: - name: position_1 type: number - name: position_2 type: number - name: position_3 type: number - name: position_4 type: number - name: position_5 type: number - name: position_6 type: number - name: position_7 type: number - name: velocity_1 type: number - name: velocity_2 type: number - name: velocity_3 type: number - name: velocity_4 type: number - name: velocity_5 type: number - name: velocity_6 type: number - name: velocity_7 type: number - name: acceleration_1 type: number - name: acceleration_2 type: number - name: acceleration_3 type: number - name: acceleration_4 type: number - name: acceleration_5 type: number - name: acceleration_6 type: number - name: acceleration_7 type: number combiner: type: tabnet size: 128 # N_a output_size: 8 # N_d sparsity: 0.000001 # lambda_sparse bn_momentum: 0.02 # m_B num_steps: 4 # N_steps relaxation_factor: 1.2 # gamma bn_virtual_bs: 4096 # B_v trainer: batch_size: 256 # B eval_batch_size: null # 65536 131072 262144 524288 epochs: 300 early_stop: 30 learning_rate: 0.005 optimizer: type: adam learning_rate_scheduler: decay: exponential decay_steps: 20000 decay_rate: 0.4 validation_metric: root_mean_squared_error - name: walmart_recruiting goal: maximize metric: accuracy validation_metric_score: 0.31689465045928955 training_rows: 453154 test_rows: 129276 validation_rows: 64624 config: output_features: - name: TripType type: category input_features: - name: VisitNumber type: number - name: Weekday type: category - name: Upc type: number - name: ScanCount type: number - name: FinelineNumber type: number combiner: type: tabnet size: 32 # N_a output_size: 128 # N_d sparsity: 0.000001 # lambda_sparse bn_momentum: 0.4 # m_B num_steps: 4 # N_steps relaxation_factor: 1.2 # gamma bn_virtual_bs: 4096 # B_v trainer: batch_size: 8192 # B eval_batch_size: null # 65536 131072 262144 524288 epochs: 300 early_stop: 30 learning_rate: 0.01 optimizer: type: adam learning_rate_scheduler: decay: exponential decay_steps: 20000 decay_rate: 0.9 validation_metric: accuracy ================================================ FILE: ludwig/automl/defaults/text/bert_config.yaml ================================================ trainer: epochs: 10 learning_rate_scheduler: warmup_fraction: 0.1 decay: linear optimizer: type: adamw use_mixed_precision: true defaults: text: encoder: type: bert trainable: true hyperopt: # goal: maximize parameters: # This parameter space was updated to be loguniform because of issues merging with the trainer.learning_rate # parameter space in ludwig/automl/defaults/combiner/concat_config.yaml. Doing automl on a text feature would # create an invalid combination of loguniform and choice paramters. # TODO(jeffkinnison): Add a second pass `merge_dicts` to handle parameter spaces trainer.learning_rate: space: loguniform lower: 0.00002 upper: 0.00003 trainer.batch_size: space: choice categories: [16, 32, 64, 128] ================================================ FILE: ludwig/backend/__init__.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 2020 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. # ============================================================================== import contextlib import logging import os from ludwig.api_annotations import DeveloperAPI from ludwig.backend.base import Backend, LocalBackend logger = logging.getLogger(__name__) # TODO: remove LOCAL_BACKEND as a global constant, replace with singleton LocalBackend.shared_instance(). LOCAL_BACKEND = LocalBackend.shared_instance() LOCAL = "local" DASK = "dask" DEEPSPEED = "deepspeed" RAY = "ray" ALL_BACKENDS = [LOCAL, DASK, DEEPSPEED, RAY] def _has_ray(): # Temporary workaround to prevent tests from automatically using the Ray backend. Taken from # https://stackoverflow.com/questions/25188119/test-if-code-is-executed-from-within-a-py-test-session if "PYTEST_CURRENT_TEST" in os.environ: return False try: import ray except ImportError: return False if ray.is_initialized(): return True try: ray.init("auto", ignore_reinit_error=True) return True except Exception: return False def get_local_backend(**kwargs): return LocalBackend(**kwargs) def create_deepspeed_backend(**kwargs): from ludwig.backend.deepspeed import DeepSpeedBackend return DeepSpeedBackend(**kwargs) def create_ray_backend(**kwargs): from ludwig.backend.ray import RayBackend return RayBackend(**kwargs) backend_registry = { LOCAL: get_local_backend, DEEPSPEED: create_deepspeed_backend, RAY: create_ray_backend, None: get_local_backend, } @DeveloperAPI def create_backend(type, **kwargs): if isinstance(type, Backend): return type if type is None and _has_ray(): type = RAY return backend_registry[type](**kwargs) @DeveloperAPI def initialize_backend(backend): if isinstance(backend, dict): backend = create_backend(**backend) else: backend = create_backend(backend) backend.initialize() return backend @contextlib.contextmanager def provision_preprocessing_workers(backend): if backend.BACKEND_TYPE == RAY: with backend.provision_preprocessing_workers(): yield else: yield ================================================ FILE: ludwig/backend/base.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 2020 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. # ============================================================================== from __future__ import annotations import time from abc import ABC, abstractmethod from collections.abc import Callable, Generator from concurrent.futures import ThreadPoolExecutor from contextlib import contextmanager from typing import Any, TYPE_CHECKING import numpy as np import pandas as pd import psutil import torch from tqdm import tqdm from ludwig.api_annotations import DeveloperAPI from ludwig.backend.utils.storage import StorageManager from ludwig.constants import MODEL_LLM from ludwig.data.cache.manager import CacheManager from ludwig.data.dataframe.base import DataFrameEngine from ludwig.data.dataframe.pandas import PANDAS from ludwig.data.dataset.base import DatasetManager from ludwig.data.dataset.pandas import PandasDatasetManager from ludwig.distributed import init_dist_strategy from ludwig.distributed.base import DistributedStrategy from ludwig.models.base import BaseModel from ludwig.schema.trainer import BaseTrainerConfig from ludwig.types import HyperoptConfigDict from ludwig.utils.audio_utils import read_audio_from_path from ludwig.utils.batch_size_tuner import BatchSizeEvaluator from ludwig.utils.dataframe_utils import from_batches, to_batches from ludwig.utils.fs_utils import get_bytes_obj_from_path from ludwig.utils.misc_utils import get_from_registry from ludwig.utils.system_utils import Resources from ludwig.utils.torch_utils import initialize_pytorch from ludwig.utils.types import DataFrame, Series if TYPE_CHECKING: from ludwig.trainers.base import BaseTrainer @DeveloperAPI class Backend(ABC): def __init__( self, dataset_manager: DatasetManager, cache_dir: str | None = None, credentials: dict[str, dict[str, Any]] | None = None, ): credentials = credentials or {} self._dataset_manager = dataset_manager self._storage_manager = StorageManager(**credentials) self._cache_manager = CacheManager(self._dataset_manager, cache_dir) @property def storage(self) -> StorageManager: return self._storage_manager @property def cache(self) -> CacheManager: return self._cache_manager @property def dataset_manager(self) -> DatasetManager: return self._dataset_manager @abstractmethod def initialize(self): raise NotImplementedError() @abstractmethod def initialize_pytorch(self, *args, **kwargs): raise NotImplementedError() @contextmanager @abstractmethod def create_trainer(self, config: BaseTrainerConfig, model: BaseModel, **kwargs) -> Generator: raise NotImplementedError() @abstractmethod def sync_model(self, model): raise NotImplementedError() @abstractmethod def broadcast_return(self, fn): raise NotImplementedError() @abstractmethod def is_coordinator(self): raise NotImplementedError() @property @abstractmethod def df_engine(self) -> DataFrameEngine: raise NotImplementedError() @property @abstractmethod def supports_multiprocessing(self): raise NotImplementedError() @abstractmethod def read_binary_files(self, column: Series, map_fn: Callable | None = None) -> Series: raise NotImplementedError() @property @abstractmethod def num_nodes(self) -> int: raise NotImplementedError() @property @abstractmethod def num_training_workers(self) -> int: raise NotImplementedError() @abstractmethod def get_available_resources(self) -> Resources: raise NotImplementedError() @abstractmethod def max_concurrent_trials(self, hyperopt_config: HyperoptConfigDict) -> int | None: raise NotImplementedError() @abstractmethod def tune_batch_size(self, evaluator_cls: type[BatchSizeEvaluator], dataset_len: int) -> int: """Returns best batch size (measured in samples / s) on the given evaluator. The evaluator class will need to be instantiated on each worker in the backend cluster, then call `evaluator.select_best_batch_size(dataset_len)`. """ raise NotImplementedError() @abstractmethod def batch_transform( self, df: DataFrame, batch_size: int, transform_fn: Callable, name: str | None = None ) -> DataFrame: """Applies `transform_fn` to every `batch_size` length batch of `df` and returns the result.""" raise NotImplementedError() def supports_batch_size_tuning(self) -> bool: return True class LocalPreprocessingMixin: @property def df_engine(self): return PANDAS @property def supports_multiprocessing(self): return True @staticmethod def read_binary_files(column: pd.Series, map_fn: Callable | None = None, file_size: int | None = None) -> pd.Series: column = column.fillna(np.nan).replace([np.nan], [None]) # normalize NaNs to None sample_fname = column.head(1).values[0] with ThreadPoolExecutor() as executor: # number of threads is inferred if isinstance(sample_fname, str): if map_fn is read_audio_from_path: # bypass torchaudio issue that no longer takes in file-like objects result = executor.map( # type: ignore[misc] lambda path: map_fn(path) if path is not None else path, column.values ) else: result = executor.map( lambda path: get_bytes_obj_from_path(path) if path is not None else path, column.values ) else: # If the sample path is not a string, assume the paths has already been read in result = column.values if map_fn is not None and map_fn is not read_audio_from_path: result = executor.map(map_fn, result) return pd.Series(result, index=column.index, name=column.name) @staticmethod def batch_transform(df: DataFrame, batch_size: int, transform_fn: Callable, name: str | None = None) -> DataFrame: name = name or "Batch Transform" batches = to_batches(df, batch_size) transform = transform_fn() out_batches = [transform(batch.reset_index(drop=True)) for batch in tqdm(batches, desc=name)] out_df = from_batches(out_batches).reset_index(drop=True) return out_df class LocalTrainingMixin: @staticmethod def initialize(): init_dist_strategy("local") @staticmethod def initialize_pytorch(*args, **kwargs): initialize_pytorch(*args, **kwargs) @staticmethod def create_predictor(model: BaseModel, **kwargs): from ludwig.models.predictor import get_predictor_cls return get_predictor_cls(model.type())(model, **kwargs) # type: ignore[call-arg] def sync_model(self, model): pass @staticmethod def broadcast_return(fn): return fn() @staticmethod def is_coordinator() -> bool: return True @staticmethod def tune_batch_size(evaluator_cls: type[BatchSizeEvaluator], dataset_len: int) -> int: evaluator = evaluator_cls() return evaluator.select_best_batch_size(dataset_len) class RemoteTrainingMixin: def sync_model(self, model): pass @staticmethod def broadcast_return(fn): return fn() @staticmethod def is_coordinator() -> bool: return True @DeveloperAPI class LocalBackend(LocalPreprocessingMixin, LocalTrainingMixin, Backend): BACKEND_TYPE = "local" _shared_instance: LocalBackend @classmethod def shared_instance(cls) -> LocalBackend: """Returns a shared singleton LocalBackend instance.""" if not hasattr(cls, "_shared_instance"): cls._shared_instance = cls() return cls._shared_instance def __init__(self, **kwargs) -> None: super().__init__(dataset_manager=PandasDatasetManager(self), **kwargs) @property def num_nodes(self) -> int: return 1 @property def num_training_workers(self) -> int: return 1 def get_available_resources(self) -> Resources: return Resources(cpus=psutil.cpu_count(), gpus=torch.cuda.device_count()) def max_concurrent_trials(self, hyperopt_config: HyperoptConfigDict) -> int | None: # Every trial will be run with Pandas and NO Ray Datasets. Allow Ray Tune to use all the # trial resources it wants, because there is no Ray Datasets process to compete with it for CPUs. return None def create_trainer( self, config: BaseTrainerConfig, model: BaseModel, **kwargs, ) -> BaseTrainer: # type: ignore[override] from ludwig.trainers.registry import get_llm_trainers_registry, get_trainers_registry trainer_cls: type if model.type() == MODEL_LLM: trainer_cls = get_from_registry(config.type, get_llm_trainers_registry()) else: trainer_cls = get_from_registry(model.type(), get_trainers_registry()) return trainer_cls(config=config, model=model, **kwargs) @DeveloperAPI class DataParallelBackend(LocalPreprocessingMixin, Backend, ABC): BACKEND_TYPE = "deepspeed" def __init__(self, **kwargs): super().__init__(dataset_manager=PandasDatasetManager(self), **kwargs) self._distributed: DistributedStrategy | None = None @abstractmethod def initialize(self): pass def initialize_pytorch(self, *args, **kwargs): initialize_pytorch( *args, local_rank=self._distributed.local_rank(), local_size=self._distributed.local_size(), **kwargs ) def create_trainer( self, config: BaseTrainerConfig, model: BaseModel, **kwargs, ) -> BaseTrainer: # type: ignore[override] from ludwig.trainers.trainer import Trainer return Trainer(config, model, distributed=self._distributed, **kwargs) def create_predictor(self, model: BaseModel, **kwargs): from ludwig.models.predictor import get_predictor_cls return get_predictor_cls(model.type())(model, distributed=self._distributed, **kwargs) # type: ignore[call-arg] def sync_model(self, model): # Model weights are only saved on the coordinator, so broadcast # to all other ranks self._distributed.sync_model(model) def broadcast_return(self, fn): """Returns the result of calling `fn` on coordinator, broadcast to all other ranks. Specifically, `fn` is only executed on coordinator, but its result is returned by every rank by broadcasting the return value from coordinator. """ result = fn() if self.is_coordinator() else None if self._distributed: name = f"broadcast_return_{int(time.time())}" result = self._distributed.broadcast_object(result, name=name) return result def is_coordinator(self): return self._distributed.rank() == 0 @property def num_nodes(self) -> int: return self._distributed.size() // self._distributed.local_size() @property def num_training_workers(self) -> int: return self._distributed.size() def get_available_resources(self) -> Resources: # TODO(travis): this double-counts on the same device, it should use a cross-communicator instead cpus = torch.as_tensor([psutil.cpu_count()], dtype=torch.int) cpus = self._distributed.allreduce(cpus).item() gpus = torch.as_tensor([torch.cuda.device_count()], dtype=torch.int) gpus = self._distributed.allreduce(gpus).item() return Resources(cpus=cpus, gpus=gpus) def max_concurrent_trials(self, hyperopt_config: HyperoptConfigDict) -> int | None: # Return None since there is no Ray component return None def tune_batch_size(self, evaluator_cls: type[BatchSizeEvaluator], dataset_len: int) -> int: evaluator = evaluator_cls() return evaluator.select_best_batch_size(dataset_len) ================================================ FILE: ludwig/backend/datasource.py ================================================ """Custom Ray datasource utilities for reading binary files with None handling.""" import logging from typing import Optional, TYPE_CHECKING import pandas as pd import ray import urllib3 from ludwig.utils.fs_utils import get_bytes_obj_from_http_path, is_http if TYPE_CHECKING: import pyarrow logger = logging.getLogger(__name__) def read_binary_files_with_index( paths_and_idxs: list[tuple[str | None, int]], filesystem: Optional["pyarrow.fs.FileSystem"] = None, ) -> "ray.data.Dataset": """Read binary files into a Ray Dataset, handling None paths and HTTP URLs. Each row in the resulting dataset has columns: - "data": the raw bytes of the file (or None if path was None/failed) - "idx": the original index for reordering Args: paths_and_idxs: List of (path, index) tuples. Path can be None. filesystem: PyArrow filesystem for reading non-HTTP files. Returns: A ray.data.Dataset with "data" and "idx" columns. """ def _read_file(path: str | None, idx: int) -> dict: if path is None: return {"data": None, "idx": idx} elif is_http(path): try: data = get_bytes_obj_from_http_path(path) except urllib3.exceptions.HTTPError as e: logger.warning(e) data = None return {"data": data, "idx": idx} else: try: with filesystem.open_input_stream(path) as f: data = f.read() except Exception as e: logger.warning(f"Failed to read file {path}: {e}") data = None return {"data": data, "idx": idx} # Create a dataset from the paths and indices, then map to read files records = [{"path": p, "idx": i} for p, i in paths_and_idxs] ds = ray.data.from_items(records) def read_batch(batch: pd.DataFrame) -> pd.DataFrame: results = [] for _, row in batch.iterrows(): result = _read_file(row["path"], row["idx"]) results.append(result) return pd.DataFrame(results) ds = ds.map_batches(read_batch, batch_format="pandas") return ds ================================================ FILE: ludwig/backend/deepspeed.py ================================================ from typing import Any import deepspeed from ludwig.backend.base import DataParallelBackend from ludwig.constants import FALLBACK_BATCH_SIZE from ludwig.distributed import init_dist_strategy from ludwig.utils.batch_size_tuner import BatchSizeEvaluator class DeepSpeedBackend(DataParallelBackend): BACKEND_TYPE = "deepspeed" def __init__( self, zero_optimization: dict[str, Any] | None = None, fp16: dict[str, Any] | None = None, bf16: dict[str, Any] | None = None, compression_training: dict[str, Any] | None = None, **kwargs ): super().__init__(**kwargs) self.zero_optimization = zero_optimization self.fp16 = fp16 self.bf16 = bf16 self.compression_training = compression_training def initialize(self): # Unlike when we use the Ray backend, we need to initialize the `torch.distributed` context so we can # broadcast, allgather, etc. before preparing the model within the trainer. deepspeed.init_distributed() self._distributed = init_dist_strategy( self.BACKEND_TYPE, zero_optimization=self.zero_optimization, fp16=self.fp16, bf16=self.bf16, compression_training=self.compression_training, ) def supports_batch_size_tuning(self) -> bool: # TODO(travis): need to fix checkpoint saving/loading for DeepSpeed to enable tuning return False def tune_batch_size(self, evaluator_cls: type[BatchSizeEvaluator], dataset_len: int) -> int: return FALLBACK_BATCH_SIZE ================================================ FILE: ludwig/backend/ray.py ================================================ #! /usr/bin/env python # Copyright (c) 2020 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. # ============================================================================== import contextlib import copy import logging import os import tempfile from collections.abc import Callable from functools import partial from typing import Any, TYPE_CHECKING import dask import numpy as np import pandas as pd import ray import ray.train as rt import torch import tqdm from fsspec.config import conf from pyarrow.fs import FSSpecHandler, PyFileSystem from ray import ObjectRef from ray.train import Checkpoint, RunConfig, ScalingConfig from ray.train.constants import TRAIN_ENABLE_WORKER_SPREAD_ENV from ray.train.torch import TorchConfig, TorchTrainer from ray.util.dask import ray_dask_get from ray.util.placement_group import placement_group, remove_placement_group if TYPE_CHECKING: from ludwig.api import LudwigModel from ludwig.backend.base import Backend, RemoteTrainingMixin from ludwig.backend.datasource import read_binary_files_with_index from ludwig.constants import MODEL_ECD, MODEL_LLM, NAME, PREPROCESSING, PROC_COLUMN, TYPE from ludwig.data.dataframe.base import DataFrameEngine try: from ludwig.data.dataset.ray import ( _SCALAR_TYPES, cast_as_tensor_dtype, RayDataset, RayDatasetManager, RayDatasetShard, ) except (ImportError, AttributeError): _SCALAR_TYPES = cast_as_tensor_dtype = RayDataset = RayDatasetManager = RayDatasetShard = None from ludwig.models.base import BaseModel from ludwig.models.ecd import ECD from ludwig.models.predictor import BasePredictor, get_output_columns, get_predictor_cls from ludwig.schema.trainer import ECDTrainerConfig from ludwig.trainers.registry import get_ray_trainers_registry, register_ray_trainer from ludwig.trainers.trainer import BaseTrainer, RemoteTrainer from ludwig.utils.data_utils import use_credentials from ludwig.utils.fs_utils import get_fs_and_path from ludwig.utils.misc_utils import get_from_registry from ludwig.utils.system_utils import Resources from ludwig.utils.torch_utils import get_torch_device, initialize_pytorch from ludwig.utils.types import Series logger = logging.getLogger(__name__) FIFTEEN_MINS_IN_S = 15 * 60 def _num_nodes() -> int: node_resources = [node["Resources"] for node in ray.nodes()] return len(node_resources) def get_trainer_kwargs(**kwargs) -> dict[str, Any]: kwargs = copy.deepcopy(kwargs) # Our goal is to have a worker per resource used for training. # The priority is GPUs, but can fall back to CPUs if there are no # GPUs available. use_gpu = kwargs.get("use_gpu", int(ray.cluster_resources().get("GPU", 0)) > 0) if use_gpu: num_workers = int(ray.cluster_resources().get("GPU", 0)) else: num_workers = _num_nodes() # Remove nics if present (legacy option) kwargs.pop("nics", None) defaults = dict( backend=TorchConfig(), num_workers=num_workers, use_gpu=use_gpu, resources_per_worker={ "CPU": 0 if use_gpu else 1, "GPU": 1 if use_gpu else 0, }, ) return {**defaults, **kwargs} def _create_dask_engine(**kwargs): from ludwig.data.dataframe.dask import DaskEngine return DaskEngine(**kwargs) def _create_modin_engine(**kwargs): from ludwig.data.dataframe.modin import ModinEngine return ModinEngine(**kwargs) def _create_pandas_engine(**kwargs): from ludwig.data.dataframe.pandas import PandasEngine return PandasEngine(**kwargs) _engine_registry = { "dask": _create_dask_engine, "modin": _create_modin_engine, "pandas": _create_pandas_engine, } def _get_df_engine(processor): logger.info(f"Ray processor params: {processor}") if processor is None: # TODO ray: find an informed way to set the parallelism, in practice # it looks like Dask handles this well on its own most of the time return _create_dask_engine() processor_kwargs = processor.copy() dtype = processor_kwargs.pop("type", "dask") engine_cls = _engine_registry.get(dtype) return engine_cls(**processor_kwargs) def _make_picklable(obj): """Recursively convert defaultdicts (which contain unpicklable lambdas) to regular dicts.""" from collections import defaultdict if isinstance(obj, defaultdict): return {k: _make_picklable(v) for k, v in obj.items()} elif isinstance(obj, dict): return {k: _make_picklable(v) for k, v in obj.items()} elif isinstance(obj, tuple) and hasattr(obj, "_fields"): # NamedTuple: reconstruct with the same field names return type(obj)(**{f: _make_picklable(getattr(obj, f)) for f in obj._fields}) elif isinstance(obj, list): return [_make_picklable(item) for item in obj] elif isinstance(obj, tuple): return tuple(_make_picklable(item) for item in obj) return obj def train_fn( executable_kwargs: dict[str, Any] = None, model_ref: ObjectRef = None, # noqa: F821 training_set_metadata: dict[str, Any] = None, features: dict[str, dict] = None, **kwargs, ): """Ray Train worker function for distributed training. Runs inside each Ray worker process. Loads the model from an object ref, wraps dataset shards, trains, and saves results to a Ray checkpoint so the driver can retrieve them (Ray Train 2.x requires a checkpoint for metrics). """ # Pin GPU before loading the model to prevent memory leaking onto other devices initialize_pytorch() # Initialize a local distributed strategy so metric modules can sync. from ludwig.distributed import init_dist_strategy init_dist_strategy("local") train_shard = RayDatasetShard( rt.get_dataset_shard("train"), features, training_set_metadata, ) try: val_shard = rt.get_dataset_shard("val") except KeyError: val_shard = None if val_shard is not None: val_shard = RayDatasetShard( val_shard, features, training_set_metadata, ) try: test_shard = rt.get_dataset_shard("test") except KeyError: test_shard = None if test_shard is not None: test_shard = RayDatasetShard( test_shard, features, training_set_metadata, ) model = ray.get(model_ref) # Use Ray Train's device assignment which respects use_gpu setting, # rather than get_torch_device() which always picks CUDA if available. from ray.train.torch import get_device as ray_get_device device = ray_get_device() model = model.to(device) trainer = RemoteTrainer(model=model, report_tqdm_to_ray=True, **executable_kwargs) results = trainer.train(train_shard, val_shard, test_shard, **kwargs) if results is not None: # only return the model state dict back to the head node. trained_model, *args = results results = (trained_model.cpu().state_dict(), *args) torch.cuda.empty_cache() # Save results to a checkpoint so the driver can retrieve them. # In Ray Train 2.x, result.metrics is only populated when a checkpoint is provided. train_results = results, trainer.validation_field, trainer.validation_metric # Convert defaultdicts to regular dicts so they can be pickled by torch.save. train_results = _make_picklable(train_results) with tempfile.TemporaryDirectory() as tmpdir: torch.save(train_results, os.path.join(tmpdir, "train_results.pt")) rt.report(metrics={}, checkpoint=Checkpoint.from_directory(tmpdir)) @ray.remote def tune_batch_size_fn( dataset: RayDataset = None, data_loader_kwargs: dict[str, Any] = None, executable_kwargs: dict[str, Any] = None, model: ECD = None, # noqa: F821 ludwig_config: dict[str, Any] = None, training_set_metadata: dict[str, Any] = None, features: dict[str, dict] = None, **kwargs, ) -> int: # Pin GPU before loading the model to prevent memory leaking onto other devices initialize_pytorch() try: ds = dataset.to_ray_dataset(shuffle=False) train_shard = RayDatasetShard( ds, features, training_set_metadata, ) device = get_torch_device() model = model.to(device) trainer = RemoteTrainer(model=model, **executable_kwargs) return trainer.tune_batch_size(ludwig_config, train_shard, **kwargs) finally: torch.cuda.empty_cache() @ray.remote def tune_learning_rate_fn( dataset: RayDataset, config: dict[str, Any], data_loader_kwargs: dict[str, Any] = None, executable_kwargs: dict[str, Any] = None, model: ECD = None, # noqa: F821 training_set_metadata: dict[str, Any] = None, features: dict[str, dict] = None, **kwargs, ) -> float: # Pin GPU before loading the model to prevent memory leaking onto other devices initialize_pytorch() try: ds = dataset.to_ray_dataset(shuffle=False) train_shard = RayDatasetShard( ds, features, training_set_metadata, ) device = get_torch_device() model = model.to(device) trainer = RemoteTrainer(model=model, **executable_kwargs) return trainer.tune_learning_rate(config, train_shard, **kwargs) finally: torch.cuda.empty_cache() class TqdmCallback(rt.UserCallback): """Class for a custom ray callback that updates tqdm progress bars in the driver process.""" def __init__(self) -> None: """Constructor for TqdmCallback.""" super().__init__() self.progess_bars = {} def after_report(self, run_context, metrics: list[dict], checkpoint=None) -> None: """Called every time ray.train.report is called from subprocesses. In Ray 2.x, metrics is a list of metric dicts (one per worker). We look for progress_bar data from the coordinator worker. """ for result in metrics: progress_bar_opts = result.get("progress_bar") if not progress_bar_opts: continue # Skip commands received by non-coordinators if not progress_bar_opts["is_coordinator"]: continue _id = progress_bar_opts["id"] action = progress_bar_opts.get("action") if action == "create": progress_bar_config = progress_bar_opts.get("config") self.progess_bars[_id] = tqdm.tqdm(**progress_bar_config) elif action == "close": if _id in self.progess_bars: self.progess_bars[_id].close() elif action == "update": update_by = progress_bar_opts.get("update_by", 1) if _id in self.progess_bars: self.progess_bars[_id].update(update_by) @contextlib.contextmanager def spread_env(use_gpu: bool = False, num_workers: int = 1, **kwargs): if TRAIN_ENABLE_WORKER_SPREAD_ENV in os.environ: # User set this explicitly, so honor their selection yield return try: if not use_gpu and num_workers > 1: # When doing CPU-only training, default to a SPREAD policy to avoid # packing too many workers on a single machine os.environ[TRAIN_ENABLE_WORKER_SPREAD_ENV] = "1" yield finally: if TRAIN_ENABLE_WORKER_SPREAD_ENV in os.environ: del os.environ[TRAIN_ENABLE_WORKER_SPREAD_ENV] def _build_scaling_config(trainer_kwargs: dict[str, Any]) -> ScalingConfig: """Convert legacy trainer kwargs to a Ray ScalingConfig.""" return ScalingConfig( num_workers=trainer_kwargs.get("num_workers", 1), use_gpu=trainer_kwargs.get("use_gpu", False), resources_per_worker=trainer_kwargs.get("resources_per_worker"), ) def run_train_remote(train_loop, trainer_kwargs: dict[str, Any], callbacks=None, datasets=None, train_loop_config=None): """Run a distributed training function using Ray TorchTrainer.""" resolved_kwargs = get_trainer_kwargs(**trainer_kwargs) scaling_config = _build_scaling_config(resolved_kwargs) torch_config = resolved_kwargs.get("backend", TorchConfig()) run_config_kwargs = {} if callbacks: run_config_kwargs["callbacks"] = callbacks with spread_env(**resolved_kwargs): torch_trainer = TorchTrainer( train_loop_per_worker=train_loop, train_loop_config=train_loop_config, torch_config=torch_config, scaling_config=scaling_config, run_config=RunConfig(**run_config_kwargs), datasets=datasets, ) result = torch_trainer.fit() return result @register_ray_trainer(MODEL_ECD, default=True) class RayTrainerV2(BaseTrainer): def __init__( self, model: BaseModel, trainer_kwargs: dict[str, Any], data_loader_kwargs: dict[str, Any], executable_kwargs: dict[str, Any], **kwargs, ): self.model = model.cpu() self.data_loader_kwargs = data_loader_kwargs self.executable_kwargs = executable_kwargs self.trainer_kwargs = trainer_kwargs self._validation_field = None self._validation_metric = None @staticmethod def get_schema_cls(): return ECDTrainerConfig def train( self, training_set: RayDataset, validation_set: RayDataset | None = None, test_set: RayDataset | None = None, **kwargs, ): executable_kwargs = self.executable_kwargs kwargs = { "training_set_metadata": training_set.training_set_metadata, "features": training_set.features, **kwargs, } dataset = {"train": training_set.to_ray_dataset(shuffle=True)} if validation_set is not None: dataset["val"] = validation_set.to_ray_dataset(shuffle=False) if test_set is not None: dataset["test"] = test_set.to_ray_dataset(shuffle=False) train_loop_config = {"executable_kwargs": executable_kwargs, "model_ref": ray.put(self.model), **kwargs} def _train_loop(config): train_fn(**config) result = run_train_remote( _train_loop, trainer_kwargs=self.trainer_kwargs, callbacks=[TqdmCallback()], datasets=dataset, train_loop_config=train_loop_config, ) # Load training results from the checkpoint saved by train_fn with result.checkpoint.as_directory() as tmpdir: train_results = torch.load(os.path.join(tmpdir, "train_results.pt"), weights_only=False) results, self._validation_field, self._validation_metric = train_results # load state dict back into the model state_dict, *args = results self.model.load_state_dict(state_dict) results = (self.model, *args) return results def train_online(self, *args, **kwargs): # TODO: When this is implemented we also need to update the # Tqdm flow to report back the callback raise NotImplementedError() def tune_batch_size( self, config: dict[str, Any], training_set: RayDataset, **kwargs, ) -> int: return ray.get( tune_batch_size_fn.options(num_cpus=self.num_cpus, num_gpus=self.num_gpus).remote( dataset=training_set, data_loader_kwargs=self.data_loader_kwargs, executable_kwargs=self.executable_kwargs, model=ray.put(self.model), ludwig_config=config, training_set_metadata=training_set.training_set_metadata, features=training_set.features, **kwargs, ) ) def tune_learning_rate(self, config, training_set: RayDataset, **kwargs) -> float: return ray.get( tune_learning_rate_fn.options(num_cpus=self.num_cpus, num_gpus=self.num_gpus).remote( dataset=training_set, config=config, data_loader_kwargs=self.data_loader_kwargs, executable_kwargs=self.executable_kwargs, model=ray.put(self.model), training_set_metadata=training_set.training_set_metadata, features=training_set.features, **kwargs, ) ) @property def validation_field(self): return self._validation_field @property def validation_metric(self): return self._validation_metric @property def config(self) -> ECDTrainerConfig: return self.executable_kwargs["config"] @property def batch_size(self) -> int: return self.config.batch_size @batch_size.setter def batch_size(self, value: int): self.config.batch_size = value @property def eval_batch_size(self) -> int: return self.config.eval_batch_size if self.config.eval_batch_size is not None else self.config.batch_size @eval_batch_size.setter def eval_batch_size(self, value: int): self.config.eval_batch_size = value @property def resources_per_worker(self) -> dict[str, Any]: trainer_kwargs = get_trainer_kwargs(**self.trainer_kwargs) return trainer_kwargs.get("resources_per_worker", {}) @property def num_cpus(self) -> int: return self.resources_per_worker.get("CPU", 1) @property def num_gpus(self) -> int: return self.resources_per_worker.get("GPU", 0) def set_base_learning_rate(self, learning_rate: float): self.config.learning_rate = learning_rate def shutdown(self): pass def eval_fn( predictor_kwargs: dict[str, Any] = None, model_ref: ObjectRef = None, # noqa: F821 training_set_metadata: dict[str, Any] = None, features: dict[str, dict] = None, **kwargs, ): """Ray Train worker function for distributed evaluation. Runs inside each Ray worker process. Loads the model from an object ref, wraps the eval dataset shard, runs prediction and evaluation, and saves results to a Ray checkpoint for driver retrieval. """ # Pin GPU before loading the model to prevent memory leaking onto other devices initialize_pytorch() # Initialize a local distributed strategy so metric modules can sync. from ludwig.distributed import init_dist_strategy init_dist_strategy("local") try: eval_shard = RayDatasetShard( rt.get_dataset_shard("eval"), features, training_set_metadata, ) model = ray.get(model_ref) # Use Ray Train's device assignment which respects use_gpu setting from ray.train.torch import get_device as ray_get_device device = ray_get_device() model = model.to(device) predictor_cls = get_predictor_cls(model.type()) predictor = predictor_cls(dist_model=model, model=model, report_tqdm_to_ray=True, **predictor_kwargs) eval_results = predictor.batch_evaluation(eval_shard, **kwargs) # Save results to a checkpoint so the driver can retrieve them. # In Ray Train 2.x, result.metrics is only populated when a checkpoint is provided. eval_results = _make_picklable(eval_results) with tempfile.TemporaryDirectory() as tmpdir: torch.save(eval_results, os.path.join(tmpdir, "eval_results.pt")) rt.report(metrics={}, checkpoint=Checkpoint.from_directory(tmpdir)) finally: torch.cuda.empty_cache() class RayPredictor(BasePredictor): def __init__( self, model: BaseModel, df_engine: DataFrameEngine, trainer_kwargs, data_loader_kwargs, **predictor_kwargs ): self.batch_size = predictor_kwargs["batch_size"] self.trainer_kwargs = trainer_kwargs self.data_loader_kwargs = data_loader_kwargs self.predictor_kwargs = predictor_kwargs self.actor_handles = [] self.model = model.cpu() self.df_engine = df_engine def get_trainer_kwargs(self) -> dict[str, Any]: return get_trainer_kwargs(**self.trainer_kwargs) def get_resources_per_worker(self) -> tuple[int, int]: trainer_kwargs = self.get_trainer_kwargs() resources_per_worker = trainer_kwargs.get("resources_per_worker", {}) num_gpus = resources_per_worker.get("GPU", 0) num_cpus = resources_per_worker.get("CPU", (1 if num_gpus == 0 else 0)) return num_cpus, num_gpus def batch_predict(self, dataset: RayDataset, *args, collect_logits: bool = False, **kwargs): self._check_dataset(dataset) predictor_kwargs = self.predictor_kwargs output_columns = get_output_columns(self.model.output_features, include_logits=collect_logits) batch_predictor = self.get_batch_infer_model( self.model, predictor_kwargs, output_columns, dataset.features, dataset.training_set_metadata, *args, collect_logits=collect_logits, **kwargs, ) columns = [f.proc_column for f in self.model.input_features.values()] def to_tensors(df: pd.DataFrame) -> pd.DataFrame: for c in columns: df[c] = cast_as_tensor_dtype(df[c]) return df num_cpus, num_gpus = self.get_resources_per_worker() predictions = dataset.ds.map_batches(to_tensors, batch_format="pandas").map_batches( batch_predictor, batch_size=self.batch_size, compute=ray.data.ActorPoolStrategy(), batch_format="pandas", num_cpus=num_cpus, num_gpus=num_gpus, ) predictions = self.df_engine.from_ray_dataset(predictions) return predictions def predict_single(self, batch): raise NotImplementedError("predict_single can only be called on a local predictor") def batch_evaluation( self, dataset: RayDataset, collect_predictions: bool = False, collect_logits=False, **kwargs, ): # We need to be in a distributed context to collect the aggregated metrics, since it relies on collective # communication ops. However, distributed training is not suitable for transforming one big dataset to another. # For that we will use Ray Datasets. Therefore, we break this up into two separate steps, and two passes over # the dataset. In the future, we can explore ways to combine these into a single step to reduce IO. # Collect eval metrics by distributing work across nodes / gpus datasets = {"eval": dataset.to_ray_dataset(shuffle=False)} predictor_kwargs = { **self.predictor_kwargs, "collect_predictions": False, } eval_loop_config = { "predictor_kwargs": predictor_kwargs, "model_ref": ray.put(self.model), "training_set_metadata": dataset.training_set_metadata, "features": dataset.features, **kwargs, } def _eval_loop(config): eval_fn(**config) result = run_train_remote( _eval_loop, trainer_kwargs=self.trainer_kwargs, datasets=datasets, train_loop_config=eval_loop_config, ) # Load eval results from the checkpoint saved by eval_fn with result.checkpoint.as_directory() as tmpdir: eval_stats, _ = torch.load(os.path.join(tmpdir, "eval_results.pt"), weights_only=False) predictions = None if collect_predictions: # Collect eval predictions by using Ray Datasets to transform partitions of the data in parallel predictions = self.batch_predict(dataset, collect_logits=collect_logits) return eval_stats, predictions def batch_collect_activations(self, model, *args, **kwargs): raise NotImplementedError("Ray backend does not support collecting activations at this time.") def _check_dataset(self, dataset): if not isinstance(dataset, RayDataset): raise RuntimeError(f"Ray backend requires RayDataset for inference, " f"found: {type(dataset)}") def shutdown(self): for handle in self.actor_handles: ray.kill(handle) self.actor_handles.clear() def get_batch_infer_model( self, model: "LudwigModel", # noqa: F821 predictor_kwargs: dict[str, Any], output_columns: list[str], features: dict[str, dict], training_set_metadata: dict[str, Any], *args, **kwargs, ): model_ref = ray.put(model) _, num_gpus = self.get_resources_per_worker() class BatchInferModel: def __init__(self): model = ray.get(model_ref) # Respect the GPU setting from resources_per_worker. # When num_gpus=0, force CPU even if CUDA is available on the machine, # to avoid device mismatches between model outputs and targets. if num_gpus > 0: device = get_torch_device() else: device = "cpu" self.model = model.to(device) self.output_columns = output_columns self.features = features self.training_set_metadata = training_set_metadata self.reshape_map = { f[PROC_COLUMN]: training_set_metadata[f[NAME]].get("reshape") for f in features.values() } predictor_cls = get_predictor_cls(self.model.type()) predictor = predictor_cls(dist_model=self.model, model=self.model, **predictor_kwargs) self.predict = partial(predictor.predict_single, *args, **kwargs) def __call__(self, df: pd.DataFrame) -> pd.DataFrame: dataset = self._prepare_batch(df) predictions = self.predict(batch=dataset).set_index(df.index) ordered_predictions = predictions[self.output_columns] return ordered_predictions def _prepare_batch(self, batch: pd.DataFrame) -> dict[str, np.ndarray]: res = {} for c in self.features.keys(): if self.features[c][TYPE] not in _SCALAR_TYPES: # Ensure columns stacked instead of turned into np.array([np.array, ...], dtype=object) objects res[c] = np.stack(batch[c].values) else: res[c] = batch[c].to_numpy() for c in self.features.keys(): reshape = self.reshape_map.get(c) if reshape is not None: res[c] = res[c].reshape((-1, *reshape)) return res return BatchInferModel class RayBackend(RemoteTrainingMixin, Backend): BACKEND_TYPE = "ray" def __init__(self, processor=None, trainer=None, loader=None, preprocessor_kwargs=None, **kwargs): super().__init__(dataset_manager=RayDatasetManager(self), **kwargs) self._preprocessor_kwargs = preprocessor_kwargs or {} self._df_engine = _get_df_engine(processor) self._distributed_kwargs = trainer or {} self._pytorch_kwargs = {} self._data_loader_kwargs = loader or {} self._preprocessor_pg = None def initialize(self): initialize_ray() dask.config.set(scheduler=ray_dask_get) # Disable placement groups on dask dask.config.set(annotations={"ray_remote_args": {"placement_group": None}}) # Prevent Dask from converting object-dtype columns to PyArrow strings, # which corrupts binary data, numpy arrays, and complex Python objects. dask.config.set({"dataframe.convert-string": False}) def generate_bundles(self, num_cpu): # Ray requires that each bundle be scheduleable on a single node. # So a bundle of 320 cpus would never get scheduled. For now a simple heuristic # to be used is to just request 1 cpu at a time. return [{"CPU": 1} for _ in range(int(num_cpu))] @contextlib.contextmanager def provision_preprocessing_workers(self): num_cpu = self._preprocessor_kwargs.get("num_cpu") if not num_cpu: logger.info( "Backend config has num_cpu not set." " provision_preprocessing_workers() is a no-op in this case." ) yield else: bundles = self.generate_bundles(num_cpu) logger.info("Requesting bundles of %s for preprocessing", bundles) self._preprocessor_pg = placement_group(bundles) ready = self._preprocessor_pg.wait(FIFTEEN_MINS_IN_S) if not ready: remove_placement_group(self._preprocessor_pg) raise TimeoutError( "Ray timed out in provisioning the placement group for preprocessing." f" {num_cpu} CPUs were requested but were unable to be provisioned." ) logger.info("%s CPUs were requested and successfully provisioned", num_cpu) try: with dask.config.set(annotations={"ray_remote_args": {"placement_group": self._preprocessor_pg}}): yield finally: self._release_preprocessing_workers() def _release_preprocessing_workers(self): if self._preprocessor_pg is not None: remove_placement_group(self._preprocessor_pg) self._preprocessor_pg = None def initialize_pytorch(self, **kwargs): # Make sure we don't claim any GPU resources on the head node initialize_pytorch(gpus=-1) self._pytorch_kwargs = kwargs def create_trainer(self, model: BaseModel, **kwargs) -> "BaseTrainer": # noqa: F821 executable_kwargs = {**kwargs, **self._pytorch_kwargs} if model.type() == MODEL_LLM: from ludwig.trainers.registry import get_llm_ray_trainers_registry trainer_config = kwargs.get("config") trainer_type = trainer_config.type if trainer_config else None trainer_cls = get_from_registry(trainer_type, get_llm_ray_trainers_registry()) else: trainer_cls = get_from_registry(model.type(), get_ray_trainers_registry()) # Deep copy to workaround https://github.com/ray-project/ray/issues/24139 all_kwargs = { "model": model, "trainer_kwargs": copy.deepcopy(self._distributed_kwargs), "data_loader_kwargs": self._data_loader_kwargs, "executable_kwargs": executable_kwargs, } all_kwargs.update(kwargs) return trainer_cls(**all_kwargs) def create_predictor(self, model: BaseModel, **kwargs): executable_kwargs = {**kwargs, **self._pytorch_kwargs} return RayPredictor( model, self.df_engine, copy.deepcopy(self._distributed_kwargs), self._data_loader_kwargs, **executable_kwargs, ) def set_distributed_kwargs(self, **kwargs): self._distributed_kwargs = kwargs @property def df_engine(self): return self._df_engine @property def supports_multiprocessing(self): return False def check_lazy_load_supported(self, feature): if not feature[PREPROCESSING]["in_memory"]: raise ValueError( f"RayBackend does not support lazy loading of data files at train time. " f"Set preprocessing config `in_memory: True` for feature {feature[NAME]}" ) def read_binary_files(self, column: Series, map_fn: Callable | None = None, file_size: int | None = None) -> Series: column = column.fillna(np.nan).replace([np.nan], [None]) # normalize NaNs to None # Assume that the list of filenames is small enough to fit in memory. Should be true unless there # are literally billions of filenames. # TODO(travis): determine if there is a performance penalty to passing in individual files instead of # a directory. If so, we can do some preprocessing to determine if it makes sense to read the full directory # then filter out files as a postprocessing step (depending on the ratio of included to excluded files in # the directory). Based on a preliminary look at how Ray handles directory expansion to files, it looks like # there should not be any difference between providing a directory versus a list of files. pd_column = self.df_engine.compute(column) fnames = pd_column.values.tolist() idxs = pd_column.index.tolist() # Sample a filename to extract the filesystem info sample_fname = fnames[0] if isinstance(sample_fname, str): fs, _ = get_fs_and_path(sample_fname) filesystem = PyFileSystem(FSSpecHandler(fs)) paths_and_idxs = list(zip(fnames, idxs)) ds = read_binary_files_with_index(paths_and_idxs, filesystem=filesystem) # Rename "data" column to "value" for downstream compatibility ds = ds.rename_columns({"data": "value"}) else: # Assume the path has already been read in, so just convert directly to a dataset # Name the column "value" to match the behavior of the above column_df = column.to_frame(name="value") column_df["idx"] = column_df.index ds = self.df_engine.to_ray_dataset(column_df) # Collect the Ray Dataset to pandas to avoid Arrow's string coercion # for binary/object columns (to_dask() converts bytes to string[pyarrow], # corrupting binary data and complex Python objects). pdf = ds.to_pandas() if map_fn is not None: with use_credentials(conf): pdf["value"] = pdf["value"].map(map_fn) pdf = pdf.rename(columns={"value": column.name}) if "idx" in pdf.columns: pdf = pdf.set_index("idx", drop=True) pdf.index.name = column.index.name # Convert to Dask for downstream compatibility. # Note: dataframe.convert-string is disabled globally in RayBackend.initialize() # to prevent object-dtype columns from being coerced to PyArrow strings. df = self.df_engine.from_pandas(pdf) return df[column.name] @property def num_nodes(self) -> int: if not ray.is_initialized(): return 1 return len(ray.nodes()) @property def num_training_workers(self) -> int: return self._distributed_kwargs.get("num_workers", 1) def max_concurrent_trials(self, hyperopt_config) -> int | None: # Limit concurrency based on available resources to avoid deadlocks between # Ray Tune trials and the Ray Datasets used internally for distributed training. resources = self.get_available_resources() num_cpus_per_trial = self._distributed_kwargs.get("resources_per_worker", {}).get("CPU", 1) num_workers = self._distributed_kwargs.get("num_workers", 1) cpus_per_trial = num_cpus_per_trial * num_workers if cpus_per_trial > 0 and resources.cpus > 0: return max(1, int(resources.cpus // cpus_per_trial)) return None def tune_batch_size(self, evaluator_cls, dataset_len: int) -> int: evaluator = evaluator_cls() return evaluator.select_best_batch_size(dataset_len) def batch_transform(self, df, batch_size: int, transform_fn, name: str | None = None): name = name or "Batch Transform" import dask.dataframe as dd from ludwig.utils.dataframe_utils import from_batches, to_batches # Compute Dask DataFrame to pandas before batching, as Dask-expr # doesn't support row slicing via integer indexing (df[i:j]). npartitions = df.npartitions if hasattr(df, "npartitions") else 1 df = self.df_engine.compute(df) batches = to_batches(df, batch_size) transform = transform_fn() out_batches = [transform(batch.reset_index(drop=True)) for batch in batches] out_df = from_batches(out_batches).reset_index(drop=True) # Convert back to Dask so downstream code (split, etc.) still works return dd.from_pandas(out_df, npartitions=max(1, npartitions)) def get_available_resources(self) -> Resources: resources = ray.cluster_resources() return Resources(cpus=resources.get("CPU", 0), gpus=resources.get("GPU", 0)) def initialize_ray(): if not ray.is_initialized(): try: ray.init("auto", ignore_reinit_error=True) except ConnectionError: init_ray_local() def init_ray_local(): logger.info("Initializing new Ray cluster...") ray.init(ignore_reinit_error=True) ================================================ FILE: ludwig/backend/utils/__init__.py ================================================ ================================================ FILE: ludwig/backend/utils/storage.py ================================================ import contextlib from typing import Any, Optional, Union from ludwig.utils import data_utils CredInputs = Optional[Union[str, dict[str, Any]]] DEFAULTS = "defaults" ARTIFACTS = "artifacts" DATASETS = "datasets" CACHE = "cache" class Storage: def __init__(self, creds: dict[str, Any] | None): self._creds = creds @contextlib.contextmanager def use_credentials(self): with data_utils.use_credentials(self._creds): yield @property def credentials(self) -> dict[str, Any] | None: return self._creds class StorageManager: def __init__( self, defaults: CredInputs = None, artifacts: CredInputs = None, datasets: CredInputs = None, cache: CredInputs = None, ): defaults = load_creds(defaults) cred_inputs = { DEFAULTS: defaults, ARTIFACTS: load_creds(artifacts), DATASETS: load_creds(datasets), CACHE: load_creds(cache), } self.storages = {k: Storage(v if v is not None else defaults) for k, v in cred_inputs.items()} @property def defaults(self) -> Storage: return self.storages[DEFAULTS] @property def artifacts(self) -> Storage: """TODO(travis): Currently used for hyperopt, but should be used for all outputs.""" return self.storages[ARTIFACTS] @property def datasets(self) -> Storage: """TODO(travis): Should be used to read in datasets.""" return self.storages[DATASETS] @property def cache(self) -> Storage: return self.storages[CACHE] def load_creds(cred: CredInputs) -> dict[str, Any]: if isinstance(cred, str): cred = data_utils.load_json(cred) return cred ================================================ FILE: ludwig/benchmarking/README.md ================================================ # Ludwig Benchmarking ### Some use cases - Regression testing for ML experiments across releases and PRs. - Model performance testing for experimenting with new features and hyperparameters. - Resource usage tracking for the full ML pipeline. ## Ludwig benchmarking CLI and API To run benchmarks, run the following command from the command line ``` ludwig benchmark --benchmarking_config path/to/benchmarking/config.yaml ``` To use the API ``` from ludwig.benchmarking.benchmark import benchmark benchmarking_config_path = "path/to/benchmarking/config.yaml" benchmark(benchmarking_config_path) ``` In what follows, we describe what the benchmarking config looks for multiple use cases. ## The benchmarking config The benchmarking config is where you can specify 1. The datasets you want to run the benchmarks on and their configs. 1. Whether these experiments are hyperopt or regular train and eval experiments. 1. The name of the experiment. 1. A python script to edit the specified Ludwig configs programmatically/on the fly. 1. The export path of these experiment's artifacts. (remotely or locally) 1. Whether to use `LudwigProfiler` to track resource usage for preprocessing, training, and evaluation of the experiment. You can find an example of a benchmarking config in the `examples/` directory. ## Basic Usage Say you implemented a new feature and would like to test it on several datasets. In this case, this is what the benchmarking config could look like ``` experiment_name: SMOTE_test hyperopt: false export: export_artifacts: true export_base_path: s3://benchmarking.us-west-2.ludwig.com/bench/ # include the slash at the end. experiments: - dataset_name: ames_housing config_path: /home/ray/configs/ames_housing_SMOTE.yaml experiment_name: SMOTE_test_with_hyperopt hyperopt: true - dataset_name: protein - ... ... - dataset_name: mercedes_benz_greener config_path: /home/ray/configs/mercedes_benz_greener_SMOTE.yaml ``` For each experiment: - `dataset_name`: name of the dataset in `ludwig.datasets` to run the benchmark on. - `config_path` (optional): path to Ludwig config. If not specified, this will load the config corresponding to the dataset only containing `input_features` and `output_features`. This will run `LudwigModel.experiment` on the datasets with their specified configs. If these configs contain a hyperopt section and you'd like to run hyperopt, change to `hyperopt: true`. You can specify the same dataset multiple times with different configs. **Exporting artifacts** By specifying `export_artifacts: true`, this will export the experiment artifacts to the `export_base_path`. Once the model is trained and the artifacts are pushed to the specified path, you will get a similar message to the following: ``` Uploaded metrics report and experiment config to s3://benchmarking.us-west-2.ludwig.com/bench/ames_housing/SMOTE_test ``` This is the directory structure of the exported artifacts for one of the experiments. ``` s3://benchmarking.us-west-2.ludwig.com/bench/ └── ames_housing └── SMOTE_test ├── config.yaml └── experiment_run ├── description.json ├── model │   ├── logs │   │   ├── test │   │   │   └── events.out.tfevents.1663320893.macbook-pro.lan.8043.2 │   │   ├── training │   │   │   └── events.out.tfevents.1663320893.macbook-pro.lan.8043.0 │   │   └── validation │   │   └── events.out.tfevents.1663320893.macbook-pro.lan.8043.1 │   ├── model_hyperparameters.json │   ├── training_progress.json │   └── training_set_metadata.json ├── test_statistics.json └── training_statistics.json ``` Note that model checkpoints are not exported. Any other experiments on the `ames_housing` dataset will also live under `s3://benchmarking.us-west-2.ludwig.com/bench/ames_housing/` **Overriding parameters** The benchmarking config's global parameters `experiment_name` and `hyperopt` can be overridden if specified within an experiment. ## Programmatically editing Ludwig configs To apply some changes to multiple Ludwig configs, you can specify a path to a python script that does this without the need to do manual modifications across many configs. Example: ``` experiment_name: logistic_regression_hyperopt hyperopt: true process_config_file_path: /home/ray/process_config.py export: export_artifacts: true export_base_path: s3://benchmarking.us-west-2.ludwig.com/bench/ # include the slash at the end. experiments: - dataset_name: ames_housing config_path: /home/ray/configs/ames_housing_SMOTE.yaml ... ``` In `/home/ray/process_config.py`, define the following function and add custom code to modify ludwig configs ``` def process_config(ludwig_config: dict, experiment_dict: dict) -> dict: """Modify a Ludwig config. :param ludwig_config: a Ludwig config. :param experiment_dict: a benchmarking config experiment dictionary. returns: a modified Ludwig config. """ # code to modify the Ludwig config. return ludwig_config ``` View the `examples/` folder for an example `process_config.py`. ## Benchmarking the resource usage with `LudwigProfiler` To benchmark the resource usage of the preprocessing, training, and evaluation steps of `LudwigModel.experiment`, you can specify in the benchmarking config global parameters ``` profiler: enable: true use_torch_profiler: false logging_interval: 0.1 ``` - `enable: true` will run benchmarking with `LudwigProfiler`. - `use_torch_profiler: false` will skip using the torch profiler. - `logging_interval: 0.1` will instruct `LudwigProfiler` to collect resource usage information every 0.1 seconds. Note that profiling is only enabled in the case where `hyperopt: false`. `LudwigProfiler` is passed in to `LudwigModel` callbacks. The specific callbacks that will be called are: - `on_preprocess_(start/end)` - `on_train_(start/end)` - `on_evaluation_(start/end)` This is an example directory output when using the profiler: ``` full_bench_with_profiler_with_torch ├── config.yaml ├── experiment_run ├── system_resource_usage │   ├── evaluation │   │   └── run_0.json │   ├── preprocessing │   │   └── run_0.json │   └── training │   └── run_0.json └── torch_ops_resource_usage ├── evaluation │   └── run_0.json ├── preprocessing │   └── run_0.json └── training └── run_0.json ``` The only difference is the `system_resource_usage` and `torch_ops_resource_usage`. The difference between these two outputs can be found in the `LudwigProfiler` README. ## Parameters and defaults Each of these parameters can also be specified in the experiments section to override the global value. If not specified, the value of the global parameter will be propagated to the experiments. - `experiment_name` (required): name of the benchmarking run. - `export` (required): dictionary specifying whether to export the experiment artifacts and the export path. - `hyperopt` (optional): whether this is a hyperopt run or `LudwigModel.experiment`. - `process_config_file_path` (optional): path to python script that will modify configs. - `profiler` (optional): dictionary specifying whether to use the profiler and its parameters. ## Comparing experiments You can summarize the exported artifacts of two experiments on multiple datasets. For example, if you ran two experiments on the datasets `ames_housing` called `small_batch_size` and `big_batch_size` where you varied the batch size, you can create a diff summary of the model performance and resource usage of the two experiments. This is how: ``` from ludwig.benchmarking.summarize import summarize_metrics dataset_list, metric_diffs, resource_usage_diffs = summarize_metrics( bench_config_path = "path/to/benchmarking_config.yaml", base_experiment = "small_batch_size", experimental_experiment = "big_batch_size", download_base_path = "s3://benchmarking.us-west-2.ludwig.com/bench/") ``` This will print ``` Model performance metrics for *small_batch_size* vs. *big_batch_size* on dataset *ames_housing* Output Feature Name Metric Name small_batch_size big_batch_size Diff Diff Percentage SalePrice mean_absolute_error 180551.609 180425.109 -126.5 -0.07 SalePrice mean_squared_error 38668763136.0 38618021888.0 -50741248.0 -0.131 SalePrice r2 -5.399 -5.391 0.008 -0.156 SalePrice root_mean_squared_error 196643.75 196514.688 -129.062 -0.066 SalePrice root_mean_squared_percentage_error 1.001 1.001 -0.001 -0.07 Exported a CSV report to summarize_output/performance_metrics/ames_housing/small_batch_size-big_batch_size.csv Resource usage for *small_batch_size* vs. *big_batch_size* on *training* of dataset *ames_housing* Metric Name small_batch_size big_batch_size Diff Diff Percentage average_cpu_memory_usage 106.96 Mb 109.43 Mb 2.48 Mb 2.315 average_cpu_utilization 1.2966666666666666 1.345 0.04833333333333334 3.728 average_global_cpu_memory_available 3.46 Gb 3.46 Gb -1.10 Mb -0.031 average_global_cpu_utilization 37.43333333333334 40.49 3.056666666666665 8.166 disk_footprint 372736 413696 40960 10.989 max_cpu_memory_usage 107.50 Mb 111.93 Mb 4.43 Mb 4.117 max_cpu_utilization 1.44 1.67 0.22999999999999998 15.972 max_global_cpu_utilization 54.1 60.9 6.799999999999997 12.569 min_global_cpu_memory_available 3.46 Gb 3.46 Gb -712.00 Kb -0.02 num_cpu 10 10 0 0.0 num_oom_events 0 0 0 inf num_runs 1 1 0 0.0 torch_cpu_average_memory_used 81.44 Kb 381.15 Kb 299.70 Kb 367.992 torch_cpu_max_memory_used 334.26 Kb 2.65 Mb 2.32 Mb 711.877 torch_cpu_time 57.400ms 130.199ms 72.799ms 126.828 torch_cuda_time 0.000us 0.000us 0.000us inf total_cpu_memory_size 32.00 Gb 32.00 Gb 0 b 0.0 total_execution_time 334.502ms 1.114s 779.024ms 232.891 Exported a CSV report to summarize_output/resource_usage_metrics/ames_housing/training-small_batch_size-big_batch_size.csv Resource usage for *small_batch_size* vs. *big_batch_size* on *evaluation* of dataset *ames_housing* ... Resource usage for *small_batch_size* vs. *big_batch_size* on *preprocessing* of dataset *ames_housing* ... ``` ================================================ FILE: ludwig/benchmarking/__init__.py ================================================ ================================================ FILE: ludwig/benchmarking/artifacts.py ================================================ import os from dataclasses import dataclass from typing import Any from ludwig.globals import MODEL_FILE_NAME from ludwig.types import ModelConfigDict, TrainingSetMetadataDict from ludwig.utils.data_utils import load_json, load_yaml @dataclass class BenchmarkingResult: # The Ludwig benchmarking config. benchmarking_config: dict[str, Any] # The config for one experiment. experiment_config: dict[str, Any] # The Ludwig config used to run the experiment. ludwig_config: ModelConfigDict # The python script that is used to process the config before being used. process_config_file: str # Loaded `description.json` file. description: dict[str, Any] # Loaded `test_statistics.json` file. test_statistics: dict[str, Any] # Loaded `training_statistics.json` file. training_statistics: dict[str, Any] # Loaded `model_hyperparameters.json` file. model_hyperparameters: dict[str, Any] # Loaded `training_progress.json` file. training_progress: dict[str, Any] # Loaded `training_set_metadata.json` file. training_set_metadata: TrainingSetMetadataDict def build_benchmarking_result(benchmarking_config: dict, experiment_idx: int): experiment_config = benchmarking_config["experiments"][experiment_idx] process_config_file = "" if experiment_config["process_config_file_path"]: with open(experiment_config["process_config_file_path"]) as f: process_config_file = "".join(f.readlines()) experiment_run_path = os.path.join(experiment_config["experiment_name"], "experiment_run") return BenchmarkingResult( benchmarking_config=benchmarking_config, experiment_config=experiment_config, ludwig_config=load_yaml(experiment_config["config_path"]), process_config_file=process_config_file, description=load_json(os.path.join(experiment_run_path, "description.json")), test_statistics=load_json(os.path.join(experiment_run_path, "test_statistics.json")), training_statistics=load_json(os.path.join(experiment_run_path, "training_statistics.json")), model_hyperparameters=load_json( os.path.join(experiment_run_path, MODEL_FILE_NAME, "model_hyperparameters.json") ), training_progress=load_json(os.path.join(experiment_run_path, MODEL_FILE_NAME, "training_progress.json")), training_set_metadata=load_json( os.path.join(experiment_run_path, MODEL_FILE_NAME, "training_set_metadata.json") ), ) ================================================ FILE: ludwig/benchmarking/benchmark.py ================================================ import argparse import importlib import logging import os import shutil from typing import Any import ludwig.datasets from ludwig.api import LudwigModel from ludwig.benchmarking.artifacts import BenchmarkingResult, build_benchmarking_result from ludwig.benchmarking.profiler_callbacks import LudwigProfilerCallback from ludwig.benchmarking.utils import ( create_default_config, delete_hyperopt_outputs, delete_model_checkpoints, export_artifacts, load_from_module, populate_benchmarking_config_with_defaults, propagate_global_parameters, save_yaml, validate_benchmarking_config, ) from ludwig.contrib import add_contrib_callback_args from ludwig.hyperopt.run import hyperopt from ludwig.utils.data_utils import load_yaml logger = logging.getLogger() def setup_experiment(experiment: dict[str, str]) -> dict[Any, Any]: """Set up the backend and load the Ludwig config. Args: experiment: dictionary containing the dataset name, config path, and experiment name. Returns a Ludwig config. """ shutil.rmtree(os.path.join(experiment["experiment_name"]), ignore_errors=True) if "config_path" not in experiment: experiment["config_path"] = create_default_config(experiment) model_config = load_yaml(experiment["config_path"]) if experiment["process_config_file_path"]: process_config_spec = importlib.util.spec_from_file_location( "process_config_file_path.py", experiment["process_config_file_path"] ) process_module = importlib.util.module_from_spec(process_config_spec) process_config_spec.loader.exec_module(process_module) model_config = process_module.process_config(model_config, experiment) experiment["config_path"] = experiment["config_path"].replace( ".yaml", "-" + experiment["experiment_name"] + "-modified.yaml" ) save_yaml(experiment["config_path"], model_config) return model_config def benchmark_one(experiment: dict[str, str | dict[str, str]]) -> None: """Run a Ludwig exepriment and track metrics given a dataset name. Args: experiment: dictionary containing the dataset name, config path, and experiment name. """ logger.info(f"\nRunning experiment *{experiment['experiment_name']}* on dataset *{experiment['dataset_name']}*") # configuring backend and paths model_config = setup_experiment(experiment) # loading dataset # dataset_module = importlib.import_module(f"ludwig.datasets.{experiment['dataset_name']}") dataset_module = ludwig.datasets.get_dataset(experiment["dataset_name"]) dataset = load_from_module(dataset_module, model_config["output_features"][0]) if experiment["hyperopt"]: # run hyperopt hyperopt( config=model_config, dataset=dataset, output_directory=experiment["experiment_name"], skip_save_model=True, skip_save_training_statistics=True, skip_save_progress=True, skip_save_log=True, skip_save_processed_input=True, skip_save_unprocessed_output=True, skip_save_predictions=True, skip_save_training_description=True, hyperopt_log_verbosity=0, ) delete_hyperopt_outputs(experiment["experiment_name"]) else: backend = None ludwig_profiler_callbacks = None if experiment["profiler"]["enable"]: ludwig_profiler_callbacks = [LudwigProfilerCallback(experiment)] # Currently, only local backend is supported with LudwigProfiler. backend = "local" logger.info("Currently, only local backend is supported with LudwigProfiler.") # run model and capture metrics model = LudwigModel( config=model_config, callbacks=ludwig_profiler_callbacks, logging_level=logging.ERROR, backend=backend ) model.experiment( dataset=dataset, output_directory=experiment["experiment_name"], skip_save_processed_input=True, skip_save_unprocessed_output=True, skip_save_predictions=True, skip_collect_predictions=True, ) delete_model_checkpoints(experiment["experiment_name"]) def benchmark(benchmarking_config: dict[str, Any] | str) -> dict[str, tuple[BenchmarkingResult, Exception]]: """Launch benchmarking suite from a benchmarking config. Args: benchmarking_config: config or config path for the benchmarking tool. Specifies datasets and their corresponding Ludwig configs, as well as export options. """ if isinstance(benchmarking_config, str): benchmarking_config = load_yaml(benchmarking_config) validate_benchmarking_config(benchmarking_config) benchmarking_config = populate_benchmarking_config_with_defaults(benchmarking_config) benchmarking_config = propagate_global_parameters(benchmarking_config) experiment_artifacts = {} for experiment_idx, experiment in enumerate(benchmarking_config["experiments"]): dataset_name = experiment["dataset_name"] try: benchmark_one(experiment) experiment_artifacts[dataset_name] = (build_benchmarking_result(benchmarking_config, experiment_idx), None) except Exception as e: logger.exception( f"Experiment *{experiment['experiment_name']}* on dataset *{experiment['dataset_name']}* failed" ) experiment_artifacts[dataset_name] = (None, e) finally: if benchmarking_config["export"]["export_artifacts"]: export_base_path = benchmarking_config["export"]["export_base_path"] export_artifacts(experiment, experiment["experiment_name"], export_base_path) return experiment_artifacts def cli(sys_argv): parser = argparse.ArgumentParser( description="This script runs a ludwig experiment on datasets specified in the benchmark config and exports " "the experiment artifact for each of the datasets following the export parameters specified in" "the benchmarking config.", prog="ludwig benchmark", usage="%(prog)s [options]", ) parser.add_argument("--benchmarking_config", type=str, help="The benchmarking config.") add_contrib_callback_args(parser) args = parser.parse_args(sys_argv) benchmark(args.benchmarking_config) ================================================ FILE: ludwig/benchmarking/examples/benchmarking_config.yaml ================================================ experiment_name: example_benchmarking_run hyperopt: false process_config_file_path: /home/ray/process_config.py profiler: enable: true use_torch_profiler: false logging_interval: 0.1 export: export_artifacts: true export_base_path: s3://benchmarking.us-west-2.ludwig.com/bench/ # include the slash at the end. experiments: - dataset_name: ames_housing config_path: /home/ray/anaconda3/lib/python3.12/site-packages/ludwig/benchmarking/configs/ames_housing.yaml - dataset_name: protein config_path: /home/ray/anaconda3/lib/python3.12/site-packages/ludwig/benchmarking/configs/protein.yaml - dataset_name: mercedes_benz_greener config_path: /home/ray/anaconda3/lib/python3.12/site-packages/ludwig/benchmarking/configs/mercedes_benz_greener.yaml - dataset_name: santander_customer_satisfaction config_path: /home/ray/anaconda3/lib/python3.12/site-packages/ludwig/benchmarking/configs/santander_customer_satisfaction.yaml - dataset_name: connect4 config_path: /home/ray/anaconda3/lib/python3.12/site-packages/ludwig/benchmarking/configs/connect4.yaml - dataset_name: otto_group_product config_path: /home/ray/anaconda3/lib/python3.12/site-packages/ludwig/benchmarking/configs/otto_group_product.yaml - dataset_name: bnp_claims_management config_path: /home/ray/anaconda3/lib/python3.12/site-packages/ludwig/benchmarking/configs/bnp_claims_management.yaml - dataset_name: santander_customer_transaction config_path: /home/ray/anaconda3/lib/python3.12/site-packages/ludwig/benchmarking/configs/santander_customer_transaction.yaml - dataset_name: allstate_claims_severity config_path: /home/ray/anaconda3/lib/python3.12/site-packages/ludwig/benchmarking/configs/allstate_claims_severity.yaml - dataset_name: naval config_path: /home/ray/anaconda3/lib/python3.12/site-packages/ludwig/benchmarking/configs/naval.yaml - dataset_name: sarcos config_path: /home/ray/anaconda3/lib/python3.12/site-packages/ludwig/benchmarking/configs/sarcos.yaml - dataset_name: walmart_recruiting config_path: /home/ray/anaconda3/lib/python3.12/site-packages/ludwig/benchmarking/configs/walmart_recruiting.yaml - dataset_name: numerai28pt6 config_path: /home/ray/anaconda3/lib/python3.12/site-packages/ludwig/benchmarking/configs/numerai28pt6.yaml - dataset_name: adult_census_income config_path: /home/ray/anaconda3/lib/python3.12/site-packages/ludwig/benchmarking/configs/adult_census_income.yaml - dataset_name: amazon_employee_access_challenge config_path: /home/ray/anaconda3/lib/python3.12/site-packages/ludwig/benchmarking/configs/amazon_employee_access_challenge.yaml - dataset_name: forest_cover config_path: /home/ray/anaconda3/lib/python3.12/site-packages/ludwig/benchmarking/configs/forest_cover.yaml - dataset_name: mushroom_edibility config_path: /home/ray/anaconda3/lib/python3.12/site-packages/ludwig/benchmarking/configs/mushroom_edibility.yaml ================================================ FILE: ludwig/benchmarking/examples/process_config.py ================================================ """This function will take in a Ludwig config, strip away all its parameters except input and output featuresand add some other parameters to run logistic regression hyperopt.""" def process_config(ludwig_config: dict, experiment_dict: dict) -> dict: """Modify a Ludwig config by programmatically adding elements to the config dictionary. The purpose is to apply changes for all datasets that are the same or are based on the attributes of `experiment_dict` (e.g. dataset_name) removing the need to manually apply small changes to configs on many datasets. :param ludwig_config: a Ludwig config. :param experiment_dict: a benchmarking config experiment dictionary. Returns: a modified Ludwig config. """ # only keep input_features and output_features main_config_keys = list(ludwig_config.keys()) for key in main_config_keys: if key not in ["input_features", "output_features"]: del ludwig_config[key] temp = { "preprocessing": {"split": {"type": "fixed"}}, "trainer": {"epochs": 1024, "early_stop": 7, "eval_batch_size": 16384, "evaluate_training_set": False}, "hyperopt": { "goal": "maximize", "output_feature": None, "metric": None, "split": "validation", "parameters": { "defaults.number.preprocessing.normalization": {"space": "choice", "categories": ["zscore", None]}, "defaults.number.preprocessing.missing_value_strategy": { "space": "choice", "categories": ["fill_with_const", "fill_with_mean"], }, "combiner.type": {"space": "choice", "categories": ["tabnet", "concat"]}, "trainer.learning_rate_scheduler.decay": {"space": "choice", "categories": [True, False]}, "trainer.learning_rate": {"space": "loguniform", "lower": 0.0001, "upper": 0.1}, "trainer.learning_rate_scheduler.decay_rate": {"space": "uniform", "lower": 0.4, "upper": 0.96}, "trainer.batch_size": {"space": "randint", "lower": 32, "upper": 2048}, }, "search_alg": {"type": "variant_generator"}, "executor": {"type": "ray", "num_samples": 1000}, "scheduler": {"type": "bohb", "reduction_factor": 2}, }, } # add config parameters from temp for key, value in temp.items(): ludwig_config[key] = value dataset_name_to_metric = { "ames_housing": "r2", "mercedes_benz_greener": "r2", "mushroom_edibility": "accuracy", "amazon_employee_access_challenge": "roc_auc", "naval": "r2", "sarcos": "r2", "protein": "r2", "adult_census_income": "accuracy", "otto_group_product": "accuracy", "santander_customer_satisfaction": "accuracy", "amazon_employee_access": "roc_auc", "numerai28pt6": "accuracy", "bnp_claims_management": "accuracy", "allstate_claims_severity": "r2", "santander_customer_transaction": "accuracy", "connect4": "accuracy", "forest_cover": "accuracy", "ieee_fraud": "accuracy", "porto_seguro_safe_driver": "accuracy", "walmart_recruiting": "accuracy", "poker_hand": "accuracy", "higgs": "accuracy", } # add hyperopt output feature and metric. dataset_name = experiment_dict["dataset_name"] ludwig_config["hyperopt"]["metric"] = dataset_name_to_metric[dataset_name] ludwig_config["hyperopt"]["output_feature"] = ludwig_config["output_features"][0]["name"] # use sparse encoder for categorical features to mimic logistic regression. for i, feature in enumerate(ludwig_config["input_features"]): if feature["type"] == "category": ludwig_config["input_features"][i]["encoder"] = "sparse" for i, feature in enumerate(ludwig_config["output_features"]): if feature["type"] == "category": ludwig_config["output_features"][i]["encoder"] = "sparse" # make sure to return the ludwig_config return ludwig_config ================================================ FILE: ludwig/benchmarking/profiler.py ================================================ import contextlib import glob import logging import os import shutil import threading import time from queue import Empty as EmptyQueueException from queue import Queue from subprocess import PIPE, Popen from typing import Any from xml.etree.ElementTree import fromstring import psutil import torch from cpuinfo import get_cpu_info from gpustat.core import GPUStatCollection from ludwig.benchmarking.profiler_dataclasses import profiler_dataclass_to_flat_dict, TorchProfilerMetrics from ludwig.benchmarking.reporting import get_metrics_from_system_usage_profiler, get_metrics_from_torch_profiler from ludwig.constants import LUDWIG_TAG from ludwig.globals import LUDWIG_VERSION from ludwig.utils.data_utils import save_json STOP_MESSAGE = "stop" logger = logging.getLogger() def get_gpu_info(): """Gathers general hardware information about an nvidia GPU. This function was copied from `experiment_impact_tracker` to get around a Pandas 2.0 breaking change impacting the package. https://github.com/Breakend/experiment-impact- tracker/blob/master/experiment_impact_tracker/gpu/nvidia.py#L48-L73 """ p = Popen(["nvidia-smi", "-q", "-x"], stdout=PIPE) outs, errors = p.communicate() xml = fromstring(outs) data = [] driver_version = xml.findall("driver_version")[0].text cuda_version = xml.findall("cuda_version")[0].text for gpu_id, gpu in enumerate(xml.getiterator("gpu")): gpu_data = {} name = [x for x in gpu.getiterator("product_name")][0].text memory_usage = gpu.findall("fb_memory_usage")[0] total_memory = memory_usage.findall("total")[0].text gpu_data["name"] = name gpu_data["total_memory"] = total_memory gpu_data["driver_version"] = driver_version gpu_data["cuda_version"] = cuda_version data.append(gpu_data) return data def monitor(queue: Queue, info: dict[str, Any], logging_interval: int, cuda_is_available: bool) -> None: """Monitors hardware resource use. Collects system specific metrics (CPU/CUDA, CPU/CUDA memory) at a `logging_interval` interval and pushes results back to the parent process. Args: queue: queue from which we can push and retrieve messages sent to the function targeted by the thread. info: dictionary containing system resource usage information about the running process. logging_interval: time interval at which we will poll the system for usage metrics. cuda_is_available: stores torch.cuda.is_available(). """ info["global_cpu_memory_available"] = [psutil.virtual_memory().available] info["global_cpu_utilization"] = [psutil.cpu_percent()] # get the pid of the parent process. tracked_process = psutil.Process(os.getpid()) # will return a meaningless 0 value on the first call because `interval` arg is set to None. tracked_process.cpu_percent(interval=logging_interval) with tracked_process.oneshot(): info["cpu_utilization"] = [tracked_process.cpu_percent() / info["num_cpu"]] info["cpu_memory_usage"] = [tracked_process.memory_full_info().uss] try: info["num_accessible_cpus"] = len(tracked_process.cpu_affinity()) except Exception: pass while True: try: message = queue.get(block=False) if isinstance(message, str): if message == STOP_MESSAGE: # synchronize CUDA to get accurate timing for jobs running on GPU. if cuda_is_available: torch.cuda.synchronize() queue.put(info) return else: queue.put(message) except EmptyQueueException: pass if cuda_is_available: gpu_infos = GPUStatCollection.new_query() for i, gpu_info in enumerate(gpu_infos): gpu_key = f"cuda_{i}" info[f"{gpu_key}_memory_used"].append(gpu_info.memory_used) with tracked_process.oneshot(): info["cpu_utilization"].append(tracked_process.cpu_percent() / info["num_cpu"]) info["cpu_memory_usage"].append(tracked_process.memory_full_info().uss) info["global_cpu_memory_available"].append(psutil.virtual_memory().available) info["global_cpu_utilization"].append(psutil.cpu_percent()) time.sleep(logging_interval) class LudwigProfiler(contextlib.ContextDecorator): """Track system resource (hardware and software) usage. Warning: If `use_torch_profiler=True` while profiling on CUDA, it's not possible to benchmark DataLoaders with `num_workers > 0` due to CUDA multiprocessing limitations. See warning under `profile` class definition: https://github.com/pytorch/pytorch/blob/master/torch/autograd/profiler.py Attributes: tag: a string tag describing the code block/function that we're tracking. (e.g trainer.train, preprocessing, etc.) output_dir: path where metrics are saved. logging_interval: time interval in seconds at which system is polled for resource usage. """ def __init__(self, tag: str, use_torch_profiler: bool, output_dir: str, logging_interval: float = 0.1) -> None: self.tag = tag self._tag = LUDWIG_TAG + self.tag self.use_torch_profiler = use_torch_profiler self.output_dir = output_dir self.logging_interval = logging_interval self.cuda_is_available = torch.cuda.is_available() self.launched = False if self.use_torch_profiler: self.profiler_activities = [torch.profiler.ProfilerActivity.CPU] if self.cuda_is_available: self.profiler_activities.append(torch.profiler.ProfilerActivity.CUDA) os.makedirs(os.path.join(self.output_dir), exist_ok=True) def _init_tracker_info(self): """Initialize new self.info, self.torch_profiler, and self.torch_record_function instances. Important to call this in __enter__ if the user decides not to create a new class instance and therefore __init__ wouldn't be called. """ self.info = {"code_block_tag": self.tag} if self.use_torch_profiler: self.torch_profiler = torch.profiler.profile(activities=self.profiler_activities, profile_memory=True) self.torch_record_function = torch.profiler.record_function(self._tag) def _populate_static_information(self) -> None: """Populate the report with static software and hardware information.""" self.info["ludwig_version"] = LUDWIG_VERSION self.info["start_disk_usage"] = shutil.disk_usage(os.path.expanduser("~")).used # CPU information cpu_info = get_cpu_info() self.info["cpu_architecture"] = cpu_info["arch"] self.info["num_cpu"] = psutil.cpu_count() self.info["cpu_name"] = cpu_info.get("brand_raw", "unknown") self.info["total_cpu_memory_size"] = psutil.virtual_memory().total # GPU information if self.cuda_is_available: gpu_infos = get_gpu_info() gpu_usage = GPUStatCollection.new_query() for i, gpu_info in enumerate(gpu_infos): gpu_key = f"cuda_{i}" self.info[f"{gpu_key}_memory_used"] = [gpu_usage[i].memory_used] self.info[f"{gpu_key}_name"] = gpu_info["name"] self.info[f"{gpu_key}_total_memory"] = gpu_info["total_memory"] self.info[f"{gpu_key}_driver_version"] = gpu_info["driver_version"] self.info[f"{gpu_key}_cuda_version"] = gpu_info["cuda_version"] # recording in microseconds to be in line with torch profiler time recording. self.info["start_time"] = time.perf_counter_ns() / 1000 def __enter__(self): """Populate static information and monitors resource usage.""" if self.launched: raise RuntimeError("LudwigProfiler already launched. You can't use the same instance.") self._init_tracker_info() self._populate_static_information() if self.use_torch_profiler: # contextlib.ExitStack gracefully handles situations where __enter__ or __exit__ calls throw exceptions. with contextlib.ExitStack() as ctx_exit_stack: try: # Launch torch.profiler to track PyTorch operators. ctx_exit_stack.enter_context(self.torch_profiler) except RuntimeError: # PyTorch profiler is already enabled on this thread. # Using the running PyTorch profiler to track events. self.torch_profiler = None ctx_exit_stack.enter_context(self.torch_record_function) self._ctx_exit_stack = ctx_exit_stack.pop_all() try: # Starting thread to monitor system resource usage. self.queue = Queue() self.t = threading.Thread( target=monitor, args=( self.queue, self.info, self.logging_interval, self.cuda_is_available, ), ) self.t.start() self.launched = True except Exception: self.launched = False logger.exception("Encountered exception when launching tracker thread.") return self def __exit__(self, exc_type, exc_val, exc_tb) -> None: """Stop profiling, postprocess and export resource usage metrics.""" try: self.queue.put(STOP_MESSAGE) self.t.join() result = self.queue.get() # If monitor thread crashed, result may be a string instead of dict if isinstance(result, dict): self.info = result # recording in microseconds to be in line with torch profiler time recording. self.info["end_time"] = time.perf_counter_ns() / 1000 self.info["end_disk_usage"] = shutil.disk_usage(os.path.expanduser("~")).used self.launched = False except Exception: logger.exception("Encountered exception when joining tracker thread.") finally: if self.use_torch_profiler: self._ctx_exit_stack.close() self._export_torch_metrics() self._export_system_usage_metrics() def _export_system_usage_metrics(self): """Export system resource usage metrics (no torch operators).""" system_usage_metrics = get_metrics_from_system_usage_profiler(self.info) output_subdir = os.path.join(self.output_dir, "system_resource_usage", system_usage_metrics.code_block_tag) os.makedirs(output_subdir, exist_ok=True) num_prev_runs = len(glob.glob(os.path.join(output_subdir, "run_*.json"))) file_name = os.path.join(output_subdir, f"run_{num_prev_runs}.json") save_json(file_name, profiler_dataclass_to_flat_dict(system_usage_metrics)) def _reformat_torch_usage_metrics_tags( self, torch_usage_metrics: dict[str, Any] ) -> dict[str, list[TorchProfilerMetrics]]: reformatted_dict = {} for key, value in torch_usage_metrics.items(): assert key.startswith(LUDWIG_TAG) reformatted_key = key[len(LUDWIG_TAG) :] reformatted_dict[reformatted_key] = value return reformatted_dict def _export_torch_metrics(self): """Export resource usage metrics of torch operators.""" if self.torch_profiler: torch_usage_metrics = get_metrics_from_torch_profiler(self.torch_profiler) torch_usage_metrics = self._reformat_torch_usage_metrics_tags(torch_usage_metrics) for tag, runs in torch_usage_metrics.items(): temp_dir = os.path.join(self.output_dir, "torch_ops_resource_usage", tag) os.makedirs(temp_dir, exist_ok=True) for run in runs: num_prev_runs = len(glob.glob(os.path.join(temp_dir, "run_*.json"))) save_json(os.path.join(temp_dir, f"run_{num_prev_runs}.json"), profiler_dataclass_to_flat_dict(run)) ================================================ FILE: ludwig/benchmarking/profiler_callbacks.py ================================================ from typing import Any from ludwig.api_annotations import DeveloperAPI from ludwig.benchmarking.profiler import LudwigProfiler from ludwig.callbacks import Callback from ludwig.constants import EVALUATION, PREPROCESSING, TRAINING # TODO: Change annotation to PublicAPI once Ludwig 0.7 is released @DeveloperAPI class LudwigProfilerCallback(Callback): """Class that defines the methods necessary to hook into process.""" def __init__(self, experiment: dict[str, Any]): self.experiment_name = experiment["experiment_name"] self.use_torch_profiler = experiment["profiler"]["use_torch_profiler"] self.logging_interval = experiment["profiler"]["logging_interval"] self.preprocess_profiler = None self.train_profiler = None self.evaluation_profiler = None def on_preprocess_start(self, *args, **kwargs): self.preprocess_profiler = LudwigProfiler( tag=PREPROCESSING, output_dir=self.experiment_name, use_torch_profiler=self.use_torch_profiler, logging_interval=self.logging_interval, ) self.preprocess_profiler.__enter__() def on_preprocess_end(self, *args, **kwargs): self.preprocess_profiler.__exit__(None, None, None) del self.preprocess_profiler def on_train_start(self, *args, **kwargs): self.train_profiler = LudwigProfiler( tag=TRAINING, output_dir=self.experiment_name, use_torch_profiler=self.use_torch_profiler, logging_interval=self.logging_interval, ) self.train_profiler.__enter__() def on_train_end(self, *args, **kwargs): self.train_profiler.__exit__(None, None, None) del self.train_profiler def on_evaluation_start(self): self.evaluation_profiler = LudwigProfiler( tag=EVALUATION, output_dir=self.experiment_name, use_torch_profiler=self.use_torch_profiler, logging_interval=self.logging_interval, ) self.evaluation_profiler.__enter__() def on_evaluation_end(self): self.evaluation_profiler.__exit__(None, None, None) del self.evaluation_profiler ================================================ FILE: ludwig/benchmarking/profiler_dataclasses.py ================================================ import dataclasses from dataclasses import dataclass from ludwig.utils.data_utils import flatten_dict @dataclass class DeviceUsageMetrics: # Max CUDA memory utilization of the code block. max_memory_used: float # Average CUDA memory utilization of the code block. average_memory_used: float @dataclass class SystemResourceMetrics: # Name of the code block/function to be profiled. code_block_tag: str # Name of the CPU that the code ran on. cpu_name: str # CPU architecture that the code ran on. cpu_architecture: str # Number of CPUs on the machine. num_cpu: int # Total CPU memory size. total_cpu_memory_size: float # Ludwig version in the environment. ludwig_version: str # Total execution time of the code block. total_execution_time: float # The change in disk memory before and after the code block ran. disk_footprint: float # Max CPU utilization of the code block. max_cpu_utilization: float # Max CPU memory (RAM) utilization of the code block. max_cpu_memory_usage: float # Min system-wide CPU memory available (how much physical memory is left). min_global_cpu_memory_available: float # Max system-wide CPU utilization. max_global_cpu_utilization: float # Average CPU utilization of the code block. average_cpu_utilization: float # Average CPU memory (RAM) utilization of the code block. average_cpu_memory_usage: float # Average system-wide CPU memory available (how much physical memory is left). average_global_cpu_memory_available: float # Average system-wide CPU utilization. average_global_cpu_utilization: float # Per device usage. Dictionary containing max and average memory used per device. device_usage: dict[str, DeviceUsageMetrics] @dataclass class TorchProfilerMetrics: # Time taken by torch ops to execute on the CPU. torch_cpu_time: float # Time taken by torch ops to execute on CUDA devices. torch_cuda_time: float # Number of out of memory events. num_oom_events: int # Per device usage by torch ops. Dictionary containing max and average memory used per device. device_usage: dict[str, DeviceUsageMetrics] def profiler_dataclass_to_flat_dict(data: SystemResourceMetrics | TorchProfilerMetrics) -> dict: """Returns a flat dictionary representation, with the device_usage key removed.""" nested_dict = dataclasses.asdict(data) nested_dict[""] = nested_dict.pop("device_usage") return flatten_dict(nested_dict, sep="") ================================================ FILE: ludwig/benchmarking/reporting.py ================================================ from collections import Counter, defaultdict from statistics import mean from typing import Any import torch from torch._C._autograd import _KinetoEvent from torch.autograd import DeviceType, profiler_util from ludwig.benchmarking.profiler_dataclasses import DeviceUsageMetrics, SystemResourceMetrics, TorchProfilerMetrics from ludwig.constants import LUDWIG_TAG def initialize_stats_dict(main_function_events: list[profiler_util.FunctionEvent]) -> dict[str, list]: """Initialize dictionary which stores resource usage information per tagged code block. :param main_function_events: list of main function events. """ info = {} for event_name in [evt.name for evt in main_function_events]: info[event_name] = [] return info def get_memory_details(kineto_event: _KinetoEvent) -> tuple[str, int]: """Get device name and number of bytes (de)allocated during an event. :param kineto_event: a Kineto event instance. """ if kineto_event.device_type() in [DeviceType.CPU, DeviceType.MKLDNN, DeviceType.IDEEP]: return "cpu", kineto_event.nbytes() elif kineto_event.device_type() in [DeviceType.CUDA, DeviceType.HIP]: return f"cuda_{kineto_event.device_index()}", kineto_event.nbytes() else: raise ValueError(f"Device {kineto_event.device_type()} is not valid.") def get_device_memory_usage( kineto_event: _KinetoEvent, memory_events: list[list[_KinetoEvent | bool]] ) -> dict[str, DeviceUsageMetrics]: """Get CPU and CUDA memory usage for an event. :param kineto_event: a Kineto event instance. :param memory_events: list of memory events. """ mem_records_acc = profiler_util.MemRecordsAcc(memory_events) start_us = kineto_event.start_ns() / 1000 end_us = start_us + kineto_event.duration_ns() / 1000 records_in_interval = mem_records_acc.in_interval(start_us, end_us) memory_so_far = defaultdict(int) count_so_far = defaultdict(int) average_so_far = defaultdict(float) max_so_far = defaultdict(int) for mem_record in records_in_interval: device, nbytes = get_memory_details(mem_record[0]) memory_so_far[device] += nbytes max_so_far[device] = max(max_so_far[device], memory_so_far[device]) average_so_far[device] = (memory_so_far[device] + (average_so_far[device] * count_so_far[device])) / ( count_so_far[device] + 1 ) count_so_far[device] += 1 memory_info_per_device = {} for device in count_so_far: memory_info_per_device[f"torch_{device}_"] = DeviceUsageMetrics( max_memory_used=max_so_far[device], average_memory_used=average_so_far[device] ) return memory_info_per_device def get_torch_op_time(events: list[profiler_util.FunctionEvent], attr: str) -> int | float: """Get time torch operators spent executing for a list of events. :param events: list of events. :param attr: a FunctionEvent attribute. Expecting one of "cpu_time_total", "device_time_total". """ if attr not in ["cpu_time_total", "device_time_total"]: return -1 total = 0 for e in events: # Possible trace_names are torch ops, or tagged code blocks by LudwigProfiler (which are # prepended with LUDWIG_TAG). if LUDWIG_TAG not in e.trace_name: total += getattr(e, attr) else: total += get_torch_op_time(e.cpu_children, attr) return total def get_device_run_durations(function_event: profiler_util.FunctionEvent) -> tuple[float, float]: """Get CPU and device run durations for an event. :param function_event: a function event instance. """ torch_cpu_time = get_torch_op_time(function_event.cpu_children, "cpu_time_total") torch_device_time = get_torch_op_time(function_event.cpu_children, "device_time_total") return torch_cpu_time, torch_device_time def get_num_oom_events(kineto_event: _KinetoEvent, out_of_memory_events: list[list[_KinetoEvent | bool]]) -> int: oom_records_acc = profiler_util.MemRecordsAcc(out_of_memory_events) start_us = kineto_event.start_ns() / 1000 end_us = start_us + kineto_event.duration_ns() / 1000 records_in_interval = oom_records_acc.in_interval(start_us, end_us) return len(list(records_in_interval)) def get_resource_usage_report( main_kineto_events: list[_KinetoEvent], main_function_events: list[profiler_util.FunctionEvent], memory_events: list[list[_KinetoEvent | bool]], out_of_memory_events: list[list[_KinetoEvent | bool]], info: dict[str, Any], ) -> dict[str, list[TorchProfilerMetrics]]: """Get relevant information from Kineto events and function events exported by the profiler. :param main_kineto_events: list of main Kineto events. :param main_function_events: list of main function events. :param memory_events: list of memory events. :param out_of_memory_events: list of out of memory events. :param info: dictionary used to record resource usage metrics. """ main_kineto_events = sorted( (evt for evt in main_kineto_events if LUDWIG_TAG in evt.name()), key=lambda x: x.correlation_id() ) main_function_events = sorted((evt for evt in main_function_events if LUDWIG_TAG in evt.name), key=lambda x: x.id) for kineto_event, function_event in zip(main_kineto_events, main_function_events): # Two different instances of `function_event` can have the same name if a the same # tagged code block/function was executed more than once. memory_info_per_device = get_device_memory_usage(kineto_event, memory_events) torch_cpu_time, torch_cuda_time = get_device_run_durations(function_event) num_oom_events = get_num_oom_events(kineto_event, out_of_memory_events) torch_profiler_metrics = TorchProfilerMetrics( torch_cpu_time=torch_cpu_time, torch_cuda_time=torch_cuda_time, num_oom_events=num_oom_events, device_usage=memory_info_per_device, ) info[function_event.name].append(torch_profiler_metrics) return info def get_all_events(kineto_events: list[_KinetoEvent], function_events: profiler_util.EventList) -> tuple[ list[_KinetoEvent], list[profiler_util.FunctionEvent], list[list[_KinetoEvent | bool]], list[list[_KinetoEvent | bool]], ]: """Return main Kineto and function events, memory and OOM events for functions/code blocks tagged in LudwigProfiler. :param kineto_events: list of Kineto Events. :param function_events: list of function events. """ # LUDWIG_TAG is prepended to LudwigProfiler tags. This edited tag is passed in to `torch.profiler.record_function` # so we can easily retrieve events for code blocks wrapped with LudwigProfiler. main_function_events = [evt for evt in function_events if LUDWIG_TAG in evt.name] main_kineto_events = [event for event in kineto_events if LUDWIG_TAG in event.name()] memory_events = [[event, False] for event in kineto_events if profiler_util.MEMORY_EVENT_NAME in event.name()] # profiler_util.OUT_OF_MEMORY_EVENT_NAME seems to only be in newer versions of torch. out_of_memory_events = [[event, False] for event in kineto_events if "[OutOfMemory]" in event.name()] return main_kineto_events, main_function_events, memory_events, out_of_memory_events def get_metrics_from_torch_profiler(profile: torch.profiler.profiler.profile) -> dict[str, list[TorchProfilerMetrics]]: """Export time and resource usage metrics (CPU and CUDA) from a PyTorch profiler. The profiler keeps track of *torch operations* being executed in C++. It keeps track of what device they're executed on, their execution time, and memory usage. We only track the aforementioned metrics, but the torch profiler can keep track of the stack trace, FLOPs, and torch modules. Tracking each additional item adds overhead. The torch profiler surfaces these metrics that are tracked under the hood by `libkineto`. More on the Kineto project: https://github.com/pytorch/kineto :param profile: profiler object that contains all the events that were registered during the execution of the wrapped code block. """ # events in both of these lists are in chronological order. kineto_events = profile.profiler.kineto_results.events() function_events = profile.profiler.function_events main_kineto_events, main_function_events, memory_events, out_of_memory_events = get_all_events( kineto_events, function_events ) assert Counter([event.name for event in main_function_events]) == Counter( [event.name() for event in main_kineto_events] ) info = initialize_stats_dict(main_function_events) info = get_resource_usage_report( main_kineto_events, main_function_events, memory_events, out_of_memory_events, info ) return info def get_metrics_from_system_usage_profiler(system_usage_info: dict) -> SystemResourceMetrics: """Package system resource usage metrics (no torch operators) in a dataclass. :param system_usage_info: dictionary containing resource usage information. """ device_usage_dict: dict[str, DeviceUsageMetrics] = {} for key in system_usage_info: if "cuda_" in key and "_memory_used" in key: cuda_device_name = "_".join(key.split("_")[:2]) + "_" max_memory_used = max(system_usage_info[key], default=0) average_memory_used = mean(system_usage_info.get(key, [0])) device_usage_dict[cuda_device_name] = DeviceUsageMetrics( max_memory_used=max_memory_used, average_memory_used=average_memory_used ) return SystemResourceMetrics( code_block_tag=system_usage_info["code_block_tag"], cpu_name=system_usage_info.get("cpu_name", "unknown"), cpu_architecture=system_usage_info["cpu_architecture"], num_cpu=system_usage_info["num_cpu"], total_cpu_memory_size=system_usage_info["total_cpu_memory_size"], ludwig_version=system_usage_info["ludwig_version"], total_execution_time=system_usage_info["end_time"] - system_usage_info["start_time"], disk_footprint=system_usage_info["end_disk_usage"] - system_usage_info["start_disk_usage"], max_cpu_utilization=max(system_usage_info["cpu_utilization"], default=0), max_cpu_memory_usage=max(system_usage_info["cpu_memory_usage"], default=0), min_global_cpu_memory_available=min(system_usage_info["global_cpu_memory_available"], default=0), max_global_cpu_utilization=max(system_usage_info["global_cpu_utilization"], default=0), average_cpu_utilization=mean(system_usage_info.get("cpu_utilization", [0])), average_cpu_memory_usage=mean(system_usage_info.get("cpu_memory_usage", [0])), average_global_cpu_memory_available=mean(system_usage_info.get("global_cpu_memory_available", [0])), average_global_cpu_utilization=mean(system_usage_info.get("global_cpu_utilization", [0])), device_usage=device_usage_dict, ) ================================================ FILE: ludwig/benchmarking/summarize.py ================================================ import argparse import logging import os import shutil from ludwig.benchmarking.summary_dataclasses import ( build_metrics_diff, build_resource_usage_diff, export_metrics_diff_to_csv, export_resource_usage_diff_to_csv, MetricsDiff, ResourceUsageDiff, ) from ludwig.benchmarking.utils import download_artifacts logger = logging.getLogger() def summarize_metrics( bench_config_path: str, base_experiment: str, experimental_experiment: str, download_base_path: str ) -> tuple[list[str], list[MetricsDiff], list[list[ResourceUsageDiff]]]: """Build metric and resource usage diffs from experiment artifacts. bench_config_path: bench config file path. Can be the same one that was used to run these experiments. base_experiment: name of the experiment we're comparing against. experimental_experiment: name of the experiment we're comparing. download_base_path: base path under which live the stored artifacts of the benchmarking experiments. """ local_dir, dataset_list = download_artifacts( bench_config_path, base_experiment, experimental_experiment, download_base_path ) metric_diffs, resource_usage_diffs = [], [] for dataset_name in dataset_list: try: metric_diff = build_metrics_diff(dataset_name, base_experiment, experimental_experiment, local_dir) metric_diffs.append(metric_diff) base_path = os.path.join(local_dir, dataset_name, base_experiment) experimental_path = os.path.join(local_dir, dataset_name, experimental_experiment) resource_usage_diff = build_resource_usage_diff( base_path, experimental_path, base_experiment, experimental_experiment ) resource_usage_diffs.append(resource_usage_diff) except Exception: logger.exception(f"Exception encountered while creating diff summary for {dataset_name}.") shutil.rmtree(local_dir, ignore_errors=True) export_and_print(dataset_list, metric_diffs, resource_usage_diffs) return dataset_list, metric_diffs, resource_usage_diffs def export_and_print( dataset_list: list[str], metric_diffs: list[MetricsDiff], resource_usage_diffs: list[list[ResourceUsageDiff]] ) -> None: """Export to CSV and print a diff of performance and resource usage metrics of two experiments. :param dataset_list: list of datasets for which to print the diffs. :param metric_diffs: Diffs for the performance metrics by dataset. :param resource_usage_diffs: Diffs for the resource usage metrics per dataset per LudwigProfiler tag. """ for dataset_name, experiment_metric_diff in zip(dataset_list, metric_diffs): output_path = os.path.join("summarize_output", "performance_metrics", dataset_name) os.makedirs(output_path, exist_ok=True) logger.info( "Model performance metrics for *{}* vs. *{}* on dataset *{}*".format( experiment_metric_diff.base_experiment_name, experiment_metric_diff.experimental_experiment_name, experiment_metric_diff.dataset_name, ) ) logger.info(experiment_metric_diff.to_string()) filename = ( "-".join([experiment_metric_diff.base_experiment_name, experiment_metric_diff.experimental_experiment_name]) + ".csv" ) export_metrics_diff_to_csv(experiment_metric_diff, os.path.join(output_path, filename)) for dataset_name, experiment_resource_diff in zip(dataset_list, resource_usage_diffs): output_path = os.path.join("summarize_output", "resource_usage_metrics", dataset_name) os.makedirs(output_path, exist_ok=True) for tag_diff in experiment_resource_diff: logger.info( "Resource usage for *{}* vs. *{}* on *{}* of dataset *{}*".format( tag_diff.base_experiment_name, tag_diff.experimental_experiment_name, tag_diff.code_block_tag, dataset_name, ) ) logger.info(tag_diff.to_string()) filename = ( "-".join( [tag_diff.code_block_tag, tag_diff.base_experiment_name, tag_diff.experimental_experiment_name] ) + ".csv" ) export_resource_usage_diff_to_csv(tag_diff, os.path.join(output_path, filename)) if __name__ == "__main__": parser = argparse.ArgumentParser( description="Summarize the model performance metrics and resource usage metrics of two experiments.", prog="python summarize.py", usage="%(prog)s [options]", ) parser.add_argument("--benchmarking_config", type=str, help="The benchmarking config.") parser.add_argument("--base_experiment", type=str, help="The name of the first experiment.") parser.add_argument("--experimental_experiment", type=str, help="The name of the second experiment.") parser.add_argument("--download_base_path", type=str, help="The base path to download experiment artifacts from.") args = parser.parse_args() summarize_metrics( args.benchmarking_config, args.base_experiment, args.experimental_experiment, args.download_base_path ) ================================================ FILE: ludwig/benchmarking/summary_dataclasses.py ================================================ import csv import logging import os from dataclasses import dataclass from statistics import mean import ludwig.modules.metric_modules # noqa: F401 from ludwig.benchmarking.utils import format_memory, format_time from ludwig.globals import MODEL_FILE_NAME, MODEL_HYPERPARAMETERS_FILE_NAME from ludwig.modules.metric_registry import get_metric_classes, metric_feature_type_registry # noqa: F401 from ludwig.types import ModelConfigDict from ludwig.utils.data_utils import load_json logger = logging.getLogger() @dataclass class MetricDiff: """Diffs for a metric.""" # Name of the metric. name: str # Value of the metric in base experiment (the one we benchmark against). base_value: float # Value of the metric in the experimental experiment. experimental_value: float # experimental_value - base_value. diff: float # Percentage of change the metric with respect to base_value. diff_percentage: float | str def __post_init__(self): """Add human-readable string representations to the field.""" if "memory" in self.name: self.base_value_str = format_memory(self.base_value) self.experimental_value_str = format_memory(self.experimental_value) self.diff_str = format_memory(self.diff) elif "time" in self.name: self.base_value_str = format_time(self.base_value) self.experimental_value_str = format_time(self.experimental_value) self.diff_str = format_time(self.diff) else: self.base_value_str = str(self.base_value) self.experimental_value_str = str(self.experimental_value) self.diff_str = str(self.diff) def build_diff(name: str, base_value: float, experimental_value: float) -> MetricDiff: """Build a diff between any type of metric. :param name: name assigned to the metric to be diff-ed. :param base_value: base value of the metric. :param experimental_value: experimental value of the metric. """ diff = experimental_value - base_value diff_percentage = 100 * diff / base_value if base_value != 0 else "inf" return MetricDiff( name=name, base_value=base_value, experimental_value=experimental_value, diff=diff, diff_percentage=diff_percentage, ) ############################## # Resource Usage Dataclasses # ############################## @dataclass class MetricsSummary: """Summary of metrics from one experiment.""" # Path containing the artifacts for the experiment. experiment_local_directory: str # Full Ludwig config. config: ModelConfigDict # LudwigModel output feature type. output_feature_type: str # LudwigModel output feature name. output_feature_name: str # Dictionary that maps from metric name to their values. metric_to_values: dict[str, float | int] # Names of metrics for the output feature. metric_names: set[str] @dataclass class MetricsDiff: """Store diffs for two experiments.""" # Dataset the two experiments are being compared on. dataset_name: str # Name of the base experiment (the one we benchmark against). base_experiment_name: str # Name of the experimental experiment. experimental_experiment_name: str # Path under which all artifacts live on the local machine. local_directory: str # `MetricsSummary` of the base_experiment. base_summary: MetricsSummary # `MetricsSummary` of the experimental_experiment. experimental_summary: MetricsSummary # `List[MetricDiff]` containing diffs for metric of the two experiments. metrics: list[MetricDiff] def to_string(self): ret = [] spacing_str = "{:<20} {:<33} {:<13} {:<13} {:<13} {:<5}" ret.append( spacing_str.format( "Output Feature Name", "Metric Name", self.base_experiment_name, self.experimental_experiment_name, "Diff", "Diff Percentage", ) ) for metric in sorted(self.metrics, key=lambda m: m.name): output_feature_name = self.base_summary.output_feature_name metric_name = metric.name experiment1_val = round(metric.base_value, 3) experiment2_val = round(metric.experimental_value, 3) diff = round(metric.diff, 3) diff_percentage = metric.diff_percentage if isinstance(diff_percentage, float): diff_percentage = round(metric.diff_percentage, 3) ret.append( spacing_str.format( output_feature_name, metric_name, experiment1_val, experiment2_val, diff, diff_percentage, ) ) return "\n".join(ret) def export_metrics_diff_to_csv(metrics_diff: MetricsDiff, path: str): """Export metrics report to .csv. :param metrics_diff: MetricsDiff object containing the diff for two experiments on a dataset. :param path: file name of the exported csv. """ with open(path, "w", newline="") as f: writer = csv.DictWriter( f, fieldnames=[ "Dataset Name", "Output Feature Name", "Metric Name", metrics_diff.base_experiment_name, metrics_diff.experimental_experiment_name, "Diff", "Diff Percentage", ], ) writer.writeheader() for metric in sorted(metrics_diff.metrics, key=lambda m: m.name): output_feature_name = metrics_diff.base_summary.output_feature_name metric_name = metric.name experiment1_val = round(metric.base_value, 3) experiment2_val = round(metric.experimental_value, 3) diff = round(metric.diff, 3) diff_percentage = metric.diff_percentage if isinstance(diff_percentage, float): diff_percentage = round(metric.diff_percentage, 3) writer.writerow( { "Dataset Name": metrics_diff.dataset_name, "Output Feature Name": output_feature_name, "Metric Name": metric_name, metrics_diff.base_experiment_name: experiment1_val, metrics_diff.experimental_experiment_name: experiment2_val, "Diff": diff, "Diff Percentage": diff_percentage, } ) logger.info(f"Exported a CSV report to {path}\n") def build_metrics_summary(experiment_local_directory: str) -> MetricsSummary: """Build a metrics summary for an experiment. :param experiment_local_directory: directory where the experiment artifacts live. e.g. local_experiment_repo/ames_housing/some_experiment/ """ config = load_json( os.path.join(experiment_local_directory, "experiment_run", MODEL_FILE_NAME, MODEL_HYPERPARAMETERS_FILE_NAME) ) report = load_json(os.path.join(experiment_local_directory, "experiment_run", "test_statistics.json")) output_feature_type: str = config["output_features"][0]["type"] output_feature_name: str = config["output_features"][0]["name"] metric_dict = report[output_feature_name] full_metric_names = get_metric_classes(output_feature_type) metric_to_values: dict[str, float | int] = { metric_name: metric_dict[metric_name] for metric_name in full_metric_names if metric_name in metric_dict } metric_names: set[str] = set(metric_to_values) return MetricsSummary( experiment_local_directory=experiment_local_directory, config=config, output_feature_name=output_feature_name, output_feature_type=output_feature_type, metric_to_values=metric_to_values, metric_names=metric_names, ) def build_metrics_diff( dataset_name: str, base_experiment_name: str, experimental_experiment_name: str, local_directory: str ) -> MetricsDiff: """Build a MetricsDiff object between two experiments on a dataset. :param dataset_name: the name of the Ludwig dataset. :param base_experiment_name: the name of the base experiment. :param experimental_experiment_name: the name of the experimental experiment. :param local_directory: the local directory where the experiment artifacts are downloaded. """ base_summary: MetricsSummary = build_metrics_summary( os.path.join(local_directory, dataset_name, base_experiment_name) ) experimental_summary: MetricsSummary = build_metrics_summary( os.path.join(local_directory, dataset_name, experimental_experiment_name) ) metrics_in_common = set(base_summary.metric_names).intersection(set(experimental_summary.metric_names)) metrics: list[MetricDiff] = [ build_diff(name, base_summary.metric_to_values[name], experimental_summary.metric_to_values[name]) for name in metrics_in_common ] return MetricsDiff( dataset_name=dataset_name, base_experiment_name=base_experiment_name, experimental_experiment_name=experimental_experiment_name, local_directory=local_directory, base_summary=base_summary, experimental_summary=experimental_summary, metrics=metrics, ) ############################## # Resource Usage Dataclasses # ############################## @dataclass class ResourceUsageSummary: """Summary of resource usage metrics from one experiment.""" # The tag with which the code block/function is labeled. code_block_tag: str # Dictionary that maps from metric name to their values. metric_to_values: dict[str, float | int] # Names of metrics for the output feature. metric_names: set[str] @dataclass class ResourceUsageDiff: """Store resource usage diffs for two experiments.""" # The tag with which the code block/function is labeled. code_block_tag: str # Name of the base experiment (the one we benchmark against). base_experiment_name: str # Name of the experimental experiment. experimental_experiment_name: str # `List[Diff]` containing diffs for metric of the two experiments. metrics: list[MetricDiff] def to_string(self): ret = [] spacing_str = "{:<36} {:<20} {:<20} {:<20} {:<5}" ret.append( spacing_str.format( "Metric Name", self.base_experiment_name, self.experimental_experiment_name, "Diff", "Diff Percentage", ) ) for metric in sorted(self.metrics, key=lambda m: m.name): diff_percentage = metric.diff_percentage if isinstance(metric.diff_percentage, float): diff_percentage = round(metric.diff_percentage, 3) ret.append( spacing_str.format( metric.name, metric.base_value_str, metric.experimental_value_str, metric.diff_str, diff_percentage, ) ) return "\n".join(ret) def export_resource_usage_diff_to_csv(resource_usage_diff: ResourceUsageDiff, path: str): """Export resource usage metrics report to .csv. :param resource_usage_diff: ResourceUsageDiff object containing the diff for two experiments on a dataset. :param path: file name of the exported csv. """ with open(path, "w", newline="") as f: writer = csv.DictWriter( f, fieldnames=[ "Code Block Tag", "Metric Name", resource_usage_diff.base_experiment_name, resource_usage_diff.experimental_experiment_name, "Diff", "Diff Percentage", ], ) writer.writeheader() for metric in sorted(resource_usage_diff.metrics, key=lambda m: m.name): diff_percentage = metric.diff_percentage if isinstance(metric.diff_percentage, float): diff_percentage = round(metric.diff_percentage, 3) writer.writerow( { "Code Block Tag": resource_usage_diff.code_block_tag, "Metric Name": metric.name, resource_usage_diff.base_experiment_name: metric.base_value_str, resource_usage_diff.experimental_experiment_name: metric.experimental_value_str, "Diff": metric.diff_str, "Diff Percentage": diff_percentage, } ) logger.info(f"Exported a CSV report to {path}\n") def average_runs(path_to_runs_dir: str) -> dict[str, int | float]: """Return average metrics from code blocks/function that ran more than once. Metrics for code blocks/functions that were executed exactly once will be returned as is. :param path_to_runs_dir: path to where metrics specific to a tag are stored. e.g. resource_usage_out_dir/torch_ops_resource_usage/LudwigModel.evaluate/ This directory will contain JSON files with the following pattern run_*.json """ runs = [load_json(os.path.join(path_to_runs_dir, run)) for run in os.listdir(path_to_runs_dir)] # asserting that keys to each of the dictionaries are consistent throughout the runs. assert len(runs) == 1 or all(runs[i].keys() == runs[i + 1].keys() for i in range(len(runs) - 1)) runs_average = {"num_runs": len(runs)} for key in runs[0]: if isinstance(runs[0][key], (int, float)): runs_average[key] = mean([run[key] for run in runs]) return runs_average def summarize_resource_usage(path: str, tags: list[str] | None = None) -> list[ResourceUsageSummary]: """Create resource usage summaries for each code block/function that was decorated with ResourceUsageTracker. Each entry of the list corresponds to the metrics collected from a code block/function run. Important: code blocks that ran more than once are averaged. :param path: corresponds to the `output_dir` argument in a ResourceUsageTracker run. :param tags: (optional) list of tags to create summary for. If None, metrics from all tags will be summarized. """ summary = dict() # metric types: system_resource_usage, torch_ops_resource_usage. all_metric_types = {"system_resource_usage", "torch_ops_resource_usage"} for metric_type in all_metric_types.intersection(os.listdir(path)): metric_type_path = os.path.join(path, metric_type) # code block tags correspond to the `tag` argument in ResourceUsageTracker. for code_block_tag in os.listdir(metric_type_path): if tags and code_block_tag not in tags: continue if code_block_tag not in summary: summary[code_block_tag] = {} run_path = os.path.join(metric_type_path, code_block_tag) # Metrics from code blocks/functions that ran more than once are averaged. summary[code_block_tag][metric_type] = average_runs(run_path) summary_list = [] for code_block_tag, metric_type_dicts in summary.items(): merged_summary: dict[str, float | int] = {} for metrics in metric_type_dicts.values(): assert "num_runs" in metrics assert "num_runs" not in merged_summary or metrics["num_runs"] == merged_summary["num_runs"] merged_summary.update(metrics) summary_list.append( ResourceUsageSummary( code_block_tag=code_block_tag, metric_to_values=merged_summary, metric_names=set(merged_summary) ) ) return summary_list def build_resource_usage_diff( base_path: str, experimental_path: str, base_experiment_name: str | None = None, experimental_experiment_name: str | None = None, ) -> list[ResourceUsageDiff]: """Build and return a ResourceUsageDiff object to diff resource usage metrics between two experiments. :param base_path: corresponds to the `output_dir` argument in the base ResourceUsageTracker run. :param experimental_path: corresponds to the `output_dir` argument in the experimental ResourceUsageTracker run. """ base_summary_list = summarize_resource_usage(base_path) experimental_summary_list = summarize_resource_usage(experimental_path) summaries_list = [] for base_summary in base_summary_list: for experimental_summary in experimental_summary_list: if base_summary.code_block_tag == experimental_summary.code_block_tag: summaries_list.append((base_summary, experimental_summary)) diffs = [] for base_summary, experimental_summary in summaries_list: metrics_in_common = set(base_summary.metric_names).intersection(set(experimental_summary.metric_names)) metrics: list[MetricDiff] = [ build_diff(name, base_summary.metric_to_values[name], experimental_summary.metric_to_values[name]) for name in metrics_in_common ] diff = ResourceUsageDiff( code_block_tag=base_summary.code_block_tag, base_experiment_name=base_experiment_name if base_experiment_name else "experiment_1", experimental_experiment_name=( experimental_experiment_name if experimental_experiment_name else "experiment_2" ), metrics=metrics, ) diffs.append(diff) return diffs ================================================ FILE: ludwig/benchmarking/utils.py ================================================ import asyncio import functools import logging import os import shutil import uuid from concurrent.futures import ThreadPoolExecutor from types import ModuleType from typing import Any import fsspec import pandas as pd import yaml from ludwig.api_annotations import DeveloperAPI from ludwig.constants import BINARY, CATEGORY from ludwig.datasets import model_configs_for_dataset from ludwig.datasets.loaders.dataset_loader import DatasetLoader from ludwig.globals import CONFIG_YAML, MODEL_FILE_NAME, MODEL_WEIGHTS_FILE_NAME from ludwig.utils.data_utils import load_yaml from ludwig.utils.dataset_utils import get_repeatable_train_val_test_split from ludwig.utils.defaults import default_random_seed from ludwig.utils.fs_utils import get_fs_and_path HYPEROPT_OUTDIR_RETAINED_FILES = [ "hyperopt_statistics.json", "params.json", "stderr", "stdout", "result.json", "error.txt", ] logger = logging.getLogger() def load_from_module( dataset_module: DatasetLoader | ModuleType, output_feature: dict[str, str], subsample_frac: float = 1 ) -> pd.DataFrame: """Load the ludwig dataset, optionally subsamples it, and returns a repeatable split. A stratified split is used for classification datasets. Args: dataset_module: ludwig datasets module (e.g. ludwig.datasets.sst2, ludwig.datasets.ames_housing, etc.) subsample_frac: percentage of the total dataset to load. """ dataset = dataset_module.load(split=False) if subsample_frac < 1: dataset = dataset.sample(frac=subsample_frac, replace=False, random_state=default_random_seed) if output_feature["type"] in [CATEGORY, BINARY]: return get_repeatable_train_val_test_split( dataset, stratify_colname=output_feature["name"], random_seed=default_random_seed, ) else: return get_repeatable_train_val_test_split(dataset, random_seed=default_random_seed) def export_artifacts(experiment: dict[str, str], experiment_output_directory: str, export_base_path: str): """Save the experiment artifacts to the `bench_export_directory`. Args: experiment: experiment dict that contains "dataset_name" (e.g. ames_housing), "experiment_name" (specified by user), and "config_path" (path to experiment config. Relative to ludwig/benchmarks/configs). experiment_output_directory: path where the model, data, and logs of the experiment are saved. export_base_path: remote or local path (directory) where artifacts are exported. (e.g. s3://benchmarking.us-west-2.ludwig.com/bench/ or your/local/bench/) """ protocol, _ = fsspec.core.split_protocol(export_base_path) fs, _ = get_fs_and_path(export_base_path) try: export_full_path = os.path.join(export_base_path, experiment["dataset_name"], experiment["experiment_name"]) # override previous experiment with the same name if fs.exists(export_full_path): fs.rm(export_full_path, recursive=True) fs.put(experiment_output_directory, export_full_path, recursive=True) fs.put( os.path.join(experiment["config_path"]), os.path.join(export_full_path, CONFIG_YAML), ) logger.info(f"Uploaded experiment artifact to\n\t{export_full_path}") except Exception: logger.exception( f"Failed to upload experiment artifacts for experiment *{experiment['experiment_name']}* on " f"dataset {experiment['dataset_name']}" ) def download_artifacts( bench_config_path: str, base_experiment: str, experimental_experiment: str, download_base_path: str, local_dir: str = "benchmarking_summaries", ) -> tuple[str, list[str]]: """Download benchmarking artifacts for two experiments. Args: bench_config_path: bench config file path. Can be the same one that was used to run these experiments. base_experiment: name of the experiment we're comparing against. experimental_experiment: name of the experiment we're comparing. download_base_path: base path under which live the stored artifacts of the benchmarking experiments. """ bench_config = load_yaml(bench_config_path) protocol, _ = fsspec.core.split_protocol(download_base_path) fs, _ = get_fs_and_path(download_base_path) os.makedirs(local_dir, exist_ok=True) coroutines = [] for experiment in bench_config["experiments"]: dataset_name = experiment["dataset_name"] for experiment_name in [base_experiment, experimental_experiment]: coroutines.append(download_one(fs, download_base_path, dataset_name, experiment_name, local_dir)) downloaded_names = asyncio.run(asyncio.gather(*coroutines, return_exceptions=True)) dataset_names = [experiment_tuple[0] for experiment_tuple in set(downloaded_names) if experiment_tuple[0]] assert ( len({experiment_tuple[1] for experiment_tuple in downloaded_names}) == 1 and downloaded_names[0][1] == local_dir ), "Experiments not downloaded to the same path" return local_dir, dataset_names @DeveloperAPI async def download_one( fs, download_base_path: str, dataset_name: str, experiment_name: str, local_dir: str ) -> tuple[str, str]: """Download `config.yaml` and `report.json` for an experiment. Args: fs: filesystem to use to download. download_base_path: base path under which live the stored artifacts of the benchmarking experiments. dataset_name: name of the dataset we ran the experiments on. experiment_name: name of the experiment (e.g. `v0.5.3_with_bert`) local_dir: local directory under which the artifacts will be downloaded. """ loop = asyncio.get_running_loop() local_experiment_dir = os.path.join(local_dir, dataset_name, experiment_name) remote_experiment_directory = os.path.join(download_base_path, dataset_name, experiment_name) os.makedirs(local_experiment_dir, exist_ok=True) try: with ThreadPoolExecutor() as pool: func = functools.partial( fs.get, remote_experiment_directory, local_experiment_dir, recursive=True, ) await loop.run_in_executor(pool, func) except Exception: logger.exception(f"Couldn't download experiment *{experiment_name}* of dataset *{dataset_name}*.") return "", local_dir return dataset_name, local_dir def validate_benchmarking_config(benchmarking_config: dict[str, Any]) -> None: """Validates the parameters of the benchmarking config. Args: benchmarking_config: benchmarking config dictionary. Raises: ValueError if any of the expected parameters is not there. """ if "experiment_name" not in benchmarking_config and not all( "experiment_name" in experiment for experiment in benchmarking_config["experiments"] ): raise ValueError("You must either specify a global experiment name or an experiment name for each experiment.") if "export" not in benchmarking_config: raise ValueError("""You must specify export parameters. Example: export: export_artifacts: true export_base_path: s3://benchmarking.us-west-2.ludwig.com/bench/ # include the slash at the end. """) if "experiments" not in benchmarking_config: raise ValueError("You must specify a list of experiments.") for experiment in benchmarking_config["experiments"]: if "dataset_name" not in experiment: raise ValueError("A Ludwig dataset must be specified.") def populate_benchmarking_config_with_defaults(benchmarking_config: dict[str, Any]) -> dict[str, Any]: """Populates the parameters of the benchmarking config with defaults. Args: benchmarking_config: benchmarking config dictionary. """ if "hyperopt" not in benchmarking_config: benchmarking_config["hyperopt"] = False if "process_config_file_path" not in benchmarking_config: benchmarking_config["process_config_file_path"] = None if "profiler" not in benchmarking_config: benchmarking_config["profiler"] = {"enable": False, "use_torch_profiler": False, "logging_interval": None} return benchmarking_config def propagate_global_parameters(benchmarking_config: dict[str, Any]) -> dict[str, Any]: """Propagate the global parameters of the benchmarking config to local experiments. Args: benchmarking_config: benchmarking config dictionary. """ for experiment in benchmarking_config["experiments"]: if "experiment_name" not in experiment: experiment["experiment_name"] = benchmarking_config["experiment_name"] if "export" not in experiment: experiment["export"] = benchmarking_config["export"] if "hyperopt" not in experiment: experiment["hyperopt"] = benchmarking_config["hyperopt"] if "process_config_file_path" not in experiment: experiment["process_config_file_path"] = benchmarking_config["process_config_file_path"] if "profiler" not in experiment: experiment["profiler"] = benchmarking_config["profiler"] return benchmarking_config def create_default_config(experiment: dict[str, Any]) -> str: """Create a Ludwig config that only contains input and output features. Args: experiment: experiment dictionary. Returns: path where the default config is saved. """ model_config = model_configs_for_dataset(experiment["dataset_name"])["default"] # only keep input_features and output_features main_config_keys = list(model_config.keys()) for key in main_config_keys: if key not in ["input_features", "output_features"]: del model_config[key] config_path = f"{experiment['dataset_name']}-{uuid.uuid4().hex}.yaml" save_yaml(config_path, model_config) return config_path def delete_model_checkpoints(output_directory: str): """Deletes outputs of the experiment run that we don't want to save with the artifacts. Args: output_directory: output directory of the hyperopt run. """ shutil.rmtree(os.path.join(output_directory, MODEL_FILE_NAME, "training_checkpoints"), ignore_errors=True) if os.path.isfile(os.path.join(output_directory, MODEL_FILE_NAME, MODEL_WEIGHTS_FILE_NAME)): os.remove(os.path.join(output_directory, MODEL_FILE_NAME, MODEL_WEIGHTS_FILE_NAME)) def delete_hyperopt_outputs(output_directory: str): """Deletes outputs of the hyperopt run that we don't want to save with the artifacts. Args: output_directory: output directory of the hyperopt run. """ for path, currentDirectory, files in os.walk(output_directory): for file in files: filename = os.path.join(path, file) if file not in HYPEROPT_OUTDIR_RETAINED_FILES: os.remove(filename) def save_yaml(filename, dictionary): with open(filename, "w") as f: yaml.dump(dictionary, f, default_flow_style=False) def format_time(time_us): """Defines how to format time in FunctionEvent. from https://github.com/pytorch/pytorch/blob/master/torch/autograd/profiler_util.py """ US_IN_SECOND = 1000.0 * 1000.0 US_IN_MS = 1000.0 if time_us >= US_IN_SECOND: return f"{time_us / US_IN_SECOND:.3f}s" if time_us >= US_IN_MS: return f"{time_us / US_IN_MS:.3f}ms" return f"{time_us:.3f}us" def format_memory(nbytes): """Returns a formatted memory size string. from https://github.com/pytorch/pytorch/blob/master/torch/autograd/profiler_util.py """ KB = 1024 MB = 1024 * KB GB = 1024 * MB if abs(nbytes) >= GB: return f"{nbytes * 1.0 / GB:.2f} Gb" elif abs(nbytes) >= MB: return f"{nbytes * 1.0 / MB:.2f} Mb" elif abs(nbytes) >= KB: return f"{nbytes * 1.0 / KB:.2f} Kb" else: return str(nbytes) + " b" ================================================ FILE: ludwig/callbacks.py ================================================ # !/usr/bin/env python # Copyright (c) 2021 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. # ============================================================================== from abc import ABC from collections.abc import Callable from typing import Any from ludwig.api_annotations import PublicAPI from ludwig.types import HyperoptConfigDict, ModelConfigDict, TrainingSetMetadataDict @PublicAPI class Callback(ABC): def on_cmdline(self, cmd: str, *args: list[str]): """Called when Ludwig is run on the command line with the callback enabled. :param cmd: The Ludwig subcommand being run, ex. "train", "evaluate", "predict", ... :param args: The full list of command-line arguments (sys.argv). """ def on_preprocess_start(self, config: ModelConfigDict, **kwargs): """Called before preprocessing starts. :param config: The config dictionary. """ def on_preprocess_end( self, training_set, validation_set, test_set, training_set_metadata: TrainingSetMetadataDict, **kwargs, ): """Called after preprocessing ends. :param training_set: The training set. :type training_set: ludwig.dataset.base.Dataset :param validation_set: The validation set. :type validation_set: ludwig.dataset.base.Dataset :param test_set: The test set. :type test_set: ludwig.dataset.base.Dataset :param training_set_metadata: Values inferred from the training set, including preprocessing settings, vocabularies, feature statistics, etc. Same as training_set_metadata.json. """ def on_hyperopt_init(self, experiment_name: str, **kwargs): """Called to initialize state before hyperparameter optimization begins. :param experiment_name: The name of the current experiment. """ def on_hyperopt_preprocessing_start(self, experiment_name: str, **kwargs): """Called before data preprocessing for hyperparameter optimization begins. :param experiment_name: The name of the current experiment. """ def on_hyperopt_preprocessing_end(self, experiment_name: str, **kwargs): """Called after data preprocessing for hyperparameter optimization is completed. :param experiment_name: The name of the current experiment. """ def on_hyperopt_start(self, experiment_name: str, **kwargs): """Called before any hyperparameter optimization trials are started. :param experiment_name: The name of the current experiment. """ def on_hyperopt_end(self, experiment_name: str, **kwargs): """Called after all hyperparameter optimization trials are completed. :param experiment_name: The name of the current experiment. """ def on_hyperopt_finish(self, experiment_name: str, **kwargs): """Deprecated. Use on_hyperopt_end instead. """ # TODO(travis): remove in favor of on_hyperopt_end for naming consistency def on_hyperopt_trial_start(self, parameters: HyperoptConfigDict, **kwargs): """Called before the start of each hyperparameter optimization trial. :param parameters: The complete dictionary of parameters for this hyperparameter optimization experiment. """ def on_hyperopt_trial_end(self, parameters: HyperoptConfigDict, **kwargs): """Called after the end of each hyperparameter optimization trial. :param parameters: The complete dictionary of parameters for this hyperparameter optimization experiment. """ def should_stop_hyperopt(self): """Returns true if the entire hyperopt run (all trials) should be stopped. See: https://docs.ray.io/en/latest/tune/api_docs/stoppers.html#ray.tune.Stopper """ return False def on_resume_training(self, is_coordinator: bool, **kwargs): pass def on_train_init( self, base_config: ModelConfigDict, experiment_directory: str, experiment_name: str, model_name: str, output_directory: str, resume_directory: str | None, **kwargs, ): """Called after preprocessing, but before the creation of the model and trainer objects. :param base_config: The user-specified config, before the insertion of defaults or inferred values. :param experiment_directory: The experiment directory, same as output_directory if no experiment specified. :param experiment_name: The experiment name. :param model_name: The model name. :param output_directory: file path to where training results are stored. :param resume_directory: model directory to resume training from, or None. """ def on_train_start( self, model, config: ModelConfigDict, config_fp: str | None, **kwargs, ): """Called after creation of trainer, before the start of training. :param model: The ludwig model. :type model: ludwig.utils.torch_utils.LudwigModule :param config: The config dictionary. :param config_fp: The file path to the config, or none if config was passed to stdin. """ def on_train_end(self, output_directory: str, **kwargs): """Called at the end of training, before the model is saved. :param output_directory: file path to where training results are stored. """ def on_trainer_train_setup(self, trainer, save_path: str, is_coordinator: bool, **kwargs): """Called in every trainer (distributed or local) before training starts. :param trainer: The trainer instance. :type trainer: trainer: ludwig.models.Trainer :param save_path: The path to the directory model is saved in. :param is_coordinator: Is this trainer the coordinator. """ def on_trainer_train_teardown(self, trainer, progress_tracker, save_path: str, is_coordinator: bool, **kwargs): """Called in every trainer (distributed or local) after training completes. :param trainer: The trainer instance. :type trainer: ludwig.models.trainer.Trainer :param progress_tracker: An object which tracks training progress. :type progress_tracker: ludwig.utils.trainer_utils.ProgressTracker :param save_path: The path to the directory model is saved in. :param is_coordinator: Is this trainer the coordinator. """ def on_batch_start(self, trainer, progress_tracker, save_path: str, **kwargs): """Called on coordinator only before each batch. :param trainer: The trainer instance. :type trainer: ludwig.models.trainer.Trainer :param progress_tracker: An object which tracks training progress. :type progress_tracker: ludwig.utils.trainer_utils.ProgressTracker :param save_path: The path to the directory model is saved in. """ def on_batch_end(self, trainer, progress_tracker, save_path: str, sync_step: bool = True, **kwargs): """Called on coordinator only after each batch. :param trainer: The trainer instance. :type trainer: ludwig.models.trainer.Trainer :param progress_tracker: An object which tracks training progress. :type progress_tracker: ludwig.utils.trainer_utils.ProgressTracker :param save_path: The path to the directory model is saved in. :param sync_step: Whether the model params were updated and synced in this step. """ def on_eval_start(self, trainer, progress_tracker, save_path: str, **kwargs): """Called on coordinator at the start of evaluation. :param trainer: The trainer instance. :type trainer: ludwig.models.trainer.Trainer :param progress_tracker: An object which tracks training progress. :type progress_tracker: ludwig.utils.trainer_utils.ProgressTracker :param save_path: The path to the directory model is saved in. """ def on_eval_end(self, trainer, progress_tracker, save_path: str, **kwargs): """Called on coordinator at the end of evaluation. :param trainer: The trainer instance. :type trainer: ludwig.models.trainer.Trainer :param progress_tracker: An object which tracks training progress. :type progress_tracker: ludwig.utils.trainer_utils.ProgressTracker :param save_path: The path to the directory model is saved in. """ def on_epoch_start(self, trainer, progress_tracker, save_path: str, **kwargs): """Called on coordinator only before the start of each epoch. :param trainer: The trainer instance. :type trainer: ludwig.models.trainer.Trainer :param progress_tracker: An object which tracks training progress. :type progress_tracker: ludwig.utils.trainer_utils.ProgressTracker :param save_path: The path to the directory model is saved in. """ def on_epoch_end(self, trainer, progress_tracker, save_path: str, **kwargs): """Called on coordinator only after the end of each epoch. :param trainer: The trainer instance. :type trainer: ludwig.models.trainer.Trainer :param progress_tracker: An object which tracks training progress. :type progress_tracker: ludwig.utils.trainer_utils.ProgressTracker :param save_path: The path to the directory model is saved in. """ def on_validation_start(self, trainer, progress_tracker, save_path: str, **kwargs): """Called on coordinator before validation starts. :param trainer: The trainer instance. :type trainer: ludwig.models.trainer.Trainer :param progress_tracker: An object which tracks training progress. :type progress_tracker: ludwig.utils.trainer_utils.ProgressTracker :param save_path: The path to the directory model is saved in. """ def on_validation_end(self, trainer, progress_tracker, save_path: str, **kwargs): """Called on coordinator after validation is complete. :param trainer: The trainer instance. :type trainer: ludwig.models.trainer.Trainer :param progress_tracker: An object which tracks training progress. :type progress_tracker: ludwig.utils.trainer_utils.ProgressTracker :param save_path: The path to the directory model is saved in. """ def on_test_start(self, trainer, progress_tracker, save_path: str, **kwargs): """Called on coordinator before testing starts. :param trainer: The trainer instance. :type trainer: ludwig.models.trainer.Trainer :param progress_tracker: An object which tracks training progress. :type progress_tracker: ludwig.utils.trainer_utils.ProgressTracker :param save_path: The path to the directory model is saved in. """ def on_test_end(self, trainer, progress_tracker, save_path: str, **kwargs): """Called on coordinator after testing ends. :param trainer: The trainer instance. :type trainer: ludwig.models.trainer.Trainer :param progress_tracker: An object which tracks training progress. :type progress_tracker: ludwig.utils.trainer_utils.ProgressTracker :param save_path: The path to the directory model is saved in. """ def should_early_stop(self, trainer, progress_tracker, is_coordinator, **kwargs): # Triggers early stopping if any callback on any worker returns True return False def on_checkpoint(self, trainer, progress_tracker, **kwargs): """Called after each checkpoint is passed, regardless of whether the model was evaluated or saved at that checkpoint.""" def on_save_best_checkpoint(self, trainer, progress_tracker, save_path, **kwargs): """Called on every worker immediately after a new best model is checkpointed.""" def on_build_metadata_start(self, df, mode: str, **kwargs): """Called before building metadata for dataset. :param df: The dataset. :type df: pd.DataFrame :param mode: "prediction", "training", or None. """ def on_build_metadata_end(self, df, mode, **kwargs): """Called after building dataset metadata. :param df: The dataset. :type df: pd.DataFrame :param mode: "prediction", "training", or None. """ def on_build_data_start(self, df, mode, **kwargs): """Called before build_data, which does preprocessing, handling missing values, adding metadata to training_set_metadata. :param df: The dataset. :type df: pd.DataFrame :param mode: "prediction", "training", or None. """ def on_build_data_end(self, df, mode, **kwargs): """Called after build_data completes. :param df: The dataset. :type df: pd.DataFrame :param mode: "prediction", "training", or None. """ def on_evaluation_start(self, **kwargs): """Called before preprocessing for evaluation.""" def on_evaluation_end(self, **kwargs): """Called after evaluation is complete.""" def on_visualize_figure(self, fig, **kwargs): """Called after a visualization is generated. :param fig: The figure. :type fig: matplotlib.figure.Figure """ def on_ludwig_end(self, **kwargs): """Convenience method for any cleanup. Not yet implemented. """ def prepare_ray_tune( self, train_fn: Callable, tune_config: dict[str, Any], tune_callbacks: list[Callable], **kwargs, ): """Configures Ray Tune callback and config. :param train_fn: The function which runs the experiment trial. :param tune_config: The ray tune configuration dictionary. :param tune_callbacks: List of callbacks (not used yet). :returns: Tuple[Callable, Dict] The train_fn and tune_config, which will be passed to ray tune. """ return train_fn, tune_config ================================================ FILE: ludwig/check.py ================================================ import argparse import logging import tempfile from ludwig.api import LudwigModel from ludwig.api_annotations import DeveloperAPI from ludwig.constants import INPUT_FEATURES, OUTPUT_FEATURES, TRAINER from ludwig.data.dataset_synthesizer import build_synthetic_dataset_df from ludwig.globals import LUDWIG_VERSION from ludwig.utils.print_utils import get_logging_level_registry, print_ludwig NUM_EXAMPLES = 100 @DeveloperAPI def check_install(logging_level: int = logging.INFO, **kwargs): config = { INPUT_FEATURES: [ {"name": "in1", "type": "text"}, {"name": "in2", "type": "category"}, {"name": "in3", "type": "number"}, ], OUTPUT_FEATURES: [{"name": "out1", "type": "binary"}], TRAINER: {"epochs": 2, "batch_size": 8}, } try: df = build_synthetic_dataset_df(NUM_EXAMPLES, config) model = LudwigModel(config, logging_level=logging_level) with tempfile.TemporaryDirectory() as tmpdir: model.train(dataset=df, output_directory=tmpdir) except Exception: print("=== CHECK INSTALL COMPLETE... FAILURE ===") raise print("=== CHECK INSTALL COMPLETE... SUCCESS ===") @DeveloperAPI def cli(sys_argv): parser = argparse.ArgumentParser( description="This command checks Ludwig installation on a synthetic dataset.", prog="ludwig check_install", usage="%(prog)s [options]", ) parser.add_argument( "-l", "--logging_level", default="warning", help="the level of logging to use", choices=["critical", "error", "warning", "info", "debug", "notset"], ) args = parser.parse_args(sys_argv) args.logging_level = get_logging_level_registry()[args.logging_level] logging.getLogger("ludwig").setLevel(args.logging_level) global logger logger = logging.getLogger("ludwig.check") print_ludwig("Check Install", LUDWIG_VERSION) check_install(**vars(args)) ================================================ FILE: ludwig/cli.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import argparse import sys import ludwig.contrib from ludwig.globals import LUDWIG_VERSION from ludwig.utils.print_utils import get_logo class CLI: """CLI describes a command line interface for interacting with Ludwig. Functions are described below. """ def __init__(self): parser = argparse.ArgumentParser( description="ludwig cli runner", usage=f"""\n{get_logo("ludwig cli", LUDWIG_VERSION)} ludwig [] Available sub-commands: train Trains a model predict Predicts using a pretrained model evaluate Evaluate a pretrained model's performance forecast Forecast the next n data points in a timeseries using a pretrained model experiment Runs a full experiment training a model and evaluating it hyperopt Perform hyperparameter optimization benchmark Run and track experiments on a number of datasets and configs, and export experiment artifacts. serve Serves a pretrained model visualize Visualizes experimental results collect_summary Prints names of weights and layers activations to use with other collect commands collect_weights Collects tensors containing a pretrained model weights collect_activations Collects tensors for each datapoint using a pretrained model datasets Downloads and lists Ludwig-ready datasets export_torchscript Exports Ludwig models to Torchscript export_triton Exports Ludwig models to Triton export_mlflow Exports Ludwig models to MLflow export_schema Exports the Ludwig config JSON schema preprocess Preprocess data and saves it into HDF5 and JSON format synthesize_dataset Creates synthetic data for testing purposes init_config Initialize a user config from a dataset and targets render_config Renders the fully populated config with all defaults set check_install Runs a quick training run on synthetic data to verify installation status upload Push trained model artifacts to a registry (e.g., Predibase, HuggingFace Hub) """, ) parser.add_argument("command", help="Subcommand to run") # parse_args defaults to [1:] for args, but you need to # exclude the rest of the args too, or validation will fail args = parser.parse_args(sys.argv[1:2]) if not hasattr(self, args.command): print("Unrecognized command") parser.print_help() exit(1) # use dispatch pattern to invoke method with same name getattr(self, args.command)() def train(self): from ludwig import train train.cli(sys.argv[2:]) def predict(self): from ludwig import predict predict.cli(sys.argv[2:]) def evaluate(self): from ludwig import evaluate evaluate.cli(sys.argv[2:]) def forecast(self): from ludwig import forecast forecast.cli(sys.argv[2:]) def experiment(self): from ludwig import experiment experiment.cli(sys.argv[2:]) def hyperopt(self): from ludwig import hyperopt_cli hyperopt_cli.cli(sys.argv[2:]) def benchmark(self): from ludwig.benchmarking import benchmark benchmark.cli(sys.argv[2:]) def serve(self): from ludwig import serve serve.cli(sys.argv[2:]) def visualize(self): from ludwig import visualize visualize.cli(sys.argv[2:]) def collect_summary(self): from ludwig import collect collect.cli_collect_summary(sys.argv[2:]) def collect_weights(self): from ludwig import collect collect.cli_collect_weights(sys.argv[2:]) def collect_activations(self): from ludwig import collect collect.cli_collect_activations(sys.argv[2:]) def export_torchscript(self): from ludwig import export export.cli_export_torchscript(sys.argv[2:]) def export_triton(self): from ludwig import export export.cli_export_triton(sys.argv[2:]) def export_mlflow(self): from ludwig import export export.cli_export_mlflow(sys.argv[2:]) def export_schema(self): from ludwig.schema.export_schema import main as export_schema_main export_schema_main(sys.argv[2:]) def preprocess(self): from ludwig import preprocess preprocess.cli(sys.argv[2:]) def synthesize_dataset(self): from ludwig.data import dataset_synthesizer dataset_synthesizer.cli(sys.argv[2:]) def init_config(self): from ludwig import automl automl.cli_init_config(sys.argv[2:]) def render_config(self): from ludwig.utils import defaults defaults.cli_render_config(sys.argv[2:]) def check_install(self): from ludwig import check check.cli(sys.argv[2:]) def datasets(self): from ludwig import datasets datasets.cli(sys.argv[2:]) def upload(self): from ludwig import upload upload.cli(sys.argv[2:]) def main(): ludwig.contrib.preload(sys.argv) CLI() if __name__ == "__main__": main() ================================================ FILE: ludwig/collect.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import argparse import importlib import logging import os import sys import numpy as np import torch import torchinfo from ludwig.api import LudwigModel from ludwig.backend import ALL_BACKENDS, Backend from ludwig.callbacks import Callback from ludwig.constants import FULL, TEST, TRAINING, VALIDATION from ludwig.contrib import add_contrib_callback_args from ludwig.globals import LUDWIG_VERSION from ludwig.utils.print_utils import get_logging_level_registry, print_boxed, print_ludwig from ludwig.utils.strings_utils import make_safe_filename logger = logging.getLogger(__name__) def collect_activations( model_path: str, layers: list[str], dataset: str, data_format: str = None, split: str = FULL, batch_size: int = 128, output_directory: str = "results", gpus: list[str] = None, gpu_memory_limit: float | None = None, allow_parallel_threads: bool = True, callbacks: list[Callback] = None, backend: Backend | str = None, **kwargs, ) -> list[str]: """Uses the pretrained model to collect the tensors corresponding to a datapoint in the dataset. Saves the tensors to the experiment directory. # Inputs :param model_path: (str) filepath to pre-trained model. :param layers: (List[str]) list of strings for layer names in the model to collect activations. :param dataset: (str) source containing the data to make predictions. :param data_format: (str, default: `None`) format to interpret data sources. Will be inferred automatically if not specified. Valid formats are `'auto'`, `'csv'`, `'excel'`, `'feather'`, `'fwf'`, `'hdf5'` (cache file produced during previous training), `'html'` (file containing a single HTML `
`), `'json'`, `'jsonl'`, `'parquet'`, `'pickle'` (pickled Pandas DataFrame), `'sas'`, `'spss'`, `'stata'`, `'tsv'`. :param split: (str, default: `full`) split on which to perform predictions. Valid values are `'training'`, `'validation'`, `'test'` and `'full'`. :param batch_size: (int, default `128`) size of batches for processing. :param output_directory: (str, default: `'results'`) the directory that will contain the training statistics, TensorBoard logs, the saved model and the training progress files. :param gpus: (list, default: `None`) list of GPUs that are available for training. :param gpu_memory_limit: (float: default: `None`) maximum memory fraction [0, 1] allowed to allocate per GPU device. :param allow_parallel_threads: (bool, default: `True`) allow PyTorch to use multithreading parallelism to improve performance at the cost of determinism. :param callbacks: (list, default: `None`) a list of `ludwig.callbacks.Callback` objects that provide hooks into the Ludwig pipeline. :param backend: (Union[Backend, str]) `Backend` or string name of backend to use to execute preprocessing / training steps. # Return :return: (List[str]) list of filepath to `*.npy` files containing the activations. """ logger.info(f"Dataset path: {dataset}") logger.info(f"Model path: {model_path}") logger.info(f"Output path: {output_directory}") logger.info("\n") model = LudwigModel.load( model_path, gpus=gpus, gpu_memory_limit=gpu_memory_limit, allow_parallel_threads=allow_parallel_threads, callbacks=callbacks, backend=backend, ) # collect activations print_boxed("COLLECT ACTIVATIONS") collected_tensors = model.collect_activations( layers, dataset, data_format=data_format, split=split, batch_size=batch_size ) # saving os.makedirs(output_directory, exist_ok=True) saved_filenames = save_tensors(collected_tensors, output_directory) logger.info(f"Saved to: {output_directory}") return saved_filenames def collect_weights(model_path: str, tensors: list[str], output_directory: str = "results", **kwargs) -> list[str]: """Loads a pretrained model and collects weights. # Inputs :param model_path: (str) filepath to pre-trained model. :param tensors: (list, default: `None`) List of tensor names to collect weights :param output_directory: (str, default: `'results'`) the directory where collected weights will be stored. # Return :return: (List[str]) list of filepath to `*.npy` files containing the weights. """ logger.info(f"Model path: {model_path}") logger.info(f"Output path: {output_directory}") logger.info("\n") model = LudwigModel.load(model_path) # collect weights print_boxed("COLLECT WEIGHTS") collected_tensors = model.collect_weights(tensors) # saving os.makedirs(output_directory, exist_ok=True) saved_filenames = save_tensors(collected_tensors, output_directory) logger.info(f"Saved to: {output_directory}") return saved_filenames def save_tensors(collected_tensors, output_directory): filenames = [] for tensor_name, tensor_value in collected_tensors: np_filename = os.path.join(output_directory, make_safe_filename(tensor_name) + ".npy") if isinstance(tensor_value, torch.Tensor): # Skip non-tensor collected artifacts, e.g. used_tokens. np.save(np_filename, tensor_value.detach().cpu().numpy()) filenames.append(np_filename) return filenames def print_model_summary(model_path: str, **kwargs) -> None: """Loads a pretrained model and prints names of weights and layers activations. # Inputs :param model_path: (str) filepath to pre-trained model. # Return :return: (`None`) """ model = LudwigModel.load(model_path) # Move model to CPU for torchinfo summary to avoid device mismatch issues. model.model.cpu() logger.info(torchinfo.summary(model.model, input_data=[model.model.get_model_inputs()], depth=20)) logger.info("\nModules:\n") for name, _ in model.model.named_children(): logger.info(name) logger.info("\nParameters:\n") for name, _ in model.model.named_parameters(): logger.info(name) def pretrained_summary(pretrained_model: str, **kwargs) -> None: """Loads a pretrained model from Huggingface or Torchvision models and prints names of layers. # Inputs :param pretrained_model: (str) name of model to load (case sensitive). # Return :return: (`None`) """ from transformers import AutoConfig, AutoModel model = None # get access token if available token = os.getenv("HUGGING_FACE_HUB_TOKEN") if token is None: logger.info("No token provided. Continuing loading without token access.") elif not token: raise ValueError("Invalid token provided. Exiting.") else: logger.info("Valid token provided. Proceeding with token access.") # Try to load from transformers/HF # TODO -> Fix OOM on large models e.g. llama 3 8B try: config = AutoConfig.from_pretrained(pretrained_model, token=token, low_cpu_mem_usage=True) model = AutoModel.from_config(config=config) logger.info(f"Loaded {pretrained_model} from Hugging Face Transformers.") except Exception as e: logger.error(f"Failed to load {pretrained_model} from Hugging Face Transformers: {e}") # Try and load from torchvision-models if model is None: try: module = importlib.import_module("torchvision.models") model = getattr(module, pretrained_model)(weights=None) except AttributeError: logger.error(f"{pretrained_model} is not a valid torchvision model.") if model: for name, _ in model.named_parameters(): logger.info(name) else: logger.error(f"Unable to load the model {pretrained_model} from any known source.") def cli_collect_activations(sys_argv): """Command Line Interface to communicate with the collection of tensors and there are several options that can specified when calling this function: --data_csv: Filepath for the input csv --data_hdf5: Filepath for the input hdf5 file, if there is a csv file, this is not read --d: Refers to the dataset type of the file being read, by default is *generic* --s: Refers to the split of the data, can be one of: train, test, validation, full --m: Input model that is necessary to collect to the tensors, this is a required *option* --t: Tensors to collect --od: Output directory of the model, defaults to results --bs: Batch size --g: Number of gpus that are to be used --gf: Fraction of each GPUs memory to use. --v: Verbose: Defines the logging level that the user will be exposed to """ parser = argparse.ArgumentParser( description="This script loads a pretrained model and uses it collect " "tensors for each datapoint in the dataset.", prog="ludwig collect_activations", usage="%(prog)s [options]", ) # --------------- # Data parameters # --------------- parser.add_argument("--dataset", help="input data file path", required=True) parser.add_argument( "--data_format", help="format of the input data", default="auto", choices=[ "auto", "csv", "excel", "feather", "fwf", "hdf5", "html" "tables", "json", "jsonl", "parquet", "pickle", "sas", "spss", "stata", "tsv", ], ) parser.add_argument( "-s", "--split", default=FULL, choices=[TRAINING, VALIDATION, TEST, FULL], help="the split to obtain the model activations from", ) # ---------------- # Model parameters # ---------------- parser.add_argument("-m", "--model_path", help="model to load", required=True) parser.add_argument("-lyr", "--layers", help="tensors to collect", nargs="+", required=True) # ------------------------- # Output results parameters # ------------------------- parser.add_argument( "-od", "--output_directory", type=str, default="results", help="directory that contains the results" ) # ------------------ # Generic parameters # ------------------ parser.add_argument("-bs", "--batch_size", type=int, default=128, help="size of batches") # ------------------ # Runtime parameters # ------------------ parser.add_argument("-g", "--gpus", type=int, default=0, help="list of gpu to use") parser.add_argument( "-gml", "--gpu_memory_limit", type=float, default=None, help="maximum memory fraction [0, 1] allowed to allocate per GPU device", ) parser.add_argument( "-dpt", "--disable_parallel_threads", action="store_false", dest="allow_parallel_threads", help="disable PyTorch from using multithreading for reproducibility", ) parser.add_argument( "-b", "--backend", help="specifies backend to use for parallel / distributed execution, " "defaults to local execution", choices=ALL_BACKENDS, ) parser.add_argument( "-l", "--logging_level", default="info", help="the level of logging to use", choices=["critical", "error", "warning", "info", "debug", "notset"], ) add_contrib_callback_args(parser) args = parser.parse_args(sys_argv) args.callbacks = args.callbacks or [] for callback in args.callbacks: callback.on_cmdline("collect_activations", *sys_argv) args.logging_level = get_logging_level_registry()[args.logging_level] logging.getLogger("ludwig").setLevel(args.logging_level) global logger logger = logging.getLogger("ludwig.collect") print_ludwig("Collect Activations", LUDWIG_VERSION) collect_activations(**vars(args)) def cli_collect_weights(sys_argv): """Command Line Interface to collecting the weights for the model. --m: Input model that is necessary to collect to the tensors, this is a required *option* --t: Tensors to collect --od: Output directory of the model, defaults to results --v: Verbose: Defines the logging level that the user will be exposed to """ parser = argparse.ArgumentParser( description="This script loads a pretrained model " "and uses it collect weights.", prog="ludwig collect_weights", usage="%(prog)s [options]", ) # ---------------- # Model parameters # ---------------- parser.add_argument("-m", "--model_path", help="model to load", required=True) parser.add_argument("-t", "--tensors", help="tensors to collect", nargs="+", required=True) # ------------------------- # Output results parameters # ------------------------- parser.add_argument( "-od", "--output_directory", type=str, default="results", help="directory that contains the results" ) # ------------------ # Runtime parameters # ------------------ parser.add_argument( "-l", "--logging_level", default="info", help="the level of logging to use", choices=["critical", "error", "warning", "info", "debug", "notset"], ) add_contrib_callback_args(parser) args = parser.parse_args(sys_argv) args.callbacks = args.callbacks or [] for callback in args.callbacks: callback.on_cmdline("collect_weights", *sys_argv) args.logging_level = get_logging_level_registry()[args.logging_level] logging.getLogger("ludwig").setLevel(args.logging_level) global logger logger = logging.getLogger("ludwig.collect") print_ludwig("Collect Weights", LUDWIG_VERSION) collect_weights(**vars(args)) def cli_collect_summary(sys_argv): """Command Line Interface to collecting a summary of the model layers and weights. --m: Input model that is necessary to collect to the tensors --pm: Model name in order to fetch from Huggingface or Torchvision --v: Verbose: Defines the logging level that the user will be exposed to """ parser = argparse.ArgumentParser( description="This script loads a pretrained model " "and prints names of weights and layers activations " "to use with other collect commands", prog="ludwig collect_summary", usage="%(prog)s [options]", ) # ---------------- # Model parameters # ---------------- parser.add_argument("-m", "--model_path", help="model to load", required=False) parser.add_argument( "-pm", "--pretrained_model", help="pretrained model to summarize (torchvision and huggingface)", required=False ) # ------------------ # Runtime parameters # ------------------ parser.add_argument( "-l", "--logging_level", default="info", help="the level of logging to use", choices=["critical", "error", "warning", "info", "debug", "notset"], ) add_contrib_callback_args(parser) args = parser.parse_args(sys_argv) args.callbacks = args.callbacks or [] for callback in args.callbacks: callback.on_cmdline("collect_summary", *sys_argv) args.logging_level = get_logging_level_registry()[args.logging_level] logging.getLogger("ludwig").setLevel(args.logging_level) global logger logger = logging.getLogger("ludwig.collect") print_ludwig("Collect Summary", LUDWIG_VERSION) if args.model_path: print_model_summary(**vars(args)) elif args.pretrained_model and not args.model_path: pretrained_summary(**vars(args)) if __name__ == "__main__": if len(sys.argv) > 1: if sys.argv[1] == "activations": cli_collect_activations(sys.argv[2:]) elif sys.argv[1] == "weights": cli_collect_weights(sys.argv[2:]) elif sys.argv[1] == "names": cli_collect_summary(sys.argv[2:]) else: print("Unrecognized command") else: print("Unrecognized command") ================================================ FILE: ludwig/combiners/__init__.py ================================================ ================================================ FILE: ludwig/combiners/combiners.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import logging from abc import ABC from dataclasses import dataclass from functools import lru_cache import torch from torch.nn import Linear, ModuleList from ludwig.api_annotations import DeveloperAPI from ludwig.constants import BINARY, ENCODER_OUTPUT, NUMBER from ludwig.encoders.registry import get_sequence_encoder_registry from ludwig.features.base_feature import InputFeature from ludwig.modules.attention_modules import TransformerStack from ludwig.modules.embedding_modules import Embed from ludwig.modules.fully_connected_modules import FCStack from ludwig.modules.reduction_modules import SequenceReducer from ludwig.modules.tabnet_modules import TabNet from ludwig.schema.combiners.base import BaseCombinerConfig from ludwig.schema.combiners.comparator import ComparatorCombinerConfig from ludwig.schema.combiners.concat import ConcatCombinerConfig from ludwig.schema.combiners.project_aggregate import ProjectAggregateCombinerConfig from ludwig.schema.combiners.sequence import SequenceCombinerConfig from ludwig.schema.combiners.sequence_concat import SequenceConcatCombinerConfig from ludwig.schema.combiners.tab_transformer import TabTransformerCombinerConfig from ludwig.schema.combiners.tabnet import TabNetCombinerConfig from ludwig.schema.combiners.transformer import TransformerCombinerConfig from ludwig.utils.misc_utils import get_from_registry from ludwig.utils.registry import Registry from ludwig.utils.torch_utils import LudwigModule, sequence_length_3D from ludwig.utils.torch_utils import sequence_mask as torch_sequence_mask logger = logging.getLogger(__name__) @dataclass class Handle: """This class provides an opaque handle to the input features, preventing them from being registered as state. This is important because we already reference the `input_features` as an attribute of ECD, so we don't need it to appear twice in the state_dict. Furthermore, DeepSpeed will get terribly confused if have the input features set as an attribute of the combiner, and lead to shape mismatch errors when we go to load a saved checkpoint. """ input_features: dict[str, "InputFeature"] @DeveloperAPI class Combiner(LudwigModule, ABC): """Base class for combiners, which implements common properties. Subclasses will usually override: __init__() to set properties and allocate resources. Should call super().__init__(input_features). forward() performs the forward pass given a dictionary of encoder outputs. get_schema_cls() must returns the class of the corresponding schema for the combiner type. """ def __init__(self, input_features: dict[str, "InputFeature"]): super().__init__() self.handle = Handle(input_features) @property def concatenated_shape(self) -> torch.Size: # compute the size of the last dimension for the incoming encoder outputs # this is required to setup the fully connected layer shapes = [ torch.prod(torch.Tensor([*self.handle.input_features.get(k).output_shape])) for k in self.handle.input_features ] return torch.Size([torch.sum(torch.Tensor(shapes)).type(torch.int32)]) @property def input_shape(self) -> dict: # input to combiner is a dictionary of the input features encoder # outputs, this property returns dictionary of output shapes for each # input feature's encoder output shapes. return {k: self.handle.input_features.get(k).output_shape for k in self.handle.input_features} @property @lru_cache(maxsize=1) def output_shape(self) -> torch.Size: pseudo_input = {} for k in self.handle.input_features: pseudo_input[k] = { ENCODER_OUTPUT: torch.rand( 2, *self.handle.input_features.get(k).output_shape, dtype=self.input_dtype, device=self.device ) } output_tensor = self.forward(pseudo_input) return output_tensor["combiner_output"].size()[1:] combiner_impl_registry = Registry[type[Combiner]]() def register_combiner(config_cls: type[BaseCombinerConfig]): def wrap(cls: type[Combiner]): combiner_impl_registry[config_cls] = cls return cls return wrap def create_combiner(config: BaseCombinerConfig, **kwargs) -> Combiner: return combiner_impl_registry[type(config)](config=config, **kwargs) @register_combiner(ConcatCombinerConfig) class ConcatCombiner(Combiner): def __init__(self, input_features: dict[str, "InputFeature"] = None, config: ConcatCombinerConfig = None, **kwargs): super().__init__(input_features) self.name = "ConcatCombiner" logger.debug(f" {self.name}") self.flatten_inputs = config.flatten_inputs self.fc_stack = None # todo future: this may be redundant, check fc_layers = config.fc_layers if fc_layers is None: fc_layers = [] for i in range(config.num_fc_layers): fc_layers.append({"output_size": config.output_size}) self.fc_layers = fc_layers logger.debug(" FCStack") self.fc_stack = FCStack( first_layer_input_size=self.concatenated_shape[-1], layers=config.fc_layers, num_layers=config.num_fc_layers, default_output_size=config.output_size, default_use_bias=config.use_bias, default_weights_initializer=config.weights_initializer, default_bias_initializer=config.bias_initializer, default_norm=config.norm, default_norm_params=config.norm_params, default_activation=config.activation, default_dropout=config.dropout, residual=config.residual, ) if input_features and len(input_features) == 1 and self.fc_layers is None: self.supports_masking = True def forward(self, inputs: dict) -> dict: # encoder outputs encoder_outputs = [inputs[k][ENCODER_OUTPUT] for k in inputs] # ================ Flatten ================ if self.flatten_inputs: batch_size = encoder_outputs[0].shape[0] encoder_outputs = [torch.reshape(eo, [batch_size, -1]) for eo in encoder_outputs] # ================ Concat ================ if len(encoder_outputs) > 1: hidden = torch.cat(encoder_outputs, 1) else: hidden = list(encoder_outputs)[0] # ================ Fully Connected ================ hidden = self.fc_stack(hidden) return_data = {"combiner_output": hidden} if len(inputs) == 1: # Workaround for including additional tensors from output of input encoders for # potential use in decoders, e.g. LSTM state for seq2seq. # TODO(Justin): Think about how to make this communication work for multi-sequence # features. Other combiners. for key, value in [d for d in inputs.values()][0].items(): if key != ENCODER_OUTPUT: return_data[key] = value return return_data @register_combiner(SequenceConcatCombinerConfig) class SequenceConcatCombiner(Combiner): def __init__( self, input_features: dict[str, "InputFeature"], config: SequenceConcatCombinerConfig = None, **kwargs ): super().__init__(input_features) self.name = "SequenceConcatCombiner" logger.debug(f" {self.name}") self.reduce_output = config.reduce_output self.reduce_sequence = SequenceReducer( reduce_mode=config.reduce_output, max_sequence_length=self.concatenated_shape[0], encoding_size=self.concatenated_shape[1], ) if self.reduce_output is None: self.supports_masking = True self.main_sequence_feature = config.main_sequence_feature @property def concatenated_shape(self) -> torch.Size: # computes the effective shape of the input tensor after combining # all the encoder outputs # determine max sequence length by finding the first sequence tensor # assume all the sequences are of the same size, if not true # this will be caught during processing seq_size = None for k in self.handle.input_features: # dim-2 output_shape implies a sequence [seq_size, hidden] if len(self.handle.input_features.get(k).output_shape) == 2: seq_size = self.handle.input_features.get(k).output_shape[0] break # collect the size of the last dimension for all input feature # encoder outputs shapes = [ self.handle.input_features.get(k).output_shape[-1] for k in self.handle.input_features ] # output shape not input shape return torch.Size([seq_size, sum(shapes)]) def forward(self, inputs: dict) -> dict: # encoder outputs if self.main_sequence_feature is None or self.main_sequence_feature not in inputs: for if_name, if_outputs in inputs.items(): # todo: when https://github.com/ludwig-ai/ludwig/issues/810 is closed # convert following test from using shape to use explicit # if_outputs[TYPE] values for sequence features if len(if_outputs[ENCODER_OUTPUT].shape) == 3: self.main_sequence_feature = if_name break if self.main_sequence_feature is None: raise Exception("No sequence feature available for sequence combiner") main_sequence_feature_encoding = inputs[self.main_sequence_feature] representation = main_sequence_feature_encoding[ENCODER_OUTPUT] representations = [representation] sequence_max_length = representation.shape[1] sequence_length = sequence_length_3D(representation) # ================ Concat ================ for if_name, if_outputs in inputs.items(): if if_name != self.main_sequence_feature: if_representation = if_outputs[ENCODER_OUTPUT] if len(if_representation.shape) == 3: # The following check makes sense when # both representations have a specified # sequence length dimension. If they do not, # then this check is simply checking if None == None # and will not catch discrepancies in the different # feature length dimension. Those errors will show up # at training time. Possible solutions to this is # to enforce a length second dimension in # sequential feature placeholders, but that # does not work with BucketedBatcher that requires # the second dimension to be undefined in order to be # able to trim the data points and speed up computation. # So for now we are keeping things like this, make sure # to write in the documentation that training time # dimensions mismatch may occur if the sequential # features have different lengths for some data points. if if_representation.shape[1] != representation.shape[1]: raise ValueError( "The sequence length of the input feature {} " "is {} and is different from the sequence " "length of the main sequence feature {} which " "is {}.\n Shape of {}: {}, shape of {}: {}.\n" "Sequence lengths of all sequential features " "must be the same in order to be concatenated " "by the sequence concat combiner. " "Try to impose the same max sequence length " "as a preprocessing parameter to both features " "or to reduce the output of {}.".format( if_name, if_representation.shape[1], self.main_sequence_feature, representation.shape[1], if_name, if_representation.shape, if_name, representation.shape, if_name, ) ) # this assumes all sequence representations have the # same sequence length, 2nd dimension representations.append(if_representation) elif len(if_representation.shape) == 2: multipliers = (1, sequence_max_length, 1) tiled_representation = torch.tile(torch.unsqueeze(if_representation, 1), multipliers) representations.append(tiled_representation) else: raise ValueError( "The representation of {} has rank {} and cannot be" " concatenated by a sequence concat combiner. " "Only rank 2 and rank 3 tensors are supported.".format(if_name, len(if_representation.shape)) ) hidden = torch.cat(representations, 2) logger.debug(f" concat_hidden: {hidden}") # ================ Mask ================ sequence_mask = torch_sequence_mask(sequence_length, sequence_max_length) hidden = torch.multiply(hidden, torch.unsqueeze(sequence_mask, -1).type(torch.float32)) # ================ Reduce ================ hidden = self.reduce_sequence(hidden) return_data = {"combiner_output": hidden} if len(inputs) == 1: for key, value in [d for d in inputs.values()][0].items(): if key != ENCODER_OUTPUT: return_data[key] = value return return_data @register_combiner(SequenceCombinerConfig) class SequenceCombiner(Combiner): def __init__(self, input_features: dict[str, "InputFeature"], config: SequenceCombinerConfig = None, **kwargs): super().__init__(input_features) self.name = "SequenceCombiner" logger.debug(f" {self.name}") self.combiner = SequenceConcatCombiner( input_features, config=SequenceConcatCombinerConfig(reduce_output=None, main_sequence_feature=config.main_sequence_feature), ) logger.debug( f"combiner input shape {self.combiner.concatenated_shape}, " f"output shape {self.combiner.output_shape}" ) self.encoder_obj = get_from_registry(config.encoder.type, get_sequence_encoder_registry())( should_embed=False, reduce_output=config.reduce_output, embedding_size=self.combiner.output_shape[1], max_sequence_length=self.combiner.output_shape[0], **kwargs, ) if hasattr(self.encoder_obj, "supports_masking") and self.encoder_obj.supports_masking: self.supports_masking = True @property def concatenated_shape(self) -> torch.Size: # computes the effective shape of the input tensor after combining # all the encoder outputs # determine max sequence length by finding the first sequence tensor # assume all the sequences are of the same size, if not true # this will be caught during processing seq_size = None for k in self.handle.input_features: # dim-2 output_shape implies a sequence [seq_size, hidden] if len(self.handle.input_features.get(k).output_shape) == 2: seq_size = self.handle.input_features.get(k).output_shape[0] break # collect the size of the last dimension for all input feature # encoder outputs shapes = [ self.handle.input_features.get(k).output_shape[-1] for k in self.handle.input_features ] # output shape not input shape return torch.Size([seq_size, sum(shapes)]) def forward(self, inputs: dict) -> dict: # encoder outputs # ================ Concat ================ hidden = self.combiner(inputs) # ================ Sequence encoding ================ hidden = self.encoder_obj(hidden["combiner_output"]) return_data = {"combiner_output": hidden[ENCODER_OUTPUT]} for key, value in hidden.items(): if key != ENCODER_OUTPUT: return_data[key] = value return return_data @register_combiner(TabNetCombinerConfig) class TabNetCombiner(Combiner): def __init__( self, input_features: dict[str, "InputFeature"], config: TabNetCombinerConfig = None, **kwargs ) -> None: super().__init__(input_features) self.name = "TabNetCombiner" logger.debug(f" {self.name}") self.tabnet = TabNet( self.concatenated_shape[-1], config.size, config.output_size, num_steps=config.num_steps, num_total_blocks=config.num_total_blocks, num_shared_blocks=config.num_shared_blocks, relaxation_factor=config.relaxation_factor, bn_epsilon=config.bn_epsilon, bn_momentum=config.bn_momentum, bn_virtual_bs=config.bn_virtual_bs, sparsity=config.sparsity, entmax_mode=config.entmax_mode, entmax_alpha=config.entmax_alpha, ) if config.dropout > 0: self.dropout = torch.nn.Dropout(config.dropout) else: self.dropout = None @property def concatenated_shape(self) -> torch.Size: # compute the size of the last dimension for the incoming encoder outputs # this is required to setup shapes = [ torch.prod(torch.Tensor([*self.handle.input_features.get(k).output_shape])) for k in self.handle.input_features ] return torch.Size([torch.sum(torch.Tensor(shapes)).type(torch.int32)]) def forward( self, inputs: torch.Tensor, # encoder outputs ) -> dict: encoder_outputs = [inputs[k][ENCODER_OUTPUT] for k in inputs] # ================ Flatten ================ batch_size = encoder_outputs[0].shape[0] encoder_outputs = [torch.reshape(eo, [batch_size, -1]) for eo in encoder_outputs] # ================ Concat ================ if len(encoder_outputs) > 1: hidden = torch.cat(encoder_outputs, 1) else: hidden = list(encoder_outputs)[0] # ================ TabNet ================ hidden, aggregated_mask, masks = self.tabnet(hidden) if self.dropout: hidden = self.dropout(hidden) return_data = { "combiner_output": hidden, "aggregated_attention_masks": aggregated_mask, "attention_masks": masks, } if len(inputs) == 1: for key, value in [d for d in inputs.values()][0].items(): if key != ENCODER_OUTPUT: return_data[key] = value return return_data @property def output_shape(self) -> torch.Size: return self.tabnet.output_shape @register_combiner(TransformerCombinerConfig) class TransformerCombiner(Combiner): def __init__( self, input_features: dict[str, "InputFeature"] = None, config: TransformerCombinerConfig = None, **kwargs ): super().__init__(input_features) self.name = "TransformerCombiner" logger.debug(f" {self.name}") self.reduce_output = config.reduce_output self.reduce_sequence = SequenceReducer( reduce_mode=config.reduce_output, max_sequence_length=len(input_features), encoding_size=config.hidden_size, ) if self.reduce_output is None: self.supports_masking = True # max sequence length for Transformer layer is number of input features self.max_sequence_length = len(input_features) logger.debug(" Projectors") self.projectors = ModuleList( # regardless of rank-2 or rank-3 input, torch.prod() calculates size # after flattening the encoder output tensor [ Linear( torch.prod(torch.Tensor([*input_features.get(inp).output_shape])).type(torch.int32), config.hidden_size, ) for inp in input_features ] ) logger.debug(" TransformerStack") self.transformer_stack = TransformerStack( input_size=config.hidden_size, max_sequence_length=self.max_sequence_length, hidden_size=config.hidden_size, num_heads=config.num_heads, output_size=config.transformer_output_size, num_layers=config.num_layers, dropout=config.dropout, ) if self.reduce_output is not None: logger.debug(" FCStack") self.fc_stack = FCStack( self.transformer_stack.output_shape[-1], layers=config.fc_layers, num_layers=config.num_fc_layers, default_output_size=config.output_size, default_use_bias=config.use_bias, default_weights_initializer=config.weights_initializer, default_bias_initializer=config.bias_initializer, default_norm=config.norm, default_norm_params=config.norm_params, default_activation=config.fc_activation, default_dropout=config.fc_dropout, fc_residual=config.fc_residual, ) def forward( self, inputs, # encoder outputs ) -> dict: encoder_outputs = [inputs[k][ENCODER_OUTPUT] for k in inputs] # ================ Flatten ================ batch_size = encoder_outputs[0].shape[0] encoder_outputs = [torch.reshape(eo, [batch_size, -1]) for eo in encoder_outputs] # ================ Project & Concat ================ projected = [self.projectors[i](eo) for i, eo in enumerate(encoder_outputs)] hidden = torch.stack(projected) # shape [num_eo, bs, h] hidden = torch.permute(hidden, (1, 0, 2)) # shape [bs, num_eo, h] # ================ Transformer Layers ================ hidden = self.transformer_stack(hidden) # ================ Sequence Reduction ================ if self.reduce_output is not None: hidden = self.reduce_sequence(hidden) # ================ FC Layers ================ hidden = self.fc_stack(hidden) return_data = {"combiner_output": hidden} if len(inputs) == 1: for key, value in [d for d in inputs.values()][0].items(): if key != ENCODER_OUTPUT: return_data[key] = value return return_data @register_combiner(TabTransformerCombinerConfig) class TabTransformerCombiner(Combiner): def __init__( self, input_features: dict[str, "InputFeature"] = None, config: TabTransformerCombinerConfig = None, **kwargs ): super().__init__(input_features) self.name = "TabTransformerCombiner" logger.debug(f"Initializing {self.name}") self.reduce_output = config.reduce_output self.reduce_sequence = SequenceReducer( reduce_mode=config.reduce_output, max_sequence_length=len(input_features), encoding_size=config.hidden_size ) self.supports_masking = True self.embed_input_feature_name = config.embed_input_feature_name if self.embed_input_feature_name: vocab = [ i_f for i_f in input_features if input_features.get(i_f).type() != NUMBER or input_features.get(i_f).type() != BINARY ] if self.embed_input_feature_name == "add": self.embed_i_f_name_layer = Embed(vocab, config.hidden_size, force_embedding_size=True) projector_size = config.hidden_size elif isinstance(self.embed_input_feature_name, int): if self.embed_input_feature_name > config.hidden_size: raise ValueError( "TabTransformer parameter " "`embed_input_feature_name` " "specified integer value ({}) " "needs to be smaller than " "`hidden_size` ({}).".format(self.embed_input_feature_name, config.hidden_size) ) self.embed_i_f_name_layer = Embed( vocab, self.embed_input_feature_name, force_embedding_size=True, ) projector_size = config.hidden_size - self.embed_input_feature_name else: raise ValueError( "TabTransformer parameter " "`embed_input_feature_name` " "should be either None, an integer or `add`, " "the current value is " "{}".format(self.embed_input_feature_name) ) else: projector_size = config.hidden_size logger.debug(" Projectors") self.unembeddable_features = [] self.embeddable_features = [] for i_f in input_features: if input_features.get(i_f).type() in {NUMBER, BINARY}: self.unembeddable_features.append(i_f) else: self.embeddable_features.append(i_f) self.projectors = ModuleList() for i_f in self.embeddable_features: flatten_size = self.get_flatten_size(input_features.get(i_f).output_shape) self.projectors.append(Linear(flatten_size[0], projector_size)) # input to layer_norm are the encoder outputs for unembeddable features, # which are number or binary features. These should be 2-dim # tensors. Size should be concatenation of these tensors. concatenated_unembeddable_encoders_size = 0 for i_f in self.unembeddable_features: concatenated_unembeddable_encoders_size += input_features.get(i_f).output_shape[0] # Skip LayerNorm when normalizing a single value — LayerNorm(1) always # outputs zero which kills gradients for all downstream parameters. if concatenated_unembeddable_encoders_size > 1: self.layer_norm = torch.nn.LayerNorm(concatenated_unembeddable_encoders_size) else: self.layer_norm = torch.nn.Identity() logger.debug(" TransformerStack") self.transformer_stack = TransformerStack( input_size=config.hidden_size, max_sequence_length=len(self.embeddable_features), hidden_size=config.hidden_size, # todo: can we just use projector_size? # hidden_size, num_heads=config.num_heads, output_size=config.transformer_output_size, num_layers=config.num_layers, dropout=config.dropout, ) logger.debug(" FCStack") # determine input size to fully connected layer based on reducer if config.reduce_output == "concat": fc_input_size = len(self.embeddable_features) * config.hidden_size else: fc_input_size = self.reduce_sequence.output_shape[-1] if len(self.embeddable_features) > 0 else 0 self.fc_stack = FCStack( fc_input_size + concatenated_unembeddable_encoders_size, layers=config.fc_layers, num_layers=config.num_fc_layers, default_output_size=config.output_size, default_use_bias=config.use_bias, default_weights_initializer=config.weights_initializer, default_bias_initializer=config.bias_initializer, default_norm=config.norm, default_norm_params=config.norm_params, default_activation=config.fc_activation, default_dropout=config.fc_dropout, fc_residual=config.fc_residual, ) self._empty_hidden = torch.empty([1, 0]) self._embeddable_features_indices = torch.arange(0, len(self.embeddable_features)) # Create empty tensor of shape [1, 0] to use as hidden in case there are no category or numeric/binary features. self.register_buffer("empty_hidden", self._empty_hidden) self.register_buffer("embeddable_features_indices", self._embeddable_features_indices) @staticmethod def get_flatten_size(output_shape: torch.Size) -> torch.Size: size = torch.prod(torch.Tensor([*output_shape])) return torch.Size([size.type(torch.int32)]) @property def output_shape(self) -> torch.Size: return self.fc_stack.output_shape def forward( self, inputs: dict, # encoder outputs ) -> dict: unembeddable_encoder_outputs = [inputs[k][ENCODER_OUTPUT] for k in inputs if k in self.unembeddable_features] embeddable_encoder_outputs = [inputs[k][ENCODER_OUTPUT] for k in inputs if k in self.embeddable_features] batch_size = ( embeddable_encoder_outputs[0].shape[0] if len(embeddable_encoder_outputs) > 0 else unembeddable_encoder_outputs[0].shape[0] ) # ================ Project & Concat embeddables ================ if len(embeddable_encoder_outputs) > 0: # ============== Flatten ================= embeddable_encoder_outputs = [torch.reshape(eo, [batch_size, -1]) for eo in embeddable_encoder_outputs] projected = [self.projectors[i](eo) for i, eo in enumerate(embeddable_encoder_outputs)] hidden = torch.stack(projected) # num_eo, bs, h hidden = torch.permute(hidden, (1, 0, 2)) # bs, num_eo, h if self.embed_input_feature_name: i_f_names_idcs = torch.reshape( torch.arange(0, len(embeddable_encoder_outputs), device=self.device), [-1, 1] ) embedded_i_f_names = self.embed_i_f_name_layer(i_f_names_idcs) embedded_i_f_names = torch.unsqueeze(embedded_i_f_names, dim=0) embedded_i_f_names = torch.tile(embedded_i_f_names, [batch_size, 1, 1]) if self.embed_input_feature_name == "add": hidden = hidden + embedded_i_f_names else: hidden = torch.cat([hidden, embedded_i_f_names], -1) # ================ Transformer Layers ================ hidden = self.transformer_stack(hidden) # ================ Sequence Reduction ================ hidden = self.reduce_sequence(hidden) else: # create empty tensor because there are no category features hidden = torch.empty([batch_size, 0], device=self.device) # ================ Concat Skipped ================ if len(unembeddable_encoder_outputs) > 0: unembeddable_encoder_outputs = [torch.reshape(eo, [batch_size, -1]) for eo in unembeddable_encoder_outputs] # ================ Flatten ================ if len(unembeddable_encoder_outputs) > 1: unembeddable_hidden = torch.cat(unembeddable_encoder_outputs, -1) # tf.keras.layers.concatenate else: unembeddable_hidden = list(unembeddable_encoder_outputs)[0] unembeddable_hidden = self.layer_norm(unembeddable_hidden) else: # create empty tensor because there are not numeric/binary features unembeddable_hidden = torch.tile(self.empty_hidden, [batch_size, 0]) # ================ Concat Skipped and Others ================ # When reduce_output is None, hidden is 3D [batch, seq, dim] but # unembeddable_hidden is 2D [batch, dim]. Expand to match. if hidden.dim() == 3 and unembeddable_hidden.dim() == 2: unembeddable_hidden = unembeddable_hidden.unsqueeze(1).expand(-1, hidden.size(1), -1) hidden = torch.cat([hidden, unembeddable_hidden], -1) # ================ FC Layers ================ hidden = self.fc_stack(hidden) return_data = {"combiner_output": hidden} if len(inputs) == 1: for key, value in [d for d in inputs.values()][0].items(): if key != ENCODER_OUTPUT: return_data[key] = value return return_data @register_combiner(ComparatorCombinerConfig) class ComparatorCombiner(Combiner): def __init__( self, input_features: dict[str, "InputFeature"], config: ComparatorCombinerConfig = None, **kwargs, ): super().__init__(input_features) self.name = "ComparatorCombiner" logger.debug(f"Entering {self.name}") self.entity_1 = config.entity_1 self.entity_2 = config.entity_2 self.required_inputs = set(config.entity_1 + config.entity_2) self.output_size = config.output_size self.fc_stack = None # todo future: this may be redundant, check fc_layers = config.fc_layers if fc_layers is None and config.num_fc_layers is not None: fc_layers = [] for _ in range(config.num_fc_layers): fc_layers.append({"output_size": config.output_size}) if fc_layers is not None: logger.debug("Setting up FCStack") self.e1_fc_stack = FCStack( self.get_entity_shape(config.entity_1)[-1], layers=fc_layers, num_layers=config.num_fc_layers, default_output_size=config.output_size, default_use_bias=config.use_bias, default_weights_initializer=config.weights_initializer, default_bias_initializer=config.bias_initializer, default_norm=config.norm, default_norm_params=config.norm_params, default_activation=config.activation, default_dropout=config.dropout, ) self.e2_fc_stack = FCStack( self.get_entity_shape(config.entity_2)[-1], layers=fc_layers, num_layers=config.num_fc_layers, default_output_size=config.output_size, default_use_bias=config.use_bias, default_weights_initializer=config.weights_initializer, default_bias_initializer=config.bias_initializer, default_norm=config.norm, default_norm_params=config.norm_params, default_activation=config.activation, default_dropout=config.dropout, ) self.last_fc_layer_output_size = fc_layers[-1]["output_size"] # todo: set initializer and regularization self.register_buffer( "bilinear_weights", torch.randn([self.last_fc_layer_output_size, self.last_fc_layer_output_size], dtype=torch.float32), ) def get_entity_shape(self, entity: list) -> torch.Size: sizes = [torch.prod(torch.Tensor([*self.handle.input_features.get(k).output_shape])) for k in entity] return torch.Size([torch.sum(torch.Tensor(sizes)).type(torch.int32)]) @property def output_shape(self) -> torch.Size: return torch.Size([2 * self.last_fc_layer_output_size + 2]) def forward( self, inputs: dict, # encoder outputs ) -> dict[str, torch.Tensor]: # encoder outputs if inputs.keys() != self.required_inputs: raise ValueError(f"Missing inputs {self.required_inputs - set(inputs.keys())}") ############ # Entity 1 # ############ e1_enc_outputs = [inputs[k][ENCODER_OUTPUT] for k in self.entity_1] # ================ Flatten ================ batch_size = e1_enc_outputs[0].shape[0] e1_enc_outputs = [torch.reshape(eo, [batch_size, -1]) for eo in e1_enc_outputs] # ================ Concat ================ if len(e1_enc_outputs) > 1: e1_hidden = torch.cat(e1_enc_outputs, 1) else: e1_hidden = list(e1_enc_outputs)[0] # ================ Fully Connected ================ e1_hidden = self.e1_fc_stack(e1_hidden) # [bs, output_size] ############ # Entity 2 # ############ e2_enc_outputs = [inputs[k][ENCODER_OUTPUT] for k in self.entity_2] # ================ Flatten ================ batch_size = e2_enc_outputs[0].shape[0] e2_enc_outputs = [torch.reshape(eo, [batch_size, -1]) for eo in e2_enc_outputs] # ================ Concat ================ if len(e2_enc_outputs) > 1: e2_hidden = torch.cat(e2_enc_outputs, 1) else: e2_hidden = list(e2_enc_outputs)[0] # ================ Fully Connected ================ e2_hidden = self.e2_fc_stack(e2_hidden) # [bs, output_size] ########### # Compare # ########### if e1_hidden.shape != e2_hidden.shape: raise ValueError( f"Mismatching shapes among dimensions! " f"entity1 shape: {e1_hidden.shape} " f"entity2 shape: {e2_hidden.shape}" ) element_wise_mul = e1_hidden * e2_hidden # [bs, output_size] dot_product = torch.sum(element_wise_mul, 1, keepdim=True) # [bs, 1] abs_diff = torch.abs(e1_hidden - e2_hidden) # [bs, output_size] bilinear_prod = torch.sum( torch.mm(e1_hidden, self.bilinear_weights) * e2_hidden, dim=1, keepdim=True ) # [bs, 1] logger.debug( "preparing combiner output by concatenating these tensors: " f"dot_product: {dot_product.shape}, element_size_mul: {element_wise_mul.shape}" f", abs_diff: {abs_diff.shape}, bilinear_prod {bilinear_prod.shape}" ) hidden = torch.cat([dot_product, element_wise_mul, abs_diff, bilinear_prod], 1) # [bs, 2 * output_size + 2] return {"combiner_output": hidden} @register_combiner(ProjectAggregateCombinerConfig) class ProjectAggregateCombiner(Combiner): def __init__( self, input_features: dict[str, "InputFeature"] = None, config: ProjectAggregateCombinerConfig = None, **kwargs ): super().__init__(input_features) self.name = "ProjectAggregateCombiner" logger.debug(f" {self.name}") logger.debug(" Projectors") self.projectors = ModuleList( # regardless of rank-2 or rank-3 input, torch.prod() calculates size # after flattening the encoder output tensor [ Linear( torch.prod(torch.Tensor([*input_features.get(inp).output_shape])).type(torch.int32), config.projection_size, ) for inp in input_features ] ) self.fc_stack = None # todo future: this may be redundant, check fc_layers = config.fc_layers if fc_layers is None and config.num_fc_layers is not None: fc_layers = [] for i in range(config.num_fc_layers): fc_layers.append({"output_size": config.output_size}) self.fc_layers = fc_layers if self.fc_layers is not None: logger.debug(" FCStack") self.fc_stack = FCStack( first_layer_input_size=config.projection_size, layers=config.fc_layers, num_layers=config.num_fc_layers, default_output_size=config.output_size, default_use_bias=config.use_bias, default_weights_initializer=config.weights_initializer, default_bias_initializer=config.bias_initializer, default_norm=config.norm, default_norm_params=config.norm_params, default_activation=config.activation, default_dropout=config.dropout, residual=config.residual, ) if input_features and len(input_features) == 1 and self.fc_layers is None: self.supports_masking = True def forward(self, inputs: dict) -> dict: # encoder outputs encoder_outputs = [inputs[k][ENCODER_OUTPUT] for k in inputs] # ================ Flatten ================ batch_size = encoder_outputs[0].shape[0] encoder_outputs = [torch.reshape(eo, [batch_size, -1]) for eo in encoder_outputs] # ================ Project ================ projected = [self.projectors[i](eo) for i, eo in enumerate(encoder_outputs)] hidden = torch.stack(projected) hidden = torch.permute(hidden, (1, 0, 2)) # shape [bs, num_eo, h] # ================ Aggregate ================ hidden = torch.mean(hidden, dim=1) # ================ Fully Connected ================ if self.fc_stack is not None: hidden = self.fc_stack(hidden) return_data = {"combiner_output": hidden} if len(inputs) == 1: # Workaround for including additional tensors from output of input encoders for # potential use in decoders, e.g. LSTM state for seq2seq. # TODO(Justin): Think about how to make this communication work for multi-sequence # features. Other combiners. for key, value in [d for d in inputs.values()][0].items(): if key != ENCODER_OUTPUT: return_data[key] = value return return_data ================================================ FILE: ludwig/config_sampling/__init__.py ================================================ ================================================ FILE: ludwig/config_sampling/explore_schema.py ================================================ import copy import random from collections import deque, namedtuple from typing import Any, Deque import pandas as pd from ludwig.config_sampling.parameter_sampling import handle_property_type, ParameterBaseTypes from ludwig.constants import SEQUENCE, TEXT, TIMESERIES from ludwig.data.dataset_synthesizer import build_synthetic_dataset_df from ludwig.schema.model_types.base import ModelConfig from ludwig.types import ModelConfigDict from ludwig.utils.misc_utils import merge_dict # number of examples to generate for synthetic dataset NUM_SYNTHETIC_EXAMPLES = 10 ConfigOption = namedtuple("ConfigOption", ["config_option", "fully_explored"]) def explore_properties( jsonschema_properties: dict[str, Any], parent_parameter_path: str, dq: Deque[ConfigOption], allow_list: list[str] = [], ) -> Deque[tuple[dict, bool]]: """Recursively explores the `properties` part of any subsection of the schema. Args: jsonschema_properties: any properties section of the schema. parent_parameter_path: period-delimited list of parent dictionary keys up to the given jsonschema_properties (e.g. defaults.number.preprocessing) dq: dequeue data structure that stores tuples of (config_options, fully_explored). config_options: Dict[str, List], fully_explored: bool is a dictionary is a dictionary of parameter name to list of values to explore. fully_explored is a boolean value indicating whether all subsections of the properties dictionary have been explored. allow_list: list of top level keys of the properties dictionary to skip. Returns: A deque of (dict, bool) tuples. - The first element of the tuple contains a dictionary of config options, which maps from a ludwig config parameter to a list of the values to be explored for that parameter. Here's an example: trainer.batch_size: ["auto", 2, 43] trainer.learning_rate: ["auto", 0.1, 0.00002, 0.32424] ... - The second element of the tuple is whether we've explored this "config path" fully. This is important to track when recursing into nested structures. """ # processed_dq will contain complete config options with all the parameters in the properties dictionary # dq will contain configs options that are still being completed. processed_dq = deque() while dq and not dq[0].fully_explored: for parameter_name_or_section, jsonschema_property in jsonschema_properties.items(): if allow_list and parameter_name_or_section not in allow_list: continue parameter_path = ( f"{parent_parameter_path}.{parameter_name_or_section}" if parent_parameter_path else parameter_name_or_section ) config_options, _ = dq.popleft() if "properties" in jsonschema_property and "allOf" in jsonschema_property: for child_item in jsonschema_property["allOf"]: expanded_config_options_dq = explore_from_all_of( config_options=copy.deepcopy(config_options), item=child_item, key_so_far=parameter_path ) # add returned child config options to the deque to be processed. dq.extend(expanded_config_options_dq) elif "properties" in jsonschema_property and "allOf" not in jsonschema_property: # This is the case where we don't have a list of properties, just a properties # dictionary nested inside another. child_properties = jsonschema_property["properties"] # a new dequeue to be passed to explore parameters from raw_entry = deque([ConfigOption(copy.deepcopy(config_options), False)]) child_config_options_dq = explore_properties(child_properties, parameter_path, raw_entry) merged_config_options_dq = merge_dq(config_options, child_config_options_dq) # add returned config options to the deque to be processed. dq.extend(merged_config_options_dq) else: # this is the base case. parameter_samples = get_samples(jsonschema_property) if parameter_samples: config_options[parameter_path] = parameter_samples # add config_options back to queue. fully_explored = False because we still didn't finish # exploring all the keys in the properties dictionary. dq.appendleft(ConfigOption(config_options, False)) # at this point, we finished exploring all keys of the properties dictionary. Add all config options # to the processed queue. while dq: config_options, _ = dq.popleft() processed_dq.append(ConfigOption(config_options, True)) return processed_dq def get_samples(jsonschema_property: dict[str, Any]) -> list[ParameterBaseTypes]: """Get possible values for a leaf property (no sub-properties). Args: jsonschema_property: leaf property in the schema. Has no sub-properties. """ if "oneOf" in jsonschema_property: temp = [] for elem in jsonschema_property["oneOf"]: temp += get_potential_values(elem) return temp else: return get_potential_values(jsonschema_property) def merge_dq(config_options: dict[str, Any], child_config_options_dq: Deque[ConfigOption]) -> Deque[ConfigOption]: """Merge config_options with the child_config_options in the dq.""" dq = deque() while child_config_options_dq: child_config_options, visited = child_config_options_dq.popleft() cfg = merge_dict(child_config_options, config_options) dq.append(ConfigOption(cfg, visited)) return dq def explore_from_all_of(config_options: dict[str, Any], item: dict[str, Any], key_so_far: str) -> Deque[ConfigOption]: """Takes a child of `allOf` and calls `explore_properties` on it.""" for parameter_name_or_section in item["if"]["properties"]: config_options[key_so_far + "." + parameter_name_or_section] = item["if"]["properties"][ parameter_name_or_section ]["const"] jsonschema_properties = item["then"]["properties"] raw_entry = deque([ConfigOption(copy.deepcopy(config_options), False)]) return explore_properties(jsonschema_properties, parent_parameter_path=key_so_far, dq=raw_entry) def get_potential_values(item: dict[str, Any]) -> list[ParameterBaseTypes | list[ParameterBaseTypes]]: """Returns a list of values to explore for a config parameter. Param: item: config parameter-specific dictionary. Considered as a leaf in the schema. Contains type, default, and parameter metadata, etc. """ temp = [] item_type = item.get("type") if item_type is None: # No explicit type — try to infer from enum/const/default if "enum" in item: return [v for v in item["enum"] if v is not None] if "const" in item: return [item["const"]] if "default" in item: return [item["default"]] return [] # Case where we're using OneOf (e.g. to allow batch size 'auto' and integers) if isinstance(item_type, list): for property_type in item_type: temp += handle_property_type(property_type, item) else: temp += handle_property_type(item_type, item) # Make sure values are unique. Not using set because some values are unhashable. unique_temp = [] for temp_item in temp: if temp_item not in unique_temp: unique_temp.append(temp_item) return unique_temp def generate_possible_configs(config_options: dict[str, Any]): """Generate exhaustive configs from config_options. This function does not take a cross product of all the options for all the config parameters. It selects parameter values independently from each other. Args: config_options: dictionary mapping from ludwig config parameter to all values to be explored. Here's an example of what it could look like: trainer.batch_size: ["auto", 2, 43] trainer.learning_rate: ["auto", 0.1, 0.00002, 0.32424] ... """ # The number of configs to generate is the max length of the lists of samples over all parameters. num_configs = 1 for parameter_name in config_options: if isinstance(config_options[parameter_name], list): num_configs = max(num_configs, len(config_options[parameter_name])) config_options[parameter_name] = deque(config_options[parameter_name]) for _ in range(num_configs): config = {} for parameter_name in config_options: # if parameter is regular parameter with explored values. if config_options[parameter_name] and not isinstance(config_options[parameter_name], str): config[parameter_name] = config_options[parameter_name].popleft() # case for parameters where we don't have choices such as `encoder.type: parallel_cnn` that # cause the downstream parameters to change. elif isinstance(config_options[parameter_name], str): config[parameter_name] = config_options[parameter_name] yield create_nested_dict(config) def create_nested_dict(flat_dict: dict[str, float | str]) -> ModelConfigDict: """Generate a nested dict out of a flat dict whose keys are delimited by a delimiter character. Args: flat_dict: potential generated baseline config. Here's an example of what it could look like: trainer.batch_size: 324 trainer.learning_rate: 0.0635 The expected output would be trainer: batch_size: 324 learning_rate: 0.0635 """ def to_nested_format(parameter_name: str, value: str | int | float, delimiter: str = ".") -> dict[str, Any]: # https://stackoverflow.com/a/40401961 split_parameter_name = parameter_name.split(delimiter) for parameter_name_or_section in reversed(split_parameter_name): value = {parameter_name_or_section: value} return value config = {} for parameter_name_or_section in flat_dict: config = merge_dict( config, to_nested_format(parameter_name_or_section, copy.deepcopy(flat_dict[parameter_name_or_section])) ) return config def combine_configs( explored: Deque[tuple[dict, bool]], config: ModelConfigDict ) -> list[tuple[ModelConfigDict, pd.DataFrame]]: """Merge base config with explored sections. Args: explored: deque containing all the config options. config: base Ludwig config to merge the explored configs with. """ dataset = build_synthetic_dataset_df(NUM_SYNTHETIC_EXAMPLES, config) ret = [] for config_options, _ in explored: for default_config in generate_possible_configs(config_options=config_options): merged_config = merge_dict(copy.deepcopy(config), default_config) try: ModelConfig.from_dict(merged_config) ret.append((merged_config, dataset)) except Exception: pass return ret def combine_configs_for_comparator_combiner( explored: Deque[tuple], config: ModelConfigDict ) -> list[tuple[ModelConfigDict, pd.DataFrame]]: """Merge base config with explored sections. Completes the entity_1 and entity_2 paramters of the comparator combiner. Args: explored: deque containing all the config options. config: base Ludwig config to merge the explored configs with. """ dataset = build_synthetic_dataset_df(NUM_SYNTHETIC_EXAMPLES, config) ret = [] for item in explored: for default_config in generate_possible_configs(config_options=item[0]): merged_config = merge_dict(copy.deepcopy(config), default_config) # create two random lists for entity1 and entity2 entity_names = [feature["name"] for feature in config["input_features"]] random.shuffle(entity_names) entity_1_size = random.randint(1, len(entity_names) - 1) merged_config["combiner"]["entity_1"] = entity_names[:entity_1_size] merged_config["combiner"]["entity_2"] = entity_names[entity_1_size:] try: ModelConfig.from_dict(merged_config) ret.append((merged_config, dataset)) except Exception: pass return ret def combine_configs_for_sequence_combiner( explored: Deque[tuple], config: ModelConfigDict ) -> list[tuple[ModelConfigDict, pd.DataFrame]]: """Merge base config with explored sections. Uses the right reduce_output strategy for the sequence and sequence_concat combiners. Args: explored: deque containing all the config options. config: base Ludwig config to merge the explored configs with. """ dataset = build_synthetic_dataset_df(NUM_SYNTHETIC_EXAMPLES, config) ret = [] for item in explored: for default_config in generate_possible_configs(config_options=item[0]): merged_config = merge_dict(copy.deepcopy(config), default_config) for i in range(len(merged_config["input_features"])): if merged_config["input_features"][i]["type"] in {SEQUENCE, TEXT, TIMESERIES}: merged_config["input_features"][0]["encoder"] = {"type": "embed", "reduce_output": None} try: ModelConfig.from_dict(merged_config) ret.append((merged_config, dataset)) except Exception: pass return ret ================================================ FILE: ludwig/config_sampling/parameter_sampling.py ================================================ import random from typing import Any, Union from ludwig.schema.metadata.parameter_metadata import ExpectedImpact # base types for ludwig config parameters. ParameterBaseTypes = Union[str, float, int, bool, None] def handle_property_type( property_type: str, item: dict[str, Any], expected_impact: ExpectedImpact = ExpectedImpact.HIGH ) -> list[ParameterBaseTypes | list[ParameterBaseTypes]]: """Return possible parameter values for a parameter type. Args: property_type: type of the parameter (e.g. array, number, etc.) item: dictionary containing details on the parameter such as default, min and max values. expected_impact: threshold expected impact that we'd like to include. """ parameter_metadata = item.get("parameter_metadata", None) if not parameter_metadata: return [] # don't explore internal only parameters. if parameter_metadata.get("internal_only", True): return [] # don't explore parameters that have expected impact less than HIGH. if parameter_metadata.get("expected_impact", ExpectedImpact.LOW) < expected_impact: return [] if property_type == "number": return explore_number(item) elif property_type == "integer": return explore_integer(item) elif property_type == "string": return explore_string(item) elif property_type == "boolean": return explore_boolean() elif property_type == "null": return explore_null() elif property_type == "array": return explore_array(item) else: return [] def explore_array(item: dict[str, Any]) -> list[list[ParameterBaseTypes]]: """Return possible parameter values for the `array` parameter type. Args: item: dictionary containing details on the parameter such as default, min and max values. """ candidates = [] if "default" in item and item["default"]: candidates.append(item["default"]) item_choices = [] maxlen = 0 # In the case where the length of the array isn't defined. if not isinstance(item["items"], list): return [] for item_of in item["items"]: choices = handle_property_type(item_of["type"], item_of) maxlen = max(maxlen, len(choices)) item_choices.append(choices) # pad to same length for i in range(len(item_choices)): item_choices[i] = maxlen * item_choices[i] item_choices[i] = item_choices[i][:maxlen] merged = list(zip(*item_choices)) + candidates return [list(tup) for tup in merged] def explore_number(item: dict[str, Any]) -> list[ParameterBaseTypes]: """Return possible parameter values for the `number` parameter type. Args: item: dictionary containing details on the parameter such as default, min and max values. TODO(Wael): Improve logic. """ minimum, maximum = 0, 1 if "default" not in item or item["default"] is None: candidates = [] else: candidates = [1, 2, item["default"], 2 * (item["default"] + 1), item["default"] // 2, -1 * item["default"]] if "minimum" in item: minimum = item["minimum"] candidates = [num for num in candidates if num > minimum] if "maximum" in item: maximum = item["maximum"] candidates = [num for num in candidates if num < maximum] return candidates + [random.random() * 0.99 * maximum] def explore_integer(item: dict[str, Any]) -> list[ParameterBaseTypes]: """Return possible parameter values for the `integer` parameter type. Args: item: dictionary containing details on the parameter such as default, min and max values. TODO(Wael): Improve logic. """ minimum, maximum = 0, 10 if "default" not in item or item["default"] is None: candidates = [] else: candidates = [item["default"], 2 * (item["default"] + 1), item["default"] // 2, -1 * item["default"]] if "minimum" in item: minimum = item["minimum"] candidates = [num for num in candidates if num >= item["minimum"]] if "maximum" in item: maximum = item["maximum"] candidates = [num for num in candidates if num <= item["maximum"]] return candidates + [random.randint(minimum, maximum)] def explore_string(item: dict[str, Any]) -> list[ParameterBaseTypes]: """Return possible parameter values for the `string` parameter type. Args: item: dictionary containing details on the parameter such as default, min and max values. """ if "enum" in item: return item["enum"] return [item["default"]] def explore_boolean() -> list[bool]: """Return possible parameter values for the `boolean` parameter type (i.e. [True, False])""" return [True, False] def explore_null() -> list[None]: """Return possible parameter values for the `null` parameter type (i.e. [None])""" return [None] ================================================ FILE: ludwig/config_validation/__init__.py ================================================ ================================================ FILE: ludwig/config_validation/checks.py ================================================ """Checks that are not easily covered by marshmallow JSON schema validation like parameter interdependencies.""" from abc import ABC, abstractmethod from collections.abc import Callable from re import findall from typing import TYPE_CHECKING from transformers import AutoConfig from ludwig.api_annotations import DeveloperAPI from ludwig.constants import ( AUDIO, BINARY, IMAGE, IN_MEMORY, MIN_QUANTIZATION_BITS_FOR_MERGE_AND_UNLOAD, MODEL_ECD, MODEL_LLM, SEQUENCE, SET, TEXT, TIMESERIES, VECTOR, ) from ludwig.error import ConfigValidationError from ludwig.utils.metric_utils import get_feature_to_metric_names_map_from_feature_collection from ludwig.utils.misc_utils import merge_dict if TYPE_CHECKING: from ludwig.schema.model_config import ModelConfig # Set of all sequence feature types. SEQUENCE_OUTPUT_FEATURE_TYPES = {SEQUENCE, TEXT, SET, VECTOR} class ConfigCheckRegistry: """A registry of configuration checks.""" def __init__(self): self._registry = [] def register(self, check_fn): self._registry.append(check_fn) def check_config(self, config: "ModelConfig") -> None: # noqa: F821 for check_fn in self._registry: check_fn(config) _CONFIG_CHECK_REGISTRY = ConfigCheckRegistry() def get_config_check_registry(): """Returns the config check registry.""" return _CONFIG_CHECK_REGISTRY @DeveloperAPI def register_config_check(fn) -> Callable: """Registers a config check function.""" _CONFIG_CHECK_REGISTRY.register(fn) class ConfigCheck(ABC): """Checks instances of comprehensive (all parameters and defaults filled in) schema-validated config.""" @staticmethod @abstractmethod def check(config: "ModelConfig") -> None: # noqa: F821 """Checks config for validity.""" raise NotImplementedError @register_config_check def check_feature_names_unique(config: "ModelConfig") -> None: # noqa: F821 """Checks that all feature names are unique.""" input_features = config.input_features input_feature_names = {input_feature.name for input_feature in input_features} output_features = config.output_features output_feature_names = {output_feature.name for output_feature in output_features} if len(input_feature_names) + len(output_feature_names) != len(input_features) + len(output_features): raise ConfigValidationError("Feature names must be unique.") @register_config_check def check_tied_features_valid(config: "ModelConfig") -> None: # noqa: F821 """Checks that all tied features are valid.""" input_features = config.input_features input_feature_names = {input_feature.name for input_feature in input_features} for input_feature in input_features: if input_feature.tied and input_feature.tied not in input_feature_names: raise ConfigValidationError( f"Feature {input_feature.name} is tied to feature {input_feature.tied}, but the " f"'{input_feature.tied}' feature does not exist." ) @register_config_check def check_training_runway(config: "ModelConfig") -> None: # noqa: F821 """Checks that checkpoints_per_epoch and steps_per_checkpoint aren't simultaneously defined.""" if config.model_type == MODEL_ECD: if config.trainer.checkpoints_per_epoch != 0 and config.trainer.steps_per_checkpoint != 0: raise ConfigValidationError( "It is invalid to specify both trainer.checkpoints_per_epoch AND " "trainer.steps_per_checkpoint. Please specify one or the other, or specify neither to " "checkpoint/eval the model every epoch." ) @register_config_check def check_ray_backend_in_memory_preprocessing(config: "ModelConfig") -> None: # noqa: F821 """Checks that in memory preprocessing is used with Ray backend.""" if config.backend is None: return if not hasattr(config.trainer, "preprocessing") or not hasattr(config.trainer.preprocessing, IN_MEMORY): return if config.backend.type == "ray" and not config.trainer.preprocessing.in_memory: raise ConfigValidationError( "RayBackend does not support lazy loading of data files at train time. " "Set preprocessing config `in_memory: True`" ) for input_feature in config.input_features: if input_feature.type == AUDIO or input_feature.type == IMAGE: if not input_feature.preprocessing.in_memory and config.backend.type != "ray": raise ConfigValidationError( "RayBackend does not support lazy loading of data files at train time. " f"Set preprocessing config `in_memory: True` for input feature {input_feature.name}" ) def check_sequence_concat_combiner_requirements(config: "ModelConfig") -> None: # noqa: F821 """Checks that sequence concat combiner has at least one input feature that's sequential.""" if config.model_type != MODEL_ECD: return if config.combiner != "sequence_concat": return has_sequence_input = False for input_feature in config.input_features: if input_feature.type in SEQUENCE_OUTPUT_FEATURE_TYPES: has_sequence_input = True break if not has_sequence_input: raise ConfigValidationError( "Sequence concat combiner should only be used for at least one sequential input feature." ) @register_config_check def check_comparator_combiner_requirements(config: "ModelConfig") -> None: # noqa: F821 """Checks that all of the feature names for entity_1 and entity_2 are valid features.""" if config.model_type != MODEL_ECD: return if config.combiner.type != "comparator": return input_feature_names = [input_feature.name for input_feature in config.input_features] for feature_name in config.combiner.entity_1: if feature_name not in input_feature_names: raise ConfigValidationError( f"Feature {feature_name} in entity_1 for the comparator combiner is not a valid " "input feature name." ) for feature_name in config.combiner.entity_2: if feature_name not in input_feature_names: raise ConfigValidationError( f"Feature {feature_name} in entity_2 for the comparator combiner is not a valid " "input feature name." ) if sorted(config.combiner.entity_1 + config.combiner.entity_2) != sorted(input_feature_names): raise ConfigValidationError("Not all input features are present as entities in the comparator combiner.") @register_config_check def check_class_balance_preprocessing(config: "ModelConfig") -> None: # noqa: F821 """Class balancing is only available for datasets with a single output feature.""" if config.preprocessing.oversample_minority or config.preprocessing.undersample_majority: if len(config.output_features) != 1: raise ConfigValidationError("Class balancing is only available for datasets with a single output feature.") if config.output_features[0].type != BINARY: raise ConfigValidationError("Class balancing is only supported for binary output features.") @register_config_check def check_sampling_exclusivity(config: "ModelConfig") -> None: # noqa: F821 """Oversample minority and undersample majority are mutually exclusive.""" if config.preprocessing.oversample_minority and config.preprocessing.undersample_majority: raise ConfigValidationError( "Oversample minority and undersample majority are mutually exclusive. Specify only one method." ) @register_config_check def check_validation_metric_exists(config: "ModelConfig") -> None: # noqa: F821 """Checks that the specified validation metric exists.""" validation_metric_name = config.trainer.validation_metric # Get all valid metrics. feature_to_metric_names_map = get_feature_to_metric_names_map_from_feature_collection(config.output_features) all_valid_metrics = set() for metric_names in feature_to_metric_names_map.values(): all_valid_metrics.update(metric_names) if validation_metric_name not in all_valid_metrics: raise ConfigValidationError( f"User-specified trainer.validation_metric '{validation_metric_name}' is not valid. " f"Available metrics are: {all_valid_metrics}" ) @register_config_check def check_splitter(config: "ModelConfig") -> None: # noqa: F821 """Checks the validity of the splitter configuration.""" from ludwig.data.split import get_splitter splitter = get_splitter(**config.preprocessing.split.to_dict()) splitter.validate(config) @register_config_check def check_hf_tokenizer_requirements(config: "ModelConfig") -> None: # noqa: F821 """Checks that the HuggingFace tokenizer has a pretrained_model_name_or_path specified.""" for input_feature in config.input_features: if input_feature.type == TEXT: if input_feature.preprocessing.tokenizer == "hf_tokenizer": if input_feature.preprocessing.pretrained_model_name_or_path is None: raise ConfigValidationError( "Pretrained model name or path must be specified for HuggingFace tokenizer." ) @register_config_check def check_hf_encoder_requirements(config: "ModelConfig") -> None: # noqa: F821 """Checks that a HuggingFace encoder has a pretrained_model_name_or_path specified.""" for input_feature in config.input_features: if input_feature.type == TEXT: if hasattr(input_feature.encoder, "use_pretrained"): if input_feature.preprocessing.pretrained_model_name_or_path is None: raise ConfigValidationError( "Pretrained model name or path must be specified for HuggingFace encoder." ) @register_config_check def check_stacked_transformer_requirements(config: "ModelConfig") -> None: # noqa: F821 """Checks that the transformer encoder type correctly configures `num_heads` and `hidden_size`""" def is_divisible(hidden_size: int, num_heads: int) -> bool: """Checks that hidden_size is divisible by num_heads.""" return hidden_size % num_heads == 0 sequence_types = [SEQUENCE, TEXT, TIMESERIES] for input_feature in config.input_features: if_type = input_feature.type encoder = input_feature.encoder if ( if_type in sequence_types and encoder.type == "transformer" and not is_divisible(encoder.hidden_size, encoder.num_heads) ): raise ConfigValidationError( f"Input feature {input_feature.name} transformer encoder requires encoder.hidden_size to be divisible " f"by encoder.num_heads. Found hidden_size {encoder.hidden_size} and num_heads {encoder.num_heads}." ) @register_config_check def check_hyperopt_search_algorithm_dependencies_installed(config: "ModelConfig") -> None: # noqa: F821 """Check that the hyperopt search algorithm dependencies are installed.""" if config.hyperopt is None: return try: config.hyperopt.search_alg.dependencies_installed() except ImportError as e: raise ConfigValidationError(e.msg) @register_config_check def check_hyperopt_scheduler_dependencies_installed(config: "ModelConfig") -> None: # noqa: F821 """Check that the hyperopt scheduler dependencies are installed.""" if config.hyperopt is None: return try: config.hyperopt.executor.scheduler.dependencies_installed() except ImportError as e: raise ConfigValidationError(e.msg) @register_config_check def check_tagger_decoder_requirements(config: "ModelConfig") -> None: # noqa: F821 """Checks that the tagger decoder has at least one sequence, text or timeseries input feature where the encoder's reduce_output will produce a 3D shaped output from the combiner.""" # Check if there is a text or sequence output feature using a tagger decoder output_feature_with_tagger_decoder = False for output_feature in config.output_features: if output_feature.type in {TEXT, SEQUENCE} and output_feature.decoder.type == "tagger": output_feature_with_tagger_decoder = True if not output_feature_with_tagger_decoder: return # Check that there is at least one sequence, text or timeseries input feature that doesn't reduce the # output of the encoder. has_sequence_feature = False for input_feature in config.input_features: if input_feature.type in {SEQUENCE, TEXT, TIMESERIES}: has_sequence_feature = True if input_feature.encoder.reduce_output is None: return if not has_sequence_feature: raise ConfigValidationError("Tagger decoder requires at least one text, sequence or timeseries input feature.") else: raise ConfigValidationError( "Tagger decoder requires at least one of the text, sequence or timeseries input feature encoders to have " "`reduce_output` set to `None`." ) @register_config_check def check_hyperopt_parameter_dicts(config: "ModelConfig") -> None: # noqa: F821 """Checks for hyperopt parameter dicts against their config objects.""" if config.hyperopt is None: return from ludwig.schema.hyperopt.utils import get_parameter_cls, parameter_config_registry # noqa: F401 for parameter, space in config.hyperopt.parameters.items(): # skip nested hyperopt parameters if parameter != ".": parameter_attribute_path = parameter.split(".") passed = False for root in [config, config.input_features, config.output_features]: current = root for p in parameter_attribute_path: try: current = current.__getattribute__(p) if p == parameter_attribute_path[-1]: passed = True except AttributeError: break if passed: break if not passed: raise ConfigValidationError( f"The supplied hyperopt parameter {parameter} is not a valid config field. Check the Ludwig " "docs for the list of valid parameters." ) try: space_cls = get_parameter_cls(space["space"]) space_cls.from_dict(space) except KeyError: space_types = ", ".join(parameter_config_registry.keys()) raise ConfigValidationError( f"Invalid hyperopt parameter space requested for `hyperopt.parameters.{parameter}`. Valid spaces " f"are {space_types}." ) @register_config_check def check_concat_combiner_requirements(config: "ModelConfig") -> None: # noqa: F821 """Checks that if the concat combiner receives a mixture of sequence and non-sequence features, that all sequence features are configured with reduce_output to be 2D tensors.""" if config.model_type != MODEL_ECD: return if config.combiner.type != "concat": return has_unreduced_sequence_feature = False has_non_sequence_feature = False for input_feature in config.input_features: if ( input_feature.type in {SEQUENCE, TEXT, TIMESERIES} and hasattr(input_feature.encoder, "reduce_output") and input_feature.encoder.reduce_output is None ): has_unreduced_sequence_feature = True else: has_non_sequence_feature = True if has_unreduced_sequence_feature and has_non_sequence_feature: raise ConfigValidationError( "The concat combiner cannot receive a mix of unreduced sequence features (3D) and non-sequence features " "(2D). Options: 1) Set reduce_output in sequence feature encoders to a value other than None to ensure 2D " "encoder outputs, 2) Choose a different combiner like `sequence_concat` which can handle a mix of 2D and " "3D encoder output shapes, or 3) Remove features to ensure that output shapes from all encoders are the " "same dimension (all 2D or all 3D)." ) @register_config_check def check_hyperopt_nested_parameter_dicts(config: "ModelConfig") -> None: # noqa: F821 """Checks that all nested parameters in a hyperopt config exist.""" if config.hyperopt is None or "." not in config.hyperopt.parameters: return from ludwig.schema.hyperopt.utils import get_parameter_cls # noqa: F401 from ludwig.schema.model_types.base import ModelConfig space = config.hyperopt.parameters["."] # Build the config that would be produced by each parameter dict to validate subsections that may be in config_dict = config.to_dict() del config_dict["hyperopt"] for category in space["categories"]: for i, k in enumerate(category.keys()): try: config.__getattribute__(k) except AttributeError: raise ConfigValidationError(f"Invalid config block {k} in nested hyperopt parameter dict {i}: {space}.") category_dict = merge_dict(config_dict, category) try: ModelConfig.from_dict(category_dict) except ConfigValidationError as e: raise ConfigValidationError(f"Invalid config in hyperopt nested parameter config: {category}. {e.message}") try: space_cls = get_parameter_cls("choice") space_cls.from_dict(space) except KeyError: raise ConfigValidationError( f"Nested hyperparameter search spaces must be of type 'choice'. Requested space type: {space['space']}" ) @register_config_check def check_llm_exactly_one_input_text_feature(config: "ModelConfig"): # noqa: F821 if config.model_type != MODEL_LLM: return if len(config.input_features) == 1 and config.input_features[0].type == TEXT: return else: raise ConfigValidationError("LLM requires exactly one text input feature.") @register_config_check def check_llm_finetuning_output_feature_config(config: "ModelConfig"): # noqa: F821 """Checks that the output feature config for LLM finetuning is valid.""" if config.model_type != MODEL_LLM: return if config.trainer.type != "finetune": return if config.output_features[0].type != TEXT: raise ConfigValidationError( "LLM finetuning requires the output feature to be a text feature. If you are trying to use a different " "output feature type such as category or binary, please change the output feature type to text." ) @register_config_check def check_llm_finetuning_trainer_config(config: "ModelConfig"): # noqa: F821 """Ensures that trainer type is finetune if adapter is not None.""" if config.model_type != MODEL_LLM: return if ( config.trainer.type == "none" and config.adapter is not None and config.adapter.pretrained_adapter_weights is not None ): # If performing zero-shot, we must specify pretrained adapter weights return if config.adapter is not None and config.trainer.type != "finetune": raise ConfigValidationError("LLM finetuning requires trainer type to be finetune.") @register_config_check def check_llm_finetuning_backend_config(config: "ModelConfig"): # noqa: F821 """Checks that the LLM finetuning using Ray is configured correctly. DDP strategy is not supported for LLM finetuning because it leads to OOMs since the model is large and DDP strategy requires a copy of the model on each GPU. """ if config.model_type != MODEL_LLM: return # LLM finetuning is only supported by the finetune trainer type if ( config.trainer.type != "finetune" and config.adapter is not None and config.adapter.pretrained_adapter_weights is not None ): return # Using local backend, so skip the checks below if not hasattr(config.backend, "type"): return backend = config.backend if not hasattr(backend.trainer, "strategy") or backend.trainer.strategy != "deepspeed": raise ConfigValidationError("LLM finetuning with Ray requires the DeepSpeed strategy.") # Deepspeed requires GPU if not backend.trainer.use_gpu or backend.trainer.resources_per_worker.GPU < 1: raise ConfigValidationError("LLM finetuning with DeepSpeed requires GPU.") @register_config_check def check_llm_finetuning_adalora_config(config: "ModelConfig"): """Checks that the adalora adapter is configured correctly. We check against PEFT's predefined target module list for ADALORA to see if this target_modules is present there. If not, AdaloraModel will run into issues downstream. """ if config.model_type != MODEL_LLM: return if not config.adapter: return if config.adapter.type != "adalora": return from peft.utils import TRANSFORMERS_MODELS_TO_ADALORA_TARGET_MODULES_MAPPING model_config = _get_llm_model_config(config.base_model) if model_config.model_type not in TRANSFORMERS_MODELS_TO_ADALORA_TARGET_MODULES_MAPPING: raise ConfigValidationError( f"Adalora adapter is not supported for {model_config.model_type} model. " f"Supported model types are: {list(TRANSFORMERS_MODELS_TO_ADALORA_TARGET_MODULES_MAPPING.keys())}. " "If you know the target modules for your model, please specify them in the config through the " "`target_modules` key." ) @register_config_check def check_llm_finetuning_adaption_prompt_parameters(config: "ModelConfig"): """Checks that the adaption_prompt adapter is configured correctly. Adaption prompt is only supported for Llama models. """ if config.model_type != MODEL_LLM: return if not config.adapter: return if config.adapter.type != "adaption_prompt": return from peft.tuners.adaption_prompt.config import TRANSFORMERS_MODEL_CONFIG # Adaption Config is currently only supported for Llama model types model_config = _get_llm_model_config(config.base_model) if model_config.model_type not in TRANSFORMERS_MODEL_CONFIG: raise ConfigValidationError( f"Adaption prompt adapter is not supported for {model_config.model_type} model. " f"Supported model types are: {list(TRANSFORMERS_MODEL_CONFIG.keys())}." ) def _get_llm_model_config(model_name: str) -> AutoConfig: """Returns the LLM model config.""" return AutoConfig.from_pretrained(model_name) # TODO(geoffrey, arnav): uncomment this when we have reconciled the config with the backend kwarg in api.py # @register_config_check def check_llm_quantization_backend_incompatibility(config: "ModelConfig") -> None: # noqa: F821 """Checks that LLM model type with quantization uses the local backend.""" if config.model_type != MODEL_LLM: return if config.quantization is None: return backend_type = None if config.backend: backend_type = config.backend.get("type", None) # If backend was explicitly set to Ray, then we need to raise an error if backend_type == "ray": raise ConfigValidationError(f"LLM with quantization requires the 'local' backend, found: '{backend_type}'") # If the backend is not explicitly set, then we need to check if a Ray process is running # If a Ray process is running, then we need to raise an error because the backend will be set to Ray if config.backend is None: try: # May not be installed, so we need to catch the ImportError import ray if ray.is_initialized(): raise ConfigValidationError( "LLM with quantization requires the 'local' backend, but backend will be set " "to Ray since Ray is already running locally." ) except ImportError: pass @register_config_check def check_llm_text_encoder_is_not_used_with_ecd(config: "ModelConfig") -> None: """Checks that a pretrained text encoder is not used for ECD models with a text output feature.""" if config.model_type != MODEL_ECD: return if config.input_features[0].type != TEXT: return if config.output_features[0].type != TEXT: return if ( hasattr(config.input_features[0].encoder, "pretrained_model_name_or_path") and config.input_features[0].encoder.pretrained_model_name_or_path ): raise ConfigValidationError("Please use the `model_type: llm` for text-to-text models.") @register_config_check def check_qlora_requirements(config: "ModelConfig") -> None: # noqa: F821 """Checks that all the necessary settings are in place for QLoRA.""" if config.model_type != MODEL_LLM or config.trainer.type == "none": return if config.quantization and (not config.adapter or config.adapter.type != "lora"): raise ConfigValidationError("Fine-tuning and LLM with quantization requires using the 'lora' adapter") @register_config_check def check_qlora_merge_and_unload_compatibility(config: "ModelConfig") -> None: # noqa: F821 """Checks that model.merge_and_unload() is supported by underlying model.save_pretrained() when merging QLoRA layers.""" if config.model_type != MODEL_LLM or config.trainer.type == "none": return if not ( config.adapter and config.adapter.type in ["lora", "adalora"] and config.adapter.postprocessor and config.adapter.postprocessor.merge_adapter_into_base_model and config.quantization ): return if config.quantization.bits < MIN_QUANTIZATION_BITS_FOR_MERGE_AND_UNLOAD: raise ConfigValidationError( f"""This operation will entail merging LoRA layers on a {config.quantization.bits}-bit \ quantized model. Calling "save_pretrained()" on that model is currently unsupported. If you want to merge the LoRA \ adapter weights into the base model, you need to use 8-bit quantization or do non-quantized based training by removing \ the quantization section from your Ludwig configuration.""" ) @register_config_check def check_prompt_requirements(config: "ModelConfig") -> None: # noqa: F821 """Checks that prompt's template and task properties are valid, according to the description on the schema.""" if config.model_type != MODEL_LLM: return # TODO: `prompt` by default should be set to null, not a default dict: # # If no prompt is provided, no validation necessary: # if not config.prompt: # return from ludwig.schema.llms.prompt import PromptConfig, RetrievalConfig if config.prompt == PromptConfig(): return template = config.prompt.template task = config.prompt.task retrieval = config.prompt.retrieval # If template is NOT provided, then task is required for zero/few shot learning: if not template and not task: raise ConfigValidationError("A prompt task is required if no template is provided!") template_refs = set(findall(r"\{(.*?)\}", template)) if isinstance(template, str) else set() # If a template IS provided (i.e. we are not doing a built-in zero/few-shot learning), then... if template: # If task is also provided, the template must contain it: if task and "__task__" not in template_refs: raise ConfigValidationError( "When providing a task, you must make sure that the task keyword `{__task__} is " "present somewhere in the template string!" ) # If retrieval is also provided, the template must reference it: # TODO: retrieval by default should be set to null, not a default dict: if retrieval and retrieval != RetrievalConfig() and "__context__" not in template_refs: raise ConfigValidationError( "When providing a retrieval config, you must make sure that the task keyword `{__context__}` is " "present somewhere in the template string!" ) # Otherwise, the template should at least contain the sample keyword or some input column: # TODO: len(template_refs) is a hacky attempt to check that there are references to *something* in the # string. The proper validation is to check the references against the features in the user's dataset - but we # do not have access to the dataset in this code path right now. if not task: if len(template_refs) == 0 and "__sample__" not in template_refs: raise ConfigValidationError( "A template must contain at least one reference to a column or the sample keyword {__sample__} for " "a JSON-serialized representation of non-output feature columns." ) # Raise an error if template has a placeholder for the output feature name (column). output_feature_col = config.output_features[0].column if output_feature_col in template_refs: raise ConfigValidationError( "Prompt template should not have a reference to the output feature. The output feature is " "automatically added to the end of the prompt template merged with the input at training time." ) @register_config_check def check_sample_ratio_and_size_compatible(config: "ModelConfig") -> None: sample_ratio = config.preprocessing.sample_ratio sample_size = config.preprocessing.sample_size if sample_size is not None and sample_ratio < 1.0: raise ConfigValidationError("sample_size cannot be used when sample_ratio < 1.0") ================================================ FILE: ludwig/config_validation/preprocessing.py ================================================ def check_global_max_sequence_length_fits_prompt_template(metadata, global_preprocessing_parameters): """Checks that the prompt template fits within the global max sequence length.""" if ( "global_max_sequence_length" in global_preprocessing_parameters and global_preprocessing_parameters["global_max_sequence_length"] is not None ): for feature_name, feature_metadata in metadata.items(): if ( "prompt_template_num_tokens" in feature_metadata and feature_metadata["prompt_template_num_tokens"] > global_preprocessing_parameters["global_max_sequence_length"] ): raise ValueError( f'The prompt contains ({feature_metadata["prompt_template_num_tokens"]}) tokens, which is more ' f"than the the global_max_sequence_length " f'({global_preprocessing_parameters["global_max_sequence_length"]}), which will remove all unique ' "information. Shorten the prompt, or increase the global max sequence length to > " f'({feature_metadata["prompt_template_num_tokens"]}) to include the full prompt.' ) ================================================ FILE: ludwig/config_validation/validation.py ================================================ from functools import lru_cache from threading import Lock import jsonschema.exceptions from jsonschema import Draft7Validator, validate from jsonschema.validators import extend from ludwig.api_annotations import DeveloperAPI from ludwig.constants import BASE_MODEL, MODEL_ECD, MODEL_LLM, MODEL_TYPE from ludwig.error import ConfigValidationError # TODO(travis): figure out why we need these imports to avoid circular import error from ludwig.schema.combiners.utils import get_combiner_jsonschema # noqa from ludwig.schema.features.utils import get_input_feature_jsonschema, get_output_feature_jsonschema # noqa from ludwig.schema.hyperopt import get_hyperopt_jsonschema # noqa from ludwig.schema.trainer import get_model_type_jsonschema, get_trainer_jsonschema # noqa from ludwig.schema.utils import unload_jsonschema_from_marshmallow_class VALIDATION_LOCK = Lock() @DeveloperAPI @lru_cache(maxsize=3) def get_schema(model_type: str = MODEL_ECD): # Force populate combiner registry: import ludwig.combiners.combiners # noqa: F401 from ludwig.schema.model_types.base import model_type_schema_registry cls = model_type_schema_registry[model_type] props = unload_jsonschema_from_marshmallow_class(cls)["properties"] # TODO: Replace with more robust required logic later. required = ["input_features", "output_features"] if model_type == MODEL_LLM: required += [BASE_MODEL] return { "type": "object", "properties": props, "title": "model_options", "description": "Settings for Ludwig configuration", "required": required, "additionalProperties": True, } @lru_cache(maxsize=1) def get_validator(): # Manually add support for tuples (pending upstream changes: https://github.com/Julian/jsonschema/issues/148): def custom_is_array(checker, instance): return isinstance(instance, list) or isinstance(instance, tuple) # This creates a new class, so cache to prevent a memory leak: # https://github.com/python-jsonschema/jsonschema/issues/868 type_checker = Draft7Validator.TYPE_CHECKER.redefine("array", custom_is_array) return extend(Draft7Validator, type_checker=type_checker) @DeveloperAPI def check_schema(updated_config): """Emulates the pure JSONSchema validation that could be used in an environment without marshmallow. The incoming config may not be comprehensive, but is assumed to be up to date with the latest ludwig schema. """ model_type = updated_config.get(MODEL_TYPE, MODEL_ECD) error = None with VALIDATION_LOCK: try: validate(instance=updated_config, schema=get_schema(model_type=model_type), cls=get_validator()) except jsonschema.exceptions.ValidationError as e: # Capture error but don't raise here, otherwise we get the full output from `e`, which contains a dump # of the entire schema error = e if error is not None: raise ConfigValidationError(f"Failed to validate JSON schema for config. Error: {error.message}") from error ================================================ FILE: ludwig/constants.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== INPUT_FEATURES = "input_features" OUTPUT_FEATURES = "output_features" INPUT = "input" OUTPUT = "output" BINARY = "binary" CATEGORY = "category" CATEGORY_DISTRIBUTION = "category_distribution" INT = "int" FLOAT = "float" SPACE = "space" NUMBER = "number" SET = "set" BAG = "bag" TEXT = "text" SEQUENCE = "sequence" TIMESERIES = "timeseries" IMAGE = "image" AUDIO = "audio" DATE = "date" H3 = "h3" VECTOR = "vector" HEIGHT = "height" WIDTH = "width" INFER_IMAGE_DIMENSIONS = "infer_image_dimensions" INFER_IMAGE_MAX_HEIGHT = "infer_image_max_height" INFER_IMAGE_MAX_WIDTH = "infer_image_max_width" INFER_IMAGE_SAMPLE_SIZE = "infer_image_sample_size" INFER_IMAGE_NUM_CLASSES = "infer_image_num_classes" IMAGE_MAX_CLASSES = 128 NUM_CLASSES = "num_classes" NUM_CHANNELS = "num_channels" REQUIRES_EQUAL_DIMENSIONS = "requires_equal_dimensions" USE_PRETRAINED = "use_pretrained" TRAINABLE = "trainable" CLASS_WEIGHTS = "class_weights" USED_TOKENS = "used_tokens" LOSS = "loss" ROC_AUC = "roc_auc" EVAL_LOSS = "eval_loss" TRAIN_MEAN_LOSS = "train_mean_loss" SEQUENCE_SOFTMAX_CROSS_ENTROPY = "sequence_softmax_cross_entropy" NEXT_TOKEN_SOFTMAX_CROSS_ENTROPY = "next_token_softmax_cross_entropy" SOFTMAX_CROSS_ENTROPY = "softmax_cross_entropy" SIGMOID_CROSS_ENTROPY = "sigmoid_cross_entropy" BINARY_WEIGHTED_CROSS_ENTROPY = "binary_weighted_cross_entropy" THRESHOLD = "threshold" VALIDATION_METRIC = "validation_metric" ACCURACY = "accuracy" ACCURACY_MICRO = "accuracy_micro" HITS_AT_K = "hits_at_k" MEAN_HITS_AT_K = "mean_hits_at_k" ERROR = "error" ABSOLUTE_ERROR = "absolute_error" SQUARED_ERROR = "squared_error" MEAN_SQUARED_ERROR = "mean_squared_error" ROOT_MEAN_SQUARED_ERROR = "root_mean_squared_error" ROOT_MEAN_SQUARED_PERCENTAGE_ERROR = "root_mean_squared_percentage_error" MEAN_ABSOLUTE_ERROR = "mean_absolute_error" MEAN_ABSOLUTE_PERCENTAGE_ERROR = "mean_absolute_percentage_error" HUBER = "huber" CORN = "corn" R2 = "r2" EDIT_DISTANCE = "edit_distance" PERPLEXITY = "perplexity" NEXT_TOKEN_PERPLEXITY = "next_token_perplexity" JACCARD = "jaccard" PRECISION = "precision" RECALL = "recall" SPECIFICITY = "specificity" PREDICTIONS = "predictions" RESPONSE = "RESPONSE" TOP_K = "top_k" TOP_K_PREDICTIONS = "top_k_predictions" PROBABILITY = "probability" PROBABILITIES = "probabilities" SPLIT_PROBABILITIES = "split_probabilities" TOKEN_ACCURACY = "token_accuracy" LAST_ACCURACY = "last_accuracy" SEQUENCE_ACCURACY = "sequence_accuracy" LAST_PROBABILITIES = "last_probabilities" LAST_PREDICTIONS = "last_predictions" LENGTHS = "lengths" TIED = "tied" COMBINED = "combined" PREPROCESSING = "preprocessing" FILL_WITH_CONST = "fill_with_const" FILL_WITH_MODE = "fill_with_mode" FILL_WITH_MEAN = "fill_with_mean" FILL_WITH_FALSE = "fill_with_false" FILL_WITH_TRUE = "fill_with_true" BFILL = "bfill" FFILL = "ffill" DROP_ROW = "drop_row" MISSING_VALUE_STRATEGY = "missing_value_strategy" MISSING_VALUE_STRATEGY_OPTIONS = [ FILL_WITH_CONST, FILL_WITH_MODE, BFILL, FFILL, DROP_ROW, ] CROP_OR_PAD = "crop_or_pad" INTERPOLATE = "interpolate" RESIZE_METHODS = [CROP_OR_PAD, INTERPOLATE] # Special symbols for text. STOP_SYMBOL = "" START_SYMBOL = "" PADDING_SYMBOL = "" UNKNOWN_SYMBOL = "" TRAINER = "trainer" OPTIMIZER = "optimizer" METRIC = "metric" PREDICTION = "prediction" LOGITS = "logits" HIDDEN = "hidden" LAST_HIDDEN = "last_hidden" ENCODER_OUTPUT = "encoder_output" ENCODER_OUTPUT_STATE = "encoder_output_state" PROJECTION_INPUT = "projection_input" LEARNING_RATE_SCHEDULER = "learning_rate_scheduler" SEMANTIC = "semantic" RANDOM = "random" SUM = "sum" APPEND = "append" SEQ_SUM = "seq_sum" AVG_EXP = "avg_exp" TRAIN = "train" TRAINING = "training" VALIDATION = "validation" TEST = "test" EVALUATION = "evaluation" SPLIT = "split" FORCE_SPLIT = "force_split" STRATIFY = "stratify" FULL = "full" TRAIN_SPLIT = 0 VALIDATION_SPLIT = 1 TEST_SPLIT = 2 MIN_DATASET_SPLIT_ROWS = 3 # The minimum number of rows in a split. Splits smaller than this size are treated as empty. META = "meta" HYPEROPT = "hyperopt" STRATEGY = "strategy" EXECUTOR = "executor" MINIMIZE = "minimize" MAXIMIZE = "maximize" SAMPLER = "sampler" NUM_SAMPLES = "num_samples" SEARCH_ALG = "search_alg" SCHEDULER = "scheduler" PARAMETERS = "parameters" MAX_CONCURRENT_TRIALS = "max_concurrent_trials" CPU_RESOURCES_PER_TRIAL = "cpu_resources_per_trial" GPU_RESOURCES_PER_TRIAL = "gpu_resources_per_trial" GOAL = "goal" GRID_SEARCH = "grid_search" NAME = "name" COLUMN = "column" TYPE = "type" ACTIVE = "active" RAY = "ray" IN_MEMORY = "in_memory" PROC_COLUMN = "proc_column" CHECKSUM = "checksum" HDF5 = "hdf5" PARQUET = "parquet" SRC = "dataset_src" EARLY_STOP = "early_stop" EPOCHS = "epochs" BATCH_SIZE = "batch_size" EVAL_BATCH_SIZE = "eval_batch_size" EFFECTIVE_BATCH_SIZE = "effective_batch_size" MAX_BATCH_SIZE = "max_batch_size" DEFAULT_BATCH_SIZE = "auto" FALLBACK_BATCH_SIZE = 128 # The smallest batch size that is supported on Ludwig. MINIMUM_BATCH_SIZE = 1 # 2^40. Used for `max_batch_size` config param. Not a hard constraint for `batch_size` config param. MAX_POSSIBLE_BATCH_SIZE = 1099511627776 # min batch size. Used as a floor for batch size tuning. MIN_POSSIBLE_BATCH_SIZE = 1 # max batch size for dataset is 20% of dataset size MAX_BATCH_SIZE_DATASET_FRACTION = 0.2 MAX_CPU_BATCH_SIZE = 128 LEARNING_RATE = "learning_rate" INPUT_SIZE = "input_size" USE_BIAS = "use_bias" BIAS = "bias" DEFAULT_USE_BIAS = "default_use_bias" DEFAULT_BIAS = "default_bias" CONV_USE_BIAS = "conv_use_bias" CONV_BIAS = "conv_bias" AUTO = "auto" CONFIG = "config" CLIP = "clip" DEPENDENCIES = "dependencies" REDUCE_INPUT = "reduce_input" REDUCE_DEPENDENCIES = "reduce_dependencies" BACKEND = "backend" COMBINER = "combiner" ENCODER = "encoder" DECODER = "decoder" TRAINABLE = "trainable" DEFAULTS = "defaults" DEFAULT = "default" DEFAULT_VALIDATION_METRIC = "default_validation_metric" BALANCE_PERCENTAGE_TOLERANCE = 0.03 IMBALANCE_DETECTION_RATIO = 0.05 TABULAR = "tabular" AUTOML_DEFAULT_TABULAR_MODEL = "tabnet" AUTOML_DEFAULT_TEXT_ENCODER = "bert" AUTOML_SMALLER_TEXT_ENCODER = "distilbert" AUTOML_TEXT_ENCODER_MAX_TOKEN_LEN = 512 AUTOML_SMALLER_TEXT_LENGTH = 128 AUTOML_LARGE_TEXT_DATASET = 100000 AUTOML_MAX_ROWS_PER_CHECKPOINT = 350000 AUTOML_DEFAULT_IMAGE_ENCODER = "stacked_cnn" HYPEROPT_WARNING = ( "You are running the ludwig train command but there’s a hyperopt section present in your config. " "It will be ignored. If you want to run hyperopt you should use the following command: ludwig " "hyperopt\n\n" ) CONTINUE_PROMPT = "Do you want to continue? " DEFAULT_AUDIO_TENSOR_LENGTH = 70000 AUDIO_FEATURE_KEYS = [ "type", "window_length_in_s", "window_shift_in_s", "num_fft_points", "window_type", "num_filter_bands", ] BASE_MODEL = "base_model" MODEL_TYPE = "model_type" MODEL_ECD = "ecd" MODEL_LLM = "llm" DASK_MODULE_NAME = "dask.dataframe" LUDWIG_VERSION = "ludwig_version" PREPROCESSOR = "preprocessor" PREDICTOR = "predictor" POSTPROCESSOR = "postprocessor" TARGET_MODULES = "target_modules" GENERATION = "generation" PROMPT = "prompt" ADAPTER = "adapter" QUANTIZATION = "quantization" MIN_QUANTIZATION_BITS_FOR_MERGE_AND_UNLOAD = 8 PRETRAINED_ADAPTER_WEIGHTS = "pretrained_adapter_weights" MERGE_ADAPTER_INTO_BASE_MODEL = "merge_adapter_into_base_model" PROGRESSBAR = "progressbar" # CrossEntropyLoss for LLMs IGNORE_INDEX_TOKEN_ID = -100 S3 = "s3" CACHE = "cache" # If `use_torch_profiler=True` in LudwigProfiler, LUDWIG_TAG is prepended to the specified experiment tag # (LudwigProfiler(tag="...", ..)). This edited tag is passed in to `torch.profiler.record_function` so we can # retrieve torch ops for the tagged code blocks/functions. LUDWIG_TAG = "[ludwig]" # Retry constants TRIES = 5 DELAY = 1 BACKOFF = 2 JITTER = (0, 1) # image support constants IMAGENET1K = "imagenet1k" AUGMENTATION = "augmentation" LUDWIG_SCHEMA_VALIDATION_POLICY = "LUDWIG_SCHEMA_VALIDATION_POLICY" ================================================ FILE: ludwig/contrib.py ================================================ # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== """Module for handling contributed support.""" import argparse from ludwig.contribs import contrib_registry, ContribLoader def create_load_action(contrib_loader: ContribLoader) -> argparse.Action: class LoadContribAction(argparse.Action): def __call__(self, parser, namespace, values, option_string): items = getattr(namespace, self.dest) or [] items.append(contrib_loader.load()) setattr(namespace, self.dest, items) return LoadContribAction def add_contrib_callback_args(parser: argparse.ArgumentParser): for contrib_name, contrib_loader in contrib_registry.items(): parser.add_argument( f"--{contrib_name}", dest="callbacks", nargs=0, action=create_load_action(contrib_loader), ) def preload(argv): for arg in argv: if arg.startswith("--"): arg = arg[2:] if arg in contrib_registry: contrib_registry[arg].preload() ================================================ FILE: ludwig/contribs/__init__.py ================================================ # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== """All contrib classes must implement the `ludwig.callbacks.Callback` interface. If you don't want to handle the call, either provide an empty method with `pass`, or just don't implement the method. """ from abc import ABC, abstractmethod from ludwig.callbacks import Callback class ContribLoader(ABC): @abstractmethod def load(self) -> Callback: """Returns an instantiation of the callback instance, whose callback hooks will be invoked at runtime.""" def preload(self): """Will always be called when Ludwig CLI is invoked, preload gives the callback an opportunity to import or create any shared resources. Importing required 3rd-party libraries should be done here i.e. import wandb. preload is guaranteed to be called before any other callback method, and will only be called once per process. """ # Contributors, load your class here: class AimLoader(ContribLoader): def load(self) -> Callback: from ludwig.contribs.aim import AimCallback return AimCallback() def preload(self): import aim # noqa class CometLoader(ContribLoader): def load(self) -> Callback: from ludwig.contribs.comet import CometCallback return CometCallback() def preload(self): import comet_ml # noqa class WandbLoader(ContribLoader): def load(self) -> Callback: from ludwig.contribs.wandb import WandbCallback return WandbCallback() def preload(self): import wandb # noqa class MlflowLoader(ContribLoader): def load(self) -> Callback: from ludwig.contribs.mlflow import MlflowCallback return MlflowCallback() contrib_registry = { # Contributors, add your class here: "comet": CometLoader(), "wandb": WandbLoader(), "mlflow": MlflowLoader(), "aim": AimLoader(), } ================================================ FILE: ludwig/contribs/aim.py ================================================ import json import logging from ludwig.api_annotations import PublicAPI from ludwig.callbacks import Callback from ludwig.utils.data_utils import NumpyEncoder from ludwig.utils.package_utils import LazyLoader aim = LazyLoader("aim", globals(), "aim") logger = logging.getLogger(__name__) @PublicAPI class AimCallback(Callback): """Class that defines the methods necessary to hook into process.""" def __init__(self, repo=None): self.repo = repo def on_train_init( self, base_config, experiment_directory, experiment_name, model_name, output_directory, resume_directory, ): logger.info("aim.on_train_init() called...") try: query = f'run.name == "{model_name}"' if self.repo is None: aim_repo = aim.Repo.default_repo() else: aim_repo = aim.Repo.from_path(self.repo) runs_generator = aim_repo.query_runs(query) run = next(runs_generator.iter_runs()) run_hash = run.run.hash self.aim_run = aim.Run(run_hash=run_hash, repo=self.repo, experiment=experiment_name) except Exception: self.aim_run = aim.Run(repo=self.repo, experiment=experiment_name) self.aim_run.name = model_name self.aim_run["base_config"] = self.normalize_config(base_config) params = dict(name=model_name, dir=experiment_directory) self.aim_run["params"] = params def aim_track(self, progress_tracker): logger.info(f"aim.aim_track() called for epoch {progress_tracker.epoch}, step: {progress_tracker.steps}") if self.aim_run: for key, value in progress_tracker.log_metrics().items(): if "metrics" in key and "best" not in key: metrics_dict_name, feature_name, metric_name = key.split(".") self.aim_run.track( value, name=metric_name, context={metrics_dict_name: feature_name}, epoch=progress_tracker.epoch, step=progress_tracker.steps, ) def on_trainer_train_teardown(self, trainer, progress_tracker, save_path, is_coordinator: bool): pass def on_train_start(self, model, config, *args, **kwargs): logger.info("aim.on_train_start() called...") config = config.copy() del config["input_features"] del config["output_features"] self.aim_run["train_config"] = self.normalize_config(config) def on_train_end(self, output_directory, *args, **kwargs): pass def on_eval_end(self, trainer, progress_tracker, save_path): optimizer_config = {} for index, group in enumerate(trainer.optimizer.param_groups): for key in group: if "param" not in key: optimizer_config[f"param_group_{index}_{key}"] = group[key] self.aim_run["optimizer_config"] = self.normalize_config(optimizer_config) self.aim_track(progress_tracker) def on_ludwig_end(self): self.aim_run.close() self.aim_run = None def on_visualize_figure(self, fig): logger.info("aim.on_visualize_figure() called...") if self.aim_run: self.aim_run.track(aim.Figure(fig), name="Figure", context={"type": "Training Figure"}) @staticmethod def normalize_config(config): """Convert to json string and back again to remove numpy types.""" return json.loads(json.dumps(config, cls=NumpyEncoder)) ================================================ FILE: ludwig/contribs/comet.py ================================================ # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import logging import os from datetime import datetime from ludwig.api_annotations import PublicAPI from ludwig.callbacks import Callback from ludwig.utils.package_utils import LazyLoader comet_ml = LazyLoader("comet_ml", globals(), "comet_ml") logger = logging.getLogger(__name__) @PublicAPI class CometCallback(Callback): """Class that defines the methods necessary to hook into process.""" def __init__(self): self.cometml_experiment = None def on_train_init( self, base_config, experiment_directory, experiment_name, model_name, output_directory, resume_directory, ): if self.cometml_experiment: # Comet ML already initialized return try: self.cometml_experiment = comet_ml.Experiment(log_code=False, project_name=experiment_name) except Exception: self.cometml_experiment = None logger.exception("comet_ml.Experiment() had errors. Perhaps you need to define COMET_API_KEY") raise self.cometml_experiment.set_name(model_name) self.cometml_experiment.set_filename("Ludwig API") config = comet_ml.get_config() self._save_config(config, directory=experiment_directory) def on_train_start(self, model, config, config_fp, *args, **kwargs): if self.cometml_experiment: # todo v0.4: currently not clear way to set model graph # see: https://github.com/comet-ml/issue-tracking/issues/296 # if model: # self.cometml_experiment.set_model_graph( # str(model._graph.as_graph_def())) if config: if config_fp: base_name = os.path.basename(config_fp) else: base_name = "config.yaml" if "." in base_name: base_name = base_name.rsplit(".", 1)[0] + ".json" else: base_name = base_name + ".json" self.cometml_experiment.log_asset_data(config, base_name) def on_train_end(self, output_directory, *args, **kwargs): if self.cometml_experiment: self.cometml_experiment.log_asset_folder(output_directory) def on_eval_end(self, trainer, progress_tracker, save_path): """Called from ludwig/models/model.py.""" if self.cometml_experiment: for key, value in progress_tracker.log_metrics().items(): self.cometml_experiment.log_metric(key, value) def on_epoch_end(self, trainer, progress_tracker, save_path): """Called from ludwig/models/model.py.""" if self.cometml_experiment: for key, value in progress_tracker.log_metrics().items(): self.cometml_experiment.log_metric(key, value) def on_visualize_figure(self, fig): if self.cometml_experiment: self.cometml_experiment.log_figure(fig) def on_cmdline(self, cmd, *args): self.cometml_experiment = None if cmd in {"train", "experiment"}: # create a new experiment try: self.cometml_experiment = comet_ml.Experiment(log_code=False) except Exception: logger.exception("comet_ml.Experiment() had errors. Perhaps you need to define COMET_API_KEY") return elif cmd in {"visualize", "predict", "evaluate"}: # restore from an existing experiment try: self.cometml_experiment = comet_ml.ExistingExperiment() except Exception: logger.exception("Ignored --comet. No '.comet.config' file") return else: # unhandled command return cli = self._make_command_line(cmd, args) self.cometml_experiment.set_code(cli) self.cometml_experiment.set_filename("Ludwig CLI") self._log_html(cli) config = comet_ml.get_config() self._save_config(config) def _save_config(self, config, directory="."): # save the .comet.config here: config["comet.experiment_key"] = self.cometml_experiment.id config.save(directory=directory) def _log_html(self, text): # log the text to the html tab: now = datetime.now() timestamp = now.strftime("%m/%d/%Y %H:%M:%S") self.cometml_experiment.log_html(f"

{timestamp}: {text}

") def _make_command_line(self, cmd, args): # put the commet flag back in: arg_str = " ".join(list(args[:2]) + ["--comet"] + list(args[2:])) return f"ludwig {cmd} {arg_str}" ================================================ FILE: ludwig/contribs/mlflow/__init__.py ================================================ import logging import os import queue import threading from ludwig.api_annotations import DeveloperAPI, PublicAPI from ludwig.callbacks import Callback from ludwig.constants import TRAINER from ludwig.globals import MODEL_FILE_NAME, MODEL_HYPERPARAMETERS_FILE_NAME, TRAIN_SET_METADATA_FILE_NAME from ludwig.types import TrainingSetMetadataDict from ludwig.utils.data_utils import chunk_dict, flatten_dict, save_json, to_json_dict from ludwig.utils.package_utils import LazyLoader mlflow = LazyLoader("mlflow", globals(), "mlflow") logger = logging.getLogger(__name__) def _get_runs(experiment_id: str): return mlflow.tracking.client.MlflowClient().search_runs([experiment_id]) @DeveloperAPI def get_or_create_experiment_id(experiment_name, artifact_uri: str = None): """Gets experiment id from mlflow.""" experiment = mlflow.get_experiment_by_name(experiment_name) if experiment is not None: return experiment.experiment_id return mlflow.create_experiment(name=experiment_name, artifact_location=artifact_uri) # Included for backwards compatibility, Deprecated. # TODO(daniel): delete this. _get_or_create_experiment_id = get_or_create_experiment_id @PublicAPI class MlflowCallback(Callback): def __init__(self, tracking_uri=None, log_artifacts: bool = True): self.logged_steps = set() if tracking_uri: mlflow.set_tracking_uri(tracking_uri) self.tracking_uri = mlflow.get_tracking_uri() active_run = mlflow.active_run() if active_run is not None: # Use experiment already set in the current environment self.run = active_run self.experiment_id = self.run.info.experiment_id self.experiment_name = mlflow.get_experiment(self.experiment_id).name self.external_run = True else: # Will create an experiment at training time self.run = None self.experiment_id = None self.experiment_name = None self.external_run = False self.run_ended = False self.training_set_metadata = None self.config = None self.save_in_background = True self.save_fn = None self.save_thread = None self.log_artifacts = log_artifacts def get_experiment_id(self, experiment_name): return get_or_create_experiment_id(experiment_name) def on_preprocess_end( self, training_set: "Dataset", # noqa validation_set: "Dataset", # noqa test_set: "Dataset", # noqa training_set_metadata: TrainingSetMetadataDict, ): self.training_set_metadata = training_set_metadata def on_hyperopt_init(self, experiment_name): self.experiment_id = self.get_experiment_id(experiment_name) self.experiment_name = experiment_name def on_hyperopt_trial_start(self, parameters): # Filter out mlflow params like tracking URI, experiment ID, etc. params = {k: v for k, v in parameters.items() if k != "mlflow"} self._log_params({"hparam": params}) # TODO(travis): figure out a good way to support this. The problem with # saving artifacts in the background with hyperopt is early stopping. If # the scheduler decides to terminate a process, then currently there's no # mechanism to detect this a "flush" the queue of pending writes before # stopping. Should work with Ray Tune team to come up with a solution. self.save_in_background = False def on_train_init(self, base_config, experiment_name, output_directory, resume_directory, **kwargs): # Experiment may already have been set during hyperopt init, in # which case we don't want to create a new experiment / run, as # this should be handled by the executor. if self.experiment_id is None: mlflow.end_run() self.experiment_id = self.get_experiment_id(experiment_name) self.experiment_name = experiment_name active_run = mlflow.active_run() if active_run is not None: # Currently active run started by Ray Tune MLflow mixin or external run self.run = active_run else: run_id = None if resume_directory is not None: previous_runs = _get_runs(self.experiment_id) if len(previous_runs) > 0: run_id = previous_runs[0].info.run_id if run_id is not None: self.run = mlflow.start_run(run_id=run_id) else: run_name = os.path.basename(output_directory) self.run = mlflow.start_run(experiment_id=self.experiment_id, run_name=run_name) self.log_config(base_config) def log_config(self, config): if self.log_artifacts: mlflow.log_dict(to_json_dict(config), "config.yaml") def on_train_start(self, config, **kwargs): self.config = config self._log_params({TRAINER: config[TRAINER]}) def on_train_end(self, output_directory): if self.log_artifacts: _log_artifacts(output_directory) if self.run is not None and not self.external_run: # Only end runs managed internally to this callback mlflow.end_run() self.run_ended = True def on_trainer_train_setup(self, trainer, save_path, is_coordinator): if not is_coordinator: return # When running on a remote worker, the model metadata files will only have been # saved to the driver process, so re-save it here before uploading. training_set_metadata_path = os.path.join(save_path, TRAIN_SET_METADATA_FILE_NAME) if not os.path.exists(training_set_metadata_path): save_json(training_set_metadata_path, self.training_set_metadata) model_hyperparameters_path = os.path.join(save_path, MODEL_HYPERPARAMETERS_FILE_NAME) if not os.path.exists(model_hyperparameters_path): save_json(model_hyperparameters_path, self.config) if self.save_in_background: save_queue = queue.Queue() self.save_fn = lambda args: save_queue.put(args) self.save_thread = threading.Thread(target=_log_mlflow_loop, args=(save_queue, self.log_artifacts)) self.save_thread.start() else: self.save_fn = lambda args: _log_mlflow(*args, self.log_artifacts) def on_eval_end(self, trainer, progress_tracker, save_path): if progress_tracker.steps not in self.logged_steps: self.logged_steps.add(progress_tracker.steps) # Adds a tuple to the logging queue. # True is passed to indicate that the background saving loop should continue. self.save_fn((progress_tracker.log_metrics(), progress_tracker.steps, save_path, True)) def on_trainer_train_teardown(self, trainer, progress_tracker, save_path, is_coordinator): if is_coordinator: if progress_tracker.steps not in self.logged_steps: self.logged_steps.add(progress_tracker.steps) # Adds a tuple to the logging queue. # False is passed to indicate that the background saving loop should break. self.save_fn((progress_tracker.log_metrics(), progress_tracker.steps, save_path, False)) # False ensures that the background saving loop breaks. # TODO(Justin): This should probably live in on_ludwig_end, once that's implemented. self.save_fn((None, None, None, False)) # Close the save_thread. if self.save_thread is not None: self.save_thread.join() # if self.save_thread.is_alive(): # logger.warning("MLFlow save thread timed out and did not close properly.") def on_visualize_figure(self, fig): # TODO: need to also include a filename for this figure # mlflow.log_figure(fig) pass def prepare_ray_tune(self, train_fn, tune_config, tune_callbacks): from functools import wraps from ray.air.integrations.mlflow import setup_mlflow mlflow_config = { "experiment_id": self.experiment_id, "experiment_name": self.experiment_name, "tracking_uri": mlflow.get_tracking_uri(), } @wraps(train_fn) def wrapper(config, **kwargs): setup_mlflow(config, **mlflow_config) return train_fn(config, **kwargs) return wrapper, { **tune_config, } def _log_params(self, params): flat_params = flatten_dict(params) for chunk in chunk_dict(flat_params, chunk_size=100): mlflow.log_params(chunk) def __setstate__(self, d): self.__dict__ = d if self.tracking_uri: mlflow.set_tracking_uri(self.tracking_uri) if self.run and not self.run_ended: # Run has already been set, but may not be active due to training workers running in a separate # process, so resume the run mlflow.end_run() self.run = mlflow.start_run(run_id=self.run.info.run_id, experiment_id=self.run.info.experiment_id) def _log_mlflow_loop(q: queue.Queue, log_artifacts: bool = True): """The save_fn for the background thread that logs to MLFlow when save_in_background is True.""" should_continue = True while should_continue: elem = q.get() log_metrics, steps, save_path, should_continue = elem if log_metrics is None: # Break out of the loop if we're not going to log anything. break if "llm_eval_examples" in log_metrics and log_metrics["llm_eval_examples"] is not None: # mlflow.log_dict(log_metrics["llm_eval_examples"], artifact_file="llm_eval_examples.json") # Delete the table from the metrics dict so we don't try to log it with the other metrics del log_metrics["llm_eval_examples"] mlflow.log_metrics(log_metrics, step=steps) if not q.empty(): # in other words, don't bother saving the model artifacts # if we're about to do it again continue if log_artifacts: _log_model(save_path) def _log_mlflow(log_metrics, steps, save_path, should_continue, log_artifacts: bool = True): """The save_fn for the MlflowCallback. This is used when save_in_background is False. """ if log_metrics is not None: if "llm_eval_examples" in log_metrics and log_metrics["llm_eval_examples"] is not None: # mlflow.log_dict(log_metrics["llm_eval_examples"], artifact_file="llm_eval_examples.json") # Delete the table from the metrics dict so we don't try to log it with the other metrics del log_metrics["llm_eval_examples"] mlflow.log_metrics(log_metrics, step=steps) if log_artifacts: _log_model(save_path) def _log_artifacts(output_directory): try: contents = os.listdir(output_directory) except FileNotFoundError: logger.warning(f"_log_artifacts: output_directory does not exist: {output_directory}") return for fname in contents: lpath = os.path.join(output_directory, fname) if fname == MODEL_FILE_NAME: _log_model(lpath) else: mlflow.log_artifact(lpath) def _log_model(lpath): # Lazy import to avoid requiring this package from ludwig.contribs.mlflow.model import log_saved_model log_saved_model(lpath) ================================================ FILE: ludwig/contribs/mlflow/model.py ================================================ import logging import os import shutil import tempfile import mlflow import yaml from mlflow import pyfunc from mlflow.exceptions import MlflowException from mlflow.models import Model from mlflow.models.model import MLMODEL_FILE_NAME from mlflow.models.signature import ModelSignature from mlflow.models.utils import _save_example, ModelInputExample from mlflow.tracking._model_registry import DEFAULT_AWAIT_MAX_SLEEP_SECONDS from mlflow.tracking.artifact_utils import _download_artifact_from_uri from mlflow.utils.environment import _mlflow_conda_env from mlflow.utils.model_utils import _get_flavor_configuration from ludwig.api_annotations import DeveloperAPI from ludwig.globals import MODEL_FILE_NAME, MODEL_HYPERPARAMETERS_FILE_NAME from ludwig.utils.data_utils import load_json FLAVOR_NAME = "ludwig" _logger = logging.getLogger(__name__) def get_default_conda_env(): """ :return: The default Conda environment for MLflow Models produced by calls to :func:`save_model()` and :func:`log_model()`. """ import ludwig # Ludwig is not yet available via the default conda channels, so we install it via pip return _mlflow_conda_env( additional_conda_deps=None, additional_pip_deps=[f"ludwig=={ludwig.__version__}"], additional_conda_channels=None, ) def save_model( ludwig_model, path, conda_env=None, mlflow_model=None, signature: ModelSignature = None, input_example: ModelInputExample = None, **kwargs, ): """Save a Ludwig model to a path on the local file system. :param ludwig_model: Ludwig model (an instance of `ludwig.api.LudwigModel`_) to be saved. :param path: Local path where the model is to be saved. :param conda_env: Either a dictionary representation of a Conda environment or the path to a Conda environment yaml file. If provided, this describes the environment this model should be run in. At minimum, it should specify the dependencies contained in :func:`get_default_conda_env()`. If ``None``, the default :func:`get_default_conda_env()` environment is added to the model. The following is an *example* dictionary representation of a Conda environment:: { 'name': 'mlflow-env', 'channels': ['defaults'], 'dependencies': [ 'python=3.7.0', 'pip': [ 'ludwig==0.4.0' ] ] } :param mlflow_model: :py:mod:`mlflow.models.Model` this flavor is being added to. :param signature: (Experimental) :py:class:`ModelSignature ` describes model input and output :py:class:`Schema `. The model signature can be :py:func:`inferred ` from datasets with valid model input (e.g. the training dataset with target column omitted) and valid model output (e.g. model predictions generated on the training dataset), for example: .. code-block:: python from mlflow.models.signature import infer_signature train = df.drop_column("target_label") predictions = ... # compute model predictions signature = infer_signature(train, predictions) :param input_example: (Experimental) Input example provides one or several instances of valid model input. The example can be used as a hint of what data to feed the model. The given example will be converted to a Pandas DataFrame and then serialized to json using the Pandas split-oriented format. Bytes are base64-encoded. """ import ludwig path = os.path.abspath(path) if os.path.exists(path): raise MlflowException(f"Path '{path}' already exists") model_data_subpath = MODEL_FILE_NAME model_data_path = os.path.join(path, model_data_subpath) os.makedirs(path) if mlflow_model is None: mlflow_model = Model() if signature is not None: mlflow_model.signature = signature if input_example is not None: _save_example(mlflow_model, input_example, path) # Save the Ludwig model ludwig_model.save(model_data_path) conda_env_subpath = "conda.yaml" if conda_env is None: conda_env = get_default_conda_env() elif not isinstance(conda_env, dict): with open(conda_env) as f: conda_env = yaml.safe_load(f) with open(os.path.join(path, conda_env_subpath), "w") as f: yaml.safe_dump(conda_env, stream=f, default_flow_style=False) pyfunc.add_to_model( mlflow_model, loader_module="ludwig.contribs.mlflow.model", data=model_data_subpath, env=conda_env_subpath, ) schema_keys = {"name", "column", "type"} config = ludwig_model.config mlflow_model.add_flavor( FLAVOR_NAME, ludwig_version=ludwig.__version__, ludwig_schema={ "input_features": [ {k: v for k, v in feature.items() if k in schema_keys} for feature in config["input_features"] ], "output_features": [ {k: v for k, v in feature.items() if k in schema_keys} for feature in config["output_features"] ], }, data=model_data_subpath, ) mlflow_model.save(os.path.join(path, MLMODEL_FILE_NAME)) def log_model( ludwig_model, artifact_path, conda_env=None, registered_model_name=None, signature: ModelSignature = None, input_example: ModelInputExample = None, await_registration_for=DEFAULT_AWAIT_MAX_SLEEP_SECONDS, ): """Log a Ludwig model as an MLflow artifact for the current run. Saves the model locally in MLflow format, then logs it as a run artifact using mlflow.log_artifacts(). This ensures the model appears as a run artifact (compatible with MLflow 3.x where Model.log() uses the model registry instead). """ with tempfile.TemporaryDirectory() as tmpdir: local_path = os.path.join(tmpdir, "model") save_model( ludwig_model, path=local_path, conda_env=conda_env, signature=signature, input_example=input_example, ) mlflow.log_artifacts(local_path, artifact_path) if registered_model_name is not None: run_id = mlflow.active_run().info.run_id mlflow.register_model( f"runs:/{run_id}/{artifact_path}", registered_model_name, await_registration_for=await_registration_for, ) def _load_model(path): from ludwig.api import LudwigModel return LudwigModel.load(path, backend="local") def _load_pyfunc(path): """Load PyFunc implementation. Called by ``pyfunc.load_pyfunc``. :param path: Local filesystem path to the MLflow Model with the ``ludwig`` flavor. """ return _LudwigModelWrapper(_load_model(path)) def load_model(model_uri): """Load a Ludwig model from a local file or a run. :param model_uri: The location, in URI format, of the MLflow model. For example: - ``/Users/me/path/to/local/model`` - ``relative/path/to/local/model`` - ``s3://my_bucket/path/to/model`` - ``runs://run-relative/path/to/model`` For more information about supported URI schemes, see `Referencing Artifacts `_. :return: A Ludwig model (an instance of `ludwig.api.LudwigModel`_). """ local_model_path = _download_artifact_from_uri(artifact_uri=model_uri) flavor_conf = _get_flavor_configuration(model_path=local_model_path, flavor_name=FLAVOR_NAME) model_data_path = os.path.join(local_model_path, flavor_conf.get("data", "model")) return _load_model(path=model_data_path) class _LudwigModelWrapper: def __init__(self, ludwig_model): self.ludwig_model = ludwig_model def predict(self, dataframe): pred_df, _ = self.ludwig_model.predict(dataframe) return pred_df def export_model(model_path, output_path, registered_model_name=None): if registered_model_name: if not model_path.startswith("runs:/") or output_path is not None: # No run specified, so in order to register the model in mlflow, we need # to create a new run and upload the model as an artifact first output_path = output_path or MODEL_FILE_NAME log_model( _CopyModel(model_path), artifact_path=output_path, registered_model_name=registered_model_name, ) else: # Registering a model from an artifact of an existing run mlflow.register_model( model_path, registered_model_name, ) else: # No model name means we only want to save the model locally save_model( _CopyModel(model_path), path=output_path, ) @DeveloperAPI def log_saved_model(lpath): """Log a saved Ludwig model directory as a proper MLflow model artifact.""" if os.path.isdir(lpath): log_model( _CopyModel(lpath), artifact_path="model", ) elif os.path.isfile(lpath): mlflow.log_artifact(lpath, "model") class _CopyModel: """Get model data without requiring us to read the model weights into memory.""" def __init__(self, lpath): self.lpath = lpath def save(self, path): shutil.copytree(self.lpath, path) @property def config(self): return load_json(os.path.join(self.lpath, MODEL_HYPERPARAMETERS_FILE_NAME)) ================================================ FILE: ludwig/contribs/wandb.py ================================================ # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import logging import os from ludwig.api_annotations import PublicAPI from ludwig.callbacks import Callback from ludwig.utils.package_utils import LazyLoader wandb = LazyLoader("wandb", globals(), "wandb") logger = logging.getLogger(__name__) @PublicAPI class WandbCallback(Callback): """Class that defines the methods necessary to hook into process.""" def on_train_init( self, base_config, experiment_directory, experiment_name, model_name, output_directory, resume_directory, ): logger.info("wandb.on_train_init() called...") wandb.init( project=os.getenv("WANDB_PROJECT", experiment_name), name=model_name, sync_tensorboard=True, dir=output_directory, ) wandb.save(os.path.join(experiment_directory, "*")) def on_train_start(self, model, config, *args, **kwargs): logger.info("wandb.on_train_start() called...") config = config.copy() del config["input_features"] del config["output_features"] wandb.config.update(config) def on_eval_end(self, trainer, progress_tracker, save_path): """Called from ludwig/models/model.py.""" for key, value in progress_tracker.log_metrics().items(): wandb.log({key: value}) def on_epoch_end(self, trainer, progress_tracker, save_path): """Called from ludwig/models/model.py.""" for key, value in progress_tracker.log_metrics().items(): wandb.log({key: value}) def on_visualize_figure(self, fig): logger.info("wandb.on_visualize_figure() called...") if wandb.run: wandb.log({"figure": fig}) def on_train_end(self, output_directory): wandb.finish() ================================================ FILE: ludwig/data/__init__.py ================================================ ================================================ FILE: ludwig/data/batcher/__init__.py ================================================ ================================================ FILE: ludwig/data/batcher/base.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 2020 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. # ============================================================================== from abc import ABC, abstractmethod import numpy as np class Batcher(ABC): @abstractmethod def next_batch(self) -> dict[str, np.ndarray]: raise NotImplementedError() @abstractmethod def last_batch(self) -> bool: raise NotImplementedError() @abstractmethod def set_epoch(self, epoch: int, batch_size: int): raise NotImplementedError() ================================================ FILE: ludwig/data/batcher/bucketed.py ================================================ #! /usr/bin/env python # 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. # ============================================================================== import numpy as np from ludwig.data.batcher.base import Batcher class BucketedBatcher(Batcher): def __init__( self, dataset, bucketing_field, batch_size=128, buckets=10, should_shuffle=True, ignore_last=False, should_trim=False, trim_side="right", ): self.should_shuffle = should_shuffle self.bucketing_field = bucketing_field self.should_trim = should_trim self.trim_side = trim_side # store our dataset as well self.dataset = dataset field = dataset.get_dataset()[bucketing_field] field_lengths = np.apply_along_axis(lambda x: np.sign(x).sum(), 1, field) sorted_idcs = np.argsort(field_lengths) self.buckets_idcs = [] datapoints_per_bucket = len(field) // buckets for b in range(buckets): start = datapoints_per_bucket * b end = datapoints_per_bucket * (b + 1) if b < buckets - 1 else len(sorted_idcs) self.buckets_idcs.append(sorted_idcs[start:end]) if should_shuffle: self.shuffle(self.buckets_idcs) self.ignore_last = ignore_last self.batch_size = batch_size self.total_size = min(map(len, dataset.get_dataset().values())) self.bucket_sizes = np.array([x for x in map(len, self.buckets_idcs)]) self.steps_per_epoch = self._compute_steps_per_epoch() self.indices = np.array([0] * buckets) self.step = 0 self.epoch = 0 def shuffle(self, buckets_idcs): for i in range(len(buckets_idcs)): np.random.shuffle(buckets_idcs[i]) def next_batch(self): if self.last_batch(): if self.should_shuffle: self.shuffle(self.buckets_idcs) self.set_epoch(self.epoch + 1) if self.ignore_last: idcs_below_size = self.indices + self.batch_size < self.bucket_sizes else: idcs_below_size = self.indices < self.bucket_sizes i = np.random.choice(np.arange(0, len(self.buckets_idcs))[idcs_below_size]) selected_bucket = self.buckets_idcs[i] selected_idcs = selected_bucket[self.indices[i] : self.indices[i] + self.batch_size] sub_batch = {} for key in self.dataset.get_dataset(): if key == self.bucketing_field and self.should_trim: selected_samples = self.dataset.get(key, selected_idcs) max_length = np.sign(selected_samples).sum(axis=1).max() if self.trim_side == "right": sub_batch[key] = selected_samples[:, :max_length] elif self.trim_side == "left": sub_batch[key] = selected_samples[:, -max_length:] else: raise ValueError("Invalid trim side:", self.trim_side) else: sub_batch[key] = self.dataset.get(key, selected_idcs) self.indices[i] += self.batch_size self.step += 1 return sub_batch def last_batch(self): return not np.any(self.indices < self.bucket_sizes) or ( self.ignore_last and not np.any(self.indices + self.batch_size < self.bucket_sizes) ) def set_epoch(self, epoch, batch_size): self.indices = np.array([0] * len(self.buckets_idcs)) self.step = 0 self.epoch = epoch self.batch_size = batch_size self.steps_per_epoch = self._compute_steps_per_epoch() def _compute_steps_per_epoch(self) -> int: return int(np.sum(np.ceil(self.bucket_sizes / self.batch_size)).item()) # dynamic_length_encoders = { # 'rnn', # 'embed' # } # # todo future: reintroduce the bucketed batcher # def initialize_batcher(dataset, batch_size=128, bucketing_field=None, # input_features=None, preprocessing=None, # should_shuffle=True, ignore_last=False): # if bucketing_field is not None: # bucketing_feature = [ # feature for feature in input_features if # feature[NAME] == bucketing_field # ] # if not bucketing_feature: # raise ValueError( # 'Bucketing field {} not present in input features'.format( # bucketing_field # ) # ) # else: # bucketing_feature = bucketing_feature[0] # should_trim = bucketing_feature[ # 'encoder'] in dynamic_length_encoders # if 'preprocessing' in bucketing_feature: # trim_side = bucketing_feature['preprocessing']['padding'] # else: # trim_side = preprocessing[bucketing_feature[TYPE]]['padding'] # # batcher = BucketedBatcher( # dataset, # bucketing_field=bucketing_field, # batch_size=batch_size, # buckets=10, # ignore_last=ignore_last, # should_shuffle=should_shuffle, # should_trim=should_trim, # trim_side=trim_side # ) # else: # batcher = Batcher( # dataset, # batch_size, # should_shuffle=should_shuffle, # ignore_last=ignore_last # ) # return batcher ================================================ FILE: ludwig/data/batcher/iterable.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 2020 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. # ============================================================================== from ludwig.data.batcher.base import Batcher class IterableBatcher(Batcher): def __init__(self, dataset, data, steps_per_epoch, ignore_last=False): self.dataset = dataset self.data = data self.data_it = iter(data) self.ignore_last = ignore_last self.steps_per_epoch = steps_per_epoch self.step = 0 def next_batch(self): if self.last_batch(): raise StopIteration() sub_batch = {} batch = next(self.data_it) for features_name in self.dataset.features: sub_batch[features_name] = self.dataset.get(features_name, batch) self.step += 1 return sub_batch def last_batch(self): return self.step >= self.steps_per_epoch or (self.ignore_last and self.step + 1 >= self.steps_per_epoch) def set_epoch(self, epoch, batch_size): # TODO ray: implement dynamic batch size self.step = 0 ================================================ FILE: ludwig/data/batcher/random_access.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import logging import math import torch from ludwig.api_annotations import DeveloperAPI from ludwig.data.batcher.base import Batcher logger = logging.getLogger(__name__) @DeveloperAPI class RandomAccessBatcher(Batcher): def __init__(self, dataset, sampler, batch_size=128, ignore_last=False, augmentation_pipeline=None): # store our dataset as well self.dataset = dataset self.sampler = sampler self.sample_it = iter(self.sampler) self.ignore_last = ignore_last self.batch_size = batch_size self.total_size = len(sampler) self.augmentation_pipeline = augmentation_pipeline self.steps_per_epoch = self._compute_steps_per_epoch() self.index = 0 self.step = 0 def next_batch(self): if self.last_batch(): raise StopIteration() indices = [] for _ in range(self.batch_size): try: indices.append(next(self.sample_it)) self.index += 1 except StopIteration: break sub_batch = {feature_name: self.dataset.get(feature_name, indices) for feature_name in self.dataset.features} if self.augmentation_pipeline: for feature_name, augmentations in self.augmentation_pipeline.items(): logger.debug(f"RandomAccessBatcher applying augmentation pipeline to batch for feature {feature_name}") sub_batch[feature_name] = augmentations(torch.tensor(sub_batch[feature_name])) self.step += 1 return sub_batch def last_batch(self): """Returns whether we've exhausted all batches for this epoch. If False, then there is at least 1 more batch available with next_batch(). """ # If our current index in the dataset exceeds the size of the dataset, # we've finished the epoch and can indicate that this is the last batch if self.index >= self.total_size: return True # This avoids the case where batch size > total size and no steps have been done. # For e.g., batch size = 128 but the dataset only has 100 rows. elif self.ignore_last and self.step: # index += batch_size after each epoch. So, if our current index in total dataset is 1 less than the total # dataset size, then the last batch will only have 1 row. # If this happens, we drop the last batch, unless batch_size is 1. if self.batch_size > 1 and self.index - self.total_size == -1: logger.info("Last batch in epoch only has 1 sample and will be dropped.") return True return False def set_epoch(self, epoch, batch_size): self.batch_size = batch_size self.steps_per_epoch = self._compute_steps_per_epoch() self.index = 0 self.step = 0 self.sampler.set_epoch(epoch) self.sample_it = iter(self.sampler) def _compute_steps_per_epoch(self): return int(math.ceil(self.total_size / self.batch_size)) ================================================ FILE: ludwig/data/batcher/test_batcher.py ================================================ import logging import pandas as pd import yaml from ludwig.api import LudwigModel from ludwig.data.dataset.pandas import PandasDataset def test_pandas_size(): df = pd.DataFrame( {"name": ["joe", "janice", "sara"], "mask": ["green", "black", "pink"], "weapon": ["stick", "gun", "gun"]} ) config = yaml.safe_load(""" model_type: llm base_model: HuggingFaceH4/tiny-random-LlamaForCausalLM input_features: - name: name type: text preprocessing: max_sequence_length: 256 column: name output_features: - name: weapon type: text preprocessing: max_sequence_length: 256 column: weapon preprocessing: split: type: random probabilities: - 1 - 0 - 0 """) model = LudwigModel(config=config, logging_level=logging.INFO) data = model.preprocess(df, skip_save_processed_input=False) training_set = data[0] assert training_set.size == len(df) # Check if string loading works as well # data[0].data_hdf5_fp is the string filepath to the cached data from preprocessing data_from_str = PandasDataset(data[0].data_hdf5_fp, data[0].features, None) assert data_from_str.size == len(df) def test_pandas_batcher_use_all_samples(): df = pd.DataFrame( {"name": ["joe", "janice", "sara"], "mask": ["green", "black", "pink"], "weapon": ["stick", "gun", "gun"]} ) config = yaml.safe_load(""" model_type: llm base_model: HuggingFaceH4/tiny-random-LlamaForCausalLM input_features: - name: name type: text preprocessing: max_sequence_length: 256 column: name output_features: - name: weapon type: text preprocessing: max_sequence_length: 256 column: weapon preprocessing: split: type: random probabilities: - 1 - 0 - 0 """) model = LudwigModel(config=config, logging_level=logging.INFO) data = model.preprocess(df, skip_save_processed_input=False) training_set = data[0] features = training_set.dataset.keys() batches = [] with training_set.initialize_batcher(batch_size=1) as batcher: while not batcher.last_batch(): batch = batcher.next_batch() batches.append(batch) assert (len(batches)) == training_set.size # Check to see if all items are used exactly once for feature in features: for i in range(len(training_set.dataset[feature])): # Each of the arrays in the line below should contain the vector representation of a feature of sample i assert (batches[i][feature].squeeze() == training_set.dataset[feature][i].squeeze()).all() # Check if string loading works as well batches = [] # data[0].data_hdf5_fp is the string filepath to the cached data from preprocessing data_from_str = PandasDataset(data[0].data_hdf5_fp, data[0].features, None) features = data_from_str.dataset.keys() with data_from_str.initialize_batcher(batch_size=1) as batcher: while not batcher.last_batch(): batch = batcher.next_batch() batches.append(batch) assert (len(batches)) == data_from_str.size # Check to see if all items are used exactly once for feature in features: for i in range(len(data_from_str.dataset[feature])): # Each of the arrays in the line below should contain the vector representation of a feature of sample i assert (batches[i][feature].squeeze() == data_from_str.dataset[feature][i].squeeze()).all() ================================================ FILE: ludwig/data/cache/__init__.py ================================================ ================================================ FILE: ludwig/data/cache/manager.py ================================================ import logging import os from ludwig.constants import CHECKSUM, META, TEST, TRAINING, VALIDATION from ludwig.data.cache.types import alphanum, CacheableDataset from ludwig.data.cache.util import calculate_checksum from ludwig.data.dataset.base import DatasetManager from ludwig.utils import data_utils from ludwig.utils.fs_utils import delete, path_exists logger = logging.getLogger(__name__) class DatasetCache: def __init__(self, config, checksum, cache_map, dataset_manager): self.config = config self.checksum = checksum self.cache_map = cache_map self.dataset_manager = dataset_manager def get(self): training_set_metadata_fp = self.cache_map[META] if not path_exists(training_set_metadata_fp): return None try: cached_training_set_metadata = data_utils.load_json(training_set_metadata_fp) except Exception: logger.exception(f"Failed to load cached training set metadata at {training_set_metadata_fp}") return None cached_training_set = self.cache_map[TRAINING] if path_exists(self.cache_map[TRAINING]) else None if not cached_training_set: logger.warning(f"Failed to load cached training set at {self.cache_map[TRAINING]}") cached_validation_set = self.cache_map[VALIDATION] if path_exists(self.cache_map[VALIDATION]) else None if not cached_validation_set: logger.warning(f"Failed to load cached validation set at {self.cache_map[VALIDATION]}") cached_test_set = self.cache_map[TEST] if path_exists(self.cache_map[TEST]) else None if not cached_test_set: logger.warning(f"Failed to load cached test set at {self.cache_map[TEST]}") valid = self.checksum == cached_training_set_metadata.get(CHECKSUM) and cached_training_set is not None return valid, cached_training_set_metadata, cached_training_set, cached_test_set, cached_validation_set def put(self, training_set, test_set, validation_set, training_set_metadata): logger.info(f"Writing preprocessed training set cache to {self.cache_map[TRAINING]}") training_set = self.dataset_manager.save( self.cache_map[TRAINING], training_set, self.config, training_set_metadata, TRAINING, ) if validation_set is not None: logger.info(f"Writing preprocessed validation set cache to {self.cache_map[VALIDATION]}") validation_set = self.dataset_manager.save( self.cache_map[VALIDATION], validation_set, self.config, training_set_metadata, VALIDATION, ) if test_set is not None: logger.info(f"Writing preprocessed test set cache to {self.cache_map[TEST]}") test_set = self.dataset_manager.save( self.cache_map[TEST], test_set, self.config, training_set_metadata, TEST, ) logger.info(f"Writing train set metadata to {self.cache_map[META]}") data_utils.save_json(self.cache_map[META], training_set_metadata) return training_set, test_set, validation_set, training_set_metadata def delete(self): for fname in self.cache_map.values(): if path_exists(fname): # Parquet entries in the cache_ma can be pointers to directories. delete(fname, recursive=True) def get_cached_obj_path(self, cached_obj_name: str) -> str: return self.cache_map.get(cached_obj_name) class CacheManager: def __init__( self, dataset_manager: DatasetManager, cache_dir: str | None = None, ): self._dataset_manager = dataset_manager self._cache_dir = cache_dir def get_dataset_cache( self, config: dict, dataset: CacheableDataset | None = None, training_set: CacheableDataset | None = None, test_set: CacheableDataset | None = None, validation_set: CacheableDataset | None = None, ) -> DatasetCache: if dataset is not None: key = self.get_cache_key(dataset, config) cache_map = { META: self.get_cache_path(dataset, key, META, "json"), TRAINING: self.get_cache_path(dataset, key, TRAINING), TEST: self.get_cache_path(dataset, key, TEST), VALIDATION: self.get_cache_path(dataset, key, VALIDATION), } return DatasetCache(config, key, cache_map, self._dataset_manager) else: key = self.get_cache_key(training_set, config) cache_map = { META: self.get_cache_path(training_set, key, META, "json"), TRAINING: self.get_cache_path(training_set, key, TRAINING), TEST: self.get_cache_path(test_set, key, TEST), VALIDATION: self.get_cache_path(validation_set, key, VALIDATION), } return DatasetCache(config, key, cache_map, self._dataset_manager) def get_cache_key(self, dataset: CacheableDataset, config: dict) -> str: return calculate_checksum(dataset, config) def get_cache_path(self, dataset: CacheableDataset | None, key: str, tag: str, ext: str | None = None) -> str: if self._cache_dir is None and dataset is not None: # Use the input dataset filename (minus the extension) as the cache path stem = dataset.get_cache_path() else: # To avoid collisions across different directories, we use the unique checksum # as the cache path stem = alphanum(key) ext = ext or self.data_format cache_fname = f"{stem}.{tag}.{ext}" return os.path.join(self.get_cache_directory(dataset), cache_fname) def get_cache_directory(self, dataset: CacheableDataset | None) -> str: if self._cache_dir is None: if dataset is None: return os.getcwd() return dataset.get_cache_directory() return self._cache_dir def can_cache(self, skip_save_processed_input: bool) -> bool: return self._dataset_manager.can_cache(skip_save_processed_input) @property def data_format(self) -> str: return self._dataset_manager.data_format ================================================ FILE: ludwig/data/cache/types.py ================================================ #! /usr/bin/env python # Copyright (c) 2022 Predibase, 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. # ============================================================================== import os import re import uuid from abc import ABC, abstractmethod from dataclasses import dataclass from pathlib import Path from typing import Union from ludwig.api_annotations import DeveloperAPI from ludwig.utils.fs_utils import checksum from ludwig.utils.types import DataFrame def alphanum(v): """Filters a string to only its alphanumeric characters.""" return re.sub(r"\W+", "", v) @DeveloperAPI class CacheableDataset(ABC): name: str checksum: str @abstractmethod def get_cache_path(self) -> str: raise NotImplementedError() @abstractmethod def get_cache_directory(self) -> str: raise NotImplementedError() @abstractmethod def unwrap(self) -> str | DataFrame: raise NotImplementedError() @DeveloperAPI @dataclass class CacheableDataframe(CacheableDataset): df: DataFrame name: str checksum: str def get_cache_path(self) -> str: return alphanum(self.name) def get_cache_directory(self) -> str: return os.getcwd() def unwrap(self) -> str | DataFrame: return self.df @DeveloperAPI @dataclass class CacheablePath(CacheableDataset): path: str @property def name(self) -> str: return Path(self.path).stem @property def checksum(self) -> str: return checksum(self.path) def get_cache_path(self) -> str: return self.name def get_cache_directory(self) -> str: return os.path.dirname(self.path) def unwrap(self) -> str | DataFrame: return self.path CacheInput = Union[str, DataFrame, CacheableDataset] def wrap(dataset: CacheInput | None) -> CacheableDataset: if dataset is None: return None if isinstance(dataset, CacheableDataset): return dataset if isinstance(dataset, str): return CacheablePath(path=dataset) # TODO(travis): could try hashing the in-memory dataset, but this is tricky for Dask checksum = str(uuid.uuid1()) name = checksum return CacheableDataframe(df=dataset, name=name, checksum=checksum) ================================================ FILE: ludwig/data/cache/util.py ================================================ import ludwig from ludwig.constants import DEFAULTS, INPUT_FEATURES, OUTPUT_FEATURES, PREPROCESSING, PROC_COLUMN, TYPE from ludwig.data.cache.types import CacheableDataset from ludwig.types import ModelConfigDict from ludwig.utils.data_utils import hash_dict def calculate_checksum(original_dataset: CacheableDataset, config: ModelConfigDict): """Calculates a checksum for a dataset and model config. The checksum is used to determine if the dataset and model config have changed since the last time the model was trained. If either has changed, a different checksum will be produced which will lead to a cache miss and force preprocessing to be performed again. """ features = config.get(INPUT_FEATURES, []) + config.get(OUTPUT_FEATURES, []) + config.get("features", []) info = { "ludwig_version": ludwig.globals.LUDWIG_VERSION, "dataset_checksum": original_dataset.checksum, "global_preprocessing": config.get(PREPROCESSING, {}), "global_defaults": config.get(DEFAULTS, {}), # PROC_COLUMN contains both the feature name and the feature hash that is computed # based on each feature's preprocessing parameters and the feature's type. # creating a sorted list out of the dict because hash_dict requires all values # of the dict to be ordered object to ensure the creation fo the same hash "feature_proc_columns": sorted({feature[PROC_COLUMN] for feature in features}), "feature_types": [feature[TYPE] for feature in features], "feature_preprocessing": [feature.get(PREPROCESSING, {}) for feature in features], } # LLM-specific params if "prompt" in config: info["prompt"] = config["prompt"] return hash_dict(info, max_length=None).decode("ascii") ================================================ FILE: ludwig/data/concatenate_datasets.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import argparse import logging import numpy as np from ludwig.backend import LOCAL_BACKEND from ludwig.constants import SPLIT from ludwig.utils.data_utils import read_csv logger = logging.getLogger(__name__) def concatenate_csv(train_csv, vali_csv, test_csv, output_csv): concatenated_df = concatenate_files(train_csv, vali_csv, test_csv, read_csv, LOCAL_BACKEND) logger.info("Saving concatenated dataset as csv..") concatenated_df.to_csv(output_csv, encoding="utf-8", index=False) logger.info("done") def concatenate_files(train_fname, vali_fname, test_fname, read_fn, backend): df_lib = backend.df_engine.df_lib logger.info("Loading training file...") train_df = read_fn(train_fname, df_lib) logger.info("done") logger.info("Loading validation file..") vali_df = read_fn(vali_fname, df_lib) if vali_fname is not None else None logger.info("done") logger.info("Loading test file..") test_df = read_fn(test_fname, df_lib) if test_fname is not None else None logger.info("done") logger.info("Concatenating files..") concatenated_df = concatenate_df(train_df, vali_df, test_df, backend) logger.info("done") return concatenated_df def concatenate_df(train_df, vali_df, test_df, backend): train_size = len(train_df) vali_size = len(vali_df) if vali_df is not None else 0 concatenated_df = backend.df_engine.df_lib.concat( [df for df in [train_df, vali_df, test_df] if df is not None], ignore_index=True ) def get_split(idx): if idx < train_size: return 0 if idx < train_size + vali_size: return 1 return 2 concatenated_df[SPLIT] = concatenated_df.index.to_series().map(get_split).astype(np.int8) return concatenated_df def concatenate_splits(train_df, vali_df, test_df, backend): def to_frame(df, split): if df is None: return None df = df.index.to_frame(name=SPLIT) df[SPLIT] = split return df dfs = [train_df, vali_df, test_df] dfs = [to_frame(df, split) for split, df in enumerate(dfs)] return backend.df_engine.df_lib.concat([df for df in dfs if df is not None]) if __name__ == "__main__": parser = argparse.ArgumentParser(description="Concatenate train validation and test set") parser.add_argument("-train", "--train_csv", help="CSV containing the training set") parser.add_argument("-vali", "--vali_csv", help="CSV containing the validation set") parser.add_argument("-test", "--test_csv", help="CSV containing the test set") parser.add_argument("-o", "--output_csv", help="output csv") args = parser.parse_args() concatenate_csv(args.train_csv, args.vali_csv, args.test_csv, args.output_csv) ================================================ FILE: ludwig/data/dataframe/__init__.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 2020 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. # ============================================================================== ================================================ FILE: ludwig/data/dataframe/base.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 2020 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. # ============================================================================== from abc import ABC, abstractmethod from ludwig.utils.types import DataFrame class DataFrameEngine(ABC): @abstractmethod def df_like(self, df, proc_cols): raise NotImplementedError() @abstractmethod def parallelize(self, data): raise NotImplementedError() @abstractmethod def persist(self, data): raise NotImplementedError() @abstractmethod def compute(self, data): raise NotImplementedError() @abstractmethod def from_pandas(self, df): raise NotImplementedError() @abstractmethod def map_objects(self, series, map_fn, meta=None): raise NotImplementedError() @abstractmethod def map_partitions(self, series, map_fn, meta=None): raise NotImplementedError() @abstractmethod def map_batches(self, df, map_fn, enable_tensor_extension_casting=True): raise NotImplementedError() @abstractmethod def apply_objects(self, series, map_fn, meta=None): raise NotImplementedError() @abstractmethod def reduce_objects(self, series, reduce_fn): raise NotImplementedError() @abstractmethod def split(self, df, probabilities): """Splits the input DataFrame into sections with the given proportions.""" raise NotImplementedError() @abstractmethod def to_parquet(self, df, path, index=False): """Write the input DataFrame to the path in the Parquet format. Optionally includes the DataFrame index in the Parquet file. """ raise NotImplementedError() @abstractmethod def write_predictions(self, df: DataFrame, path: str): raise NotImplementedError() @abstractmethod def read_predictions(self, path: str) -> DataFrame: raise NotImplementedError() @abstractmethod def to_ray_dataset(self, df): raise NotImplementedError() @property @abstractmethod def array_lib(self): raise NotImplementedError() @property @abstractmethod def df_lib(self): raise NotImplementedError() @property @abstractmethod def partitioned(self): raise NotImplementedError() @abstractmethod def set_parallelism(self, parallelism): raise NotImplementedError() ================================================ FILE: ludwig/data/dataframe/dask.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 2020 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. # ============================================================================== import collections import logging from contextlib import contextmanager import dask import dask.array as da import dask.dataframe as dd import ray from dask.diagnostics import ProgressBar from packaging import version from pyarrow.fs import FSSpecHandler, PyFileSystem from ray.data import Dataset, read_parquet from ludwig.api_annotations import DeveloperAPI from ludwig.data.dataframe.base import DataFrameEngine from ludwig.utils.data_utils import get_pa_schema, get_parquet_filename, split_by_slices from ludwig.utils.dataframe_utils import set_index_name from ludwig.utils.fs_utils import get_fs_and_path TMP_COLUMN = "__TMP_COLUMN__" # This is to be compatible with pyarrow.lib.schema PandasBlockSchema = collections.namedtuple("PandasBlockSchema", ["names", "types"]) logger = logging.getLogger(__name__) _ray_230 = version.parse(ray.__version__) >= version.parse("2.3.0") @DeveloperAPI def set_scheduler(scheduler): dask.config.set(scheduler=scheduler) @DeveloperAPI def reset_index_across_all_partitions(df): """Compute a monotonically increasing index across all partitions. This differs from dd.reset_index, which computes an independent index for each partition. Source: https://stackoverflow.com/questions/61395351/how-to-reset-index-on-concatenated-dataframe-in-dask """ # Create temporary column of ones df = df.assign(**{TMP_COLUMN: 1}) # Set the index to the cumulative sum of TMP_COLUMN, which we know to be sorted; this improves efficiency. df = df.set_index(df[TMP_COLUMN].cumsum() - 1, sorted=True) # Drop temporary column and ensure the index is not named TMP_COLUMN df = df.drop(columns=TMP_COLUMN) df = df.map_partitions(lambda pd_df: set_index_name(pd_df, None)) return df @DeveloperAPI class DaskEngine(DataFrameEngine): def __init__(self, parallelism=None, persist=True, _use_ray=True, **kwargs): from ray.util.dask import ray_dask_get self._parallelism = parallelism self._persist = persist if _use_ray: set_scheduler(ray_dask_get) def set_parallelism(self, parallelism): self._parallelism = parallelism def df_like(self, df: dd.DataFrame, proc_cols: dict[str, dd.Series]): """Outer joins the given DataFrame with the given processed columns. NOTE: If any of the processed columns have been repartitioned, the original index is replaced with a monotonically increasing index, which is used to define the new divisions and align the various partitions. """ # Our goal is to preserve the index of the input dataframe but to drop # all its columns. Because to_frame() creates a column from the index, # we need to drop it immediately following creation. dataset = df.index.to_frame(name=TMP_COLUMN).drop(columns=TMP_COLUMN) repartitioned_cols = {} for k, v in proc_cols.items(): if v.npartitions == dataset.npartitions: # Outer join cols with equal partitions. # Dask aligns by index automatically, so no need to force divisions. dataset[k] = v else: # If partitions have changed (e.g. due to conversion from Ray dataset), we handle separately repartitioned_cols[k] = v # Assumes that there is a globally unique index (see preprocessing.build_dataset) if repartitioned_cols: if not dataset.known_divisions: # Sometimes divisions are unknown despite having a usable index– set_index to know divisions dataset = dataset.assign(**{TMP_COLUMN: dataset.index}) dataset = dataset.set_index(TMP_COLUMN, drop=True) dataset = dataset.map_partitions(lambda pd_df: set_index_name(pd_df, dataset.index.name)) # Find the divisions of the column with the largest number of partitions proc_col_with_max_npartitions = max(repartitioned_cols.values(), key=lambda x: x.npartitions) new_divisions = proc_col_with_max_npartitions.divisions # Repartition all columns to have the same divisions dataset = dataset.repartition(divisions=new_divisions) repartitioned_cols = {k: v.repartition(divisions=new_divisions) for k, v in repartitioned_cols.items()} # Outer join the remaining columns for k, v in repartitioned_cols.items(): dataset[k] = v return dataset def parallelize(self, data): if self.parallelism: return data.repartition(npartitions=self.parallelism) return data def persist(self, data): # No graph optimizations to prevent dropping custom annotations # https://github.com/dask/dask/issues/7036 return data.persist(optimize_graph=False) if self._persist else data def concat(self, dfs): return self.df_lib.concat(dfs) def compute(self, data): return data.compute() def from_pandas(self, df): parallelism = self._parallelism or 1 return dd.from_pandas(df, npartitions=parallelism) def map_objects(self, series, map_fn, meta=None): meta = meta if meta is not None else (series.name, "object") return series.map(map_fn, meta=meta) def map_partitions(self, series, map_fn, meta=None): meta = meta if meta is not None else (series.name, "object") return series.map_partitions(map_fn, meta=meta) def map_batches(self, series, map_fn, enable_tensor_extension_casting=True): """Map a function over batches of a Dask Series. Args: series: Dask Series map_fn: Function to apply to each batch enable_tensor_extension_casting: Whether to enable tensor extension casting at the end of the Ray Datasets map_batches call. This is useful in cases where the output is not supported by the ray Tensor dtype extension, such as when the output consists of ragged tensors. """ import ray.data with tensor_extension_casting(enable_tensor_extension_casting): ds = ray.data.from_dask(series) ds = ds.map_batches(map_fn, batch_format="pandas") return ds.to_dask() def apply_objects(self, df, apply_fn, meta=None): meta = meta if meta is not None else ("result", "object") return df.apply(apply_fn, axis=1, meta=meta) def reduce_objects(self, series, reduce_fn): result = series.reduction(reduce_fn, aggregate=reduce_fn, meta=(series.name, "object")).compute() # The result type depends on the Dask version and what reduce_fn returns. # Access the scalar value safely regardless of return type. if hasattr(result, "iloc"): return result.iloc[0] return result def split(self, df, probabilities): # Split the DataFrame proprotionately along partitions. This is an inexact solution designed # to speed up the split process, as splitting within partitions would be significantly # more expensive. # TODO(travis): revisit in the future to make this more precise # First ensure that every split receives at least one partition. # If not, we need to increase the number of partitions to satisfy this constraint. min_prob = min(probabilities) min_partitions = int(1 / min_prob) if df.npartitions < min_partitions: df = df.repartition(npartitions=min_partitions) n = df.npartitions slices = df.partitions return split_by_slices(slices, n, probabilities) def remove_empty_partitions(self, df): # Reference: https://stackoverflow.com/questions/47812785/remove-empty-partitions-in-dask ll = list(df.map_partitions(len).compute()) if all([ll_i > 0 for ll_i in ll]): return df df_delayed = df.to_delayed() df_delayed_new = list() empty_partition = None for ix, n in enumerate(ll): if n == 0: empty_partition = df.get_partition(ix) else: df_delayed_new.append(df_delayed[ix]) if not df_delayed_new: # All partitions are empty, return a single empty partition return empty_partition df = dd.from_delayed(df_delayed_new, meta=empty_partition) return df def to_parquet(self, df, path, index=False): schema = get_pa_schema(df) with ProgressBar(): df.to_parquet( path, engine="pyarrow", write_index=index, schema=schema, name_function=get_parquet_filename, ) def write_predictions(self, df: dd.DataFrame, path: str): ds = self.to_ray_dataset(df) # We disable tensor extension casting here because we are writing out to Parquet and there is no need # to cast to the ray Tensor dtype extension before doing so (they will be written out as object dtype as if # we were writing to parquet using dask). with tensor_extension_casting(False): fs, path = get_fs_and_path(path) ds.write_parquet(path, filesystem=PyFileSystem(FSSpecHandler(fs))) def read_predictions(self, path: str) -> dd.DataFrame: fs, path = get_fs_and_path(path) ds = read_parquet(path, filesystem=PyFileSystem(FSSpecHandler(fs))) return self.from_ray_dataset(ds) def to_ray_dataset(self, df) -> Dataset: from ray.data import from_dask return from_dask(df) def from_ray_dataset(self, dataset) -> dd.DataFrame: # NOTE: When the dataset is an empty MapBatches(BatchInferModel), Ray's native to_dask() raises an IndexError. try: return dataset.to_dask() except IndexError as e: logging.warning( f"Encountered an empty Dataset, {dataset.show()} with error {e}. Manually returning an empty dask " "DataFrame." ) return dd.DataFrame.from_dict({}, npartitions=1) def reset_index(self, df): return reset_index_across_all_partitions(df) @property def array_lib(self): return da @property def df_lib(self): return dd @property def parallelism(self): return self._parallelism @property def partitioned(self): return True @contextmanager def tensor_extension_casting(enforced: bool): """This context manager is used to enforce or disable tensor extension casting. Ray Datasets will automatically cast tensor columns to the ray Tensor dtype extension at the end of map_batches calls and before writing to Parquet. This context manager can be used to disable this behavior and keep the tensor columns as object dtype. This is useful for writing to Parquet using dask. Args: enforced (bool): Whether to enforce tensor extension casting. """ from ray.data.context import DatasetContext ctx = DatasetContext.get_current() prev_enable_tensor_extension_casting = ctx.enable_tensor_extension_casting try: ctx.enable_tensor_extension_casting = enforced yield finally: ctx.enable_tensor_extension_casting = prev_enable_tensor_extension_casting ================================================ FILE: ludwig/data/dataframe/modin.py ================================================ #! /usr/bin/env python # Copyright (c) 2022 Predibase, 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. # ============================================================================== import os import modin.pandas as pd import numpy as np from ludwig.data.dataframe.base import DataFrameEngine from ludwig.globals import PREDICTIONS_SHAPES_FILE_NAME from ludwig.utils.data_utils import get_pa_schema, load_json, save_json, split_by_slices from ludwig.utils.dataframe_utils import flatten_df, unflatten_df class ModinEngine(DataFrameEngine): def __init__(self, **kwargs): super().__init__() def df_like(self, df, proc_cols): # df argument unused for pandas, which can instantiate df directly return pd.DataFrame(proc_cols) def parallelize(self, data): return data def persist(self, data): return data def compute(self, data): return data def from_pandas(self, df): return pd.DataFrame(df) def map_objects(self, series, map_fn, meta=None): return series.map(map_fn) def map_batches(self, df, map_fn, enable_tensor_extension_casting=True): return map_fn(df) def map_partitions(self, series, map_fn, meta=None): return map_fn(series) def apply_objects(self, df, apply_fn, meta=None): return df.apply(apply_fn, axis=1) def reduce_objects(self, series, reduce_fn): return reduce_fn(series) def split(self, df, probabilities): return split_by_slices(df.iloc, len(df), probabilities) def remove_empty_partitions(self, df): return df def to_parquet(self, df, path, index=False): schema = get_pa_schema(df) df.to_parquet( path, engine="pyarrow", index=index, schema=schema, ) def write_predictions(self, df: pd.DataFrame, path: str): df, column_shapes = flatten_df(df, self) self.to_parquet(df, path) save_json(os.path.join(os.path.dirname(path), PREDICTIONS_SHAPES_FILE_NAME), column_shapes) def read_predictions(self, path: str) -> pd.DataFrame: pred_df = pd.read_parquet(path) column_shapes = load_json(os.path.join(os.path.dirname(path), PREDICTIONS_SHAPES_FILE_NAME)) return unflatten_df(pred_df, column_shapes, self) def to_ray_dataset(self, df): from ray.data import from_modin return from_modin(df) def from_ray_dataset(self, dataset) -> pd.DataFrame: return dataset.to_modin() def reset_index(self, df): return df.reset_index(drop=True) @property def array_lib(self): return np @property def df_lib(self): return pd @property def partitioned(self): return False def set_parallelism(self, parallelism): pass ================================================ FILE: ludwig/data/dataframe/pandas.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 2020 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. # ============================================================================== import os import numpy as np import pandas as pd from ludwig.data.dataframe.base import DataFrameEngine from ludwig.globals import PREDICTIONS_SHAPES_FILE_NAME from ludwig.utils.data_utils import load_json, save_json, split_by_slices from ludwig.utils.dataframe_utils import flatten_df, unflatten_df class PandasEngine(DataFrameEngine): def __init__(self, **kwargs): super().__init__() def df_like(self, df, proc_cols): # df argument unused for pandas, which can instantiate df directly return pd.DataFrame(proc_cols) def parallelize(self, data): return data def persist(self, data): return data def compute(self, data): return data @staticmethod def concat(dfs) -> pd.DataFrame: return pd.concat(dfs) def from_pandas(self, df): return df def map_objects(self, series, map_fn, meta=None): return series.map(map_fn) def map_batches(self, df, map_fn, enable_tensor_extension_casting=True): return map_fn(df) def map_partitions(self, series, map_fn, meta=None): return map_fn(series) def apply_objects(self, df, apply_fn, meta=None): return df.apply(apply_fn, axis=1) def reduce_objects(self, series, reduce_fn): return reduce_fn(series) def split(self, df, probabilities): return split_by_slices(df.iloc, len(df), probabilities) @staticmethod def remove_empty_partitions(df: pd.DataFrame) -> pd.DataFrame: return df def to_parquet(self, df, path, index=False): df.to_parquet(path, engine="pyarrow", index=index) def write_predictions(self, df: pd.DataFrame, path: str): df, column_shapes = flatten_df(df, self) self.to_parquet(df, path) save_json(os.path.join(os.path.dirname(path), PREDICTIONS_SHAPES_FILE_NAME), column_shapes) def read_predictions(self, path: str) -> pd.DataFrame: pred_df = pd.read_parquet(path) column_shapes = load_json(os.path.join(os.path.dirname(path), PREDICTIONS_SHAPES_FILE_NAME)) return unflatten_df(pred_df, column_shapes, self) def to_ray_dataset(self, df): from ray.data import from_pandas return from_pandas(df) @staticmethod def from_ray_dataset(dataset) -> pd.DataFrame: return dataset.to_pandas() @staticmethod def reset_index(df) -> pd.DataFrame: return df.reset_index(drop=True) @property def array_lib(self): return np @property def df_lib(self): return pd @property def partitioned(self): return False def set_parallelism(self, parallelism): pass PANDAS = PandasEngine() ================================================ FILE: ludwig/data/dataset/__init__.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 2020 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. # ============================================================================== def get_pandas_dataset_manager(**kwargs): from ludwig.data.dataset.pandas import PandasDatasetManager return PandasDatasetManager(**kwargs) def get_ray_dataset_manager(**kwargs): from ludwig.data.dataset.ray import RayDatasetManager return RayDatasetManager(**kwargs) dataset_registry = { "hdf5": get_pandas_dataset_manager, "ray": get_ray_dataset_manager, None: get_pandas_dataset_manager, } def create_dataset_manager(backend, cache_format, **kwargs): return dataset_registry[cache_format](backend=backend, **kwargs) ================================================ FILE: ludwig/data/dataset/base.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 2020 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. # ============================================================================== from __future__ import annotations import contextlib from abc import ABC, abstractmethod from collections.abc import Iterable from ludwig.data.batcher.base import Batcher from ludwig.distributed import DistributedStrategy from ludwig.features.base_feature import BaseFeature from ludwig.utils.defaults import default_random_seed from ludwig.utils.types import DataFrame class Dataset(ABC): @abstractmethod def __len__(self) -> int: raise NotImplementedError() @contextlib.contextmanager @abstractmethod def initialize_batcher( self, batch_size: int = 128, should_shuffle: bool = True, random_seed: int = default_random_seed, ignore_last: bool = False, distributed: DistributedStrategy = None, ) -> Batcher: raise NotImplementedError() @abstractmethod def to_df(self, features: Iterable[BaseFeature] | None = None) -> DataFrame: raise NotImplementedError() @abstractmethod def to_scalar_df(self, features: Iterable[BaseFeature] | None = None) -> DataFrame: raise NotImplementedError() @property def in_memory_size_bytes(self) -> int: raise NotImplementedError() class DatasetManager(ABC): @abstractmethod def create(self, dataset, config, training_set_metadata) -> Dataset: raise NotImplementedError() @abstractmethod def save(self, cache_path, dataset, config, training_set_metadata, tag) -> Dataset: raise NotImplementedError() @abstractmethod def can_cache(self, skip_save_processed_input) -> bool: raise NotImplementedError() @property @abstractmethod def data_format(self) -> str: raise NotImplementedError() ================================================ FILE: ludwig/data/dataset/pandas.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== from __future__ import annotations import contextlib from collections.abc import Iterable from typing import TYPE_CHECKING import numpy as np from pandas import DataFrame from ludwig.constants import PREPROCESSING, TRAINING from ludwig.data.batcher.base import Batcher from ludwig.data.batcher.random_access import RandomAccessBatcher from ludwig.data.dataset.base import Dataset, DatasetManager from ludwig.data.sampler import DistributedSampler from ludwig.distributed import DistributedStrategy from ludwig.features.base_feature import BaseFeature from ludwig.utils.data_utils import DATA_TRAIN_HDF5_FP, load_hdf5, save_hdf5 from ludwig.utils.dataframe_utils import from_numpy_dataset, to_numpy_dataset, to_scalar_df from ludwig.utils.defaults import default_random_seed from ludwig.utils.fs_utils import download_h5 from ludwig.utils.misc_utils import get_proc_features if TYPE_CHECKING: from ludwig.backend.base import Backend class PandasDataset(Dataset): def __init__(self, dataset, features, data_hdf5_fp): self.features = features self.data_hdf5_fp = data_hdf5_fp if isinstance(dataset, str): dataset = load_hdf5(dataset) self.dataset = to_numpy_dataset(dataset) self.size = len(list(self.dataset.values())[0]) def to_df(self, features: Iterable[BaseFeature] | None = None) -> DataFrame: """Convert the dataset to a Pandas DataFrame.""" if features: return from_numpy_dataset({feature.feature_name: self.dataset[feature.proc_column] for feature in features}) return from_numpy_dataset(self.dataset) def to_scalar_df(self, features: Iterable[BaseFeature] | None = None) -> DataFrame: return to_scalar_df(self.to_df(features)) def get(self, proc_column, idx=None): if idx is None: idx = range(self.size) if ( self.data_hdf5_fp is None or PREPROCESSING not in self.features[proc_column] or "in_memory" not in self.features[proc_column]["preprocessing"] ): return self.dataset[proc_column][idx] if self.features[proc_column][PREPROCESSING]["in_memory"]: return self.dataset[proc_column][idx] sub_batch = self.dataset[proc_column][idx] indices = np.empty((3, len(sub_batch)), dtype=np.int64) indices[0, :] = sub_batch indices[1, :] = np.arange(len(sub_batch)) indices = indices[:, np.argsort(indices[0])] with download_h5(self.data_hdf5_fp) as h5_file: im_data = h5_file[proc_column + "_data"][indices[0, :], :, :] indices[2, :] = np.arange(len(sub_batch)) indices = indices[:, np.argsort(indices[1])] return im_data[indices[2, :]] def get_dataset(self) -> dict[str, np.ndarray]: return self.dataset def __len__(self): return self.size @property def processed_data_fp(self) -> str | None: return self.data_hdf5_fp @property def in_memory_size_bytes(self) -> int: df = self.to_df() return df.memory_usage(deep=True).sum() if df is not None else 0 @contextlib.contextmanager def initialize_batcher( self, batch_size: int = 128, should_shuffle: bool = True, random_seed: int = default_random_seed, ignore_last: bool = False, distributed: DistributedStrategy = None, augmentation_pipeline=None, ) -> Batcher: sampler = DistributedSampler( len(self), shuffle=should_shuffle, random_seed=random_seed, distributed=distributed ) batcher = RandomAccessBatcher( self, sampler, batch_size=batch_size, ignore_last=ignore_last, augmentation_pipeline=augmentation_pipeline, ) yield batcher class PandasDatasetManager(DatasetManager): def __init__(self, backend: Backend): self.backend: Backend = backend def create(self, dataset, config, training_set_metadata) -> Dataset: return PandasDataset(dataset, get_proc_features(config), training_set_metadata.get(DATA_TRAIN_HDF5_FP)) def save(self, cache_path, dataset, config, training_set_metadata, tag) -> Dataset: save_hdf5(cache_path, dataset) if tag == TRAINING: training_set_metadata[DATA_TRAIN_HDF5_FP] = cache_path return dataset def can_cache(self, skip_save_processed_input) -> bool: return self.backend.is_coordinator() and not skip_save_processed_input @property def data_format(self) -> str: return "hdf5" ================================================ FILE: ludwig/data/dataset/ray.py ================================================ #! /usr/bin/env python # 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. # ============================================================================== import contextlib import math import queue import threading from functools import lru_cache from typing import Any import numpy as np import pandas as pd from pyarrow.fs import FSSpecHandler, PyFileSystem from ray.data import Dataset as RayNativeDataset from ray.data import read_parquet from ray.data.extensions import TensorArray from ludwig.backend.base import Backend from ludwig.constants import BINARY, CATEGORY, NAME, NUMBER, TYPE from ludwig.data.batcher.base import Batcher from ludwig.data.dataset.base import Dataset, DatasetManager from ludwig.utils.data_utils import DATA_TRAIN_HDF5_FP, DATA_TRAIN_PARQUET_FP from ludwig.utils.defaults import default_random_seed from ludwig.utils.fs_utils import get_fs_and_path from ludwig.utils.misc_utils import get_proc_features from ludwig.utils.types import DataFrame, Series _SCALAR_TYPES = {BINARY, CATEGORY, NUMBER} def cast_as_tensor_dtype(series: Series) -> Series: return TensorArray(series) def read_remote_parquet(path: str): fs, path = get_fs_and_path(path) return read_parquet(path, filesystem=PyFileSystem(FSSpecHandler(fs))) class RayDataset(Dataset): """Wrapper around ray.data.Dataset.""" def __init__( self, df: str | DataFrame, features: dict[str, dict], training_set_metadata: dict[str, Any], backend: Backend, ): self.df_engine = backend.df_engine self.ds = self.df_engine.to_ray_dataset(df) if not isinstance(df, str) else read_remote_parquet(df) self.features = features self.training_set_metadata = training_set_metadata self.data_hdf5_fp = training_set_metadata.get(DATA_TRAIN_HDF5_FP) self.data_parquet_fp = training_set_metadata.get(DATA_TRAIN_PARQUET_FP) def to_ray_dataset( self, shuffle: bool = True, shuffle_seed: int = default_random_seed, ) -> RayNativeDataset: """Returns a ray.data.Dataset, optionally shuffled. In modern Ray (2.5+), datasets use lazy execution by default, so there's no need for explicit windowing or pipelining. """ ds = self.ds if shuffle: ds = ds.random_shuffle(seed=shuffle_seed) return ds @contextlib.contextmanager def initialize_batcher(self, batch_size=128, should_shuffle=True, random_seed=0, ignore_last=False, **kwargs): ds = self.ds if should_shuffle: ds = ds.random_shuffle(seed=random_seed) yield RayDatasetBatcher( ds, self.features, self.training_set_metadata, batch_size, self.size, ) def __len__(self): return self.ds.count() @property def size(self): return len(self) @property def in_memory_size_bytes(self): return self.ds.size_bytes() if self.ds is not None else 0 def to_df(self, features=None): return self.df_engine.from_ray_dataset(self.ds) def to_scalar_df(self, features=None): from ludwig.utils.dataframe_utils import to_scalar_df return to_scalar_df(self.to_df(features)) class RayDatasetManager(DatasetManager): def __init__(self, backend): self.backend = backend def create(self, dataset: str | DataFrame, config: dict[str, Any], training_set_metadata: dict[str, Any]): return RayDataset(dataset, get_proc_features(config), training_set_metadata, self.backend) def save( self, cache_path: str, dataset: DataFrame, config: dict[str, Any], training_set_metadata: dict[str, Any], tag: str, ): self.backend.df_engine.to_parquet(dataset, cache_path) return cache_path def can_cache(self, skip_save_processed_input): return not skip_save_processed_input @property def data_format(self): return "parquet" class RayDatasetShard(Dataset): """Wraps a Ray DataIterator (from ray.train.get_dataset_shard) for distributed training.""" def __init__( self, dataset_shard, features: dict[str, dict], training_set_metadata: dict[str, Any], ): self.dataset_shard = dataset_shard self.features = features self.training_set_metadata = training_set_metadata @contextlib.contextmanager def initialize_batcher(self, batch_size=128, should_shuffle=True, random_seed=0, ignore_last=False, **kwargs): yield RayDatasetShardBatcher( self.dataset_shard, self.features, self.training_set_metadata, batch_size, self.size, ) @lru_cache(1) def __len__(self): # TODO(travis): find way to avoid calling this, as it's expensive # DataIterator doesn't have a direct count method; use iter to count count = 0 for batch in self.dataset_shard.iter_batches(batch_size=4096, batch_format="pandas"): count += len(batch) return count @property def size(self): return len(self) def to_df(self, features=None): raise NotImplementedError("RayDatasetShard does not support to_df; use full RayDataset instead.") def to_scalar_df(self, features=None): raise NotImplementedError("RayDatasetShard does not support to_scalar_df; use full RayDataset instead.") class _BaseBatcher(Batcher): """Shared batching logic for preparing batches from pandas DataFrames.""" def __init__( self, features: dict[str, dict], training_set_metadata: dict[str, Any], batch_size: int, samples_per_epoch: int, ): self.batch_size = batch_size self.samples_per_epoch = samples_per_epoch self.training_set_metadata = training_set_metadata self.features = features self.columns = list(features.keys()) self.reshape_map = { proc_column: training_set_metadata[feature[NAME]].get("reshape") for proc_column, feature in features.items() } self.dataset_batch_iter = None self._epoch = 0 self._next_batch = None self._last_batch = False self._step = 0 def next_batch(self): if self.last_batch(): raise StopIteration() batch = self._next_batch self._fetch_next_batch() self._step += 1 return batch def last_batch(self): return self._last_batch def set_epoch(self, epoch, batch_size): self.batch_size = batch_size if epoch != self._epoch: self._fetch_next_epoch() self._epoch = epoch @property def step(self): return self._step @property def steps_per_epoch(self): return math.ceil(self.samples_per_epoch / self.batch_size) def _fetch_next_batch(self): if self.dataset_batch_iter is None: self._last_batch = True return self._last_batch = False try: self._next_batch = next(self.dataset_batch_iter) except StopIteration: self._last_batch = True def _fetch_next_epoch(self): raise NotImplementedError def _to_tensors_fn(self): columns = self.columns features = self.features def to_tensors(df: pd.DataFrame) -> pd.DataFrame: for c in columns: # do not convert scalar columns: https://github.com/ray-project/ray/issues/20825 if features[c][TYPE] not in _SCALAR_TYPES: df[c] = cast_as_tensor_dtype(df[c]) elif features[c][TYPE] == BINARY: df[c] = df[c].astype(np.bool_) return df return to_tensors def _prepare_batch(self, batch: pd.DataFrame) -> dict[str, np.ndarray]: res = {} for c in self.columns: if self.features[c][TYPE] not in _SCALAR_TYPES: res[c] = np.stack(batch[c].values) else: res[c] = batch[c].to_numpy() for c in self.columns: reshape = self.reshape_map.get(c) if reshape is not None: res[c] = res[c].reshape((-1, *reshape)) return res class RayDatasetBatcher(_BaseBatcher): """Batcher for a full ray.data.Dataset (used by non-distributed/local Ray training).""" def __init__( self, dataset: RayNativeDataset, features: dict[str, dict], training_set_metadata: dict[str, Any], batch_size: int, samples_per_epoch: int, ): self.dataset = dataset super().__init__(features, training_set_metadata, batch_size, samples_per_epoch) self._fetch_next_epoch() def _fetch_next_epoch(self): """Create an async reader over the dataset for one epoch.""" self.dataset_batch_iter = self._create_async_reader(self.dataset) self._step = 0 self._fetch_next_batch() def _create_async_reader(self, dataset: RayNativeDataset): q = queue.Queue(maxsize=100) batch_size = self.batch_size to_tensors = self._to_tensors_fn() def producer(): for batch in dataset.map_batches(to_tensors, batch_format="pandas").iter_batches( prefetch_batches=1, batch_size=batch_size, batch_format="pandas" ): res = self._prepare_batch(batch) q.put(res) q.put(None) def async_read(): t = threading.Thread(target=producer) t.start() while True: batch = q.get(block=True) if batch is None: break yield batch t.join() return async_read() class RayDatasetShardBatcher(_BaseBatcher): """Batcher for a Ray DataIterator shard (used in distributed training workers).""" def __init__( self, data_iterator, features: dict[str, dict], training_set_metadata: dict[str, Any], batch_size: int, samples_per_epoch: int, ): self.data_iterator = data_iterator super().__init__(features, training_set_metadata, batch_size, samples_per_epoch) self._fetch_next_epoch() def _fetch_next_epoch(self): """Create an async reader from the DataIterator for one epoch.""" self.dataset_batch_iter = self._create_async_reader() self._step = 0 self._fetch_next_batch() def _create_async_reader(self): q = queue.Queue(maxsize=100) batch_size = self.batch_size to_tensors = self._to_tensors_fn() def producer(): for batch in self.data_iterator.iter_batches( batch_size=batch_size, batch_format="pandas", prefetch_batches=1, ): batch = to_tensors(batch) res = self._prepare_batch(batch) q.put(res) q.put(None) def async_read(): t = threading.Thread(target=producer) t.start() while True: batch = q.get(block=True) if batch is None: break yield batch t.join() return async_read() ================================================ FILE: ludwig/data/dataset_synthesizer.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import argparse import logging import os import random import string import sys import uuid import numpy as np import pandas as pd import torch import torchaudio import yaml from packaging import version from ludwig.api_annotations import DeveloperAPI from ludwig.constants import ( AUDIO, BAG, BINARY, CATEGORY, CATEGORY_DISTRIBUTION, DATE, DECODER, ENCODER, H3, IMAGE, INPUT_FEATURES, NAME, NUMBER, OUTPUT_FEATURES, PREPROCESSING, SEQUENCE, SET, TEXT, TIMESERIES, TYPE, VECTOR, ) from ludwig.contrib import add_contrib_callback_args from ludwig.globals import LUDWIG_VERSION from ludwig.types import ModelConfigDict from ludwig.utils.data_utils import save_csv from ludwig.utils.h3_util import components_to_h3 from ludwig.utils.misc_utils import get_from_registry from ludwig.utils.print_utils import print_ludwig logger = logging.getLogger(__name__) _TORCH_AUDIO_210 = version.parse(torchaudio.__version__) >= version.parse("2.1.0") letters = string.ascii_letters DATETIME_FORMATS = { "%m-%d-%Y": "{m:02d}-{d:02d}-{Y:04d}", "%m-%d-%Y %H:%M:%S": "{m:02d}-{d:02d}-{Y:04d} {H:02d}:{M:02d}:{S:02d}", "%m/%d/%Y": "{m:02d}/{d:02d}/{Y:04d}", "%m/%d/%Y %H:%M:%S": "{m:02d}/{d:02d}/{Y:04d} {H:02d}:{M:02d}:{S:02d}", "%m-%d-%y": "{m:02d}-{d:02d}-{y:02d}", "%m-%d-%y %H:%M:%S": "{m:02d}-{d:02d}-{y:02d} {H:02d}:{M:02d}:{S:02d}", "%m/%d/%y": "{m:02d}/{d:02d}/{y:02d}", "%m/%d/%y %H:%M:%S": "{m:02d}/{d:02d}/{y:02d} {H:02d}:{M:02d}:{S:02d}", "%d-%m-%Y": "{d:02d}-{m:02d}-{Y:04d}", "%d-%m-%Y %H:%M:%S": "{d:02d}-{m:02d}-{Y:04d} {H:02d}:{M:02d}:{S:02d}", "%d/%m/%Y": "{d:02d}/{m:02d}/{Y:04d}", "%d/%m/%Y %H:%M:%S": "{d:02d}/{m:02d}/{Y:04d} {H:02d}:{M:02d}:{S:02d}", "%d-%m-%y": "{d:02d}-{m:02d}-{y:02d}", "%d-%m-%y %H:%M:%S": "{d:02d}-{m:02d}-{y:02d} {H:02d}:{M:02d}:{S:02d}", "%d/%m/%y": "{d:02d}/{m:02d}/{y:02d}", "%d/%m/%y %H:%M:%S": "{d:02d}/{m:02d}/{y:02d} {H:02d}:{M:02d}:{S:02d}", "%y-%m-%d": "{y:02d}-{m:02d}-{d:02d}", "%y-%m-%d %H:%M:%S": "{y:02d}-{m:02d}-{d:02d} {H:02d}:{M:02d}:{S:02d}", "%y/%m/%d": "{y:02d}/{m:02d}/{d:02d}", "%y/%m/%d %H:%M:%S": "{y:02d}/{m:02d}/{d:02d} {H:02d}:{M:02d}:{S:02d}", "%Y-%m-%d": "{Y:04d}-{m:02d}-{d:02d}", "%Y-%m-%d %H:%M:%S": "{Y:04d}-{m:02d}-{d:02d} {H:02d}:{M:02d}:{S:02d}", "%Y/%m/%d": "{Y:04d}/{m:02d}/{d:02d}", "%Y/%m/%d %H:%M:%S": "{Y:04d}/{m:02d}/{d:02d} {H:02d}:{M:02d}:{S:02d}", "%y-%d-%m": "{y:02d}-{d:02d}-{m:02d}", "%y-%d-%m %H:%M:%S": "{y:02d}-{d:02d}-{m:02d} {H:02d}:{M:02d}:{S:02d}", "%y/%d/%m": "{y:02d}/{d:02d}/{m:02d}", "%y/%d/%m %H:%M:%S": "{y:02d}/{d:02d}/{m:02d} {H:02d}:{M:02d}:{S:02d}", "%Y-%d-%m": "{Y:04d}-{d:02d}-{m:02d}", "%Y-%d-%m %H:%M:%S": "{Y:04d}-{d:02d}-{m:02d} {H:02d}:{M:02d}:{S:02d}", "%Y/%d/%m": "{Y:04d}/{d:02d}/{m:02d}", "%Y/%d/%m %H:%M:%S": "{Y:04d}/{d:02d}/{m:02d} {H:02d}:{M:02d}:{S:02d}", } def _get_feature_encoder_or_decoder(feature): """Returns the nested decoder or encoder dictionary for a feature. If neither encoder nor decoder is present, creates an empty encoder dict and returns it. """ if DECODER in feature: return feature[DECODER] elif ENCODER in feature: return feature[ENCODER] else: feature[ENCODER] = {} return feature[ENCODER] def generate_string(length): sequence = [] for _ in range(length): sequence.append(random.choice(letters)) return "".join(sequence) def build_vocab(size): vocab = [] for _ in range(size): vocab.append(generate_string(random.randint(2, 10))) return vocab def return_none(feature): return None def assign_vocab(feature): encoder_or_decoder = _get_feature_encoder_or_decoder(feature) encoder_or_decoder["idx2str"] = build_vocab(encoder_or_decoder.get("vocab_size", 10)) encoder_or_decoder["vocab_size"] = len(encoder_or_decoder["idx2str"]) def build_feature_parameters(features): feature_parameters = {} for feature in features: feature_builder_function = get_from_registry(feature[TYPE], parameters_builders_registry) feature_parameters[feature[NAME]] = feature_builder_function(feature) return feature_parameters parameters_builders_registry = { "category": assign_vocab, "text": assign_vocab, "number": return_none, "binary": return_none, "set": assign_vocab, "bag": assign_vocab, "sequence": assign_vocab, "timeseries": return_none, "image": return_none, "audio": return_none, "date": return_none, "h3": return_none, VECTOR: return_none, CATEGORY_DISTRIBUTION: return_none, } @DeveloperAPI def build_synthetic_dataset_df(dataset_size: int, config: ModelConfigDict) -> pd.DataFrame: for feature in config[OUTPUT_FEATURES]: if DECODER not in feature: feature[DECODER] = {} features = config[INPUT_FEATURES] + config[OUTPUT_FEATURES] df = build_synthetic_dataset(dataset_size, features) data = [next(df) for _ in range(dataset_size + 1)] return pd.DataFrame(data[1:], columns=data[0]) @DeveloperAPI def build_synthetic_dataset(dataset_size: int, features: list[dict], outdir: str = "."): """Synthesizes a dataset for testing purposes. :param dataset_size: (int) size of the dataset :param features: (List[dict]) list of features to generate in YAML format. Provide a list containing one dictionary for each feature, each dictionary must include a name, a type and can include some generation parameters depending on the type :param outdir: (str) Path to an output directory. Used for saving synthetic image and audio files. Example content for features: [ {name: text_1, type: text, vocab_size: 20, max_len: 20}, {name: text_2, type: text, vocab_size: 20, max_len: 20}, {name: category_1, type: category, vocab_size: 10}, {name: category_2, type: category, vocab_size: 15}, {name: number_1, type: number}, {name: number_2, type: number}, {name: binary_1, type: binary}, {name: binary_2, type: binary}, {name: set_1, type: set, vocab_size: 20, max_len: 20}, {name: set_2, type: set, vocab_size: 20, max_len: 20}, {name: bag_1, type: bag, vocab_size: 20, max_len: 10}, {name: bag_2, type: bag, vocab_size: 20, max_len: 10}, {name: sequence_1, type: sequence, vocab_size: 20, max_len: 20}, {name: sequence_2, type: sequence, vocab_size: 20, max_len: 20}, {name: timeseries_1, type: timeseries, max_len: 20}, {name: timeseries_2, type: timeseries, max_len: 20}, {name: date_1, type: date}, {name: date_2, type: date}, {name: h3_1, type: h3}, {name: h3_2, type: h3}, {name: vector_1, type: vector}, {name: vector_2, type: vector}, ] """ build_feature_parameters(features) header = [] for feature in features: header.append(feature[NAME]) yield header for _ in range(dataset_size): yield generate_datapoint(features=features, outdir=outdir) def generate_datapoint(features: list[dict], outdir: str) -> str | int | bool: """Returns a synthetic example containing features specified by the features spec. `outdir` is only used for generating synthetic image and synthetic audio features. Otherwise, it is unused. """ datapoint = [] for feature in features: if "cycle" in feature and feature["cycle"] is True and feature[TYPE] in cyclers_registry: cycler_function = cyclers_registry[feature[TYPE]] feature_value = cycler_function(feature) else: generator_function = get_from_registry(feature[TYPE], generators_registry) feature_value = generator_function(feature=feature, outdir=outdir) datapoint.append(feature_value) return datapoint def generate_category(feature, outdir: str | None = None) -> str: """Returns a random category. `outdir` is unused. """ encoder_or_decoder = _get_feature_encoder_or_decoder(feature) return random.choice(encoder_or_decoder["idx2str"]) def generate_number(feature, outdir: str | None = None) -> int: """Returns a random number. `outdir` is unused. """ return random.uniform(feature["min"] if "min" in feature else 0, feature["max"] if "max" in feature else 1) def generate_binary(feature, outdir: str | None = None) -> bool: """Returns a random boolean. `outdir` is unused. """ choices = feature.get("bool2str", [False, True]) p = feature["prob"] if "prob" in feature else 0.5 return np.random.choice(choices, p=[1 - p, p]) def generate_sequence(feature, outdir: str | None = None) -> str: """Returns a random sequence. `outdir` is unused. """ encoder_or_decoder = _get_feature_encoder_or_decoder(feature) length = encoder_or_decoder.get("max_len", 10) if "min_len" in encoder_or_decoder: length = random.randint(encoder_or_decoder["min_len"], length) sequence = [random.choice(encoder_or_decoder["idx2str"]) for _ in range(length)] encoder_or_decoder["vocab_size"] = ( encoder_or_decoder["vocab_size"] + 4 ) # For special symbols: START, STOP, PAD, UNK. return " ".join(sequence) def generate_set(feature, outdir: str | None = None) -> str: """Returns a random set. `outdir` is unused. """ encoder_or_decoder = _get_feature_encoder_or_decoder(feature) elems = [] for _ in range(random.randint(0, encoder_or_decoder.get("max_len", 3))): elems.append(random.choice(encoder_or_decoder["idx2str"])) return " ".join(list(set(elems))) def generate_bag(feature, outdir: str | None = None) -> str: """Returns a random bag. `outdir` is unused. """ encoder_or_decoder = _get_feature_encoder_or_decoder(feature) elems = [] for _ in range(random.randint(0, encoder_or_decoder.get("max_len", 3))): elems.append(random.choice(encoder_or_decoder["idx2str"])) return " ".join(elems) def generate_text(feature, outdir: str | None = None) -> str: """Returns random text. `outdir` is unused. """ encoder_or_decoder = _get_feature_encoder_or_decoder(feature) length = encoder_or_decoder.get("max_len", 10) text = [] for _ in range(random.randint(length - int(length * 0.2), length)): text.append(random.choice(encoder_or_decoder["idx2str"])) return " ".join(text) def generate_timeseries(feature, max_len=10, outdir: str | None = None) -> str: """Returns a random timeseries. `outdir` is unused. """ encoder = _get_feature_encoder_or_decoder(feature) series = [] max_len = encoder.get("max_len", max_len) series_len = random.randint(max_len - 2, max_len) # simulates variable length for _ in range(series_len): series.append(str(random.uniform(encoder.get("min", 0), encoder.get("max", 1)))) return " ".join(series) def generate_audio(feature, outdir: str) -> str: """Generates random audio and saves it to the outdir. Returns the path to the directory of saved files. """ destination_folder = feature.get("destination_folder", outdir) if PREPROCESSING in feature: audio_length = feature[PREPROCESSING].get("audio_file_length_limit_in_s", 2) else: audio_length = feature.get("audio_file_length_limit_in_s", 1) sampling_rate = 16000 num_samples = int(audio_length * sampling_rate) audio = np.sin(np.arange(num_samples) / 100 * 2 * np.pi) * 2 * (np.random.random(num_samples) - 0.5) audio_tensor = torch.tensor(np.array([audio])).type(torch.float32) audio_filename = uuid.uuid4().hex[:10].upper() + ".wav" if not os.path.exists(destination_folder): os.makedirs(destination_folder) audio_dest_path = os.path.join(destination_folder, audio_filename) try: if _TORCH_AUDIO_210: torchaudio.save(audio_dest_path, audio_tensor, sample_rate=sampling_rate, backend="sox") torchaudio.save(audio_dest_path, audio_tensor, sampling_rate) except OSError as e: raise OSError(f"Unable to save audio to disk: {e}") return audio_dest_path def generate_image(feature, outdir: str, save_as_numpy: bool = False) -> str: """Generates random images and saves it to the outdir. Returns the path to the directory of saved files. """ save_as_numpy = feature.get("save_as_numpy", save_as_numpy) try: from torchvision.io import write_png except ImportError: logger.error( " torchvision is not installed. " "In order to install all image feature dependencies run " "pip install ludwig[image]" ) sys.exit(-1) # Read num_channels, width, height destination_folder = feature.get("destination_folder", outdir) if PREPROCESSING in feature: height = feature[PREPROCESSING].get("height", 28) width = feature[PREPROCESSING].get("width", 28) num_channels = feature[PREPROCESSING].get("num_channels", 1) else: encoder = _get_feature_encoder_or_decoder(feature) height = encoder.get("height", 28) width = encoder.get("width", 28) num_channels = encoder.get("num_channels", 1) if width <= 0 or height <= 0 or num_channels < 1: raise ValueError("Invalid arguments for generating images") # Create a Random Image img = torch.randint(0, 255, (num_channels, width, height), dtype=torch.uint8) # Generate a unique random filename image_filename = uuid.uuid4().hex[:10].upper() + ".png" # Save the image to disk either in a specified location/new folder if not os.path.exists(destination_folder): os.makedirs(destination_folder) image_dest_path = os.path.join(destination_folder, image_filename) try: # save_image(torch.from_numpy(img.astype("uint8")), image_dest_path) if save_as_numpy: with open(image_dest_path, "wb") as f: np.save(f, img.detach().cpu().numpy()) else: write_png(img, image_dest_path) except OSError as e: raise OSError(f"Unable to save images to disk: {e}") return image_dest_path def generate_datetime(feature, outdir: str | None = None) -> str: """Generates a random date time, picking a format among different types. If no format is specified, the first one is used. """ if "datetime_format" in feature: datetime_generation_format = DATETIME_FORMATS[feature["datetime_format"]] elif "preprocessing" in feature and "datetime_format" in feature["preprocessing"]: datetime_generation_format = DATETIME_FORMATS[feature["preprocessing"]["datetime_format"]] else: datetime_generation_format = DATETIME_FORMATS[next(iter(DATETIME_FORMATS))] y = random.randint(1, 99) Y = random.randint(1, 9999) m = random.randint(1, 12) d = random.randint(1, 28) H = random.randint(1, 12) M = random.randint(1, 59) S = random.randint(1, 59) return datetime_generation_format.format(y=y, Y=Y, m=m, d=d, H=H, M=M, S=S) def generate_h3(feature, outdir: str | None = None) -> str: """Returns a random h3. `outdir` is unused. """ resolution = random.randint(0, 15) # valid values [0, 15] h3_components = { "mode": 1, # we can avoid testing other modes "edge": 0, # only used in other modes "resolution": resolution, "base_cell": random.randint(0, 121), # valid values [0, 121] # valid values [0, 7] "cells": [random.randint(0, 7) for _ in range(resolution)], } return components_to_h3(h3_components) def generate_vector(feature, outdir: str | None = None) -> str: """Returns a random vector. `outdir` is unused. """ # Space delimited string with floating point numbers if PREPROCESSING in feature: vector_size = feature[PREPROCESSING].get("vector_size", 10) else: vector_size = feature.get("vector_size", 10) return " ".join([str(100 * random.random()) for _ in range(vector_size)]) def generate_category_distribution(feature, outdir: str | None = None) -> str: """Returns a random category distribution. `outdir` is unused. """ # Space delimited string with floating point numbers that sum to 1 preprocessing = feature.get(PREPROCESSING, {}) vector_size = len(preprocessing.get("vocab", ["a", "b", "c"])) v = np.random.rand(vector_size) v = v / v.sum() return " ".join([str(x) for x in v]) generators_registry = { BINARY: generate_binary, NUMBER: generate_number, CATEGORY: generate_category, SET: generate_set, BAG: generate_bag, SEQUENCE: generate_sequence, TEXT: generate_text, TIMESERIES: generate_timeseries, IMAGE: generate_image, AUDIO: generate_audio, H3: generate_h3, DATE: generate_datetime, VECTOR: generate_vector, CATEGORY_DISTRIBUTION: generate_category_distribution, } category_cycle = 0 def cycle_category(feature): global category_cycle idx2str = feature[DECODER]["idx2str"] if DECODER in feature else feature[ENCODER]["idx2str"] if category_cycle >= len(idx2str): category_cycle = 0 category = idx2str[category_cycle] category_cycle += 1 return category binary_cycle = False def cycle_binary(feature): global binary_cycle if binary_cycle: binary_cycle = False return True else: binary_cycle = True return False cyclers_registry = {"category": cycle_category, "binary": cycle_binary} def cli_synthesize_dataset(dataset_size: int, features: list[dict], output_path: str, **kwargs) -> None: """Symthesizes a dataset for testing purposes. :param dataset_size: (int) size of the dataset :param features: (List[dict]) list of features to generate in YAML format. Provide a list contaning one dictionary for each feature, each dictionary must include a name, a type and can include some generation parameters depending on the type :param output_path: (str) path where to save the output CSV file Example content for features: [ {name: text_1, type: text, vocab_size: 20, max_len: 20}, {name: text_2, type: text, vocab_size: 20, max_len: 20}, {name: category_1, type: category, vocab_size: 10}, {name: category_2, type: category, vocab_size: 15}, {name: number_1, type: number}, {name: number_2, type: number}, {name: binary_1, type: binary}, {name: binary_2, type: binary}, {name: set_1, type: set, vocab_size: 20, max_len: 20}, {name: set_2, type: set, vocab_size: 20, max_len: 20}, {name: bag_1, type: bag, vocab_size: 20, max_len: 10}, {name: bag_2, type: bag, vocab_size: 20, max_len: 10}, {name: sequence_1, type: sequence, vocab_size: 20, max_len: 20}, {name: sequence_2, type: sequence, vocab_size: 20, max_len: 20}, {name: timeseries_1, type: timeseries, max_len: 20}, {name: timeseries_2, type: timeseries, max_len: 20}, {name: date_1, type: date}, {name: date_2, type: date}, {name: h3_1, type: h3}, {name: h3_2, type: h3}, {name: vector_1, type: vector}, {name: vector_2, type: vector}, ] """ if dataset_size is None or features is None or output_path is None: raise ValueError( "Missing one or more required parameters: '--dataset_size', " "'--features' or '--output_path'" ) dataset = build_synthetic_dataset(dataset_size, features) save_csv(output_path, dataset) def cli(sys_argv): parser = argparse.ArgumentParser( description="This script generates a synthetic dataset.", prog="ludwig synthesize_dataset", usage="%(prog)s [options]", ) parser.add_argument("-od", "--output_path", type=str, help="output CSV file path") parser.add_argument("-d", "--dataset_size", help="size of the dataset", type=int, default=100) parser.add_argument( "-f", "--features", default="[\ {name: text_1, type: text, vocab_size: 20, max_len: 20}, \ {name: text_2, type: text, vocab_size: 20, max_len: 20}, \ {name: category_1, type: category, vocab_size: 10}, \ {name: category_2, type: category, vocab_size: 15}, \ {name: number_1, type: number}, \ {name: number_2, type: number}, \ {name: binary_1, type: binary}, \ {name: binary_2, type: binary}, \ {name: set_1, type: set, vocab_size: 20, max_len: 20}, \ {name: set_2, type: set, vocab_size: 20, max_len: 20}, \ {name: bag_1, type: bag, vocab_size: 20, max_len: 10}, \ {name: bag_2, type: bag, vocab_size: 20, max_len: 10}, \ {name: sequence_1, type: sequence, vocab_size: 20, max_len: 20}, \ {name: sequence_2, type: sequence, vocab_size: 20, max_len: 20}, \ {name: timeseries_1, type: timeseries, max_len: 20}, \ {name: timeseries_2, type: timeseries, max_len: 20}, \ {name: date_1, type: date}, \ {name: date_2, type: date}, \ {name: h3_1, type: h3}, \ {name: h3_2, type: h3}, \ {name: vector_1, type: vector}, \ {name: vector_2, type: vector}, \ ]", type=yaml.safe_load, help="list of features to generate in YAML format. " "Provide a list containing one dictionary for each feature, " "each dictionary must include a name, a type " "and can include some generation parameters depending on the type", ) add_contrib_callback_args(parser) args = parser.parse_args(sys_argv) args.callbacks = args.callbacks or [] for callback in args.callbacks: callback.on_cmdline("synthesize_dataset", *sys_argv) # No log level parameter this is placeholder if we add at later date # args.logging_level = get_logging_level_registry[args.logging_level] # logging.getLogger('ludwig').setLevel( # args.logging_level # ) # global logger # logger = logging.getLogger('ludwig.data.dataset_synthesizer') print_ludwig("Synthesize Dataset", LUDWIG_VERSION) cli_synthesize_dataset(**vars(args)) if __name__ == "__main__": cli(sys.argv[1:]) ================================================ FILE: ludwig/data/negative_sampling.py ================================================ import logging import time from typing import Any import numpy as np import pandas as pd import scipy from ludwig.utils.types import DataFrame def _negative_sample_user(interaction_row: np.array, neg_pos_ratio: int, extra_samples: int) -> tuple[list[int], int]: """Returns a list of negative item indices for given user-item interactions. If there are not enough negative items, takes all of them and adds the difference to the extra_samples otherwise, samples with replacement. Params: interaction_row: user-item interaction row neg_pos_ratio: number of negative samples per positive sample extra_samples: number of additional samples to add to the negative sample list Returns: Tuple of list of negative item indices and number of extra samples """ # Find all items that are not interacted with by the user neg_items = np.where(interaction_row == 0)[1] available_samples = len(neg_items) # Randomly sample negative items npos = interaction_row.shape[1] - len(neg_items) samples_required = npos * neg_pos_ratio + extra_samples should_sample = samples_required <= available_samples neg_items = np.random.choice(neg_items, samples_required, replace=False) if should_sample else neg_items return neg_items.tolist(), max(0, samples_required - available_samples) def negative_sample( df: DataFrame, user_id_col: str = "customer_id", item_id_col: str = "article_id", label_col: str = "label", neg_pos_ratio: int = 1, neg_val: Any = 0, log_pct: int = 0, ): """Negative sampling for implicit feedback datasets. Params: df: DataFrame containing user-item interactions user_id_col: column name for user ids item_id_col: column name for item ids label_col: column name for interaction labels (e.g. 1 for positive interaction) n_neg: number of negative samples per positive sample neg_val: label value for the negative samples percent_print: print progress every percent_print percent. 0 to disable Returns: Input DataFrame with negative samples appended Source: https://petamind.com/fast-uniform-negative-sampling-for-rating-matrix/ """ # TODO(joppe): support out of memory negative sampling using Dask if not isinstance(df, pd.DataFrame): df = df.compute() # Initialize sparse COOrdinate matrix from users and items in existing interactions user_id_cat = df[user_id_col].astype("category").cat user_id_codes = user_id_cat.codes.values item_id_cat = df[item_id_col].astype("category").cat item_id_codes = item_id_cat.codes.values interactions_sparse = scipy.sparse.coo_matrix((df[label_col], (user_id_codes, item_id_codes))) # Convert to dense user-item matrix so we can iterate interactions_dense = interactions_sparse.todense() nrows = interactions_dense.shape[0] niter_log = int(nrows * log_pct / 100) start_time = time.time() user_indices, item_indices = [], [] extra_samples = 0 for user_idx, interaction_row in enumerate(interactions_dense): if log_pct > 0 and user_idx % niter_log == 0: logging.info( f"Negative sampling progress: {float(user_idx) * 100 / nrows:0.0f}% " f"in {time.time() - start_time:0.2f}s" ) neg_items_for_user, extra_samples = _negative_sample_user(interaction_row, neg_pos_ratio, extra_samples) # Add to negative user-item pairs item_indices += neg_items_for_user user_indices += [user_idx] * len(neg_items_for_user) negative_samples = pd.DataFrame( { # Map back to original user and item ids user_id_col: user_id_cat.categories[user_indices], item_id_col: item_id_cat.categories[item_indices], label_col: [neg_val] * len(item_indices), } ) return pd.concat([df[[user_id_col, item_id_col, label_col]], negative_samples]) ================================================ FILE: ludwig/data/postprocessing.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import os from typing import Any, Optional import numpy as np import pandas as pd import torch from ludwig.backend import LOCAL_BACKEND from ludwig.data.utils import convert_to_dict from ludwig.utils.data_utils import DATAFRAME_FORMATS, DICT_FORMATS from ludwig.utils.dataframe_utils import to_numpy_dataset from ludwig.utils.fs_utils import has_remote_protocol, open_file from ludwig.utils.misc_utils import get_from_registry from ludwig.utils.strings_utils import make_safe_filename from ludwig.utils.types import DataFrame def postprocess( predictions, output_features, training_set_metadata, output_directory="", backend=LOCAL_BACKEND, skip_save_unprocessed_output=False, ) -> DataFrame: if not backend.is_coordinator(): # Only save unprocessed output on the coordinator skip_save_unprocessed_output = True saved_keys = set() if not skip_save_unprocessed_output: _save_as_numpy(predictions, output_directory, saved_keys, backend) def postprocess_batch(df): for of_name, output_feature in output_features.items(): df = output_feature.postprocess_predictions( df, training_set_metadata[of_name], ) return df # We disable tensor extension casting here because this step is the final data processing step and # we do not expect return to Ray Datasets after this point. The dtype of the predictions will be # whatever they would be if we did all postprocessing in Dask. predictions = backend.df_engine.map_batches(predictions, postprocess_batch, enable_tensor_extension_casting=False) # Save any new columns but do not save the original columns again if not skip_save_unprocessed_output: _save_as_numpy(predictions, output_directory, saved_keys, backend) return predictions def _save_as_numpy(predictions, output_directory, saved_keys, backend): predictions = predictions[[c for c in predictions.columns if c not in saved_keys]] npy_filename = os.path.join(output_directory, "{}.npy") numpy_predictions = to_numpy_dataset(predictions, backend) for k, v in numpy_predictions.items(): k = k.replace("<", "[").replace(">", "]") # Replace and with [UNK], [PAD] if k not in saved_keys: if has_remote_protocol(output_directory): with open_file(npy_filename.format(make_safe_filename(k)), mode="wb") as f: np.save(f, v) else: np.save(npy_filename.format(make_safe_filename(k)), v) saved_keys.add(k) def convert_dict_to_df(predictions: dict[str, dict[str, list[Any] | torch.Tensor | np.ndarray]]) -> pd.DataFrame: """Converts a dictionary of predictions into a pandas DataFrame. Example format of predictions dictionary: { "binary_C82EB": { "predictions": torch.tensor([True, True, True, False]), "probabilities": torch.tensor([[0.4777, 0.5223], [0.4482, 0.5518], [0.4380, 0.5620], [0.5059, 0.4941]]), }, "category_1491D": { "predictions": ["NkNUG", "NkNUG", "NkNUG", "NkNUG"], "probabilities": torch.tensor( [ [0.1058, 0.4366, 0.1939, 0.2637], [0.0816, 0.4807, 0.1978, 0.2399], [0.0907, 0.4957, 0.1829, 0.2308], [0.0728, 0.5015, 0.1900, 0.2357], ] ), }, "num_7B25F": {"predictions": torch.tensor([2.0436, 2.1158, 2.1222, 2.1964])}, } """ output = {} for of_name, preds_dict in predictions.items(): for key, value in preds_dict.items(): output_key = f"{of_name}_{key}" if not isinstance(value, list): value = value.tolist() output[output_key] = value return pd.DataFrame.from_dict(output) def convert_predictions( predictions, output_features, return_type="dict", backend: Optional["Backend"] = None # noqa: F821 ): convert_fn = get_from_registry(return_type, conversion_registry) return convert_fn(predictions, output_features, backend) def convert_to_df( predictions, output_features, backend: Optional["Backend"] = None, # noqa: F821 ): return predictions conversion_registry = { **{format: convert_to_dict for format in DICT_FORMATS}, **{format: convert_to_df for format in DATAFRAME_FORMATS}, } ================================================ FILE: ludwig/data/preprocessing.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import contextlib import logging import warnings from abc import ABC, abstractmethod import numpy as np import pandas as pd import torch from ludwig.api_annotations import DeveloperAPI from ludwig.backend import Backend, LOCAL_BACKEND from ludwig.config_validation.preprocessing import check_global_max_sequence_length_fits_prompt_template from ludwig.constants import ( BFILL, CHECKSUM, COLUMN, DEFAULTS, DROP_ROW, ENCODER, FFILL, FILL_WITH_CONST, FILL_WITH_FALSE, FILL_WITH_MEAN, FILL_WITH_MODE, FILL_WITH_TRUE, FULL, META, MIN_DATASET_SPLIT_ROWS, MODEL_ECD, NAME, NUMBER, PREPROCESSING, PROC_COLUMN, SPLIT, SRC, TEST, TEXT, TRAINING, TYPE, VALIDATION, ) from ludwig.data.cache.manager import DatasetCache from ludwig.data.cache.types import wrap from ludwig.data.concatenate_datasets import concatenate_df, concatenate_files, concatenate_splits from ludwig.data.dataset.base import Dataset from ludwig.data.prompt import format_input_with_prompt, index_column from ludwig.data.split import get_splitter, split_dataset from ludwig.data.utils import get_input_and_output_features, set_fixed_split from ludwig.datasets import load_dataset_uris from ludwig.features.feature_registries import get_base_type_registry from ludwig.models.embedder import create_embed_batch_size_evaluator, create_embed_transform_fn from ludwig.schema.encoders.utils import get_encoder_cls from ludwig.types import FeatureConfigDict, ModelConfigDict, PreprocessingConfigDict, TrainingSetMetadataDict from ludwig.utils import data_utils, strings_utils from ludwig.utils.backward_compatibility import upgrade_metadata from ludwig.utils.data_utils import ( CACHEABLE_FORMATS, CSV_FORMATS, DATA_TEST_PARQUET_FP, DATA_TRAIN_HDF5_FP, DATA_TRAIN_PARQUET_FP, DATA_VALIDATION_PARQUET_FP, DATAFRAME_FORMATS, DICT_FORMATS, EXCEL_FORMATS, FEATHER_FORMATS, figure_data_format, FWF_FORMATS, get_split_path, HDF5_FORMATS, HTML_FORMATS, JSON_FORMATS, JSONL_FORMATS, ORC_FORMATS, override_in_memory_flag, PARQUET_FORMATS, PICKLE_FORMATS, read_csv, read_excel, read_feather, read_fwf, read_html, read_json, read_jsonl, read_orc, read_parquet, read_pickle, read_sas, read_spss, read_stata, read_tsv, sanitize_column_names, SAS_FORMATS, SPSS_FORMATS, STATA_FORMATS, TSV_FORMATS, ) from ludwig.utils.dataframe_utils import is_dask_series_or_df from ludwig.utils.defaults import ( default_prediction_preprocessing_parameters, default_random_seed, default_training_preprocessing_parameters, ) from ludwig.utils.fs_utils import file_lock, path_exists from ludwig.utils.misc_utils import get_from_registry, merge_dict from ludwig.utils.types import DataFrame, Series # Opt-in to future pandas behavior: fillna/ffill/bfill will no longer silently downcast dtypes pd.set_option("future.no_silent_downcasting", True) REPARTITIONING_FEATURE_TYPES = {"image", "audio"} logger = logging.getLogger(__name__) class DataFormatPreprocessor(ABC): @staticmethod @abstractmethod def preprocess_for_training( config, features, dataset=None, training_set=None, validation_set=None, test_set=None, training_set_metadata=None, skip_save_processed_input=False, preprocessing_params=default_training_preprocessing_parameters, backend=LOCAL_BACKEND, random_seed=default_random_seed, callbacks=None, ): pass @staticmethod @abstractmethod def preprocess_for_prediction( config, dataset, features, preprocessing_params, training_set_metadata, backend, callbacks ): pass @staticmethod @abstractmethod def prepare_processed_data( features, dataset=None, training_set=None, validation_set=None, test_set=None, training_set_metadata=None, skip_save_processed_input=False, preprocessing_params=default_training_preprocessing_parameters, backend=LOCAL_BACKEND, random_seed=default_random_seed, ): pass class DictPreprocessor(DataFormatPreprocessor): @staticmethod def preprocess_for_training( config, features, dataset=None, training_set=None, validation_set=None, test_set=None, training_set_metadata=None, skip_save_processed_input=False, preprocessing_params=default_training_preprocessing_parameters, backend=LOCAL_BACKEND, random_seed=default_random_seed, callbacks=None, ): num_overrides = override_in_memory_flag(features, True) if num_overrides > 0: logger.warning("Using in_memory = False is not supported " "with {} data format.".format("dict")) df_engine = backend.df_engine if dataset is not None: dataset = df_engine.from_pandas(pd.DataFrame(dataset)) if training_set is not None: training_set = df_engine.from_pandas(pd.DataFrame(training_set)) if validation_set is not None: validation_set = df_engine.from_pandas(pd.DataFrame(validation_set)) if test_set is not None: test_set = df_engine.from_pandas(pd.DataFrame(test_set)) return _preprocess_df_for_training( config, features, dataset, training_set, validation_set, test_set, training_set_metadata=training_set_metadata, preprocessing_params=preprocessing_params, backend=backend, random_seed=random_seed, ) @staticmethod def preprocess_for_prediction( config, dataset, features, preprocessing_params, training_set_metadata, backend, callbacks ): dataset, training_set_metadata = build_dataset( config, pd.DataFrame(dataset), features, preprocessing_params, mode="prediction", metadata=training_set_metadata, backend=backend, callbacks=callbacks, ) return dataset, training_set_metadata, None class DataFramePreprocessor(DataFormatPreprocessor): @staticmethod def preprocess_for_training( config, features, dataset=None, training_set=None, validation_set=None, test_set=None, training_set_metadata=None, skip_save_processed_input=False, preprocessing_params=default_training_preprocessing_parameters, backend=LOCAL_BACKEND, random_seed=default_random_seed, callbacks=None, ): num_overrides = override_in_memory_flag(features, True) if num_overrides > 0: logger.warning("Using in_memory = False is not supported " "with {} data format.".format("dataframe")) if isinstance(dataset, pd.DataFrame): dataset = backend.df_engine.from_pandas(dataset) return _preprocess_df_for_training( config, features, dataset, training_set, validation_set, test_set, training_set_metadata=training_set_metadata, preprocessing_params=preprocessing_params, backend=backend, random_seed=random_seed, callbacks=callbacks, ) @staticmethod def preprocess_for_prediction( config, dataset, features, preprocessing_params, training_set_metadata, backend, callbacks ): if isinstance(dataset, pd.DataFrame): dataset = backend.df_engine.from_pandas(dataset) dataset, training_set_metadata = build_dataset( config, dataset, features, preprocessing_params, mode="prediction", metadata=training_set_metadata, backend=backend, callbacks=callbacks, ) return dataset, training_set_metadata, None class CSVPreprocessor(DataFormatPreprocessor): @staticmethod def preprocess_for_training( config, features, dataset=None, training_set=None, validation_set=None, test_set=None, training_set_metadata=None, skip_save_processed_input=False, preprocessing_params=default_training_preprocessing_parameters, backend=LOCAL_BACKEND, random_seed=default_random_seed, callbacks=None, ): return _preprocess_file_for_training( config, features, dataset, training_set, validation_set, test_set, read_fn=read_csv, training_set_metadata=training_set_metadata, skip_save_processed_input=skip_save_processed_input, preprocessing_params=preprocessing_params, backend=backend, random_seed=random_seed, callbacks=callbacks, ) @staticmethod def preprocess_for_prediction( config, dataset, features, preprocessing_params, training_set_metadata, backend, callbacks ): dataset_df = read_csv(dataset, df_lib=backend.df_engine.df_lib) training_set_metadata[SRC] = dataset dataset, training_set_metadata = build_dataset( config, dataset_df, features, preprocessing_params, mode="prediction", metadata=training_set_metadata, backend=backend, callbacks=callbacks, ) return dataset, training_set_metadata, None class TSVPreprocessor(DataFormatPreprocessor): @staticmethod def preprocess_for_training( config, features, dataset=None, training_set=None, validation_set=None, test_set=None, training_set_metadata=None, skip_save_processed_input=False, preprocessing_params=default_training_preprocessing_parameters, backend=LOCAL_BACKEND, random_seed=default_random_seed, callbacks=None, ): return _preprocess_file_for_training( config, features, dataset, training_set, validation_set, test_set, read_fn=read_tsv, training_set_metadata=training_set_metadata, skip_save_processed_input=skip_save_processed_input, preprocessing_params=preprocessing_params, backend=backend, random_seed=random_seed, callbacks=callbacks, ) @staticmethod def preprocess_for_prediction( config, dataset, features, preprocessing_params, training_set_metadata, backend, callbacks ): dataset_df = read_tsv(dataset, df_lib=backend.df_engine.df_lib) training_set_metadata[SRC] = dataset dataset, training_set_metadata = build_dataset( config, dataset_df, features, preprocessing_params, mode="prediction", metadata=training_set_metadata, backend=backend, callbacks=callbacks, ) return dataset, training_set_metadata, None class JSONPreprocessor(DataFormatPreprocessor): @staticmethod def preprocess_for_training( config, features, dataset=None, training_set=None, validation_set=None, test_set=None, training_set_metadata=None, skip_save_processed_input=False, preprocessing_params=default_training_preprocessing_parameters, backend=LOCAL_BACKEND, random_seed=default_random_seed, callbacks=None, ): return _preprocess_file_for_training( config, features, dataset, training_set, validation_set, test_set, read_fn=read_json, training_set_metadata=training_set_metadata, skip_save_processed_input=skip_save_processed_input, preprocessing_params=preprocessing_params, backend=backend, random_seed=random_seed, callbacks=callbacks, ) @staticmethod def preprocess_for_prediction( config, dataset, features, preprocessing_params, training_set_metadata, backend, callbacks ): dataset_df = read_json(dataset, backend.df_engine.df_lib) training_set_metadata[SRC] = dataset dataset, training_set_metadata = build_dataset( config, dataset_df, features, preprocessing_params, mode="prediction", metadata=training_set_metadata, backend=backend, callbacks=callbacks, ) return dataset, training_set_metadata, None class JSONLPreprocessor(DataFormatPreprocessor): @staticmethod def preprocess_for_training( config, features, dataset=None, training_set=None, validation_set=None, test_set=None, training_set_metadata=None, skip_save_processed_input=False, preprocessing_params=default_training_preprocessing_parameters, backend=LOCAL_BACKEND, random_seed=default_random_seed, callbacks=None, ): return _preprocess_file_for_training( config, features, dataset, training_set, validation_set, test_set, read_fn=read_jsonl, training_set_metadata=training_set_metadata, skip_save_processed_input=skip_save_processed_input, preprocessing_params=preprocessing_params, backend=backend, random_seed=random_seed, callbacks=callbacks, ) @staticmethod def preprocess_for_prediction( config, dataset, features, preprocessing_params, training_set_metadata, backend, callbacks ): dataset_df = read_jsonl(dataset, backend.df_engine.df_lib) training_set_metadata[SRC] = dataset dataset, training_set_metadata = build_dataset( config, dataset_df, features, preprocessing_params, mode="prediction", metadata=training_set_metadata, backend=backend, callbacks=callbacks, ) return dataset, training_set_metadata, None class ExcelPreprocessor(DataFormatPreprocessor): @staticmethod def preprocess_for_training( config, features, dataset=None, training_set=None, validation_set=None, test_set=None, training_set_metadata=None, skip_save_processed_input=False, preprocessing_params=default_training_preprocessing_parameters, backend=LOCAL_BACKEND, random_seed=default_random_seed, callbacks=None, ): return _preprocess_file_for_training( config, features, dataset, training_set, validation_set, test_set, read_fn=read_excel, training_set_metadata=training_set_metadata, skip_save_processed_input=skip_save_processed_input, preprocessing_params=preprocessing_params, backend=backend, random_seed=random_seed, callbacks=callbacks, ) @staticmethod def preprocess_for_prediction( config, dataset, features, preprocessing_params, training_set_metadata, backend, callbacks ): dataset_df = read_excel(dataset, backend.df_engine.df_lib) training_set_metadata[SRC] = dataset dataset, training_set_metadata = build_dataset( config, dataset_df, features, preprocessing_params, mode="prediction", metadata=training_set_metadata, backend=backend, callbacks=callbacks, ) return dataset, training_set_metadata, None class ParquetPreprocessor(DataFormatPreprocessor): @staticmethod def preprocess_for_training( config, features, dataset=None, training_set=None, validation_set=None, test_set=None, training_set_metadata=None, skip_save_processed_input=False, preprocessing_params=default_training_preprocessing_parameters, backend=LOCAL_BACKEND, random_seed=default_random_seed, callbacks=None, ): return _preprocess_file_for_training( config, features, dataset, training_set, validation_set, test_set, read_fn=read_parquet, training_set_metadata=training_set_metadata, skip_save_processed_input=skip_save_processed_input, preprocessing_params=preprocessing_params, backend=backend, random_seed=random_seed, callbacks=callbacks, ) @staticmethod def preprocess_for_prediction( config, dataset, features, preprocessing_params, training_set_metadata, backend, callbacks ): dataset_df = read_parquet(dataset, backend.df_engine.df_lib) training_set_metadata[SRC] = dataset dataset, training_set_metadata = build_dataset( config, dataset_df, features, preprocessing_params, mode="prediction", metadata=training_set_metadata, backend=backend, callbacks=callbacks, ) return dataset, training_set_metadata, None @staticmethod def prepare_processed_data( features, dataset=None, training_set=None, validation_set=None, test_set=None, training_set_metadata=None, skip_save_processed_input=False, preprocessing_params=default_training_preprocessing_parameters, backend=LOCAL_BACKEND, random_seed=default_random_seed, ): test_set = test_set if test_set and path_exists(test_set) else None if test_set and isinstance(test_set, str) and DATA_TEST_PARQUET_FP not in training_set_metadata: training_set_metadata[DATA_TEST_PARQUET_FP] = test_set validation_set = validation_set if validation_set and path_exists(validation_set) else None if ( validation_set and isinstance(validation_set, str) and DATA_VALIDATION_PARQUET_FP not in training_set_metadata ): training_set_metadata[DATA_VALIDATION_PARQUET_FP] = validation_set if training_set and isinstance(training_set, str) and DATA_TRAIN_PARQUET_FP not in training_set_metadata: training_set_metadata[DATA_TRAIN_PARQUET_FP] = training_set return training_set, test_set, validation_set, training_set_metadata class PicklePreprocessor(DataFormatPreprocessor): @staticmethod def preprocess_for_training( config, features, dataset=None, training_set=None, validation_set=None, test_set=None, training_set_metadata=None, skip_save_processed_input=False, preprocessing_params=default_training_preprocessing_parameters, backend=LOCAL_BACKEND, random_seed=default_random_seed, callbacks=None, ): return _preprocess_file_for_training( config, features, dataset, training_set, validation_set, test_set, read_fn=read_pickle, training_set_metadata=training_set_metadata, skip_save_processed_input=skip_save_processed_input, preprocessing_params=preprocessing_params, backend=backend, random_seed=random_seed, callbacks=callbacks, ) @staticmethod def preprocess_for_prediction( config, dataset, features, preprocessing_params, training_set_metadata, backend, callbacks ): dataset_df = read_pickle(dataset, backend.df_engine.df_lib) training_set_metadata[SRC] = dataset dataset, training_set_metadata = build_dataset( config, dataset_df, features, preprocessing_params, mode="prediction", metadata=training_set_metadata, backend=backend, callbacks=callbacks, ) return dataset, training_set_metadata, None class FatherPreprocessor(DataFormatPreprocessor): @staticmethod def preprocess_for_training( config, features, dataset=None, training_set=None, validation_set=None, test_set=None, training_set_metadata=None, skip_save_processed_input=False, preprocessing_params=default_training_preprocessing_parameters, backend=LOCAL_BACKEND, random_seed=default_random_seed, callbacks=None, ): return _preprocess_file_for_training( config, features, dataset, training_set, validation_set, test_set, read_fn=read_feather, training_set_metadata=training_set_metadata, skip_save_processed_input=skip_save_processed_input, preprocessing_params=preprocessing_params, backend=backend, random_seed=random_seed, callbacks=callbacks, ) @staticmethod def preprocess_for_prediction( config, dataset, features, preprocessing_params, training_set_metadata, backend, callbacks ): dataset_df = read_feather(dataset, backend.df_engine.df_lib) training_set_metadata[SRC] = dataset dataset, training_set_metadata = build_dataset( config, dataset_df, features, preprocessing_params, mode="prediction", metadata=training_set_metadata, backend=backend, callbacks=callbacks, ) return dataset, training_set_metadata, None class FWFPreprocessor(DataFormatPreprocessor): @staticmethod def preprocess_for_training( config, features, dataset=None, training_set=None, validation_set=None, test_set=None, training_set_metadata=None, skip_save_processed_input=False, preprocessing_params=default_training_preprocessing_parameters, backend=LOCAL_BACKEND, random_seed=default_random_seed, callbacks=None, ): return _preprocess_file_for_training( config, features, dataset, training_set, validation_set, test_set, read_fn=read_fwf, training_set_metadata=training_set_metadata, skip_save_processed_input=skip_save_processed_input, preprocessing_params=preprocessing_params, backend=backend, random_seed=random_seed, callbacks=callbacks, ) @staticmethod def preprocess_for_prediction( config, dataset, features, preprocessing_params, training_set_metadata, backend, callbacks ): dataset_df = read_fwf(dataset, backend.df_engine.df_lib) training_set_metadata[SRC] = dataset dataset, training_set_metadata = build_dataset( config, dataset_df, features, preprocessing_params, mode="prediction", metadata=training_set_metadata, backend=backend, callbacks=callbacks, ) return dataset, training_set_metadata, None class HTMLPreprocessor(DataFormatPreprocessor): @staticmethod def preprocess_for_training( config, features, dataset=None, training_set=None, validation_set=None, test_set=None, training_set_metadata=None, skip_save_processed_input=False, preprocessing_params=default_training_preprocessing_parameters, backend=LOCAL_BACKEND, random_seed=default_random_seed, callbacks=None, ): return _preprocess_file_for_training( config, features, dataset, training_set, validation_set, test_set, read_fn=read_html, training_set_metadata=training_set_metadata, skip_save_processed_input=skip_save_processed_input, preprocessing_params=preprocessing_params, backend=backend, random_seed=random_seed, callbacks=callbacks, ) @staticmethod def preprocess_for_prediction( config, dataset, features, preprocessing_params, training_set_metadata, backend, callbacks ): dataset_df = read_html(dataset, backend.df_engine.df_lib) training_set_metadata[SRC] = dataset dataset, training_set_metadata = build_dataset( config, dataset_df, features, preprocessing_params, mode="prediction", metadata=training_set_metadata, backend=backend, callbacks=callbacks, ) return dataset, training_set_metadata, None class ORCPreprocessor(DataFormatPreprocessor): @staticmethod def preprocess_for_training( config, features, dataset=None, training_set=None, validation_set=None, test_set=None, training_set_metadata=None, skip_save_processed_input=False, preprocessing_params=default_training_preprocessing_parameters, backend=LOCAL_BACKEND, random_seed=default_random_seed, callbacks=None, ): return _preprocess_file_for_training( config, features, dataset, training_set, validation_set, test_set, read_fn=read_orc, training_set_metadata=training_set_metadata, skip_save_processed_input=skip_save_processed_input, preprocessing_params=preprocessing_params, backend=backend, random_seed=random_seed, callbacks=callbacks, ) @staticmethod def preprocess_for_prediction( config, dataset, features, preprocessing_params, training_set_metadata, backend, callbacks ): dataset_df = read_orc(dataset, backend.df_engine.df_lib) training_set_metadata[SRC] = dataset dataset, training_set_metadata = build_dataset( config, dataset_df, features, preprocessing_params, mode="prediction", metadata=training_set_metadata, backend=backend, callbacks=callbacks, ) return dataset, training_set_metadata, None class SASPreprocessor(DataFormatPreprocessor): @staticmethod def preprocess_for_training( config, features, dataset=None, training_set=None, validation_set=None, test_set=None, training_set_metadata=None, skip_save_processed_input=False, preprocessing_params=default_training_preprocessing_parameters, backend=LOCAL_BACKEND, random_seed=default_random_seed, callbacks=None, ): return _preprocess_file_for_training( config, features, dataset, training_set, validation_set, test_set, read_fn=read_sas, training_set_metadata=training_set_metadata, skip_save_processed_input=skip_save_processed_input, preprocessing_params=preprocessing_params, backend=backend, random_seed=random_seed, callbacks=callbacks, ) @staticmethod def preprocess_for_prediction( config, dataset, features, preprocessing_params, training_set_metadata, backend, callbacks ): dataset_df = read_sas(dataset, backend.df_engine.df_lib) training_set_metadata[SRC] = dataset dataset, training_set_metadata = build_dataset( config, dataset_df, features, preprocessing_params, mode="prediction", metadata=training_set_metadata, backend=backend, callbacks=callbacks, ) return dataset, training_set_metadata, None class SPSSPreprocessor(DataFormatPreprocessor): @staticmethod def preprocess_for_training( config, features, dataset=None, training_set=None, validation_set=None, test_set=None, training_set_metadata=None, skip_save_processed_input=False, preprocessing_params=default_training_preprocessing_parameters, backend=LOCAL_BACKEND, random_seed=default_random_seed, callbacks=None, ): return _preprocess_file_for_training( config, features, dataset, training_set, validation_set, test_set, read_fn=read_spss, training_set_metadata=training_set_metadata, skip_save_processed_input=skip_save_processed_input, preprocessing_params=preprocessing_params, backend=backend, random_seed=random_seed, callbacks=callbacks, ) @staticmethod def preprocess_for_prediction( config, dataset, features, preprocessing_params, training_set_metadata, backend, callbacks ): dataset_df = read_spss(dataset, backend.df_engine.df_lib) training_set_metadata[SRC] = dataset dataset, training_set_metadata = build_dataset( config, dataset_df, features, preprocessing_params, mode="prediction", metadata=training_set_metadata, backend=backend, callbacks=callbacks, ) return dataset, training_set_metadata, None class StataPreprocessor(DataFormatPreprocessor): @staticmethod def preprocess_for_training( config, features, dataset=None, training_set=None, validation_set=None, test_set=None, training_set_metadata=None, skip_save_processed_input=False, preprocessing_params=default_training_preprocessing_parameters, backend=LOCAL_BACKEND, random_seed=default_random_seed, callbacks=None, ): return _preprocess_file_for_training( config, features, dataset, training_set, validation_set, test_set, read_fn=read_stata, training_set_metadata=training_set_metadata, skip_save_processed_input=skip_save_processed_input, preprocessing_params=preprocessing_params, backend=backend, random_seed=random_seed, callbacks=callbacks, ) @staticmethod def preprocess_for_prediction( config, dataset, features, preprocessing_params, training_set_metadata, backend, callbacks ): dataset_df = read_stata(dataset, backend.df_engine.df_lib) training_set_metadata[SRC] = dataset dataset, training_set_metadata = build_dataset( config, dataset_df, features, preprocessing_params, mode="prediction", metadata=training_set_metadata, backend=backend, callbacks=callbacks, ) return dataset, training_set_metadata, None class HDF5Preprocessor(DataFormatPreprocessor): @staticmethod def preprocess_for_training( config, features, dataset=None, training_set=None, validation_set=None, test_set=None, training_set_metadata=None, skip_save_processed_input=False, preprocessing_params=default_training_preprocessing_parameters, backend=LOCAL_BACKEND, random_seed=default_random_seed, callbacks=None, ): return HDF5Preprocessor.prepare_processed_data( features, dataset, training_set, validation_set, test_set, training_set_metadata, skip_save_processed_input, preprocessing_params, backend, random_seed, ) @staticmethod def preprocess_for_prediction( config, dataset, features, preprocessing_params, training_set_metadata, backend, callbacks ): hdf5_fp = dataset dataset = load_hdf5(dataset, preprocessing_params, backend, split_data=False, shuffle_training=False) return dataset, training_set_metadata, hdf5_fp @staticmethod def prepare_processed_data( features, dataset=None, training_set=None, validation_set=None, test_set=None, training_set_metadata=None, skip_save_processed_input=False, preprocessing_params=default_training_preprocessing_parameters, backend=LOCAL_BACKEND, random_seed=default_random_seed, ): if dataset is None and training_set is None: raise ValueError("One of `dataset` or `training_set` must be not None") not_none_set = dataset if dataset is not None else training_set if not training_set_metadata: raise ValueError("When providing HDF5 data, " "training_set_metadata must not be None.") logger.info("Using full hdf5 and json") if DATA_TRAIN_HDF5_FP not in training_set_metadata: logger.warning( "data_train_hdf5_fp not present in training_set_metadata. " "Adding it with the current HDF5 file path {}".format(not_none_set) ) training_set_metadata[DATA_TRAIN_HDF5_FP] = not_none_set elif training_set_metadata[DATA_TRAIN_HDF5_FP] != not_none_set: logger.warning( "data_train_hdf5_fp in training_set_metadata is {}, " "different from the current HDF5 file path {}. " "Replacing it".format(training_set_metadata[DATA_TRAIN_HDF5_FP], not_none_set) ) training_set_metadata[DATA_TRAIN_HDF5_FP] = not_none_set if dataset is not None: training_set, test_set, validation_set = load_hdf5( dataset, preprocessing_params, backend, shuffle_training=True ) elif training_set is not None: kwargs = dict(preprocessing_params=preprocessing_params, backend=backend, split_data=False) training_set = load_hdf5(training_set, shuffle_training=True, **kwargs) if validation_set is not None: validation_set = load_hdf5(validation_set, shuffle_training=False, **kwargs) if test_set is not None: test_set = load_hdf5(test_set, shuffle_training=False, **kwargs) return training_set, test_set, validation_set, training_set_metadata data_format_preprocessor_registry = { **{fmt: DictPreprocessor for fmt in DICT_FORMATS}, **{fmt: DataFramePreprocessor for fmt in DATAFRAME_FORMATS}, **{fmt: CSVPreprocessor for fmt in CSV_FORMATS}, **{fmt: TSVPreprocessor for fmt in TSV_FORMATS}, **{fmt: JSONPreprocessor for fmt in JSON_FORMATS}, **{fmt: JSONLPreprocessor for fmt in JSONL_FORMATS}, **{fmt: ExcelPreprocessor for fmt in EXCEL_FORMATS}, **{fmt: ParquetPreprocessor for fmt in PARQUET_FORMATS}, **{fmt: PicklePreprocessor for fmt in PICKLE_FORMATS}, **{fmt: FWFPreprocessor for fmt in FWF_FORMATS}, **{fmt: FatherPreprocessor for fmt in FEATHER_FORMATS}, **{fmt: HTMLPreprocessor for fmt in HTML_FORMATS}, **{fmt: ORCPreprocessor for fmt in ORC_FORMATS}, **{fmt: SASPreprocessor for fmt in SAS_FORMATS}, **{fmt: SPSSPreprocessor for fmt in SPSS_FORMATS}, **{fmt: StataPreprocessor for fmt in STATA_FORMATS}, **{fmt: HDF5Preprocessor for fmt in HDF5_FORMATS}, } def build_dataset( config, dataset_df, features, global_preprocessing_parameters, mode, metadata=None, backend=LOCAL_BACKEND, random_seed=default_random_seed, skip_save_processed_input=False, callbacks=None, ): """Builds a dataset from a dataframe and a list of features. Args: config: A dictionary containing the Ludwig model configuration dataset_df: Pandas or Dask dataframe features: List of features global_preprocessing_parameters: Global preprocessing parameters mode: One of ['training', 'prediction'] metadata: Training set metadata if available backend: Backend random_seed: Random seed skip_save_processed_input: Whether to skip saving the processed input callbacks: List of callbacks Returns: A tuple of (dataset, metadata) """ df_engine = backend.df_engine if df_engine.partitioned: if any(f["type"] in REPARTITIONING_FEATURE_TYPES for f in features) and dataset_df.npartitions > 1: # A globally unique index only matters if you know that there will be a repartition downstream for some # particular feature, i.e. for Image and Audio features on a Ray backend. # - There is a join operation in `df_like`, and the only way to do the operation is if the partitions across # all feature columns are aligned. # - In order to align the partitions, we require a way of matching samples to one another across all # partitions. Therefore, we must reset_index to create a globally unique index. # - If the number of partitions is 1, it is *highly likely* the index is globally unique. Auto-assigned # Dask indices in this case are unique, and we pd.concat train, val, and test sets with ignore_index=True # If there will NOT be a repartition downstream, then we can skip this step. # - In this case, the partitions should remain aligned throughout. # - Further, while the indices might not be globally unique, they should be unique within each partition. # - These two properties make it possible to do the join op within each partition without a global index. logger.warning( f"Dataset has {dataset_df.npartitions} partitions and feature types that cause repartitioning. " f"Resetting index to ensure globally unique indices." ) dataset_df = df_engine.reset_index(dataset_df) dataset_df = df_engine.parallelize(dataset_df) # Ensure that column names with non-word characters won't cause problems for downstream operations. # NOTE: Must be kept consistent with config sanitization in schema/model_types/base.py. dataset_df = sanitize_column_names(dataset_df) if mode == "training": sample_ratio = global_preprocessing_parameters["sample_ratio"] sample_size = global_preprocessing_parameters["sample_size"] dataset_df = _get_sampled_dataset_df(dataset_df, df_engine, sample_ratio, sample_size, random_seed) # If persisting DataFrames in memory is enabled, we want to do this after # each batch of parallel ops in order to avoid redundant computation dataset_df = df_engine.persist(dataset_df) if mode == "training": default_preprocessing_parameters = default_training_preprocessing_parameters elif mode == "prediction": default_preprocessing_parameters = default_prediction_preprocessing_parameters else: raise ValueError(f"Invalid mode {mode}") global_preprocessing_parameters = merge_dict(default_preprocessing_parameters, global_preprocessing_parameters) split_col = None if global_preprocessing_parameters["split"]["type"] == "fixed": if global_preprocessing_parameters["split"]["column"] in dataset_df.columns: split_col = dataset_df[global_preprocessing_parameters["split"]["column"]] else: logger.warning( f"Specified split column {global_preprocessing_parameters['split']['column']} for fixed " f"split strategy was not found in dataset." # noqa: E713 ) # update input features with prompt configs during preprocessing (as opposed to during the model forward pass) # so that we can compute metadata and build the dataset correctly. logger.debug("handle text features with prompt parameters") synthesized_dataset_cols = handle_features_with_prompt_config( config, dataset_df, features, split_col=split_col, backend=backend ) # Get all the unique preprocessing features to compute feature_configs = [] feature_hashes = set() for feature in features: if feature[PROC_COLUMN] not in feature_hashes: feature_configs.append(feature) feature_hashes.add(feature[PROC_COLUMN]) dataset_cols = {} for feature_config in feature_configs: col_name = feature_config[COLUMN] dataset_cols[col_name] = ( synthesized_dataset_cols[col_name] if col_name in synthesized_dataset_cols else dataset_df[col_name] ) logger.debug("build preprocessing parameters") feature_name_to_preprocessing_parameters = build_preprocessing_parameters( dataset_cols, feature_configs, global_preprocessing_parameters, backend, metadata=metadata ) # Happens after preprocessing parameters are built, so we can use precomputed fill values. logger.debug("handle missing values") # In some cases, there can be a (temporary) mismatch between the dtype of the column and the type expected by the # preprocessing config (e.g., a categorical feature represented as an int-like column). In particular, Dask # may raise an error even when there are no missing values in the column itself. # # Since we immediately cast all columns in accordance with their expected feature types after filling missing # values, we work around the above issue by temporarily treating all columns as object dtype. for col_key in dataset_cols: dataset_cols[col_key] = dataset_cols[col_key].astype(object) for feature_config in feature_configs: preprocessing_parameters = feature_name_to_preprocessing_parameters[feature_config[NAME]] handle_missing_values(dataset_cols, feature_config, preprocessing_parameters, backend) # Happens after missing values are handled to avoid NaN casting issues. logger.debug("cast columns") cast_columns(dataset_cols, feature_configs, backend) for callback in callbacks or []: callback.on_build_metadata_start(dataset_df, mode) logger.debug("build metadata") metadata: TrainingSetMetadataDict = build_metadata( config, metadata, feature_name_to_preprocessing_parameters, dataset_cols, feature_configs, backend ) check_global_max_sequence_length_fits_prompt_template(metadata, global_preprocessing_parameters) for callback in callbacks or []: callback.on_build_metadata_end(dataset_df, mode) for callback in callbacks or []: callback.on_build_data_start(dataset_df, mode) logger.debug("build data") proc_cols = build_data(dataset_cols, feature_configs, metadata, backend, skip_save_processed_input) for callback in callbacks or []: callback.on_build_data_end(dataset_df, mode) # Get any additional columns needed for splitting downstream, otherwise they will not be # included in the preprocessed output. split_params = global_preprocessing_parameters.get(SPLIT, {}) if "type" not in split_params and SPLIT in dataset_df: warnings.warn( 'Detected "split" column in the data, but using default split type ' '"random". Did you mean to set split type to "fixed"?' ) splitter = get_splitter(**split_params) for column in splitter.required_columns: if column not in dataset_df: warnings.warn( f"column: '{column}' is required by the dataset splitter with params: {split_params}, but '{column}' " f"is not present in the `dataset_df` with columns: {dataset_df.columns}. This is acceptable during " "serving setting where dataset splitting is irrelevant. You may see this warning if, for example, the " "model was trained with a configuration that used a stratified split on the target column, but for " "live predictions, a value for the target column is not to be provided." ) continue proc_cols[column] = dataset_df[column] # TODO pyarrow: this is needed for caching to work with pyarrow. if removed, the following error is raised: # "pyarrow.lib.ArrowInvalid: Can only convert 1-dimensional array values". The data is reshaped when loaded # by the batcher in the RayDataset class (see _prepare_batch). if not skip_save_processed_input and backend.cache.data_format == "parquet": for feature in features: name = feature[NAME] proc_column = feature[PROC_COLUMN] reshape = metadata[name].get("reshape") if reshape is not None: proc_cols[proc_column] = backend.df_engine.map_objects(proc_cols[proc_column], lambda x: x.reshape(-1)) # Implements an outer join of proc_cols dataset = backend.df_engine.df_like(dataset_df, proc_cols) # At this point, there should be no missing values left in the dataframe, unless # the DROP_ROW preprocessing option was selected, in which case we need to drop those # rows. len_dataset_before_drop_rows = len(dataset) dataset = dataset.dropna() len_dataset_after_drop_rows = len(dataset) if len_dataset_before_drop_rows != len_dataset_after_drop_rows: logger.warning( f"Dropped a total of {len_dataset_before_drop_rows - len_dataset_after_drop_rows} rows out of " f"{len_dataset_before_drop_rows} due to missing values" ) # NaNs introduced by outer join change dtype of dataset cols (upcast to float64), so we need to cast them back. col_name_to_dtype = {} for col_name, col in proc_cols.items(): # if col is a list of list-like objects, we assume the internal dtype of each col[i] remains unchanged. if isinstance(col, list) and isinstance(col[0], (list, np.ndarray, torch.Tensor)): continue dtype = col.dtype # Skip non-numpy extension dtypes (e.g. TensorDtype from Ray, ArrowDtype from PyArrow) # as they cannot be used with DataFrame.astype() reliably. if not isinstance(dtype, np.dtype): continue col_name_to_dtype[col_name] = dtype dataset = dataset.astype(col_name_to_dtype) # Persist the completed dataset with no NaNs dataset = backend.df_engine.persist(dataset) # Remove partitions that are empty after removing NaNs dataset = backend.df_engine.remove_empty_partitions(dataset) # Embed features with fixed encoders dataset = embed_fixed_features(dataset, feature_configs, metadata, backend) return dataset, metadata def embed_fixed_features( dataset: DataFrame, feature_configs: list[FeatureConfigDict], metadata: TrainingSetMetadataDict, backend: Backend ) -> DataFrame: """Transforms every input feature with cacheable encoder embeddings into its encoded form and updates metadata.""" # Encode features in bulk at the end features_to_encode = get_features_with_cacheable_fixed_embeddings(feature_configs, metadata) if not features_to_encode: return dataset logger.info(f"Cache encoder embeddings for features: {[f[NAME] for f in features_to_encode]}") for feature in features_to_encode: # Temporarily set to False to ensure proper encoding metadata[feature[NAME]][PREPROCESSING]["cache_encoder_embeddings"] = False batch_size = backend.tune_batch_size(create_embed_batch_size_evaluator(features_to_encode, metadata), len(dataset)) transform_fn = create_embed_transform_fn(features_to_encode, metadata) results = backend.batch_transform(dataset, batch_size, transform_fn, name="Caching encoder embeddings") for feature in features_to_encode: # Set metadata so we know to skip encoding the feature metadata[feature[NAME]][PREPROCESSING]["cache_encoder_embeddings"] = True return results def _get_sampled_dataset_df(dataset_df, df_engine, sample_ratio, sample_size, random_seed): df_len = len(dataset_df) if sample_ratio < 1.0: if not df_engine.partitioned and df_len * sample_ratio < 1: raise ValueError( f"sample_ratio {sample_ratio} is too small for dataset of length {df_len}. " f"Please increase sample_ratio or use a larger dataset." ) logger.debug(f"sample {sample_ratio} of data") dataset_df = dataset_df.sample(frac=sample_ratio, random_state=random_seed) if sample_size: if sample_size < df_len: # Cannot use 'n' parameter when using dask DataFrames -- only 'frac' is supported sample_ratio = sample_size / df_len dataset_df = dataset_df.sample(frac=sample_ratio, random_state=random_seed) else: logger.warning("sample_size is larger than dataset size, ignoring sample_size") return dataset_df def get_features_with_cacheable_fixed_embeddings( feature_configs: list[FeatureConfigDict], metadata: TrainingSetMetadataDict ) -> list[FeatureConfigDict]: """Returns list of features with `cache_encoder_embeddings=True` set in the preprocessing config.""" features_to_encode = [] for feature_config in feature_configs: # deal with encoders that have fixed preprocessing if ENCODER in feature_config: encoder_params = feature_config[ENCODER] if TYPE in encoder_params: preprocessing = metadata[feature_config[NAME]][PREPROCESSING] if preprocessing.get("cache_encoder_embeddings"): # TODO(travis): passing in MODEL_ECD is a hack here that can be removed once we move to using # the config object everywhere in preprocessing. Then we won't need to do the lookup on the # encoder schema at all. encoder_class = get_encoder_cls(MODEL_ECD, feature_config[TYPE], encoder_params[TYPE]) encoder = encoder_class.from_dict(encoder_params) if not encoder.can_cache_embeddings(): raise ValueError( f"Set `cache_encoder_embeddings=True` for feature {feature_config[NAME]} with " f"encoder {encoder_params[TYPE]}, but encoder embeddings are not static." ) # Convert to Ray Datasets, map batches to encode, then convert back to Dask features_to_encode.append(feature_config) return features_to_encode def cast_columns(dataset_cols, features, backend) -> None: """Casts columns based on their feature type.""" for feature in features: # todo figure out if additional parameters are needed # for the cast_column function try: dataset_cols[feature[COLUMN]] = get_from_registry(feature[TYPE], get_base_type_registry()).cast_column( dataset_cols[feature[COLUMN]], backend ) except KeyError as e: raise KeyError( f"Feature name {e} specified in the config was not found in dataset with columns: " # noqa: E713 + f"{list(dataset_cols.keys())}" ) def merge_preprocessing( feature_config: FeatureConfigDict, global_preprocessing_parameters: PreprocessingConfigDict ) -> FeatureConfigDict: if PREPROCESSING not in feature_config: return global_preprocessing_parameters[feature_config[TYPE]] return merge_dict(global_preprocessing_parameters[feature_config[TYPE]], feature_config[PREPROCESSING]) def build_preprocessing_parameters( dataset_cols: dict[str, Series], feature_configs: list[FeatureConfigDict], global_preprocessing_parameters: PreprocessingConfigDict, backend: Backend, metadata: TrainingSetMetadataDict | None = None, ) -> PreprocessingConfigDict: if metadata is None: metadata = {} feature_name_to_preprocessing_parameters = {} for feature_config in feature_configs: feature_name = feature_config[NAME] # if metadata already exists, we can use it to get preprocessing parameters if feature_name in metadata: feature_name_to_preprocessing_parameters[feature_name] = metadata[feature_name][PREPROCESSING] continue preprocessing_parameters = feature_config[PREPROCESSING] missing_value_strategy = preprocessing_parameters["missing_value_strategy"] fill_value = precompute_fill_value( dataset_cols, feature_config, missing_value_strategy, preprocessing_parameters, backend ) if fill_value is not None: preprocessing_parameters.update({"computed_fill_value": fill_value}) # Handle outlier replacement outlier_strategy = preprocessing_parameters.get("outlier_strategy") if outlier_strategy is not None: if outlier_strategy != missing_value_strategy: outlier_fill_value = precompute_fill_value( dataset_cols, feature_config, outlier_strategy, preprocessing_parameters, backend ) else: # Use fill value from missing_value_strategy to avoid redundant computation outlier_fill_value = fill_value if outlier_fill_value is not None: preprocessing_parameters.update({"computed_outlier_fill_value": outlier_fill_value}) feature_name_to_preprocessing_parameters[feature_name] = preprocessing_parameters return feature_name_to_preprocessing_parameters def is_input_feature(feature_config: FeatureConfigDict) -> bool: """Utility function to check for the presence of encoder in the feature config to determine if the feature is an input feature or output feature.""" return ENCODER in feature_config def build_metadata( config: ModelConfigDict, metadata: TrainingSetMetadataDict, feature_name_to_preprocessing_parameters: dict[str, PreprocessingConfigDict], dataset_cols: dict[str, Series], feature_configs: list[FeatureConfigDict], backend: Backend, ) -> TrainingSetMetadataDict: for feature_config in feature_configs: feature_name = feature_config[NAME] if feature_name in metadata: continue preprocessing_parameters = feature_name_to_preprocessing_parameters[feature_name] column = dataset_cols[feature_config[COLUMN]] metadata[feature_name] = get_from_registry(feature_config[TYPE], get_base_type_registry()).get_feature_meta( config, column, preprocessing_parameters, backend, is_input_feature(feature_config) ) metadata[feature_name][PREPROCESSING] = preprocessing_parameters return metadata def build_data( input_cols: DataFrame, feature_configs: list[dict], training_set_metadata: dict, backend: Backend, skip_save_processed_input: bool, ) -> dict[str, DataFrame]: """Preprocesses the input dataframe columns, handles missing values, and potentially adds metadata to training_set_metadata. Args: input_cols: Input dataframe to be processed. feature_configs: List of feature configs. training_set_metadata: Training set metadata. Additional fields may be added. backend: Backend for data processing. skip_save_processed_input: (bool) Whether to skip saving the processed input. Returns: Dictionary of (feature name) -> (processed data). """ proc_cols = {} for feature_config in feature_configs: # TODO(travis): instead of using raw dictionary, this should be loaded into a proper PreprocessingConfig # object, so we don't need to hackily check for the presence of added keys. preprocessing_parameters = training_set_metadata[feature_config[NAME]][PREPROCESSING] # Need to run this again here as cast_columns may have introduced new missing values handle_missing_values(input_cols, feature_config, preprocessing_parameters, backend) # For features that support it, we perform outlier removal here using metadata computed on the full dataset handle_outliers( input_cols, feature_config, preprocessing_parameters, training_set_metadata[feature_config[NAME]], backend ) get_from_registry(feature_config[TYPE], get_base_type_registry()).add_feature_data( feature_config, input_cols, proc_cols, training_set_metadata, preprocessing_parameters, backend, skip_save_processed_input, ) return proc_cols def balance_data( dataset_df: DataFrame, output_features: list[dict], preprocessing_parameters: dict, backend: Backend, random_seed: int, ): """The purpose of this function is to balance the training dataset using either over-sampling or under- sampling. Args: dataset_df: Input dataframe to be over-sampled or under-sampled. output_features: List of feature configs. preprocessing_parameters: Dictionary of the global preprocessing parameters. backend: Backend for data processing. random_seed: Integer to seed the random sampling to ensure determinism. Returns: An over-sampled or under-sampled training dataset. """ target = output_features[0][PROC_COLUMN] if backend.df_engine.partitioned: majority_class = backend.df_engine.compute(dataset_df[target].value_counts()).idxmax() minority_class = backend.df_engine.compute(dataset_df[target].value_counts()).idxmin() else: majority_class = dataset_df[target].value_counts().idxmax() minority_class = dataset_df[target].value_counts().idxmin() majority_df = dataset_df[dataset_df[target] == majority_class] minority_df = dataset_df[dataset_df[target] == minority_class] if preprocessing_parameters["oversample_minority"]: sample_fraction = (len(majority_df) * preprocessing_parameters["oversample_minority"]) / len(minority_df) minority_df = minority_df.sample(frac=sample_fraction, replace=True, random_state=random_seed) elif preprocessing_parameters["undersample_majority"]: sample_fraction = int(len(minority_df) / preprocessing_parameters["undersample_majority"]) / len(majority_df) majority_df = majority_df.sample(frac=sample_fraction, replace=False, random_state=random_seed) balanced_df = backend.df_engine.concat([minority_df, majority_df]) return balanced_df def precompute_fill_value( dataset_cols, feature, missing_value_strategy: str, preprocessing_parameters: PreprocessingConfigDict, backend ): """Precomputes the fill value for a feature. NOTE: this is called before NaNs are removed from the dataset. Modifications here must handle NaNs gracefully. NOTE: this is called before columns are cast. Modifications here must handle dtype conversion gracefully. """ if missing_value_strategy == FILL_WITH_CONST: return preprocessing_parameters["fill_value"] elif missing_value_strategy == FILL_WITH_MODE: # Requires separate handling if Dask since Dask has lazy evaluation # Otherwise, dask returns a Dask index structure instead of a value to use as a fill value return ( dataset_cols[feature[COLUMN]].value_counts().index.compute()[0] if is_dask_series_or_df(dataset_cols[feature[COLUMN]], backend) else dataset_cols[feature[COLUMN]].value_counts().index[0] ) elif missing_value_strategy == FILL_WITH_MEAN: if feature[TYPE] != NUMBER: raise ValueError( f"Filling missing values with mean is supported " f"only for number types, not for type {feature[TYPE]}.", ) return backend.df_engine.compute(dataset_cols[feature[COLUMN]].astype(float).mean()) elif missing_value_strategy in {FILL_WITH_FALSE, FILL_WITH_TRUE}: distinct_values = backend.df_engine.compute( dataset_cols[feature[COLUMN]].drop_duplicates().dropna() ).values.tolist() if len(distinct_values) > 2: raise ValueError( f"Missing value strategy `{missing_value_strategy}` " f"for column {feature[COLUMN]} expects 2 distinct values, " f"found: {len(distinct_values)} (ex: {distinct_values[:10]})" ) fill_to_bool_value = {FILL_WITH_FALSE: False, FILL_WITH_TRUE: True} bool_needed = fill_to_bool_value[missing_value_strategy] # Determine the False label. # Distinct values are sorted in reverse to mirror the selection of the default fallback_true_label (in # binary_feature.get_feature_meta) for binary columns with unconventional boolean values, "human"/"bot". for v in sorted(distinct_values, reverse=True): fallback_true_label = ( preprocessing_parameters["fallback_true_label"] # By default, preprocessing_parameters.fallback_true_label is None. if preprocessing_parameters["fallback_true_label"] else "true" ) if strings_utils.str2bool(v, fallback_true_label) is bool_needed: return v raise ValueError( f"Unable to determine {bool_needed} value for column {feature[COLUMN]} " f"with distinct values: {distinct_values}." ) # Otherwise, we cannot precompute the fill value for this dataset return None @DeveloperAPI def handle_missing_values(dataset_cols, feature, preprocessing_parameters: PreprocessingConfigDict, backend): missing_value_strategy = preprocessing_parameters["missing_value_strategy"] computed_fill_value = preprocessing_parameters.get("computed_fill_value") _handle_missing_values(dataset_cols, feature, missing_value_strategy, computed_fill_value, backend) @DeveloperAPI def handle_outliers(dataset_cols, feature, preprocessing_parameters: PreprocessingConfigDict, metadata, backend): outlier_strategy = preprocessing_parameters.get("outlier_strategy") if outlier_strategy is None: return outlier_threshold = preprocessing_parameters["outlier_threshold"] computed_fill_value = preprocessing_parameters.get("computed_outlier_fill_value") # Identify all outliers and set them to NA so they can be removed series = dataset_cols[feature[COLUMN]] dataset_cols[feature[COLUMN]] = series.mask( series.sub(metadata["mean"]).div(metadata["std"]).abs().gt(outlier_threshold) ) _handle_missing_values(dataset_cols, feature, outlier_strategy, computed_fill_value, backend) def _handle_missing_values( dataset_cols, feature, missing_value_strategy: str, computed_fill_value: float | None, backend ): if ( missing_value_strategy in {FILL_WITH_CONST, FILL_WITH_MODE, FILL_WITH_MEAN, FILL_WITH_FALSE, FILL_WITH_TRUE} and computed_fill_value is not None ): dataset_cols[feature[COLUMN]] = dataset_cols[feature[COLUMN]].fillna( computed_fill_value, ) elif missing_value_strategy in {BFILL, FFILL}: if missing_value_strategy == BFILL: dataset_cols[feature[COLUMN]] = dataset_cols[feature[COLUMN]].bfill() else: dataset_cols[feature[COLUMN]] = dataset_cols[feature[COLUMN]].ffill() # If the first few rows or last few rows of a dataset is a NaN, it will still be a NaN after ffill or bfill are # applied. This causes downstream errors with Dask (https://github.com/ludwig-ai/ludwig/issues/2452) # To get around this issue, apply the primary missing value strategy (say bfill) first, and then follow it # up with the other missing value strategy (ffill) to ensure all NaNs are filled if backend.df_engine.compute(dataset_cols[feature[COLUMN]].isna().sum()) > 0: if missing_value_strategy == FFILL: dataset_cols[feature[COLUMN]] = dataset_cols[feature[COLUMN]].bfill() else: dataset_cols[feature[COLUMN]] = dataset_cols[feature[COLUMN]].ffill() elif missing_value_strategy == DROP_ROW: # Here we only drop from this series, but after preprocessing we'll do a second # round of dropping NA values from the entire output dataframe, which will # result in the removal of the rows. len_before_dropped_rows = len(dataset_cols[feature[COLUMN]]) dataset_cols[feature[COLUMN]] = dataset_cols[feature[COLUMN]].dropna() len_after_dropped_rows = len(dataset_cols[feature[COLUMN]]) if len_before_dropped_rows != len_after_dropped_rows: logger.warning( f"DROP_ROW missing value strategy applied. Dropped {len_before_dropped_rows - len_after_dropped_rows} " f"samples out of {len_before_dropped_rows} from column {feature[COLUMN]}. The rows containing these " f"samples will ultimately be dropped from the dataset." ) else: raise ValueError(f"Invalid missing value strategy {missing_value_strategy}") def handle_features_with_prompt_config( config: ModelConfigDict, dataset_df: DataFrame, features: list[FeatureConfigDict], backend: Backend, split_col: Series | None = None, ) -> dict[str, Series]: """Updates (in-place) dataset columns with prompt configurations containing a non-None task parameter. Dataset columns that are updated here are enriched to have prompts as specified by the prompt configuration. Args: config: Model configuration. dataset_df (DataFrame): Input dataset. features (List[FeatureConfigDict]): List of feature configurations. df_engine (DataFrameEngine): Dataframe engine. split_col (Optional[Series], optional): Split column. Defaults to None. Returns: Dict[str, Series]: Modified dataset columns. """ dataset_cols = {} input_features, output_features = get_input_and_output_features(features) for input_feature_config in input_features: prompt_config = _get_prompt_config(config, input_feature_config) if prompt_config is None: continue input_col_name = input_feature_config[COLUMN] if prompt_config["retrieval"]["type"] is not None: # Ensure that the output features are in the dataset columns saved as part of the index # so that they can be retrieved later at lookup time. output_feature_col_names = [output_feature_config[COLUMN] for output_feature_config in output_features] input_and_output_col_names = set([input_col_name] + output_feature_col_names) input_and_output_cols = { feature[NAME]: dataset_df[feature[COLUMN]] for feature in features if feature[NAME] in input_and_output_col_names } retrieval_model, index_name = index_column( prompt_config["retrieval"], col_name=input_col_name, dataset_cols=input_and_output_cols, backend=backend, split_col=split_col, ) k = prompt_config["retrieval"]["k"] # NOTE: after indexing the input column, we update the index_name in the prompt config IN PLACE. # This ensures that the preprocessing parameters for this feature have an up-to-date index_name # when the training set metadata is saved. prompt_config["retrieval"]["index_name"] = index_name else: retrieval_model = None k = -1 dataset_cols[input_col_name] = format_input_with_prompt( input_col_name, dataset_df, backend, prompt_config["task"], retrieval_model=retrieval_model, k=k, template=prompt_config["template"], ) return dataset_cols def _get_prompt_config(config: ModelConfigDict, input_feature_config: dict) -> dict: if input_feature_config[TYPE] != TEXT: # Prompt config is only applied to text features return None preprocessing = input_feature_config["preprocessing"] if _has_prompt_section(preprocessing): return preprocessing["prompt"] if _has_prompt_section(config): return config["prompt"] return None def _has_prompt_section(config: dict) -> bool: return "prompt" in config and (config["prompt"]["template"] is not None or config["prompt"]["task"] is not None) def load_hdf5(hdf5_file_path, preprocessing_params, backend, split_data=True, shuffle_training=False): # TODO dask: this needs to work with DataFrames logger.info(f"Loading data from: {hdf5_file_path}") def shuffle(df): return df.sample(frac=1).reset_index(drop=True) dataset = data_utils.load_hdf5(hdf5_file_path) if not split_data: if shuffle_training: dataset = shuffle(dataset) return dataset training_set, validation_set, test_set = split_dataset(dataset, preprocessing_params, backend) if shuffle_training: training_set = shuffle(training_set) return training_set, test_set, validation_set def load_metadata(metadata_file_path: str) -> TrainingSetMetadataDict: logger.info(f"Loading metadata from: {metadata_file_path}") training_set_metadata = data_utils.load_json(metadata_file_path) # TODO(travis): decouple config from training_set_metadata so we don't need to # upgrade it over time. training_set_metadata = upgrade_metadata(training_set_metadata) return training_set_metadata def drop_extra_cols(features, dfs): retain_cols = list({feature[PROC_COLUMN]: True for feature in features}.keys()) return tuple(df[retain_cols] if df is not None else df for df in dfs) def preprocess_for_training( config, dataset=None, training_set=None, validation_set=None, test_set=None, training_set_metadata=None, data_format=None, skip_save_processed_input=False, preprocessing_params=default_training_preprocessing_parameters, backend=LOCAL_BACKEND, random_seed=default_random_seed, callbacks=None, ) -> tuple[Dataset, Dataset, Dataset, TrainingSetMetadataDict]: """Returns training, val and test datasets with training set metadata.""" # sanity check to make sure some data source is provided if dataset is None and training_set is None: raise ValueError("No training data is provided!") # preload ludwig and HF datasets dataset, training_set, validation_set, test_set = load_dataset_uris( dataset, training_set, validation_set, test_set, backend ) # determine data format if not provided or auto if not data_format or data_format == "auto": data_format = figure_data_format(dataset, training_set, validation_set, test_set) # Wrap dataset into a form we can use to manage within the cache dataset = wrap(dataset) training_set = wrap(training_set) validation_set = wrap(validation_set) test_set = wrap(test_set) try: lock_path = backend.cache.get_cache_directory(dataset) except (TypeError, ValueError): lock_path = None with file_lock(lock_path, lock_file=".lock_preprocessing"): # if training_set_metadata is a string, assume it's a path to load the json training_set_metadata = training_set_metadata or {} if training_set_metadata and isinstance(training_set_metadata, str): training_set_metadata = load_metadata(training_set_metadata) # setup features = config["input_features"] + config["output_features"] # in case data_format is one of the cacheable formats, # check if there's a cached hdf5 file with the same name, # and in case move on with the hdf5 branch. cached = False cache = backend.cache.get_dataset_cache(config, dataset, training_set, test_set, validation_set) # Unwrap dataset into the form used for preprocessing dataset = dataset.unwrap() if dataset is not None else None training_set = training_set.unwrap() if training_set is not None else None validation_set = validation_set.unwrap() if validation_set is not None else None test_set = test_set.unwrap() if test_set is not None else None if data_format in CACHEABLE_FORMATS: with backend.storage.cache.use_credentials(): # cache.get() returns valid indicating if the checksum for the current config # is equal to that from the cached training set metadata, as well as the paths to the # cached training set metadata, training set, validation_set, test set cache_results = cache.get() if cache_results is not None: valid, *cache_values = cache_results if valid: logger.info(_get_cache_hit_message(cache)) training_set_metadata, training_set, test_set, validation_set = cache_values config["data_hdf5_fp"] = training_set data_format = backend.cache.data_format cached = True dataset = None else: logger.info( "Found cached dataset and meta.json with the same filename " "of the dataset, but checksums don't match, " "if saving of processed input is not skipped " "they will be overridden" ) cache.delete() else: logger.info( f"No cached dataset found at {cache.get_cached_obj_path('training')}. " "Preprocessing the dataset." ) training_set_metadata[CHECKSUM] = cache.checksum data_format_processor = get_from_registry(data_format, data_format_preprocessor_registry) if cached or data_format == "hdf5": with backend.storage.cache.use_credentials(): # Always interpret hdf5 files as preprocessed, even if missing from the cache processed = data_format_processor.prepare_processed_data( features, dataset=dataset, training_set=training_set, validation_set=validation_set, test_set=test_set, training_set_metadata=training_set_metadata, skip_save_processed_input=skip_save_processed_input, preprocessing_params=preprocessing_params, backend=backend, random_seed=random_seed, ) training_set, test_set, validation_set, training_set_metadata = processed else: processed = data_format_processor.preprocess_for_training( config, features, dataset=dataset, training_set=training_set, validation_set=validation_set, test_set=test_set, training_set_metadata=training_set_metadata, skip_save_processed_input=skip_save_processed_input, preprocessing_params=preprocessing_params, backend=backend, random_seed=random_seed, callbacks=callbacks, ) training_set, test_set, validation_set, training_set_metadata = processed processed = (training_set, test_set, validation_set, training_set_metadata) # cache the dataset if backend.cache.can_cache(skip_save_processed_input): with backend.storage.cache.use_credentials(): logger.debug("cache processed data") processed = cache.put(*processed) # set cached=True to ensure credentials are used correctly below cached = True training_set, test_set, validation_set, training_set_metadata = processed with backend.storage.cache.use_credentials() if cached else contextlib.nullcontext(): logger.debug("create training dataset") training_dataset = backend.dataset_manager.create(training_set, config, training_set_metadata) training_set_size = len(training_dataset) if training_set_size == 0: raise ValueError("Training data is empty following preprocessing.") elif training_set_size < MIN_DATASET_SPLIT_ROWS: raise ValueError( f"Training dataset has only {training_set_size} rows following preprocessing, need" f" at least {MIN_DATASET_SPLIT_ROWS} to compute metrics." ) validation_dataset = None if validation_set is not None: logger.debug("create validation dataset") validation_dataset = backend.dataset_manager.create(validation_set, config, training_set_metadata) validation_set_size = len(validation_dataset) if validation_set_size == 0: logger.warning( "Validation set empty. If this is unintentional, please check the preprocessing configuration." ) validation_dataset = None elif validation_set_size < MIN_DATASET_SPLIT_ROWS: logger.warning( f"Validation set too small to compute metrics. Need at least {MIN_DATASET_SPLIT_ROWS} rows, got" f" {validation_set_size} after preprocessing." ) test_dataset = None if test_set is not None: logger.debug("create test dataset") test_dataset = backend.dataset_manager.create(test_set, config, training_set_metadata) test_set_size = len(test_dataset) if test_set_size == 0: logger.warning( "Test set empty. If this is unintentional, please check the preprocessing configuration." ) test_dataset = None elif test_set_size < MIN_DATASET_SPLIT_ROWS: logger.warning( f"Test set too small to compute metrics. Need at least {MIN_DATASET_SPLIT_ROWS} rows, got" f" {test_set_size} after preprocessing." ) return (training_dataset, validation_dataset, test_dataset, training_set_metadata) def _preprocess_file_for_training( config, features, dataset=None, training_set=None, validation_set=None, test_set=None, training_set_metadata=None, read_fn=read_csv, skip_save_processed_input=False, preprocessing_params=default_training_preprocessing_parameters, backend=LOCAL_BACKEND, random_seed=default_random_seed, callbacks=None, ): """Method to pre-process csv data. :param features: list of all features (input + output) :param dataset: path to the data :param training_set: training data :param validation_set: validation data :param test_set: test data :param training_set_metadata: train set metadata :param skip_save_processed_input: if False, the pre-processed data is saved as .hdf5 files in the same location as the csv files with the same names. :param preprocessing_params: preprocessing parameters :param random_seed: random seed :return: training, test, validation datasets, training metadata """ if dataset: # Use data and ignore _train, _validation and _test. # Also ignore data and train set metadata needs preprocessing logger.info("Using full raw dataset, no hdf5 and json file " "with the same name have been found") logger.info("Building dataset (it may take a while)") dataset_df = read_fn(dataset, backend.df_engine.df_lib) training_set_metadata[SRC] = dataset data, training_set_metadata = build_dataset( config, dataset_df, features, preprocessing_params, mode="training", metadata=training_set_metadata, backend=backend, random_seed=random_seed, skip_save_processed_input=skip_save_processed_input, callbacks=callbacks, ) elif training_set: # use data_train (including _validation and _test if they are present) # and ignore data and train set metadata # needs preprocessing logger.info("Using training raw csv, no hdf5 and json " "file with the same name have been found") logger.info("Building dataset (it may take a while)") concatenated_df = concatenate_files(training_set, validation_set, test_set, read_fn, backend) training_set_metadata[SRC] = training_set # Data is pre-split. preprocessing_params = set_fixed_split(preprocessing_params) data, training_set_metadata = build_dataset( config, concatenated_df, features, preprocessing_params, mode="training", metadata=training_set_metadata, backend=backend, random_seed=random_seed, callbacks=callbacks, ) else: raise ValueError("either data or data_train have to be not None") logger.debug("split train-val-test") training_data, validation_data, test_data = drop_extra_cols( features, split_dataset(data, preprocessing_params, backend, random_seed) ) if dataset and backend.is_coordinator() and not skip_save_processed_input: logger.debug("writing split file") splits_df = concatenate_splits(training_data, validation_data, test_data, backend) split_fp = get_split_path(dataset or training_set) try: backend.df_engine.to_parquet(splits_df, split_fp, index=True) except Exception as e: logger.warning( f"Encountered error: '{e}' while writing data to parquet during saving preprocessed data. " "Skipping saving processed data." ) logger.info("Building dataset: DONE") if preprocessing_params["oversample_minority"] or preprocessing_params["undersample_majority"]: training_data = balance_data( training_data, config["output_features"], preprocessing_params, backend, random_seed ) return training_data, test_data, validation_data, training_set_metadata def _preprocess_df_for_training( config, features, dataset=None, training_set=None, validation_set=None, test_set=None, training_set_metadata=None, preprocessing_params=default_training_preprocessing_parameters, backend=LOCAL_BACKEND, random_seed=default_random_seed, callbacks=None, ): """Method to pre-process dataframes. This doesn't have the option to save the processed data as hdf5 as we don't expect users to do this as the data can be processed in memory """ if dataset is not None: # needs preprocessing logger.info("Using full dataframe") elif training_set is not None: # needs preprocessing logger.info("Using training dataframe") dataset = concatenate_df(training_set, validation_set, test_set, backend) # Data is pre-split. preprocessing_params = set_fixed_split(preprocessing_params) logger.info("Building dataset (it may take a while)") data, training_set_metadata = build_dataset( config, dataset, features, preprocessing_params, mode="training", metadata=training_set_metadata, random_seed=random_seed, backend=backend, callbacks=callbacks, ) logger.debug("split train-val-test") training_set, validation_set, test_set = drop_extra_cols( features, split_dataset(data, preprocessing_params, backend, random_seed) ) logger.info("Building dataset: DONE") if preprocessing_params["oversample_minority"] or preprocessing_params["undersample_majority"]: training_set = balance_data(training_set, config["output_features"], preprocessing_params, backend, random_seed) return training_set, test_set, validation_set, training_set_metadata def preprocess_for_prediction( config, dataset, training_set_metadata=None, data_format=None, split=FULL, include_outputs=True, backend=LOCAL_BACKEND, callbacks=None, ): """Preprocesses the dataset to parse it into a format that is usable by the Ludwig core. Args: config: Config dictionary corresponding to Ludwig Model dataset: Dataset to be processed training_set_metadata: Train set metadata for the input features data_format: Format of the data split: The split of dataset to return include_outputs: Whether to include outputs backend: Type of backend to use for preprocessing callbacks: Any callbacks passed in Returns: Processed dataset along with updated training set metadata """ # Sanity Check to make sure some data source is provided if dataset is None: raise ValueError("No training data is provided!") if isinstance(dataset, Dataset): return dataset, training_set_metadata # preload ludwig and HF datasets dataset, _, _, _ = load_dataset_uris(dataset, None, None, None, backend) # determine data format if not provided or auto if not data_format or data_format == "auto": data_format = figure_data_format(dataset) # manage the in_memory parameter if data_format not in HDF5_FORMATS: num_overrides = override_in_memory_flag(config["input_features"], True) if num_overrides > 0: logger.warning("Using in_memory = False is not supported " "with {} data format.".format(data_format)) preprocessing_params = {} config_defaults = config.get(DEFAULTS, {}) for feature_type in config_defaults: preprocessing_params[feature_type] = config_defaults[feature_type].get(PREPROCESSING, {}) preprocessing_params[SPLIT] = config.get(PREPROCESSING, {}).get(SPLIT, {}) preprocessing_params = merge_dict(default_prediction_preprocessing_parameters, preprocessing_params) # if training_set_metadata is a string, assume it's a path to load the json if training_set_metadata and isinstance(training_set_metadata, str): training_set_metadata = load_metadata(training_set_metadata) # setup output_features = [] if include_outputs: output_features += config["output_features"] features = config["input_features"] + output_features # Check the cache for an already preprocessed dataset. This only # applies to scenarios where the user wishes to predict on a split # of the full dataset, where we preprocess the whole dataset together # during training. If the user wishes to predict on the full dataset, # it is assumed they are predicting on unseen data. This is done # because the cached data is stored in its split form, and would be # expensive to recombine, requiring further caching. cached = False dataset = wrap(dataset) cache = backend.cache.get_dataset_cache(config, dataset) dataset = dataset.unwrap() training_set = test_set = validation_set = None if data_format in CACHEABLE_FORMATS and split != FULL: with backend.storage.cache.use_credentials(): cache_results = cache.get() if cache_results is not None: valid, *cache_values = cache_results if valid: logger.info(_get_cache_hit_message(cache)) training_set_metadata, training_set, test_set, validation_set = cache_values config["data_hdf5_fp"] = training_set data_format = backend.cache.data_format cached = True data_format_processor = get_from_registry(data_format, data_format_preprocessor_registry) if cached: with backend.storage.cache.use_credentials(): processed = data_format_processor.prepare_processed_data( features, dataset=dataset, training_set=training_set, validation_set=validation_set, test_set=test_set, training_set_metadata=training_set_metadata, preprocessing_params=preprocessing_params, backend=backend, ) training_set, test_set, validation_set, training_set_metadata = processed else: processed = data_format_processor.preprocess_for_prediction( config, dataset, features, preprocessing_params, training_set_metadata, backend, callbacks ) dataset, training_set_metadata, new_hdf5_fp = processed training_set_metadata = training_set_metadata.copy() if new_hdf5_fp: training_set_metadata[DATA_TRAIN_HDF5_FP] = new_hdf5_fp if split != FULL: logger.debug("split train-val-test") training_set, validation_set, test_set = drop_extra_cols( features, split_dataset(dataset, preprocessing_params, backend) ) if split == TRAINING: dataset = training_set elif split == VALIDATION: dataset = validation_set elif split == TEST: dataset = test_set config = { **config, "output_features": output_features, } with backend.storage.cache.use_credentials() if cached else contextlib.nullcontext(): dataset = backend.dataset_manager.create( dataset, config, training_set_metadata, ) return dataset, training_set_metadata def _get_cache_hit_message(cache: DatasetCache) -> str: return ( "Found cached dataset and meta.json with the same filename of the dataset.\n" "Using cached values instead of preprocessing the dataset again.\n" f"- Cached training set metadata path: {cache.get_cached_obj_path(META)}\n" f"- Cached training set path: {cache.get_cached_obj_path(TRAINING)}\n" f"- Cached validation set path: {cache.get_cached_obj_path(VALIDATION)}\n" f"- Cached test set path: {cache.get_cached_obj_path(TEST)}" ) ================================================ FILE: ludwig/data/prompt.py ================================================ import json import logging import os import string from typing import Any, TYPE_CHECKING import pandas as pd if TYPE_CHECKING: from ludwig.backend.base import Backend from ludwig.models.retrieval import df_checksum, get_retrieval_model, RetrievalModel from ludwig.utils.fs_utils import get_default_cache_location, makedirs, path_exists from ludwig.utils.types import DataFrame, Series logger = logging.getLogger(__name__) CONTEXT = "__context__" SAMPLE = "__sample__" TASK = "__task__" DEFAULT_ZERO_SHOT_PROMPT_TEMPLATE = """SAMPLE INPUT: {__sample__} USER: Complete the following task: {__task__} ASSISTANT: """ DEFAULT_FEW_SHOT_PROMPT_TEMPLATE = """Below is relevant context: CONTEXT: {__context__} CONTEXT is comprised of labeled samples whose embeddings were similar to that of the sample input. The labels in these samples could aid you in your final prediction. Given this and no prior knowledge, follow the instructions below. SAMPLE INPUT: {__sample__} USER: Complete the following task: {__task__} ASSISTANT: """ def index_column( retrieval_config: dict[str, Any], col_name: str, dataset_cols: dict[str, Series], backend: "Backend", split_col: Series | None = None, ) -> tuple[RetrievalModel, str]: """Indexes a column for sample retrieval via embedding index lookup. This function indexes a column and saves the index artifact to disk. If an index name is provided as part of the `retrieval_config`, then the index in the ludwig cache with the corresponding name will be loaded instead of being built from scratch. To prevent data leakage, a split column must be provided. This ensures that the retrieval model only ever fetches samples from the training set. To ensure that the index is usable even if the original DataFrame is not available, the columns used to build the index are stored as part of the index. All operations in this function are performed on pandas objects, which means that you may run out of memory if your dataset is large. Args: retrieval_config (Dict[str, Any]): The retrieval config from the config object. col_name (str): The name of the column to index. dataset_cols (Dict[str, Series]): A dictionary mapping column names to their corresponding Series. `col_name` must be a key in this dictionary. These columns are stored as part of the index to ensure that the index is usable even if the original DataFrame is not available. df_engine (DataFrameEngine): The engine used to compute the columns into pandas objects. split_col (Optional[Series]): A column that indicates whether a sample is part of the training set. A sample is in the training set if the value in this column is 0. Returns: Tuple[RetrievalModel, str]: A tuple containing the retrieval model and the name of the index. """ retrieval_model = get_retrieval_model( retrieval_config["type"], model_name=retrieval_config["model_name"], ) index_name = retrieval_config["index_name"] index_cache_directory = os.path.join(get_default_cache_location(), "index") if not path_exists(index_cache_directory): makedirs(index_cache_directory, exist_ok=True) if index_name is None: if split_col is None: raise ValueError("split column must be provided if using retrieval") split_col = backend.df_engine.compute(split_col).astype(int) # TODO(geoffrey): add support for Dask DataFrames df = pd.DataFrame({name: backend.df_engine.compute(col) for name, col in dataset_cols.items()}) df = df[split_col == 0] # Ensures that the index is only built on the training set # Even if index name is not provided, we still want to check if an index for this df already exists in cache # If it does, load it and return immediately index_hash = df_checksum(df) index_name = f"embedding_index_{index_hash}" if path_exists(os.path.join(index_cache_directory, index_name)): logger.info( f"Index for this DataFrame with name '{index_name}' already exists. " f"Loading index from '{index_cache_directory}'" ) retrieval_model.load_index(index_name, cache_directory=index_cache_directory) return retrieval_model, index_name # Build index if index name is not provided and index for this df does not already exist in cache retrieval_model.create_dataset_index(df, backend, columns_to_index=[col_name]) logger.info(f"Saving index to cache directory '{index_cache_directory}' with name '{index_name}'") retrieval_model.save_index(index_name, cache_directory=index_cache_directory) else: logger.info(f"Loading index from cache directory '{index_cache_directory}' with name '{index_name}'") retrieval_model.load_index(index_name, cache_directory=index_cache_directory) return retrieval_model, index_name def format_input_with_prompt( input_col_name: str, dataset_df: DataFrame, backend: "Backend", task_str: str, retrieval_model: RetrievalModel | None = None, k: int = -1, template: str | None = None, ) -> Series: """Returns a new Series with the input column data formatted with the prompt. A prompt can either be zero-shot or few-shot. A zero-shot prompt is comprised of some (unlabeled) input and a task to be completed given the input. A few-shot prompt additionally includes some dynamically retrieved context, which is retrieved using the `retrieval_model.search` function. A template can be provided to customize the prompt. The template must be a string with the following fields: - __sample__ or at least one column from the input dataset: The input sample. - __context__: The context retrieved by the `search_fn` function. Only required if `search_fn` is provided. - __task__: The task to be completed given the input. Only required if `task` is set in the prompt config. Zero-shot example: Before formatting: input_col = ["I am happy"] task_str = "sentiment analysis" After formatting: input_col = ["SAMPLE INPUT: I am happy\n\nUSER: Complete the following task: sentiment analysis\n\nASSISTANT:"] Args: input_col_name (str): The name of the input column. dataset_df (DataFrame): The input dataset. backend (Backend): The backend used for map operations. task_str (str): The task to be completed given the input. retrieval_model (Optional[RetrievalModel]): The retrieval model used to retrieve context. If provided, the prompt will be few-shot. If not provided, the prompt will be zero-shot. k (int): The number of samples to retrieve. Only required if `retrieval_model` is provided. template (Optional[str]): The template to use for the prompt. If not provided, the default will be used. Returns: Series: A new Series with the input column data formatted with the prompt. """ # determine if this is a few-shot or zero-shot prompt # few-shot prompts require a search function that returns samples from some dataset is_few_shot = retrieval_model is not None # if no template is provided, use the default template if template is None: if is_few_shot: template = DEFAULT_FEW_SHOT_PROMPT_TEMPLATE else: template = DEFAULT_ZERO_SHOT_PROMPT_TEMPLATE # ensure that the prompt template has all required fields template_fields, field_to_dtype = _get_template_fields(template) try: _validate_prompt_template(template_fields, task_str, is_few_shot, dataset_df.columns, input_col_name) except ValueError as e: raise ValueError(f"template invalid for {'few-shot' if is_few_shot else 'zero-shot'} prompt: {e}") def generate_prompt(df: pd.DataFrame): if CONTEXT in template_fields: df[CONTEXT] = retrieval_model.search(df, backend, k=k, return_data=True) if SAMPLE in template_fields: # During preprocessing, we're inserting quotes that change the token IDs completely if we # don't remove the " from the string. For parity with expected user output, we need to get rid of them. # TODO(Arnav): see if there's a way to only remove them if the entry does't have quotes. This currently # removes all " from the string (even those not added by json.dumps), which is not ideal. df[SAMPLE] = df[input_col_name].map(lambda entry: json.dumps(entry, indent=2).strip('"')) if TASK in template_fields: df[TASK] = task_str def generate_prompt_for_row(row): kwargs = {col: field_to_dtype[col](row[col]) for col in template_fields} return template.format(**kwargs) return df.apply(generate_prompt_for_row, axis=1) result = backend.df_engine.map_partitions(dataset_df, generate_prompt, meta=(input_col_name, "object")) result = backend.df_engine.persist(result) # persist to prevent re-computation return result def _validate_prompt_template( template_fields: set[str], task: str | None, is_few_shot: bool, columns: list[str], input_col_name: str ): """Validates that the template contains the necessary fields for the prompt.""" if is_few_shot and CONTEXT not in template_fields: raise ValueError(f"Prompt template must contain the '{CONTEXT}' field for few-shot learning") if task is not None and TASK not in template_fields: raise ValueError(f"Prompt template must contain the '{TASK}' field if a task is provided") if SAMPLE in template_fields: if input_col_name not in columns: raise ValueError( f"Prompt template contains the '{SAMPLE}' field, " f"but the input column '{input_col_name}' is not in the dataset" ) elif not any(col in template_fields for col in columns): raise ValueError( f"Prompt template must contain either the '{SAMPLE}' field or one of the columns from the dataset" ) def _get_template_fields(template: str) -> tuple[set[str], dict[str, type]]: """Returns the fields in the template.""" parsed = [t for t in string.Formatter().parse(template) if t[1] is not None] field_set = {field for _, field, _, _ in parsed} dtype_map = {field: _get_dtype(format_spec) for _, field, format_spec, _ in parsed} return field_set, dtype_map def _get_dtype(format_spec: str) -> type: # We need to prepare data in the row for different formatting options. # If you have a number like 0.1234 in the DF and you want to format it like {number:.2f} it will fail if the # number is represented as a string in the DF. So we need to cast it to a float before formatting. if not format_spec: return str if "f" in format_spec: return float raise ValueError(f"Unsupported template format spec: {format_spec}") ================================================ FILE: ludwig/data/sampler.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 2020 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. # ============================================================================== import math import numpy as np from ludwig.distributed import DistributedStrategy from ludwig.utils.defaults import default_random_seed class DistributedSampler: """Adapted from `torch.utils.data.distributed.DistributedSampler`.""" def __init__( self, dataset_size: int, shuffle: bool = True, random_seed: int = default_random_seed, distributed: DistributedStrategy = None, ): self.dataset_size = dataset_size self.num_replicas = distributed.size() if distributed else 1 self.rank = distributed.rank() if distributed else 0 self.epoch = 0 self.num_samples = int(math.ceil(self.dataset_size * 1.0 / self.num_replicas)) self.total_size = self.num_samples * self.num_replicas self.shuffle = shuffle self.random_seed = random_seed def __iter__(self): if self.shuffle: # deterministically shuffle based on epoch and seed indices = np.random.RandomState(seed=self.random_seed + self.epoch).permutation(self.dataset_size).tolist() else: indices = list(range(self.dataset_size)) # add extra samples to make it evenly divisible indices += indices[: (self.total_size - len(indices))] assert len(indices) == self.total_size # subsample indices = indices[self.rank : self.total_size : self.num_replicas] assert len(indices) == self.num_samples return iter(indices) def __len__(self): return self.num_samples def set_epoch(self, epoch): """Sets the epoch for this sampler. When `shuffle=True`, this ensures all replicas use a different random ordering for each epoch. Otherwise, the next iteration of this sampler will yield the same ordering. :param epoch: (int) epoch number """ self.epoch = epoch ================================================ FILE: ludwig/data/split.py ================================================ #! /usr/bin/env python # Copyright (c) 2022 Predibase, 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. import logging from abc import ABC, abstractmethod from typing import TYPE_CHECKING from zlib import crc32 import numpy as np from sklearn.model_selection import train_test_split from ludwig.api_annotations import DeveloperAPI from ludwig.backend.base import Backend from ludwig.constants import BINARY, CATEGORY, DATE, MIN_DATASET_SPLIT_ROWS, SPLIT from ludwig.error import ConfigValidationError from ludwig.schema.split import ( DateTimeSplitConfig, FixedSplitConfig, HashSplitConfig, RandomSplitConfig, StratifySplitConfig, ) from ludwig.types import ModelConfigDict, PreprocessingConfigDict from ludwig.utils.data_utils import hash_dict, split_dataset_ttv from ludwig.utils.defaults import default_random_seed from ludwig.utils.registry import Registry from ludwig.utils.types import DataFrame if TYPE_CHECKING: from ludwig.schema.model_config import ModelConfig split_registry = Registry() logger = logging.getLogger(__name__) TMP_SPLIT_COL = "__SPLIT__" DEFAULT_PROBABILITIES = (0.7, 0.1, 0.2) class Splitter(ABC): @abstractmethod def split( self, df: DataFrame, backend: Backend, random_seed: int = default_random_seed ) -> tuple[DataFrame, DataFrame, DataFrame]: pass def validate(self, config: ModelConfigDict): pass def has_split(self, split_index: int) -> bool: return True @property def required_columns(self) -> list[str]: """Returns the list of columns that are required for splitting.""" return [] def _make_divisions_ensure_minimum_rows( divisions: list[int], n_examples: int, min_val_rows: int = MIN_DATASET_SPLIT_ROWS, min_test_rows: int = MIN_DATASET_SPLIT_ROWS, ) -> list[int]: """Revises divisions to ensure no dataset split has too few examples.""" result = list(divisions) n = [dn - dm for dm, dn in zip((0,) + divisions, divisions + (n_examples,))] # Number of examples in each split. if 0 < n[2] < min_test_rows and n[0] > 0: # Test set is nonempty but too small, take examples from training set. shift = min(min_test_rows - n[2], n[0]) result = [d - shift for d in result] if 0 < n[1] < min_val_rows and n[0] > 0: # Validation set is nonempty but too small, take examples from training set. result[0] -= min(min_val_rows - n[1], result[0]) return result def _split_divisions_with_min_rows(n_rows: int, probabilities: list[float]) -> list[int]: """Generates splits for a dataset of n_rows into train, validation, and test sets according to split probabilities, also ensuring that at least min_val_rows or min_test_rows are present in each nonempty split. Returns division indices to split on. """ d1 = int(np.ceil(probabilities[0] * n_rows)) if probabilities[-1] > 0: n2 = int(probabilities[1] * n_rows) d2 = d1 + n2 else: # If the last probability is 0, then use the entire remaining dataset for validation. d2 = n_rows return _make_divisions_ensure_minimum_rows((d1, d2), n_rows) @split_registry.register("random", default=True) class RandomSplitter(Splitter): def __init__(self, probabilities: list[float] = DEFAULT_PROBABILITIES, **kwargs): self.probabilities = probabilities def split( self, df: DataFrame, backend: Backend, random_seed: float = default_random_seed ) -> tuple[DataFrame, DataFrame, DataFrame]: probabilities = self.probabilities if not backend.df_engine.partitioned: divisions = _split_divisions_with_min_rows(len(df), probabilities) shuffled_df = df.sample(frac=1, random_state=random_seed) return ( shuffled_df.iloc[: divisions[0]], # Train shuffled_df.iloc[divisions[0] : divisions[1]], # Validation shuffled_df.iloc[divisions[1] :], # Test ) # The above approach is very inefficient for partitioned backends, which can split by partition. # This does not give exact guarantees on split size but is much more efficient for large datasets. return df.random_split(self.probabilities, random_state=random_seed) def has_split(self, split_index: int) -> bool: return self.probabilities[split_index] > 0 @staticmethod def get_schema_cls(): return RandomSplitConfig @split_registry.register("fixed") class FixedSplitter(Splitter): def __init__(self, column: str = SPLIT, **kwargs): self.column = column def split( self, df: DataFrame, backend: Backend, random_seed: float = default_random_seed ) -> tuple[DataFrame, DataFrame, DataFrame]: df[self.column] = df[self.column].astype(np.int8) dfs = split_dataset_ttv(df, self.column) train, test, val = tuple(df.drop(columns=self.column) if df is not None else None for df in dfs) return train, val, test @property def required_columns(self) -> list[str]: return [self.column] @staticmethod def get_schema_cls(): return FixedSplitConfig def stratify_split_dataframe( df: DataFrame, column: str, probabilities: list[float], backend: Backend, random_seed: float ) -> tuple[DataFrame, DataFrame, DataFrame]: """Splits a dataframe into train, validation, and test sets based on the values of a column. The column must be categorical (including binary). The split is stratified, meaning that the proportion of each category in each split is the same as in the original dataset. """ frac_train, frac_val, frac_test = probabilities def _safe_stratify(df, column, test_size): # Get the examples with cardinality of 1 df_cadinalities = df.groupby(column)[column].size() low_cardinality_elems = df_cadinalities.loc[lambda x: x == 1] df_low_card = df[df[column].isin(low_cardinality_elems.index)] df = df[~df[column].isin(low_cardinality_elems.index)] y = df[[column]] df_train, df_temp, _, _ = train_test_split(df, y, stratify=y, test_size=test_size, random_state=random_seed) # concat the examples with cardinality of 1 to the training DF. if len(df_low_card.index) > 0: df_train = backend.df_engine.concat([df_train, df_low_card]) return df_train, df_temp df_train, df_temp = _safe_stratify(df, column, 1.0 - frac_train) relative_frac_test = frac_test / (frac_val + frac_test) df_val, df_test = _safe_stratify(df_temp, column, relative_frac_test) return df_train, df_val, df_test @split_registry.register("stratify") class StratifySplitter(Splitter): def __init__(self, column: str, probabilities: list[float] = DEFAULT_PROBABILITIES, **kwargs): self.column = column self.probabilities = probabilities def split( self, df: DataFrame, backend: Backend, random_seed: float = default_random_seed ) -> tuple[DataFrame, DataFrame, DataFrame]: if not backend.df_engine.partitioned: return stratify_split_dataframe(df, self.column, self.probabilities, backend, random_seed) # For a partitioned dataset, we can stratify split each partition individually # to obtain a global stratified split. def split_partition(partition: DataFrame) -> DataFrame: """Splits a single partition into train, val, test. Returns a single DataFrame with the split column populated. Assumes that the split column is already present in the partition and has a default value of 0 (train). """ partition = partition.copy() _, val, test = stratify_split_dataframe(partition, self.column, self.probabilities, backend, random_seed) # Split column defaults to train, so only need to update val and test partition.loc[val.index, TMP_SPLIT_COL] = 1 partition.loc[test.index, TMP_SPLIT_COL] = 2 return partition df[TMP_SPLIT_COL] = 0 df = backend.df_engine.map_partitions(df, split_partition, meta=df) df_train = df[df[TMP_SPLIT_COL] == 0].drop(columns=TMP_SPLIT_COL) df_val = df[df[TMP_SPLIT_COL] == 1].drop(columns=TMP_SPLIT_COL) df_test = df[df[TMP_SPLIT_COL] == 2].drop(columns=TMP_SPLIT_COL) return df_train, df_val, df_test def validate(self, config: "ModelConfig"): # noqa: F821 features = [f for f in config.input_features] + [f for f in config.output_features] feature_cols = {f.column for f in features} if self.column not in feature_cols: logging.info( f"Stratify column {self.column} is not among the features. " f"Cannot establish if it is a binary or category feature." ) elif [f for f in features if f.column == self.column][0].type not in {BINARY, CATEGORY}: raise ConfigValidationError(f"Feature for stratify column {self.column} must be binary or category") def has_split(self, split_index: int) -> bool: return self.probabilities[split_index] > 0 @property def required_columns(self) -> list[str]: return [self.column] @staticmethod def get_schema_cls(): return StratifySplitConfig @split_registry.register("datetime") class DatetimeSplitter(Splitter): def __init__( self, column: str, probabilities: list[float] = DEFAULT_PROBABILITIES, datetime_format: str | None = None, fill_value: str = "", **kwargs, ): self.column = column self.probabilities = probabilities self.datetime_format = datetime_format self.fill_value = fill_value def split( self, df: DataFrame, backend: Backend, random_seed: float = default_random_seed ) -> tuple[DataFrame, DataFrame, DataFrame]: # In case the split column was preprocessed by Ludwig into a list, convert it back to a # datetime string for the sort and split def list_to_date_str(x): if not isinstance(x, list): if not isinstance(x, str): # Convert timestamps, etc. to strings and return so it can direct cast to epoch time return str(x) if len(x) != 9: # Strings not in the expected format, so assume it's a formatted datetime and return return x return f"{x[0]}-{x[1]}-{x[2]} {x[5]}:{x[6]}:{x[7]}" df[TMP_SPLIT_COL] = backend.df_engine.map_objects(df[self.column], list_to_date_str) # Convert datetime to int64 to workaround Dask limitation # https://github.com/dask/dask/issues/9003 df[TMP_SPLIT_COL] = backend.df_engine.df_lib.to_datetime(df[TMP_SPLIT_COL]).values.astype("int64") # Sort by ascending datetime and drop the temporary column df = df.sort_values(TMP_SPLIT_COL).drop(columns=TMP_SPLIT_COL) # Split using different methods based on the underlying df engine. # For Pandas, split by row index. # For Dask, split by partition, as splitting by row is very inefficient. return tuple(backend.df_engine.split(df, self.probabilities)) def validate(self, config: "ModelConfig"): # noqa: F821 features = [f for f in config.input_features] + [f for f in config.output_features] feature_cols = {f.column for f in features} if self.column not in feature_cols: logging.info( f"Datetime split column {self.column} is not among the features. " f"Cannot establish if it is a valid datetime." ) elif [f for f in features if f.column == self.column][0].type not in {DATE}: raise ConfigValidationError(f"Feature for datetime split column {self.column} must be a datetime") def has_split(self, split_index: int) -> bool: return self.probabilities[split_index] > 0 @property def required_columns(self) -> list[str]: return [self.column] @staticmethod def get_schema_cls(): return DateTimeSplitConfig @split_registry.register("hash") class HashSplitter(Splitter): def __init__( self, column: str, probabilities: list[float] = DEFAULT_PROBABILITIES, **kwargs, ): self.column = column self.probabilities = probabilities def split( self, df: DataFrame, backend: Backend, random_seed: float = default_random_seed ) -> tuple[DataFrame, DataFrame, DataFrame]: # Maximum value of the hash function crc32 max_value = 2**32 thresholds = [v * max_value for v in self.probabilities] def hash_column(x): value = hash_dict({"value": x}, max_length=None) hash_value = crc32(value) if hash_value < thresholds[0]: return 0 elif hash_value < (thresholds[0] + thresholds[1]): return 1 else: return 2 df[TMP_SPLIT_COL] = backend.df_engine.map_objects(df[self.column], hash_column).astype(np.int8) dfs = split_dataset_ttv(df, TMP_SPLIT_COL) train, test, val = tuple(df.drop(columns=TMP_SPLIT_COL) if df is not None else None for df in dfs) return train, val, test def has_split(self, split_index: int) -> bool: return self.probabilities[split_index] > 0 @property def required_columns(self) -> list[str]: return [self.column] @staticmethod def get_schema_cls(): return HashSplitConfig @DeveloperAPI def get_splitter(type: str | None = None, **kwargs) -> Splitter: splitter_cls = split_registry.get(type) if splitter_cls is None: return ValueError(f"Invalid split type: {type}") return splitter_cls(**kwargs) @DeveloperAPI def split_dataset( df: DataFrame, global_preprocessing_parameters: PreprocessingConfigDict, backend: Backend, random_seed: float = default_random_seed, ) -> tuple[DataFrame, DataFrame, DataFrame]: splitter = get_splitter(**global_preprocessing_parameters.get(SPLIT, {})) datasets: tuple[DataFrame, DataFrame, DataFrame] = splitter.split(df, backend, random_seed) if len(datasets[0].columns) == 0: raise ValueError( "Encountered an empty training set while splitting data. Please double check the preprocessing split " "configuration." ) # Remove partitions that are empty after splitting datasets = [None if dataset is None else backend.df_engine.remove_empty_partitions(dataset) for dataset in datasets] return datasets ================================================ FILE: ludwig/data/split_dataset.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import argparse import random def split(input_path, output1, output2, split): with open(input_path) as file: lines = file.readlines() random.shuffle(lines) split_idx = int(len(lines) * split) with open(output1, "w") as f: for line in lines[:split_idx]: line = line if line.endswith("\n") else line + "\n" f.write(line) with open(output2, "w") as f: for line in lines[split_idx:]: line = line if line.endswith("\n") else line + "\n" f.write(line) if __name__ == "__main__": parser = argparse.ArgumentParser(description="Split a file based on its lines") parser.add_argument("-i", "--input", required=True, help="input file names") parser.add_argument("-o1", "--output1", required=True, help="output 1 file name") parser.add_argument("-o2", "--output2", required=True, help="output 2 file name") parser.add_argument("-s", "--split", required=True, type=float, default=0.8, help="percentage of the split") args = parser.parse_args() split(args.input, args.output1, args.output2, args.split) ================================================ FILE: ludwig/data/utils.py ================================================ from typing import Optional import numpy as np from ludwig.constants import DECODER, ENCODER, SPLIT from ludwig.types import FeatureConfigDict, PreprocessingConfigDict from ludwig.utils.dataframe_utils import is_dask_series_or_df from ludwig.utils.types import DataFrame def convert_to_dict( predictions: DataFrame, output_features: dict[str, FeatureConfigDict], backend: Optional["Backend"] = None, # noqa: F821 ): """Convert predictions from DataFrame format to a dictionary.""" output = {} for of_name, output_feature in output_features.items(): feature_keys = {k for k in predictions.columns if k.startswith(of_name)} feature_dict = {} for key in feature_keys: subgroup = key[len(of_name) + 1 :] values = predictions[key] if is_dask_series_or_df(values, backend): values = values.compute() try: values = np.stack(values.to_numpy()) except ValueError: values = values.to_list() feature_dict[subgroup] = values output[of_name] = feature_dict return output def set_fixed_split(preprocessing_params: PreprocessingConfigDict) -> PreprocessingConfigDict: """Sets the split policy explicitly to a fixed split. This potentially overrides the split configuration that the user set or what came from schema defaults. """ return { **preprocessing_params, "split": { "type": "fixed", "column": SPLIT, }, } def get_input_and_output_features(feature_configs): """Returns a tuple (input_features, output_features) where each element is a list of feature configs. Determines whether a feature is an input or output feature by checking the presence of the encoder or decoder keys. """ input_features = [] output_features = [] for feature in feature_configs: if ENCODER in feature: input_features.append(feature) elif DECODER in feature: output_features.append(feature) return input_features, output_features ================================================ FILE: ludwig/datasets/README.md ================================================ ## Ludwig Datasets API The Ludwig Dataset Zoo provides datasets that can be directly plugged into a Ludwig model. For each dataset, we've also included an example Ludwig config which should train reasonably fast on a current-generation laptop. The simplest way to use a dataset is to import it: ```python from ludwig.datasets import titanic # Loads into single dataframe with a 'split' column: dataset_df = titanic.load() # Loads into split dataframes: train_df, test_df, _ = titanic.load(split=True) ``` The `ludwig.datasets` API provides functions to list, describe, and get datasets: ______________________________________________________________________ ### list_datasets Gets a list of the names of available datasets. **Example:** ```python dataset_names = ludwig.datasets.list_datasets() ``` ______________________________________________________________________ ### get_datasets_output_features If a specific dataset name is passed in, then returns the output features associated with that dataset. Otherwise, returns an ordered dictionary with dataset names as keys and dictionaries containing the output features for each dataset as values. **Example:** ```python output_features = ludwig.datasets.get_datasets_output_features(dataset="titanic") ``` ______________________________________________________________________ ### describe_dataset Gets a human-readable description string for a dataset **Example:** ```python print(ludwig.datasets.describe_dataset("titanic")) ``` ______________________________________________________________________ ### get_dataset Get a dataset module by name **Example:** ```python titanic_dataset = ludwig.datasets.get_dataset("titanic") ``` ______________________________________________________________________ ### model_configs_for_dataset Gets a dictionary of model configs for the specified dataset. Keys are the config names, and may contain the special keys: - `default` - The default config for the dataset. Should train to decent performance under 10 minutes on a typical laptop without GPU. - `best` - The best known config for the dataset. Should be replaced when a better config is found. This is a good opportunity for contributions, if you find a better one please check it in and open a PR! **Example:** ```python configs = ludwig.datasets.model_configs_for_dataset("higgs") default_higgs_config = configs["default"] best_higgs_config = configs["best"] ``` ______________________________________________________________________ ## Training a model using builtin dataset and config This example code trains a model on the Titanic dataset using the default config: ```python from ludwig.api import LudwigModel import ludwig.datasets titanic = ludwig.datasets.get_dataset("titanic") dataset_df = titanic.load() titanic_config = titanic.default_model_config model = LudwigModel(titanic_config) model.train(dataset_df) ``` Some datasets are hosted on [Kaggle](https://www.kaggle.com) and require a kaggle account. To use these, you'll need to [set up Kaggle credentials](https://www.kaggle.com/docs/api) in your environment. If the dataset is part of a Kaggle competition, you'll need to accept the terms on the competition page. To check programmatically, datasets have an `.is_kaggle_dataset` property. ## Downloading, Processing, and Exporting Datasets are first downloaded into `LUDWIG_CACHE`, which may be set as an environment variable and defaults to `$HOME/.ludwig_cache`. Datasets are automatically loaded, processed, and re-saved as parquet files. The processed dataset is saved in LUDWIG_CACHE. If the dataset contains media files including images or audio, media files are saved in subdirectories and referenced by relative paths from the dataset location. To ensure Ludwig can read these files during training, they should be accessible from Ludwig's working directory. To export the processed dataset, including any media files it depends on, use the `.export` method: ```python from ludwig.datasets import twitter_bots # Exports twitter bots dataset and image files to the current working directory. twitter_bots.export(".") # The working directory should now contain: # ./twitter_bots.parquet - The twitter bots dataset # ./profile_images - Account profile image files # ./profile_background_images - Account profile background image files ``` ================================================ FILE: ludwig/datasets/__init__.py ================================================ import argparse import importlib import logging import os from collections import OrderedDict from functools import lru_cache from io import BytesIO from typing import Any, Literal import yaml from ludwig.api_annotations import DeveloperAPI, PublicAPI from ludwig.backend.base import Backend from ludwig.constants import AUDIO, BINARY, CATEGORY, IMAGE, NUMBER, TEST, TEXT, TRAIN, TYPE, VALIDATION from ludwig.data.cache.types import CacheableDataframe from ludwig.datasets import configs from ludwig.datasets.dataset_config import DatasetConfig from ludwig.datasets.loaders.dataset_loader import DatasetLoader # PublicAPI from ludwig.datasets.utils import model_configs_for_dataset # noqa from ludwig.globals import LUDWIG_VERSION from ludwig.utils.print_utils import print_ludwig from ludwig.utils.types import DataFrame URI_PREFIX = "ludwig://" HF_PREFIX = "hf://" SPLITS = [TRAIN, VALIDATION, TEST] def _load_dataset_config(config_filename: str): """Loads a dataset config.""" config_path = os.path.join(os.path.dirname(configs.__file__), config_filename) with open(config_path) as f: return DatasetConfig.from_dict(yaml.safe_load(f)) @lru_cache(maxsize=1) def _get_dataset_configs() -> dict[str, DatasetConfig]: """Returns all dataset configs indexed by name.""" import importlib.resources config_files = [f.name for f in importlib.resources.files(configs).iterdir() if f.name.endswith(".yaml")] config_objects = [_load_dataset_config(f) for f in config_files] return {c.name: c for c in config_objects} def _get_dataset_config(dataset_name) -> DatasetConfig: """Get the config for a dataset.""" configs = _get_dataset_configs() if dataset_name not in configs: raise AttributeError(f"No config found for dataset {dataset_name}") return configs[dataset_name] @PublicAPI def get_dataset(dataset_name, cache_dir=None) -> DatasetLoader: """Gets an instance of the dataset loader for a dataset.""" config = _get_dataset_config(dataset_name) class_name = config.loader.split(".")[-1] module_name = "." + ".".join(config.loader.split(".")[:-1]) loader_module = importlib.import_module(module_name, package="ludwig.datasets.loaders") loader_cls = getattr(loader_module, class_name) if cache_dir: return loader_cls(config, cache_dir=cache_dir) return loader_cls(config) @DeveloperAPI def load_dataset_uris( dataset: str | DataFrame | None, training_set: str | DataFrame | None, validation_set: str | DataFrame | None, test_set: str | DataFrame | None, backend: Backend, ) -> tuple[ CacheableDataframe | None, CacheableDataframe | None, CacheableDataframe | None, CacheableDataframe | None, ]: """Loads and returns any Ludwig dataset URIs as CacheableDataframes. Returns the input unmodified for any non-Ludwig datasets. """ dataset_out, training_set_out, validation_set_out, test_set_out = dataset, training_set, validation_set, test_set # Check that any of the datasets begin with the `hf://` prefix denoting a Hugging Face dataset URI # Hugging Face datasets should follow the naming convention `hf://--` if _is_hf(dataset, training_set): return _load_hf_datasets(dataset, training_set, validation_set, test_set, backend) # Check that any of the datasets begin with the `ludwig://` prefix denoting a Ludwig dataset URI if dataset is not None: if isinstance(dataset, str) and dataset.startswith(URI_PREFIX): dataset_out = _load_cacheable_dataset(dataset, backend) return dataset_out, training_set_out, validation_set_out, test_set_out if training_set is not None: train_df = test_df = val_df = None training_set_checksum = None if isinstance(training_set, str) and training_set.startswith(URI_PREFIX): # For the training set, we only want to use the TRAINING split of the dataset dataset_name = training_set[len(URI_PREFIX) :] loader = get_dataset(dataset_name) train_df, test_df, val_df = loader.load(split=True) training_set_checksum = str(loader.get_mtime()) train_df = backend.df_engine.from_pandas(train_df) training_set_out = CacheableDataframe(df=train_df, name=training_set, checksum=training_set_checksum) if isinstance(validation_set, str) and validation_set.startswith(URI_PREFIX): if validation_set == training_set: # Reuse the loaded DF from the training split val_df = backend.df_engine.from_pandas(val_df) validation_set_out = CacheableDataframe(df=val_df, name=validation_set, checksum=training_set_checksum) else: validation_set_out = _load_cacheable_dataset(validation_set, backend) if isinstance(test_set, str) and test_set.startswith(URI_PREFIX): if test_set == training_set: # Reuse the loaded DF from the training split test_df = backend.df_engine.from_pandas(test_df) test_set_out = CacheableDataframe(df=test_df, name=test_set, checksum=training_set_checksum) else: test_set_out = _load_cacheable_dataset(test_set, backend) return dataset_out, training_set_out, validation_set_out, test_set_out def _is_hf(dataset, training_set): dataset_is_hf = dataset is not None and isinstance(dataset, str) and dataset.startswith(HF_PREFIX) training_set_is_hf = ( training_set is not None and isinstance(training_set, str) and training_set.startswith(HF_PREFIX) ) return dataset_is_hf or training_set_is_hf def _load_hf_datasets( dataset: str | DataFrame | None, training_set: str | DataFrame | None, validation_set: str | DataFrame | None, test_set: str | DataFrame | None, backend: Backend, ) -> tuple[ CacheableDataframe | None, CacheableDataframe | None, CacheableDataframe | None, CacheableDataframe | None, ]: """Loads and returns any Hugging Face datasets as CacheableDataframes. Returns the input unmodified for any non-HF datasets. """ dataset_out = dataset training_set_out = training_set validation_set_out = validation_set test_set_out = test_set # Check that any of the datasets begin with the `hf://` prefix denoting a Hugging Face dataset URI # Hugging Face datasets should follow the naming convention `hf://--` if dataset is not None: if isinstance(dataset, str) and dataset.startswith(HF_PREFIX): dataset_out = _load_cacheable_hf_dataset(dataset, backend) return dataset_out, training_set_out, validation_set_out, test_set_out # Because of the conditional logic (_is_hf) in load_dataset_uris, if the above block is not triggered, then # training_set must be a string that starts with HF_PREFIX train_df = test_df = val_df = None loader = get_dataset("hugging_face") hf_id, hf_subsample = _get_hf_dataset_and_subsample(training_set) train_df, val_df, test_df = loader.load(hf_id, hf_subsample, split=True) # Call hugging_face loader train_df = backend.df_engine.from_pandas(train_df) training_set_out = CacheableDataframe(df=train_df, name=training_set, checksum=None) if isinstance(validation_set, str) and validation_set.startswith(HF_PREFIX): if validation_set == training_set: # Reuse the loaded DF from the training split val_df = backend.df_engine.from_pandas(val_df) validation_set_out = CacheableDataframe(df=val_df, name=validation_set, checksum=None) else: # This handles an edge case -- NOT EXPECTED USER BEHAVIOR logging.warning( "A Hugging Face validation set has been passed in that is different from the test set. " "This is not recommended." ) validation_set_out = _load_cacheable_hf_dataset(validation_set, backend, split_set=VALIDATION) if isinstance(test_set, str) and test_set.startswith(HF_PREFIX): if test_set == training_set: # Reuse the loaded DF from the training split test_df = backend.df_engine.from_pandas(test_df) test_set_out = CacheableDataframe(df=test_df, name=test_set, checksum=None) else: # This handles an edge case -- NOT EXPECTED USER BEHAVIOR logging.warning( "A Hugging Face test set has been passed in that is different from the training set. " "This is not recommended." ) test_set_out = _load_cacheable_hf_dataset(test_set, backend, split_set=TEST) return dataset_out, training_set_out, validation_set_out, test_set_out def _load_cacheable_hf_dataset( dataset: str, backend: Backend, split_set: Literal["train", "validation", "test"] | None = None ) -> CacheableDataframe: loader = get_dataset("hugging_face") hf_id, hf_subsample = _get_hf_dataset_and_subsample(dataset) if split_set: train_df, validation_df, test_df = loader.load(hf_id, hf_subsample, split=True) df = [train_df, validation_df, test_df][ SPLITS.index(split_set) ] # split_set should be one of TRAIN, VALIDATION, or TEST else: df = loader.load(hf_id, hf_subsample, split=False) df = backend.df_engine.from_pandas(df) return CacheableDataframe(df=df, name=dataset, checksum=None) def _load_cacheable_dataset(dataset: str, backend: Backend) -> CacheableDataframe: dataset_name = dataset[len(URI_PREFIX) :] loader = get_dataset(dataset_name) df = loader.load(split=False) df = backend.df_engine.from_pandas(df) return CacheableDataframe(df=df, name=dataset, checksum=str(loader.get_mtime())) @PublicAPI def list_datasets() -> list[str]: """Returns a list of the names of all available datasets.""" return sorted(_get_dataset_configs().keys()) @PublicAPI def get_datasets_output_features( dataset: str = None, include_competitions: bool = True, include_data_modalities: bool = False ) -> dict: """Returns a dictionary with the output features for each dataset. Optionally, you can pass a dataset name which will then cause the function to return a dictionary with the output features for that dataset. Because Hugging Face Datasets are loaded dynamically through a shared connector, they don't have fixed output features. As such, we exclude Hugging Face datasets here. :param dataset: (str) name of the dataset :param include_competitions: (bool) whether to include the output features from kaggle competition datasets :param include_data_modalities: (bool) whether to include the data modalities associated with the prediction task :return: (dict) dictionary with the output features for each dataset or a dictionary with the output features for the specified dataset """ ordered_configs = OrderedDict(sorted(_get_dataset_configs().items())) competition_datasets = [] hugging_face_datasets = [] for name, config in ordered_configs.items(): if not include_competitions and config.kaggle_competition: competition_datasets.append(name) continue if config.name == "hugging_face": # There is no output_features attribute for hugging_face datasets hugging_face_datasets.append(name) continue ordered_configs[name] = {"name": config.name, "output_features": config.output_features} if include_data_modalities: column_types = {column[TYPE] for column in config.columns} data_modalities = set() if NUMBER in column_types or CATEGORY in column_types or BINARY in column_types: data_modalities.add("Tabular") if TEXT in column_types: data_modalities.add("Text") if IMAGE in column_types: data_modalities.add("Image") if AUDIO in column_types: data_modalities.add("Audio") ordered_configs[name]["data_modalities"] = data_modalities if dataset: return ordered_configs[dataset] if not include_competitions: for competition in competition_datasets: del ordered_configs[competition] del ordered_configs["hugging_face"] return ordered_configs @PublicAPI def describe_dataset(dataset_name: str) -> str: """Returns the description of the dataset.""" return _get_dataset_configs()[dataset_name].description @PublicAPI def download_dataset(dataset_name: str, output_dir: str = "."): """Downloads the dataset to the specified directory.""" output_dir = os.path.expanduser(os.path.normpath(output_dir)) dataset = get_dataset(dataset_name) dataset.export(output_dir) @DeveloperAPI def get_buffer(dataset_name: str, kaggle_username: str = None, kaggle_key: str = None) -> BytesIO: """Returns a byte buffer for the specified dataset.""" try: if dataset_name.startswith(HF_PREFIX): hf_id, hf_subsample = _get_hf_dataset_and_subsample(dataset_name) dataset = get_dataset("hugging_face").load(hf_id, hf_subsample) else: dataset = get_dataset(dataset_name).load(kaggle_username=kaggle_username, kaggle_key=kaggle_key) buffer = BytesIO(dataset.to_parquet()) return buffer except Exception as e: logging.error(logging.ERROR, f"Failed to upload dataset {dataset_name}: {e}") def _get_hf_dataset_and_subsample(dataset_name: str) -> tuple[str, str | None]: """Returns the Hugging Face ID and subsample name from the dataset name. The dataset name should follow the format "{HF_PREFIX}{hf_id}--{hf_subsample}" Examples (Dataset Name --> HF ID; HF subsample): "hf://wikisql" --> "wikisql"; None "hf://ColumbiaNLP/FLUTE" --> "ColumbiaNLP/FLUTE"; None "hf://mstz/adult--income" --> "mstz/adult"; "income" """ dataset_name = dataset_name[len(HF_PREFIX) :] dataset_name = dataset_name.split("--") if len(dataset_name) == 1: return dataset_name[0], None return dataset_name[0], dataset_name[1] def cli(sys_argv): parser = argparse.ArgumentParser( description="This command downloads and lists Ludwig-ready datasets.", prog="ludwig datasets", usage="%(prog)s [options]", ) sub_parsers = parser.add_subparsers(dest="command", help="download and list datasets") parser_download = sub_parsers.add_parser("download", help="download a dataset") parser_download.add_argument("dataset", help="dataset to download") parser_download.add_argument( "-o", "--output_dir", type=str, default=".", help="output directory to download into", required=False, ) sub_parsers.add_parser("list", help="list datasets") parser_describe = sub_parsers.add_parser("describe", help="describe datasets") parser_describe.add_argument("dataset", help="dataset to describe") args = parser.parse_args(sys_argv) print_ludwig(f"Datasets {args.command}", LUDWIG_VERSION) if args.command == "list": datasets = list_datasets() for ds in datasets: print(ds) elif args.command == "describe": print(describe_dataset(args.dataset)) elif args.command == "download": download_dataset(args.dataset, args.output_dir) else: raise ValueError(f"Unrecognized command: {args.command}") def __getattr__(name: str) -> Any: """Module-level __getattr__ allows us to return an instance of a class. For example: from ludwig.datasets import titanic returns an instance of DatasetLoader configured to load titanic. If you want to download a dataset in a non-default ludwig cache directory, there are two options: 1. set the LUDWIG_CACHE environment variable to your desired path before importing the dataset 2. Use ludwig.datasets.get_dataset(dataset_name, cache_dir=) """ public_methods = { "list_datasets", "describe_dataset", "download_dataset", "cli", "get_dataset", "model_configs_for_dataset", } if name in public_methods: return globals()[name] return get_dataset(name) ================================================ FILE: ludwig/datasets/archives.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import gzip import logging import os import shutil import tarfile from enum import Enum from zipfile import ZipFile from ludwig.utils.fs_utils import upload_output_directory logger = logging.getLogger(__name__) class ArchiveType(str, Enum): """The type of file archive.""" UNKNOWN = "unknown" ZIP = "zip" GZIP = "gz" TAR = "tar" TAR_ZIP = "tar.z" TAR_BZ2 = "tar.bz2" TAR_GZ = "tar.gz" def infer_archive_type(archive_path): """Try to infer archive type from file extension.""" # Get the path extension including multiple extensions, ex. ".tar.gz" extension = ".".join(["", *os.path.basename(archive_path).split(".")[1:]]) extension = extension.lower() if extension.endswith(".tar.z") or extension.endswith(".tar.zip"): return ArchiveType.TAR_ZIP elif extension.endswith(".tar.bz2") or extension.endswith(".tbz2"): return ArchiveType.TAR_BZ2 elif extension.endswith(".tar.gz") or extension.endswith(".tgz"): return ArchiveType.TAR_GZ elif extension.endswith(".tar"): return ArchiveType.TAR elif extension.endswith(".zip") or extension.endswith(".zipx"): return ArchiveType.ZIP elif extension.endswith(".gz") or extension.endswith(".gzip"): return ArchiveType.GZIP else: return ArchiveType.UNKNOWN def is_archive(path): """Does this path a supported archive type.""" return infer_archive_type(path) != ArchiveType.UNKNOWN def list_archive(archive_path, archive_type: ArchiveType | None = None) -> list[str]: """Return list of files extracted in an archive (without extracting them).""" if archive_type is None: archive_type = infer_archive_type(archive_path) if archive_type == ArchiveType.UNKNOWN: logger.error( f"Could not infer type of archive {archive_path}. May be an unsupported archive type." "Specify archive_type in the dataset config if this file has an unknown file extension." ) return [] if archive_type == ArchiveType.ZIP: with ZipFile(archive_path) as zfile: return zfile.namelist() elif archive_type == ArchiveType.GZIP: return [".".join(archive_path.split(".")[:-1])] # Path minus the .gz extension elif archive_type in {ArchiveType.TAR, ArchiveType.TAR_ZIP, ArchiveType.TAR_BZ2, ArchiveType.TAR_GZ}: with tarfile.open(archive_path) as tar_file: return tar_file.getnames() else: logger.error(f"Unsupported archive: {archive_path}") return [] def extract_archive(archive_path: str, archive_type: ArchiveType | None = None) -> list[str]: """Extracts files from archive (into the same directory), returns a list of extracted files. Args: archive_path - The full path to the archive. Returns A list of the files extracted. """ if archive_type is None: archive_type = infer_archive_type(archive_path) if archive_type == ArchiveType.UNKNOWN: logger.error( f"Could not infer type of archive {archive_path}. May be an unsupported archive type." "Specify archive_type in the dataset config if this file has an unknown file extension." ) return [] archive_directory = os.path.dirname(archive_path) directory_contents_before = os.listdir(archive_directory) with upload_output_directory(archive_directory) as (tmpdir, _): if archive_type == ArchiveType.ZIP: with ZipFile(archive_path) as zfile: zfile.extractall(tmpdir) elif archive_type == ArchiveType.GZIP: gzip_content_file = ".".join(archive_path.split(".")[:-1]) # Path minus the .gz extension with gzip.open(archive_path) as gzfile: with open(os.path.join(tmpdir, gzip_content_file), "wb") as output: shutil.copyfileobj(gzfile, output) elif archive_type in {ArchiveType.TAR, ArchiveType.TAR_ZIP, ArchiveType.TAR_BZ2, ArchiveType.TAR_GZ}: with tarfile.open(archive_path) as tar_file: def is_within_directory(directory, target): abs_directory = os.path.abspath(directory) abs_target = os.path.abspath(target) prefix = os.path.commonprefix([abs_directory, abs_target]) return prefix == abs_directory def safe_extract(tar, path=".", members=None, *, numeric_owner=False): for member in tar.getmembers(): member_path = os.path.join(path, member.name) if not is_within_directory(path, member_path): raise Exception("Attempted Path Traversal in Tar File") tar.extractall(path, members, numeric_owner=numeric_owner) safe_extract(tar_file, path=tmpdir) else: logger.error(f"Unsupported archive: {archive_path}") directory_contents_after = set(os.listdir(archive_directory)) return directory_contents_after.difference(directory_contents_before) ================================================ FILE: ludwig/datasets/configs/__init__.py ================================================ ================================================ FILE: ludwig/datasets/configs/adult_census_income.yaml ================================================ version: 1.0 name: adult_census_income download_urls: - https://archive.ics.uci.edu/ml/machine-learning-databases/adult/adult.data - https://archive.ics.uci.edu/ml/machine-learning-databases/adult/adult.test train_filenames: adult.data test_filenames: adult.test sha256: adult.data: 5b00264637dbfec36bdeaab5676b0b309ff9eb788d63554ca0a249491c86603d adult.test: a2a9044bc167a35b2361efbabec64e89d69ce82d9790d2980119aac5fd7e9c05 loader: adult_census_income.AdultCensusIncomeLoader description: | Predict whether income exceeds $50K/yr based on census data https://archive.ics.uci.edu/ml/datasets/adult columns: - name: age type: number - name: workclass type: category - name: fnlwgt type: category - name: education type: category - name: education-num type: category - name: marital-status type: category - name: occupation type: category - name: relationship type: category - name: race type: category - name: sex type: category - name: capital-gain type: number - name: capital-loss type: number - name: hours-per-week type: number - name: native-country type: category - name: income type: category output_features: - name: income type: binary ================================================ FILE: ludwig/datasets/configs/ae_price_prediction.yaml ================================================ version: 1.0 name: ae_price_prediction download_urls: - https://automl-mm-bench.s3.amazonaws.com/ae_price_prediction/train.pq - https://automl-mm-bench.s3.amazonaws.com/ae_price_prediction/test.pq sha256: test.pq: d05242580e011f3ac5a1a8f0069fd7788ceeacd6b2fb00ca7f409991f998c95e train.pq: 181cfebbedd5c6e2bdc6261706103edddfc6eeb4604b8c6ffdc3d084a6e09a4e train_filenames: train.pq test_filenames: test.pq description: | Innerwear Data from Victoria's Secret and Others 600,000+ innerwear product data extracted from popular retail sites https://www.kaggle.com/PromptCloudHQ/innerwear-data-from-victorias-secret-and-others columns: - name: product_name type: category - name: mrp type: category - name: price type: number - name: pdp_url type: category - name: brand_name type: category - name: product_category type: category - name: retailer type: category - name: description type: text - name: rating type: number - name: review_count type: number - name: style_attributes type: set - name: total_sizes type: set - name: available_size type: set - name: color type: category output_features: - name: price type: number ================================================ FILE: ludwig/datasets/configs/agnews.yaml ================================================ version: 1.0 name: agnews download_urls: - https://raw.githubusercontent.com/mhjabreel/CharCnn_Keras/master/data/ag_news_csv/train.csv - https://raw.githubusercontent.com/mhjabreel/CharCnn_Keras/master/data/ag_news_csv/test.csv train_filenames: train.csv test_filenames: test.csv sha256: test.csv: 521465c2428ed7f02f8d6db6ffdd4b5447c1c701962353eb2c40d548c3c85699 train.csv: 76a0a2d2f92b286371fe4d4044640910a04a803fdd2538e0f3f29a5c6f6b672e loader: agnews.AGNewsLoader description: | News articles categorized as "World", "Sports", "Business", and "Science". columns: - name: class_index type: category - name: title type: text - name: description type: text output_features: - name: class_index type: category ================================================ FILE: ludwig/datasets/configs/allstate_claims_severity.yaml ================================================ version: 1.0 name: allstate_claims_severity kaggle_competition: allstate-claims-severity archive_filenames: allstate-claims-severity.zip sha256: allstate-claims-severity.zip: 165f7b4bc5ed40f43656dc958da6572143a7e126e2d37bcd41f1299bfbaa68e2 train_filenames: train.csv test_filenames: test.csv loader: allstate_claims_severity.AllstateClaimsSeverityLoader description: | Allstate Claims Severity. https://www.kaggle.com/c/allstate-claims-severity/overview output_features: - name: loss type: number ================================================ FILE: ludwig/datasets/configs/alpaca.yaml ================================================ version: 1.0 name: alpaca download_urls: https://raw.githubusercontent.com/tatsu-lab/stanford_alpaca/main/alpaca_data.json dataset_filenames: alpaca_data.json description: | Stanford Alpaca instruction-tuning dataset (https://github.com/tatsu-lab/stanford_alpaca) for LLM fine-tuning. columns: - name: instruction type: text - name: input type: text - name: output type: text output_features: - name: output type: text ================================================ FILE: ludwig/datasets/configs/amazon_employee_access_challenge.yaml ================================================ version: 1.0 name: amazon_employee_access_challenge kaggle_competition: amazon-employee-access-challenge archive_filenames: amazon-employee-access-challenge.zip train_filenames: train.csv test_filenames: test.csv sha256: amazon-employee-access-challenge.zip: bba1cf24bc01f390e7faf3f9cdbebd6267c875d51a36a2c625ce66e0c3e71db7 description: | There is a considerable amount of data regarding an employee’s role within an organization and the resources to which they have access. Given the data related to current employees and their provisioned access, models can be built that automatically determine access privileges as employees enter and leave roles within a company. https://www.kaggle.com/c/amazon-employee-access-challenge output_features: - name: ACTION type: binary ================================================ FILE: ludwig/datasets/configs/amazon_review_polarity.yaml ================================================ version: 1.0 name: amazon_review_polarity download_urls: https://s3.amazonaws.com/fast-ai-nlp/amazon_review_polarity_csv.tgz train_filenames: amazon_review_polarity_csv/train.csv test_filenames: amazon_review_polarity_csv/test.csv sha256: amazon_review_polarity_csv.tgz: d2a3ee7a214497a5d1b8eaed7c8d7ba2737de00ada3b0ec46243983efa100361 description: | The Amazon Reviews Polarity dataset Details: 34,686,770 Amazon reviews from 6,643,669 users on 2,441,053 products, from the Stanford Network Analysis Project (SNAP). This dataset contains 600,000 training samples and 130,000 testing samples in each class. Dataset source: Character-level Convolutional Networks for Text Classification Xiang Zhang et al., 2015 columns: - name: label type: binary - name: review_title type: text - name: review_text type: text output_features: - name: label type: binary ================================================ FILE: ludwig/datasets/configs/amazon_reviews.yaml ================================================ version: 1.0 name: amazon_reviews download_urls: https://s3.amazonaws.com/fast-ai-nlp/amazon_review_full_csv.tgz train_filenames: amazon_review_full_csv/train.csv test_filenames: amazon_review_full_csv/test.csv sha256: amazon_review_full_csv.tgz: 4af62eeee139d0142e0747340b68646d23483d9475c33ea0641ee9175b423443 description: | The Amazon Reviews dataset Details: 34,686,770 Amazon reviews from 6,643,669 users on 2,441,053 products, from the Stanford Network Analysis Project (SNAP). This dataset contains 600,000 training samples and 130,000 testing samples in each class. Dataset source: Character-level Convolutional Networks for Text Classification Xiang Zhang et al., 2015 columns: - name: label type: category - name: review_title type: text - name: review_text type: text output_features: - name: label type: category ================================================ FILE: ludwig/datasets/configs/ames_housing.yaml ================================================ version: 1.0 name: ames_housing kaggle_competition: house-prices-advanced-regression-techniques archive_filenames: house-prices-advanced-regression-techniques.zip train_filenames: train.csv test_filenames: test.csv sha256: house-prices-advanced-regression-techniques.zip: 65f769a9157a2581671957ed08da8a8162d53e67b4e9970ee856b634deb11d9f description: | The Ames Housing dataset. https://www.kaggle.com/c/house-prices-advanced-regression-techniques output_features: - name: SalePrice type: number fallback_mirrors: - name: predibase download_paths: s3://ludwig-tests/ludwig_backup/house-prices-advanced-regression-techniques.zip ================================================ FILE: ludwig/datasets/configs/bbcnews.yaml ================================================ version: 1.0 name: bbcnews kaggle_competition: learn-ai-bbc archive_filenames: learn-ai-bbc.zip train_filenames: "BBC News Train.csv" test_filenames: "BBC News Test.csv" sha256: learn-ai-bbc.zip: 450dd79c6654248af15d91d94c269fe7e8001effd89389f93c7184aac6699e62 description: | BBC News Classification from Kaggle. https://www.kaggle.com/competitions/learn-ai-bbc/overview output_features: - name: Category type: category ================================================ FILE: ludwig/datasets/configs/bnp_claims_management.yaml ================================================ version: 1.0 name: bnp_claims_management kaggle_competition: bnp-paribas-cardif-claims-management archive_filenames: bnp-paribas-cardif-claims-management.zip train_filenames: train.csv test_filenames: test.csv sha256: bnp-paribas-cardif-claims-management.zip: c01a11ceae565bc95ec30a1ef4c9ffe4aa27e07d6e433776e90a4d5474f3e95d description: | The BNP Paribas Cardif Claims Management dataset. https://www.kaggle.com/c/bnp-paribas-cardif-claims-management output_features: - name: target type: binary ================================================ FILE: ludwig/datasets/configs/bookprice_prediction.yaml ================================================ version: 1.0 name: bookprice_prediction download_urls: - https://automl-mm-bench.s3.amazonaws.com/machine_hack_competitions/predict_the_price_of_books/train.csv - https://automl-mm-bench.s3.amazonaws.com/machine_hack_competitions/predict_the_price_of_books/test.csv sha256: test.csv: 75bcc853efe734a53764127428e005bb9eb7585ad3dc1dce2eb284fa04313c1b train.csv: dd978b591e623f9c5d4f9ade0f237200597afcad2c6417eb1e764698f1afcfcf train_filenames: train.csv test_filenames: test.csv description: | Here we explore a database of books of different genres, from thousands of authors. In this challenge, participants are required to use the dataset to build a Machine Learning model to predict the price of books based on a given set of features. https://machinehack.com/hackathons/predict_the_price_of_books/overview columns: - name: Title type: category - name: Author type: category - name: Edition type: category - name: Reviews type: number - name: Ratings type: number - name: Synopsis type: text - name: Genre type: category - name: BookCategory type: category - name: Price type: number output_features: - name: Price type: number ================================================ FILE: ludwig/datasets/configs/california_house_price.yaml ================================================ version: 1.0 name: california_house_price download_urls: - https://automl-mm-bench.s3.amazonaws.com/kaggle-california-house-prices/train.csv - https://automl-mm-bench.s3.amazonaws.com/kaggle-california-house-prices/test.csv sha256: test.csv: b5bb9ed6e56cbdd0a410e186a19c6fe137c2ffbb50ba6b0808540434a8123dc6 train.csv: 907d45804e622fb136a9d55bde97269f421fb9b8f7c9f34416672cf7078ee94b train_filenames: train.csv test_filenames: test.csv description: | Predict house sale prices based on the house information, such as # of bedrooms, living areas, locations, near-by schools, and the seller summary. The data consist of houses sold in California in 2020, with houses in the test dataset sold after the ones in the training dataset. https://www.kaggle.com/c/california-house-prices columns: - name: Address type: category - name: Sold Price type: number - name: Summary type: text - name: Type type: category - name: Year built type: number - name: Heating type: category - name: Cooling type: category - name: Parking type: category - name: Lot type: number - name: Bedrooms type: number - name: Bathrooms type: number - name: Full bathrooms type: number - name: Total interior livable area type: number - name: Total spaces type: number - name: Garage spaces type: number - name: Region type: category - name: Elementary School type: category - name: Elementary School Score type: number - name: Elementary School Distance type: number - name: Middle School type: category - name: Middle School Score type: number - name: Middle School Distance type: number - name: High School type: category - name: High School Score type: number - name: High School Distance type: number - name: Flooring type: set - name: Heating features type: set - name: Cooling features type: set - name: Appliances included type: set - name: Laundry features type: set - name: Parking features type: set - name: Tax assessed value type: number - name: Annual tax amount type: number - name: Listed On type: date - name: Listed Price type: number - name: Last Sold On type: date - name: Last Sold Price type: number - name: City type: category - name: Zip type: category - name: State type: category output_features: - name: Sold Price type: number ================================================ FILE: ludwig/datasets/configs/camseq.yaml ================================================ version: 1.0 name: camseq kaggle_dataset_id: carlolepelaars/camseq-semantic-segmentation archive_filenames: camseq-semantic-segmentation.zip sha256: camseq-semantic-segmentation.zip: ea3aeba2661d9b3e3ea406668e7d9240cb2ba0c7e374914bb6d866147faff502 loader: camseq.CamseqLoader preserve_paths: - images - masks description: | CamSeq01 Cambridge Labeled Objects in Video https://www.kaggle.com/datasets/carlolepelaars/camseq-semantic-segmentation columns: - name: image_path type: image - name: mask_path type: image output_features: - name: mask_path type: image ================================================ FILE: ludwig/datasets/configs/code_alpaca.yaml ================================================ version: 1.0 name: code_alpaca download_urls: https://raw.githubusercontent.com/sahil280114/codealpaca/master/data/code_alpaca_20k.json train_filenames: code_alpaca_20k.json loader: code_alpaca_loader.CodeAlpacaLoader description: | This dataset, created by sahil280114, aims to build and share an instruction-following LLaMA model for code generation. The repo containing this dataset is fully based on Stanford Alpaca, and only changes the data used for training. columns: - name: instruction type: text - name: input type: text - name: output type: text output_features: - name: output type: text ================================================ FILE: ludwig/datasets/configs/connect4.yaml ================================================ version: 1.0 name: connect4 kaggle_dataset_id: tbrewer/connect-4 archive_filenames: connect-4.zip dataset_filenames: c4_game_database.csv sha256: connect-4.zip: 46c33c47f2664948a4abe53bafee92a602773f31db615bc8bd239e1f98a3d2cf description: | Each row represents the end results of a Connect-4 game. Columns 1-42 are the positions on the grid from left to right, top to bottom. Each element in these columns represent to player's piece : 1, and -1, 0 marks an empty cell. Column 43 marks the winner of the game : -1, 1, and 0 for tie games. columns: - name: pos_01 type: number - name: pos_02 type: number - name: pos_03 type: number - name: pos_04 type: number - name: pos_05 type: number - name: pos_06 type: number - name: pos_07 type: number - name: pos_08 type: number - name: pos_09 type: number - name: pos_10 type: number - name: pos_11 type: number - name: pos_12 type: number - name: pos_13 type: number - name: pos_14 type: number - name: pos_15 type: number - name: pos_16 type: number - name: pos_17 type: number - name: pos_18 type: number - name: pos_19 type: number - name: pos_20 type: number - name: pos_21 type: number - name: pos_22 type: number - name: pos_23 type: number - name: pos_24 type: number - name: pos_25 type: number - name: pos_26 type: number - name: pos_27 type: number - name: pos_28 type: number - name: pos_29 type: number - name: pos_30 type: number - name: pos_31 type: number - name: pos_32 type: number - name: pos_33 type: number - name: pos_34 type: number - name: pos_35 type: number - name: pos_36 type: number - name: pos_37 type: number - name: pos_38 type: number - name: pos_39 type: number - name: pos_40 type: number - name: pos_41 type: number - name: pos_42 type: number - name: winner type: number output_features: - name: winner type: category ================================================ FILE: ludwig/datasets/configs/consumer_complaints.yaml ================================================ version: 1.0 name: consumer_complaints kaggle_dataset_id: selener/consumer-complaint-database archive_filenames: consumer-complaint-database.zip dataset_filenames: rows.csv loader: consumer_complaints_loader.ConsumerComplaintsLoader description: | The dataset contains different information of complaints that customers have made about a multiple products and services in the financial sector, such us Credit Reports, Student Loans, Money Transfer, etc. The date of each complaint ranges from November 2011 to May 2019. columns: - name: Date received type: Date - name: Product type: text - name: Sub-product type: text - name: Issue type: text - name: Sub-issue type: text - name: Consumer complaint narrative type: text - name: Company public response type: text - name: Company type: text - name: State type: category - name: ZIP code type: category - name: Tags type: category - name: Consumer consent provided? type: text - name: Submitted via type: category - name: Date sent to company type: date - name: Company response to consumer type: text - name: Timely response? type: binary - name: Consumer disputed? type: binary - name: Complaint ID type: number output_features: - name: Issue type: text ================================================ FILE: ludwig/datasets/configs/consumer_complaints_generation.yaml ================================================ version: 1.0 name: consumer_complaints_generation download_urls: https://predibase-public-us-west-2.s3.us-west-2.amazonaws.com/datasets/consumer_complaints_gen_tutorial.csv train_filenames: consumer_complaints_gen_tutorial.csv description: | The dataset contains different information of complaints that customers have made about a multiple products and services in the financial sector, such us Credit Reports, Student Loans, Money Transfer, etc. The date of each complaint ranges from November 2011 to May 2019. The dataset has been modified to be used for text generation. We have added a structured JSON field that contains a company generated response to the raised complaint. The idea is to fine-tune an LLM to generate this output JSON field. columns: - name: Complaint ID type: number - name: Date received type: Date - name: Product type: text - name: Issue type: text - name: Complaint type: text - name: Company Response type: text - name: Structured JSON Output type: text output_features: - name: Structured JSON Output type: text ================================================ FILE: ludwig/datasets/configs/creditcard_fraud.yaml ================================================ version: 1.0 name: creditcard_fraud kaggle_dataset_id: mlg-ulb/creditcardfraud archive_filenames: creditcardfraud.zip sha256: creditcardfraud.zip: a0360ce715992212e9ac72d8ccdca97f4be87dc1fdf2bed011358f7ab409a28a loader: creditcard_fraud.CreditCardFraudLoader description: | The Machine Learning Group ULB Dataset https://www.kaggle.com/datasets/mlg-ulb/creditcardfraud columns: - name: Time type: number - name: V1 type: number - name: V2 type: number - name: V3 type: number - name: V4 type: number - name: V5 type: number - name: V6 type: number - name: V7 type: number - name: V8 type: number - name: V9 type: number - name: V10 type: number - name: V11 type: number - name: V12 type: number - name: V13 type: number - name: V14 type: number - name: V15 type: number - name: V16 type: number - name: V17 type: number - name: V18 type: number - name: V19 type: number - name: V20 type: number - name: V21 type: number - name: V22 type: number - name: V23 type: number - name: V24 type: number - name: V25 type: number - name: V26 type: number - name: V27 type: number - name: V28 type: number - name: Amount type: number - name: Class type: number output_features: - name: Class type: binary ================================================ FILE: ludwig/datasets/configs/customer_churn_prediction.yaml ================================================ version: 1.0 name: customer_churn_prediction kaggle_competition: customer-churn-prediction-2020 archive_filenames: customer-churn-prediction-2020.zip train_filenames: train.csv test_filenames: test.csv sha256: customer-churn-prediction-2020.zip: fb5cbc787081a6a559592230c657a0520a181447da6eb2adc34a3aebbe8ed9ca description: | Dataset from a Kaggle competition that is about predicting whether a customer will change telecommunications provider, something known as "churning". https://www.kaggle.com/c/customer-churn-prediction-2020 output_features: - name: churn type: binary ================================================ FILE: ludwig/datasets/configs/data_scientist_salary.yaml ================================================ version: 1.0 name: data_scientist_salary download_urls: - https://automl-mm-bench.s3.amazonaws.com/machine_hack_competitions/predict_the_data_scientists_salary_in_india_hackathon/train.csv - https://automl-mm-bench.s3.amazonaws.com/machine_hack_competitions/predict_the_data_scientists_salary_in_india_hackathon/test.csv sha256: test.csv: 244c215f4a03cae4b107e76c7fe94269728450cabf44c943415211ce7d6437df train.csv: 99d6aa80505ac1311e97f402d5723996119e859c7f3fce261350462148debe3d train_filenames: train.csv test_filenames: test.csv description: | The training data and test data comprise of 19802 samples and of 6601 samples each from the Analytics India Annual Salary Study. https://machinehack.com/hackathons/predict_the_data_scientists_salary_in_india_hackathon/overview columns: - name: experience type: category - name: job_description type: text - name: job_desig type: category - name: job_type type: category - name: key_skills type: set - name: location type: category - name: salary type: category output_features: - name: salary type: category ================================================ FILE: ludwig/datasets/configs/dbpedia.yaml ================================================ version: 1.0 name: dbpedia download_urls: https://s3.amazonaws.com/fast-ai-nlp/dbpedia_csv.tgz train_filenames: dbpedia_csv/train.csv test_filenames: dbpedia_csv/test.csv sha256: dbpedia_csv.tgz: 42db5221ddedddb673a4cabcc5f3a7d869714c878bcfe4ba94b29d14aa38e417 description: | The DBPedia Ontology dataset. Details: 40,000 training samples and 5,000 testing samples from 14 nonoverlapping classes from DBpedia 2014. Dataset source: Character-level Convolutional Networks for Text Classification Xiang Zhang et al., 2015 columns: - name: label type: category - name: title type: category - name: content type: text output_features: - name: label type: category ================================================ FILE: ludwig/datasets/configs/electricity.yaml ================================================ version: 1.0 name: electricity download_urls: https://raw.githubusercontent.com/nimz/electricity_demand/master/elecdemand.csv sha256: elecdemand.csv: 4fd3c8a4b8168f34703b55313c5341f8e8385810a54f1a1cdf6987c1904c9698 description: | Electricity demand dataset. Half-hourly electricity demand in Victoria, Australia during 2014, along with Melbourne temperatures. Source textbook: Forecasting: Principles and Practice Rob J Hyndman and George Athanasopoulos columns: - name: Demand type: number - name: WorkDay type: binary - name: Temperature type: number output_features: - name: Demand type: number ================================================ FILE: ludwig/datasets/configs/ethos_binary.yaml ================================================ version: 1.1 name: ethos_binary download_urls: - https://raw.githubusercontent.com/intelligence-csd-auth-gr/Ethos-Hate-Speech-Dataset/master/ethos/ethos_data/Ethos_Dataset_Binary.csv sha256: Ethos_Dataset_Binary.csv: 0cd0050c2592afcb5eca5876df485ca15cda9d7d16fe32c269857260fd10d96c loader: ethos_binary.EthosBinaryLoader description: | The Ethos Hate Speech Dataset. Source Paper: ETHOS: an Online Hate Speech Detection Dataset Ioannis Mollas and Zoe Chrysopoulou and Stamatis Karlos and Grigorios Tsoumakas columns: - name: comment type: text - name: isHate type: binary output_features: - name: isHate type: binary ================================================ FILE: ludwig/datasets/configs/fake_job_postings2.yaml ================================================ version: 1.0 name: fake_job_postings2 download_urls: - https://automl-mm-bench.s3.amazonaws.com/fake_job_postings2/train.csv - https://automl-mm-bench.s3.amazonaws.com/fake_job_postings2/test.csv sha256: test.csv: a5296f49129d440434e6274bb892a1320fe1dd4c26d5a1b085786d5ea1133dd8 train.csv: b6568e415ad49cb7bd23848dfbb8d381f9de590e133a5075abbf4c1a7c7c1711 train_filenames: train.csv test_filenames: test.csv description: | This dataset contains 18K job descriptions out of which about 800 are fake. The data consists of both textual information and meta-information about the jobs. This dataset is "fake_job_postings2" in the AutoGluon paper. https://www.kaggle.com/datasets/shivamb/real-or-fake-fake-jobposting-prediction columns: - name: title type: category - name: salary_range type: category - name: description type: text - name: required_experience type: category - name: required_education type: category - name: fraudulent type: binary output_features: - name: fraudulent type: binary ================================================ FILE: ludwig/datasets/configs/fever.yaml ================================================ version: 1.0 name: fever download_urls: - https://fever.ai/download/fever/train.jsonl - https://fever.ai/download/fever/paper_dev.jsonl - https://fever.ai/download/fever/paper_test.jsonl sha256: train.jsonl: eba7e8f87076753f8494718b9a857827af7bf73e76c9e4b75420207d26e588b6 paper_test.jsonl: fb7b0280a0adc2302bbb29bfb7af37274fa585de3171bcf908f180642d11d88e paper_dev.jsonl: 41158707810008747946bf23471e82df53e77a513524b9e3ec1c2e674ef5ef8c train_filenames: train.jsonl test_filenames: paper_test.jsonl validation_filenames: paper_dev.jsonl column_types: evidence: str description: | FEVER: a Large-scale Dataset for Fact Extraction and VERification columns: - name: id type: category - name: verifiable type: category - name: label type: category - name: label type: category - name: claim type: text - name: evidence type: category - name: label type: category output_features: - name: label type: category ================================================ FILE: ludwig/datasets/configs/flickr8k.yaml ================================================ version: 1.0 name: flickr8k download_urls: - https://github.com/jbrownlee/Datasets/releases/download/Flickr8k/Flickr8k_Dataset.zip - https://github.com/jbrownlee/Datasets/releases/download/Flickr8k/Flickr8k_text.zip dataset_filenames: flickr8k_dataset.csv preserve_paths: Flicker8k_Dataset sha256: Flickr8k_Dataset.zip: 61e4b111d32b24a55b69dafd91f4c3aec07391b7b9217face15dd35d517fe6de Flickr8k_text.zip: 4992ddc8110e9aa49da5bf698522b0c8f11c448814a488584ee6bf040e5137e7 loader: flickr8k.Flickr8kLoader description: | A new benchmark collection for sentence-based image description and search, consisting of 8,000 images that are each paired with five different captions which provide clear descriptions of the salient entities and events. The images were chosen from six different Flickr groups, and tend not to contain any well-known people or locations, but were manually selected to depict a variety of scenes and situations. output_features: - name: caption0 type: text - name: caption1 type: text - name: caption2 type: text - name: caption3 type: text - name: caption4 type: text ================================================ FILE: ludwig/datasets/configs/forest_cover.yaml ================================================ version: 1.0 name: forest_cover download_urls: https://archive.ics.uci.edu/ml/machine-learning-databases/covtype/covtype.data.gz sha256: covtype.data.gz: 614360d0257557dd1792834a85a1cdebfadc3c4f30b011d56afee7ffb5b15771 dataset_filenames: covtype.data loader: forest_cover.ForestCoverLoader description: | The Forest Cover Type dataset. Predicting forest cover type from cartographic variables only. https://archive.ics.uci.edu/ml/datasets/covertype columns: - name: Elevation type: number - name: Aspect type: number - name: Slope type: number - name: Horizontal_Distance_To_Hydrology type: number - name: Vertical_Distance_To_Hydrology type: number - name: Horizontal_Distance_To_Roadways type: number - name: Hillshade_9am type: number - name: Hillshade_Noon type: number - name: Hillshade_3pm type: number - name: Horizontal_Distance_To_Fire_Points type: number - name: Wilderness_Area type: category - name: Soil_Type type: category - name: Cover_Type type: category output_features: - name: Cover_Type type: category ================================================ FILE: ludwig/datasets/configs/goemotions.yaml ================================================ version: 1.0 name: goemotions download_urls: - https://raw.githubusercontent.com/google-research/google-research/master/goemotions/data/train.tsv - https://raw.githubusercontent.com/google-research/google-research/master/goemotions/data/dev.tsv - https://raw.githubusercontent.com/google-research/google-research/master/goemotions/data/test.tsv train_filenames: train.tsv validation_filenames: dev.tsv test_filenames: test.tsv sha256: train.tsv: 1c254a142be5c00e80d819b9ae1bbd36d94b2eeb8f4b1271846508d57e57d9c5 dev.tsv: 575489c079c9de1097062a01738f998590d6b7ead66dd1c9fd1d2ba01fd8bc62 test.tsv: 0587b2dd8b27b97352adbfc3fb083d46005c8946657fdc2b1ca8b1cc7f1f8be4 loader: goemotions.GoEmotionsLoader description: | GoEmotions: A Dataset for Fine-Grained Emotion Classification. https://ai.googleblog.com/2021/10/goemotions-dataset-for-fine-grained.html columns: - name: text type: text - name: emotion_ids type: category - name: comment_id type: category output_features: - name: emotion_ids type: category ================================================ FILE: ludwig/datasets/configs/goodbooks_books.yaml ================================================ version: 1.0 name: goodbooks_books download_urls: - https://github.com/zygmuntz/goodbooks-10k/releases/download/v1.0/goodbooks-10k.zip sha256: goodbooks-10k.zip: 261b97b56db61f3fb2ce5aadbb13704d30179fcc986c17ace665a0af9ed00731 dataset_filenames: books.csv description: | goodbooks_books is a multimodal dataset of 10K books, taken from the goodreads dataset. The Goodbooks-10K dataset contains six million ratings for ten thousand most popular (with most ratings) books. The dataset also contains: books marked to read by the users book metadata (author, year, etc.) tags/shelves/genres https://github.com/zygmuntz/goodbooks-10k columns: - name: book_id type: category - name: goodreads_book_id type: category - name: best_book_id type: category - name: work_id type: category - name: books_count type: number - name: isbn type: category - name: isbn13 type: category - name: authors type: category - name: original_publication_year type: category - name: original_title type: category - name: title type: category - name: language_code type: category - name: average_rating type: number - name: ratings_count type: number - name: work_ratings_count type: number - name: work_text_reviews_count type: number - name: ratings_1 type: number - name: ratings_2 type: number - name: ratings_3 type: number - name: ratings_4 type: number - name: ratings_5 type: number - name: image_url type: image - name: small_image_url type: image output_features: - name: average_rating type: number - name: ratings_1 type: number - name: ratings_2 type: number - name: ratings_3 type: number - name: ratings_4 type: number - name: ratings_5 type: number ================================================ FILE: ludwig/datasets/configs/google_qa_answer_type_reason_explanation.yaml ================================================ version: 1.0 name: google_qa_answer_type_reason_explanation download_urls: - https://automl-mm-bench.s3.amazonaws.com/google_quest_qa/train.pq - https://automl-mm-bench.s3.amazonaws.com/google_quest_qa/dev.pq sha256: train.pq: 92274286ffb759c96bfca77001c10eb323b3531db3a0e178813db9b82e80a12a dev.pq: 2e66450215b94dc404eadc7dde83a1eabad9640d946863c298aa2d42c998ed84 train_filenames: train.pq test_filenames: dev.pq description: | Google QUEST Q&A Labeling Improving automated understanding of complex question answer content. The data for this competition includes questions and answers from various StackExchange properties. https://www.kaggle.com/c/google-quest-challenge/data Note: this is the same dataset as `google_quest_qa`. It is duplicated here to have a one-to-one mapping with the benchmarking datasets in https://arxiv.org/pdf/2111.02705.pdf In this paper, the column `answer_type_reason_explanation` is used as the output feature. columns: - name: qa_id type: category - name: question_title type: text - name: question_body type: text - name: question_user_name type: category - name: question_user_page type: category - name: answer type: text - name: answer_user_name type: category - name: answer_user_page type: category - name: url type: category - name: category type: category - name: host type: category - name: question_asker_intent_understanding type: number - name: question_body_critical type: number - name: question_conversational type: number - name: question_expect_short_answer type: number - name: question_fact_seeking type: number - name: question_has_commonly_accepted_answer type: number - name: question_interestingness_others type: number - name: question_interestingness_self type: number - name: question_multi_intent type: number - name: question_not_really_a_question type: number - name: question_opinion_seeking type: number - name: question_type_choice type: number - name: question_type_compare type: number - name: question_type_consequence type: number - name: question_type_definition type: number - name: question_type_entity type: number - name: question_type_instructions type: number - name: question_type_procedure type: number - name: question_type_reason_explanation type: number - name: question_type_spelling type: number - name: question_well_written type: number - name: answer_helpful type: number - name: answer_level_of_information type: number - name: answer_plausible type: number - name: answer_relevance type: number - name: answer_satisfaction type: number - name: answer_type_instructions type: number - name: answer_type_procedure type: number - name: answer_type_reason_explanation type: number - name: answer_well_written type: number output_features: - name: answer_type_reason_explanation type: number ================================================ FILE: ludwig/datasets/configs/google_qa_question_type_reason_explanation.yaml ================================================ version: 1.0 name: google_qa_question_type_reason_explanation download_urls: - https://automl-mm-bench.s3.amazonaws.com/google_quest_qa/train.pq - https://automl-mm-bench.s3.amazonaws.com/google_quest_qa/dev.pq sha256: train.pq: 92274286ffb759c96bfca77001c10eb323b3531db3a0e178813db9b82e80a12a dev.pq: 2e66450215b94dc404eadc7dde83a1eabad9640d946863c298aa2d42c998ed84 train_filenames: train.pq test_filenames: dev.pq description: | Google QUEST Q&A Labeling Improving automated understanding of complex question answer content. The data for this competition includes questions and answers from various StackExchange properties. https://www.kaggle.com/c/google-quest-challenge/data Note: this is the same dataset as `google_quest_qa`. It is duplicated here to have a one-to-one mapping with the benchmarking datasets in https://arxiv.org/pdf/2111.02705.pdf In this paper, the column `question_type_reason_explanation` is used as the output feature. columns: - name: qa_id type: category - name: question_title type: text - name: question_body type: text - name: question_user_name type: category - name: question_user_page type: category - name: answer type: text - name: answer_user_name type: category - name: answer_user_page type: category - name: url type: category - name: category type: category - name: host type: category - name: question_asker_intent_understanding type: number - name: question_body_critical type: number - name: question_conversational type: number - name: question_expect_short_answer type: number - name: question_fact_seeking type: number - name: question_has_commonly_accepted_answer type: number - name: question_interestingness_others type: number - name: question_interestingness_self type: number - name: question_multi_intent type: number - name: question_not_really_a_question type: number - name: question_opinion_seeking type: number - name: question_type_choice type: number - name: question_type_compare type: number - name: question_type_consequence type: number - name: question_type_definition type: number - name: question_type_entity type: number - name: question_type_instructions type: number - name: question_type_procedure type: number - name: question_type_reason_explanation type: number - name: question_type_spelling type: number - name: question_well_written type: number - name: answer_helpful type: number - name: answer_level_of_information type: number - name: answer_plausible type: number - name: answer_relevance type: number - name: answer_satisfaction type: number - name: answer_type_instructions type: number - name: answer_type_procedure type: number - name: answer_type_reason_explanation type: number - name: answer_well_written type: number output_features: - name: question_type_reason_explanation type: number ================================================ FILE: ludwig/datasets/configs/google_quest_qa.yaml ================================================ version: 1.0 name: google_quest_qa download_urls: - https://automl-mm-bench.s3.amazonaws.com/google_quest_qa/train.pq - https://automl-mm-bench.s3.amazonaws.com/google_quest_qa/dev.pq - https://automl-mm-bench.s3.amazonaws.com/google_quest_qa/test.pq sha256: test.pq: cb1bb5f32374d83ad4ef7feb4e443c9376cdd919cda40057732ef500e9a4ecf3 train.pq: 92274286ffb759c96bfca77001c10eb323b3531db3a0e178813db9b82e80a12a dev.pq: 2e66450215b94dc404eadc7dde83a1eabad9640d946863c298aa2d42c998ed84 train_filenames: train.pq validation_filenames: dev.pq test_filenames: test.pq description: | Google QUEST Q&A Labeling Improving automated understanding of complex question answer content. The data for this competition includes questions and answers from various StackExchange properties. https://www.kaggle.com/c/google-quest-challenge/data columns: - name: qa_id type: category - name: question_title type: text - name: question_body type: text - name: question_user_name type: category - name: question_user_page type: category - name: answer type: text - name: answer_user_name type: category - name: answer_user_page type: category - name: url type: category - name: category type: category - name: host type: category - name: question_asker_intent_understanding type: number - name: question_body_critical type: number - name: question_conversational type: number - name: question_expect_short_answer type: number - name: question_fact_seeking type: number - name: question_has_commonly_accepted_answer type: number - name: question_interestingness_others type: number - name: question_interestingness_self type: number - name: question_multi_intent type: number - name: question_not_really_a_question type: number - name: question_opinion_seeking type: number - name: question_type_choice type: number - name: question_type_compare type: number - name: question_type_consequence type: number - name: question_type_definition type: number - name: question_type_entity type: number - name: question_type_instructions type: number - name: question_type_procedure type: number - name: question_type_reason_explanation type: number - name: question_type_spelling type: number - name: question_well_written type: number - name: answer_helpful type: number - name: answer_level_of_information type: number - name: answer_plausible type: number - name: answer_relevance type: number - name: answer_satisfaction type: number - name: answer_type_instructions type: number - name: answer_type_procedure type: number - name: answer_type_reason_explanation type: number - name: answer_well_written type: number output_features: - name: question_type_reason_explanation type: category ================================================ FILE: ludwig/datasets/configs/higgs.yaml ================================================ version: 1.0 name: higgs download_urls: https://archive.ics.uci.edu/ml/machine-learning-databases/00280/HIGGS.csv.gz sha256: HIGGS.csv.gz: ea302c18164d4e3d916a1e2e83a9a8d07069fa6ebc7771e4c0540d54e593b698 column_types: label: int32 loader: higgs.HiggsLoader description: | The Higgs Boson dataset. This is a classification problem to distinguish between a signal process which produces Higgs bosons and a background process which does not. https://archive.ics.uci.edu/ml/datasets/HIGGS columns: - name: label type: binary - name: lepton_pT type: number - name: lepton_eta type: number - name: lepton_phi type: number - name: missing_energy_magnitude type: number - name: missing_energy_phi type: number - name: jet_1_pt type: number - name: jet_1_eta type: number - name: jet_1_phi type: number - name: jet_1_b-tag type: number - name: jet_2_pt type: number - name: jet_2_eta type: number - name: jet_2_phi type: number - name: jet_2_b-tag type: number - name: jet_3_pt type: number - name: jet_3_eta type: number - name: jet_3_phi type: number - name: jet_3_b-tag type: number - name: jet_4_pt type: number - name: jet_4_eta type: number - name: jet_4_phi type: number - name: jet_4_b-tag type: number - name: m_jj type: number - name: m_jjj type: number - name: m_lv type: number - name: m_jlv type: number - name: m_bb type: number - name: m_wbb type: number - name: m_wwbb type: number output_features: - name: label type: binary ================================================ FILE: ludwig/datasets/configs/hugging_face.yaml ================================================ version: 1.0 name: hugging_face loader: hugging_face.HFLoader description: | Hugging Face Datasets ================================================ FILE: ludwig/datasets/configs/ieee_fraud.yaml ================================================ version: 1.0 name: ieee_fraud kaggle_competition: ieee-fraud-detection archive_filenames: ieee-fraud-detection.zip sha256: ieee-fraud-detection.zip: 4cc646da09d0a9b265983ffed775b1f9ee15af5266586df610e04d6adae0b829 train_filenames: - train_identity.csv - train_transaction.csv test_filenames: - test_identity.csv - test_transaction.csv loader: ieee_fraud.IEEEFraudLoader description: | The IEEE-CIS Fraud Detection Dataset https://www.kaggle.com/c/ieee-fraud-detection/overview. output_features: - name: isFraud type: binary ================================================ FILE: ludwig/datasets/configs/imbalanced_insurance.yaml ================================================ version: 1.0 name: imbalaced_insurance kaggle_dataset_id: arashnic/imbalanced-data-practice archive_filenames: imbalanced-data-practice.zip sha256: imbalanced-data-practice.zip: 33c7d15cbdb7cc151c1d5e920a8a613b015c19222f90d4eac04ca8cfc5416847 dataset_filenames: aug_train.csv loader: split_loaders.RandomSplitLoader description: | Health Insurance Cross Sell Prediction Predict Health Insurance Owners' who will be interested in Vehicle Insurance https://www.kaggle.com/datasets/arashnic/imbalanced-data-practice columns: - name: id type: category - name: Gender type: binary - name: Age type: number - name: Driving_License type: binary - name: Region_Code type: category - name: Previously_Insured type: binary - name: Vehicle_Age type: category - name: Vehicle_Damage type: binary - name: Annual_Premium type: number - name: Policy_Sales_Channel type: - name: Vintage type: - name: Response type: output_features: - name: Response type: binary ================================================ FILE: ludwig/datasets/configs/imdb.yaml ================================================ version: 1.0 name: imdb kaggle_dataset_id: lakshmi25npathi/imdb-dataset-of-50k-movie-reviews archive_filenames: imdb-dataset-of-50k-movie-reviews.zip sha256: imdb-dataset-of-50k-movie-reviews.zip: 73a235bc5fc4df57bb5d517afa480fe6bfd4e2afc25dc5e5867fc87f2d25614d description: | IMDB dataset having 50K movie reviews for natural language processing or Text analytics. https://www.kaggle.com/datasets/lakshmi25npathi/imdb-dataset-of-50k-movie-reviews columns: - name: review type: text - name: sentiment type: category output_features: - name: sentiment type: binary ================================================ FILE: ludwig/datasets/configs/imdb_genre_prediction.yaml ================================================ version: 1.0 name: imdb_genre_prediction download_urls: - https://automl-mm-bench.s3.amazonaws.com/imdb_genre_prediction/train.csv - https://automl-mm-bench.s3.amazonaws.com/imdb_genre_prediction/test.csv sha256: test.csv: 5bca7b6ca34f4057e2a4920d6034f481055bd03061bb0128c87d6c99a6b4661f train.csv: b63f1f6fcad17f644d9266891a01d0f0e1187c277ccf6eecb80af72b92b0b621 train_filenames: train.csv test_filenames: test.csv description: | A data set of 1,000 most popular movies on IMDB in the last 10 years. The data points included are: Title, Genre, Description, Director, Actors, Year, Runtime, Rating, Votes, Revenue, Metascrore https://www.kaggle.com/PromptCloudHQ/imdb-data columns: - name: Rank type: number - name: Title type: category - name: Description type: text - name: Director type: category - name: Actors type: set - name: Year type: category - name: Runtime (Minutes) type: number - name: Rating type: Number - name: Votes type: number - name: Revenue (Millions) type: number - name: Metascore type: number - name: Genre_is_Drama type: binary output_features: - name: Genre_is_Drama type: binary ================================================ FILE: ludwig/datasets/configs/insurance_lite.yaml ================================================ version: 1.0 name: insurance_lite kaggle_dataset_id: infernape/fast-furious-and-insured archive_filenames: fast-furious-and-insured.zip sha256: fast-furious-and-insured.zip: 3b88ada517aa88d9c9187121d7ef42f4b5539808677a2b0827b989ca0fa19600 dataset_filenames: Fast_Furious_Insured/train.csv preserve_paths: Fast_Furious_Insured loader: insurance_lite.InsuranceLiteLoader description: | The dataset consists of parameters such as the images of damaged cars, the price of the cars and their insurance claim, and the like. Predict the insurance claim for the cars that are provided in the dataset. columns: - name: image_path type: image - name: insurance_company type: category - name: cost_of_vehicle type: number - name: min_coverage type: number - name: expiry_date type: date - name: max_coverage type: number - name: condition type: binary - name: amount type: number output_features: - name: amount type: number ================================================ FILE: ludwig/datasets/configs/iris.yaml ================================================ version: 1.0 name: iris download_urls: https://archive.ics.uci.edu/ml/machine-learning-databases/iris/iris.data sha256: iris.data: 6f608b71a7317216319b4d27b4d9bc84e6abd734eda7872b71a458569e2656c0 description: | Iris Dataset https://archive.ics.uci.edu/ml/datasets/Iris columns: - name: sepal_length_cm type: number - name: sepal_width_cm type: number - name: petal_length_cm type: number - name: petal_width_cm type: number - name: class type: category output_features: - name: class type: category ================================================ FILE: ludwig/datasets/configs/irony.yaml ================================================ version: 1.0 name: irony download_urls: https://raw.githubusercontent.com/bwallace/ACL-2014-irony/master/irony-labeled.csv sha256: irony-labeled.csv: 11f4d0964bd9c5c8363de2920612f5d926a4e6b3a8ab9187da2c33cfc0fdd02b description: | The Reddit Irony dataset. Source Paper: Humans Require Context to Infer Ironic Intent (so Computers Probably do, too) Byron C Wallace, Do Kook Choe, Laura Kertz, and Eugene Charniak columns: - name: comment_text type: text - name: label type: binary output_features: - name: label type: binary ================================================ FILE: ludwig/datasets/configs/jc_penney_products.yaml ================================================ version: 1.0 name: jc_penney_products download_urls: - https://automl-mm-bench.s3.amazonaws.com/jc_penney_products/train.csv - https://automl-mm-bench.s3.amazonaws.com/jc_penney_products/test.csv sha256: test.csv: 458fb13b07701897fbc0d88481823b90e884e92a42e65eeba816cdf3523b2e85 train.csv: e9e3d3da627dc544d01f4c27b1d023288c68e55ce2db2593fb7b2268a6b9b020 train_filenames: train.csv test_filenames: test.csv description: | JCPenney products 20,000 product listings from JCPenney https://www.kaggle.com/PromptCloudHQ/all-jc-penny-products columns: - name: name_title type: category - name: description type: text - name: sale_price type: number - name: average_product_rating type: number - name: brand type: category - name: total_number_reviews type: number output_features: - name: sale_price type: number ================================================ FILE: ludwig/datasets/configs/jigsaw_unintended_bias.yaml ================================================ version: 1.0 name: jigsaw_unintended_bias download_urls: - https://automl-mm-bench.s3.amazonaws.com/jigsaw_unintended_bias/train.pq - https://automl-mm-bench.s3.amazonaws.com/jigsaw_unintended_bias/dev.pq - https://automl-mm-bench.s3.amazonaws.com/jigsaw_unintended_bias/test.pq sha256: test.pq: e9f3fd6fa83ddea2af8d21e93eb677b2fa5686c9b8ae38e6293f7c3306f66fad train.pq: 30bedd5bbd5b2277b8bffa4ed3a02ce6ef7c838aa5c1338908b5ad599a6a9888 dev.pq: 57e1e3a06733fb83ad9ca46839ed8afd7d670e5e5f5c7f0026b748d760457d57 train_filenames: train.pq validation_filenames: dev.pq test_filenames: test.pq description: | A dataset labeled for identity mentions and optimizing a metric designed to measure unintended bias. Disclaimer: The dataset for this competition contains text that may be considered profane, vulgar, or offensive. https://www.kaggle.com/c/jigsaw-unintended-bias-in-toxicity-classification columns: - name: id type: category - name: target type: binary - name: comment_text type: text - name: severe_toxicity type: number - name: obscene type: number - name: identity_attack type: number - name: insult type: number - name: threat type: number - name: asian type: number - name: atheist type: number - name: bisexual type: number - name: black type: number - name: buddhist type: number - name: christian type: number - name: female type: number - name: heterosexual type: number - name: hindu type: number - name: homosexual_gay_or_lesbian type: number - name: intellectual_or_learning_disability type: number - name: jewish type: number - name: latino type: number - name: male type: number - name: muslim type: number - name: other_disability type: number - name: other_gender type: number - name: other_race_or_ethnicity type: number - name: other_religion type: number - name: other_sexual_orientation type: number - name: physical_disability type: number - name: psychiatric_or_mental_illness type: number - name: transgender type: number - name: white type: number - name: created_date type: date - name: publication_id type: category - name: parent_id type: category - name: article_id type: category - name: rating type: category - name: funny type: number - name: wow type: number - name: sad type: number - name: likes type: number - name: disagree type: number - name: sexual_explicit type: number - name: identity_annotator_count type: number - name: toxicity_annotator_count type: number output_features: - name: target type: binary ================================================ FILE: ludwig/datasets/configs/jigsaw_unintended_bias100k.yaml ================================================ version: 1.0 name: jigsaw_unintended_bias100K download_urls: - https://automl-mm-bench.s3.amazonaws.com/jigsaw_unintended_bias100K/train.pq - https://automl-mm-bench.s3.amazonaws.com/jigsaw_unintended_bias100K/test.pq sha256: test.pq: f7a0ec60ac89ffdb94919bf95e514057588a444c90ebdcb8ac90dfb0bfec3d48 train.pq: 48916c037b0a20167f6e9176cc1eedcb0e6ef942beeedb7dc02f19dfebac0229 train_filenames: train.pq test_filenames: test.pq description: | A dataset labeled for identity mentions and optimizing a metric designed to measure unintended bias. Disclaimer: The dataset for this competition contains text that may be considered profane, vulgar, or offensive. https://www.kaggle.com/c/jigsaw-unintended-bias-in-toxicity-classification columns: - name: id type: category - name: target type: binary - name: comment_text type: text - name: severe_toxicity type: number - name: obscene type: number - name: identity_attack type: number - name: insult type: number - name: threat type: number - name: asian type: number - name: atheist type: number - name: bisexual type: number - name: black type: number - name: buddhist type: number - name: christian type: number - name: female type: number - name: heterosexual type: number - name: hindu type: number - name: homosexual_gay_or_lesbian type: number - name: intellectual_or_learning_disability type: number - name: jewish type: number - name: latino type: number - name: male type: number - name: muslim type: number - name: other_disability type: number - name: other_gender type: number - name: other_race_or_ethnicity type: number - name: other_religion type: number - name: other_sexual_orientation type: number - name: physical_disability type: number - name: psychiatric_or_mental_illness type: number - name: transgender type: number - name: white type: number - name: created_date type: date - name: publication_id type: category - name: parent_id type: category - name: article_id type: category - name: rating type: category - name: funny type: number - name: wow type: number - name: sad type: number - name: likes type: number - name: disagree type: number - name: sexual_explicit type: number - name: identity_annotator_count type: number - name: toxicity_annotator_count type: number output_features: - name: target type: binary ================================================ FILE: ludwig/datasets/configs/kdd_appetency.yaml ================================================ version: 1.0 name: kdd_appetency download_urls: - https://kdd.org/cupfiles/KDDCupData/2009/orange_small_train.data.zip - https://kdd.org/cupfiles/KDDCupData/2009/orange_small_test.data.zip - https://kdd.org/cupfiles/KDDCupData/2009/orange_small_train_appetency.labels - https://raw.githubusercontent.com/catboost/benchmarks/master/quality_benchmarks/prepare_appetency_churn_upselling/appetency/stratified_train_idx_appetency.txt - https://raw.githubusercontent.com/catboost/benchmarks/master/quality_benchmarks/prepare_appetency_churn_upselling/appetency/stratified_test_idx_appetency.txt sha256: orange_small_test.data.zip: 440ac8a350144c14f4d6947c096ad675ee84aa27b4b742071662696e333cec53 orange_small_train.data.zip: 31ccb810bdbb71c16e079326443166dc3dfbf73cd358fc4a4ce7440fb1bc6040 orange_small_train_appetency.labels: edbfa40e7513804cf25c3f8b3c8f4a6cf5c77116cffc2f87ef770351250a963c stratified_train_idx_appetency.txt: 9c6bf7da6209653e13d9a1d2ef90e4afafe0ecac0eb843c8025816a445c625d9 stratified_test_idx_appetency.txt: b80fb8dcf43cd028f4b8affeab65299d580a7e5432ebbe639527dc8177f8764a dataset_filenames: orange_small_train.data loader: kdd_loader.KDDAppetencyLoader description: | The KDD Cup 2009 Appetency dataset. https://www.kdd.org/kdd-cup/view/kdd-cup-2009/Data columns: - name: Var1 type: number - name: Var2 type: number - name: Var3 type: number - name: Var4 type: number - name: Var5 type: number - name: Var6 type: number - name: Var7 type: number - name: Var8 type: number - name: Var9 type: number - name: Var10 type: number - name: Var11 type: number - name: Var12 type: number - name: Var13 type: number - name: Var14 type: number - name: Var15 type: number - name: Var16 type: number - name: Var17 type: number - name: Var18 type: number - name: Var19 type: number - name: Var20 type: number - name: Var21 type: number - name: Var22 type: number - name: Var23 type: number - name: Var24 type: number - name: Var25 type: number - name: Var26 type: number - name: Var27 type: number - name: Var28 type: number - name: Var29 type: number - name: Var30 type: number - name: Var31 type: number - name: Var32 type: number - name: Var33 type: number - name: Var34 type: number - name: Var35 type: number - name: Var36 type: number - name: Var37 type: number - name: Var38 type: number - name: Var39 type: number - name: Var40 type: number - name: Var41 type: number - name: Var42 type: number - name: Var43 type: number - name: Var44 type: number - name: Var45 type: number - name: Var46 type: number - name: Var47 type: number - name: Var48 type: number - name: Var49 type: number - name: Var50 type: number - name: Var51 type: number - name: Var52 type: number - name: Var53 type: number - name: Var54 type: number - name: Var55 type: number - name: Var56 type: number - name: Var57 type: number - name: Var58 type: number - name: Var59 type: number - name: Var60 type: number - name: Var61 type: number - name: Var62 type: number - name: Var63 type: number - name: Var64 type: number - name: Var65 type: number - name: Var66 type: number - name: Var67 type: number - name: Var68 type: number - name: Var69 type: number - name: Var70 type: number - name: Var71 type: number - name: Var72 type: number - name: Var73 type: number - name: Var74 type: number - name: Var75 type: number - name: Var76 type: number - name: Var77 type: number - name: Var78 type: number - name: Var79 type: number - name: Var80 type: number - name: Var81 type: number - name: Var82 type: number - name: Var83 type: number - name: Var84 type: number - name: Var85 type: number - name: Var86 type: number - name: Var87 type: number - name: Var88 type: number - name: Var89 type: number - name: Var90 type: number - name: Var91 type: number - name: Var92 type: number - name: Var93 type: number - name: Var94 type: number - name: Var95 type: number - name: Var96 type: number - name: Var97 type: number - name: Var98 type: number - name: Var99 type: number - name: Var100 type: number - name: Var101 type: number - name: Var102 type: number - name: Var103 type: number - name: Var104 type: number - name: Var105 type: number - name: Var106 type: number - name: Var107 type: number - name: Var108 type: number - name: Var109 type: number - name: Var110 type: number - name: Var111 type: number - name: Var112 type: number - name: Var113 type: number - name: Var114 type: number - name: Var115 type: number - name: Var116 type: number - name: Var117 type: number - name: Var118 type: number - name: Var119 type: number - name: Var120 type: number - name: Var121 type: number - name: Var122 type: number - name: Var123 type: number - name: Var124 type: number - name: Var125 type: number - name: Var126 type: number - name: Var127 type: number - name: Var128 type: number - name: Var129 type: number - name: Var130 type: number - name: Var131 type: number - name: Var132 type: number - name: Var133 type: number - name: Var134 type: number - name: Var135 type: number - name: Var136 type: number - name: Var137 type: number - name: Var138 type: number - name: Var139 type: number - name: Var140 type: number - name: Var141 type: number - name: Var142 type: number - name: Var143 type: number - name: Var144 type: number - name: Var145 type: number - name: Var146 type: number - name: Var147 type: number - name: Var148 type: number - name: Var149 type: number - name: Var150 type: number - name: Var151 type: number - name: Var152 type: number - name: Var153 type: number - name: Var154 type: number - name: Var155 type: number - name: Var156 type: number - name: Var157 type: number - name: Var158 type: number - name: Var159 type: number - name: Var160 type: number - name: Var161 type: number - name: Var162 type: number - name: Var163 type: number - name: Var164 type: number - name: Var165 type: number - name: Var166 type: number - name: Var167 type: number - name: Var168 type: number - name: Var169 type: number - name: Var170 type: number - name: Var171 type: number - name: Var172 type: number - name: Var173 type: number - name: Var174 type: number - name: Var175 type: number - name: Var176 type: number - name: Var177 type: number - name: Var178 type: number - name: Var179 type: number - name: Var180 type: number - name: Var181 type: number - name: Var182 type: number - name: Var183 type: number - name: Var184 type: number - name: Var185 type: number - name: Var186 type: number - name: Var187 type: number - name: Var188 type: number - name: Var189 type: number - name: Var190 type: number - name: Var191 type: category - name: Var192 type: category - name: Var193 type: category - name: Var194 type: category - name: Var195 type: category - name: Var196 type: category - name: Var197 type: category - name: Var198 type: category - name: Var199 type: category - name: Var200 type: category - name: Var201 type: category - name: Var202 type: category - name: Var203 type: category - name: Var204 type: category - name: Var205 type: category - name: Var206 type: category - name: Var207 type: category - name: Var208 type: category - name: Var209 type: number - name: Var210 type: category - name: Var211 type: category - name: Var212 type: category - name: Var213 type: category - name: Var214 type: category - name: Var215 type: category - name: Var216 type: category - name: Var217 type: category - name: Var218 type: category - name: Var219 type: category - name: Var220 type: category - name: Var221 type: category - name: Var222 type: category - name: Var223 type: category - name: Var224 type: category - name: Var225 type: category - name: Var226 type: category - name: Var227 type: category - name: Var228 type: category - name: Var229 type: category - name: Var230 type: number - name: target type: binary output_features: - name: target type: binary ================================================ FILE: ludwig/datasets/configs/kdd_churn.yaml ================================================ version: 1.0 name: kdd_churn download_urls: - https://kdd.org/cupfiles/KDDCupData/2009/orange_small_train.data.zip - https://kdd.org/cupfiles/KDDCupData/2009/orange_small_test.data.zip - https://kdd.org/cupfiles/KDDCupData/2009/orange_small_train_churn.labels - https://raw.githubusercontent.com/catboost/benchmarks/master/quality_benchmarks/prepare_appetency_churn_upselling/churn/stratified_train_idx_churn.txt - https://raw.githubusercontent.com/catboost/benchmarks/master/quality_benchmarks/prepare_appetency_churn_upselling/churn/stratified_test_idx_churn.txt sha256: orange_small_test.data.zip: 440ac8a350144c14f4d6947c096ad675ee84aa27b4b742071662696e333cec53 orange_small_train.data.zip: 31ccb810bdbb71c16e079326443166dc3dfbf73cd358fc4a4ce7440fb1bc6040 orange_small_train_churn.labels: fe8891cc574bd55a214514e522a5bed1eec2c3f347a49a36e51620009e7b6f5b stratified_train_idx_churn.txt: 34f9880959ced6f668b25f879fdd388b3826efeca0df03f5a2a5494ce6795406 stratified_test_idx_churn.txt: 1675a62cd49c43535eedee3b746f65f8c6a4ebd7f4d0da04e442fd658a408042 dataset_filenames: orange_small_train.data loader: kdd_loader.KDDChurnLoader description: | The KDD Cup 2009 Churn dataset. https://www.kdd.org/kdd-cup/view/kdd-cup-2009/Data columns: - name: Var1 type: number - name: Var2 type: number - name: Var3 type: number - name: Var4 type: number - name: Var5 type: number - name: Var6 type: number - name: Var7 type: number - name: Var8 type: number - name: Var9 type: number - name: Var10 type: number - name: Var11 type: number - name: Var12 type: number - name: Var13 type: number - name: Var14 type: number - name: Var15 type: number - name: Var16 type: number - name: Var17 type: number - name: Var18 type: number - name: Var19 type: number - name: Var20 type: number - name: Var21 type: number - name: Var22 type: number - name: Var23 type: number - name: Var24 type: number - name: Var25 type: number - name: Var26 type: number - name: Var27 type: number - name: Var28 type: number - name: Var29 type: number - name: Var30 type: number - name: Var31 type: number - name: Var32 type: number - name: Var33 type: number - name: Var34 type: number - name: Var35 type: number - name: Var36 type: number - name: Var37 type: number - name: Var38 type: number - name: Var39 type: number - name: Var40 type: number - name: Var41 type: number - name: Var42 type: number - name: Var43 type: number - name: Var44 type: number - name: Var45 type: number - name: Var46 type: number - name: Var47 type: number - name: Var48 type: number - name: Var49 type: number - name: Var50 type: number - name: Var51 type: number - name: Var52 type: number - name: Var53 type: number - name: Var54 type: number - name: Var55 type: number - name: Var56 type: number - name: Var57 type: number - name: Var58 type: number - name: Var59 type: number - name: Var60 type: number - name: Var61 type: number - name: Var62 type: number - name: Var63 type: number - name: Var64 type: number - name: Var65 type: number - name: Var66 type: number - name: Var67 type: number - name: Var68 type: number - name: Var69 type: number - name: Var70 type: number - name: Var71 type: number - name: Var72 type: number - name: Var73 type: number - name: Var74 type: number - name: Var75 type: number - name: Var76 type: number - name: Var77 type: number - name: Var78 type: number - name: Var79 type: number - name: Var80 type: number - name: Var81 type: number - name: Var82 type: number - name: Var83 type: number - name: Var84 type: number - name: Var85 type: number - name: Var86 type: number - name: Var87 type: number - name: Var88 type: number - name: Var89 type: number - name: Var90 type: number - name: Var91 type: number - name: Var92 type: number - name: Var93 type: number - name: Var94 type: number - name: Var95 type: number - name: Var96 type: number - name: Var97 type: number - name: Var98 type: number - name: Var99 type: number - name: Var100 type: number - name: Var101 type: number - name: Var102 type: number - name: Var103 type: number - name: Var104 type: number - name: Var105 type: number - name: Var106 type: number - name: Var107 type: number - name: Var108 type: number - name: Var109 type: number - name: Var110 type: number - name: Var111 type: number - name: Var112 type: number - name: Var113 type: number - name: Var114 type: number - name: Var115 type: number - name: Var116 type: number - name: Var117 type: number - name: Var118 type: number - name: Var119 type: number - name: Var120 type: number - name: Var121 type: number - name: Var122 type: number - name: Var123 type: number - name: Var124 type: number - name: Var125 type: number - name: Var126 type: number - name: Var127 type: number - name: Var128 type: number - name: Var129 type: number - name: Var130 type: number - name: Var131 type: number - name: Var132 type: number - name: Var133 type: number - name: Var134 type: number - name: Var135 type: number - name: Var136 type: number - name: Var137 type: number - name: Var138 type: number - name: Var139 type: number - name: Var140 type: number - name: Var141 type: number - name: Var142 type: number - name: Var143 type: number - name: Var144 type: number - name: Var145 type: number - name: Var146 type: number - name: Var147 type: number - name: Var148 type: number - name: Var149 type: number - name: Var150 type: number - name: Var151 type: number - name: Var152 type: number - name: Var153 type: number - name: Var154 type: number - name: Var155 type: number - name: Var156 type: number - name: Var157 type: number - name: Var158 type: number - name: Var159 type: number - name: Var160 type: number - name: Var161 type: number - name: Var162 type: number - name: Var163 type: number - name: Var164 type: number - name: Var165 type: number - name: Var166 type: number - name: Var167 type: number - name: Var168 type: number - name: Var169 type: number - name: Var170 type: number - name: Var171 type: number - name: Var172 type: number - name: Var173 type: number - name: Var174 type: number - name: Var175 type: number - name: Var176 type: number - name: Var177 type: number - name: Var178 type: number - name: Var179 type: number - name: Var180 type: number - name: Var181 type: number - name: Var182 type: number - name: Var183 type: number - name: Var184 type: number - name: Var185 type: number - name: Var186 type: number - name: Var187 type: number - name: Var188 type: number - name: Var189 type: number - name: Var190 type: number - name: Var191 type: category - name: Var192 type: category - name: Var193 type: category - name: Var194 type: category - name: Var195 type: category - name: Var196 type: category - name: Var197 type: category - name: Var198 type: category - name: Var199 type: category - name: Var200 type: category - name: Var201 type: category - name: Var202 type: category - name: Var203 type: category - name: Var204 type: category - name: Var205 type: category - name: Var206 type: category - name: Var207 type: category - name: Var208 type: category - name: Var209 type: number - name: Var210 type: category - name: Var211 type: category - name: Var212 type: category - name: Var213 type: category - name: Var214 type: category - name: Var215 type: category - name: Var216 type: category - name: Var217 type: category - name: Var218 type: category - name: Var219 type: category - name: Var220 type: category - name: Var221 type: category - name: Var222 type: category - name: Var223 type: category - name: Var224 type: category - name: Var225 type: category - name: Var226 type: category - name: Var227 type: category - name: Var228 type: category - name: Var229 type: category - name: Var230 type: number - name: target type: binary output_features: - name: target type: binary ================================================ FILE: ludwig/datasets/configs/kdd_upselling.yaml ================================================ version: 1.0 name: kdd_upselling download_urls: - https://kdd.org/cupfiles/KDDCupData/2009/orange_small_train.data.zip - https://kdd.org/cupfiles/KDDCupData/2009/orange_small_test.data.zip - https://kdd.org/cupfiles/KDDCupData/2009/orange_small_train_upselling.labels - https://raw.githubusercontent.com/catboost/benchmarks/master/quality_benchmarks/prepare_appetency_churn_upselling/upselling/stratified_train_idx_upselling.txt - https://raw.githubusercontent.com/catboost/benchmarks/master/quality_benchmarks/prepare_appetency_churn_upselling/upselling/stratified_test_idx_upselling.txt sha256: orange_small_test.data.zip: 440ac8a350144c14f4d6947c096ad675ee84aa27b4b742071662696e333cec53 orange_small_train.data.zip: 31ccb810bdbb71c16e079326443166dc3dfbf73cd358fc4a4ce7440fb1bc6040 orange_small_train_upselling.labels: 86effe68394fe1ab21c2d855f74adf70f442990aa95dfe5c97340fc924440e68 stratified_train_idx_upselling.txt: 659060717872177d607fbb157e8d2142c719912771d1716da11ccdd6ff915a05 stratified_test_idx_upselling.txt: 64cb66ef559b4ccff096e0d7c150c7d019321ffd6cef2362c195a56c56effcb7 dataset_filenames: orange_small_train.data loader: kdd_loader.KDDUpsellingLoader description: | The KDD Cup 2009 Upselling dataset. https://www.kdd.org/kdd-cup/view/kdd-cup-2009/Data columns: - name: Var1 type: number - name: Var2 type: number - name: Var3 type: number - name: Var4 type: number - name: Var5 type: number - name: Var6 type: number - name: Var7 type: number - name: Var8 type: number - name: Var9 type: number - name: Var10 type: number - name: Var11 type: number - name: Var12 type: number - name: Var13 type: number - name: Var14 type: number - name: Var15 type: number - name: Var16 type: number - name: Var17 type: number - name: Var18 type: number - name: Var19 type: number - name: Var20 type: number - name: Var21 type: number - name: Var22 type: number - name: Var23 type: number - name: Var24 type: number - name: Var25 type: number - name: Var26 type: number - name: Var27 type: number - name: Var28 type: number - name: Var29 type: number - name: Var30 type: number - name: Var31 type: number - name: Var32 type: number - name: Var33 type: number - name: Var34 type: number - name: Var35 type: number - name: Var36 type: number - name: Var37 type: number - name: Var38 type: number - name: Var39 type: number - name: Var40 type: number - name: Var41 type: number - name: Var42 type: number - name: Var43 type: number - name: Var44 type: number - name: Var45 type: number - name: Var46 type: number - name: Var47 type: number - name: Var48 type: number - name: Var49 type: number - name: Var50 type: number - name: Var51 type: number - name: Var52 type: number - name: Var53 type: number - name: Var54 type: number - name: Var55 type: number - name: Var56 type: number - name: Var57 type: number - name: Var58 type: number - name: Var59 type: number - name: Var60 type: number - name: Var61 type: number - name: Var62 type: number - name: Var63 type: number - name: Var64 type: number - name: Var65 type: number - name: Var66 type: number - name: Var67 type: number - name: Var68 type: number - name: Var69 type: number - name: Var70 type: number - name: Var71 type: number - name: Var72 type: number - name: Var73 type: number - name: Var74 type: number - name: Var75 type: number - name: Var76 type: number - name: Var77 type: number - name: Var78 type: number - name: Var79 type: number - name: Var80 type: number - name: Var81 type: number - name: Var82 type: number - name: Var83 type: number - name: Var84 type: number - name: Var85 type: number - name: Var86 type: number - name: Var87 type: number - name: Var88 type: number - name: Var89 type: number - name: Var90 type: number - name: Var91 type: number - name: Var92 type: number - name: Var93 type: number - name: Var94 type: number - name: Var95 type: number - name: Var96 type: number - name: Var97 type: number - name: Var98 type: number - name: Var99 type: number - name: Var100 type: number - name: Var101 type: number - name: Var102 type: number - name: Var103 type: number - name: Var104 type: number - name: Var105 type: number - name: Var106 type: number - name: Var107 type: number - name: Var108 type: number - name: Var109 type: number - name: Var110 type: number - name: Var111 type: number - name: Var112 type: number - name: Var113 type: number - name: Var114 type: number - name: Var115 type: number - name: Var116 type: number - name: Var117 type: number - name: Var118 type: number - name: Var119 type: number - name: Var120 type: number - name: Var121 type: number - name: Var122 type: number - name: Var123 type: number - name: Var124 type: number - name: Var125 type: number - name: Var126 type: number - name: Var127 type: number - name: Var128 type: number - name: Var129 type: number - name: Var130 type: number - name: Var131 type: number - name: Var132 type: number - name: Var133 type: number - name: Var134 type: number - name: Var135 type: number - name: Var136 type: number - name: Var137 type: number - name: Var138 type: number - name: Var139 type: number - name: Var140 type: number - name: Var141 type: number - name: Var142 type: number - name: Var143 type: number - name: Var144 type: number - name: Var145 type: number - name: Var146 type: number - name: Var147 type: number - name: Var148 type: number - name: Var149 type: number - name: Var150 type: number - name: Var151 type: number - name: Var152 type: number - name: Var153 type: number - name: Var154 type: number - name: Var155 type: number - name: Var156 type: number - name: Var157 type: number - name: Var158 type: number - name: Var159 type: number - name: Var160 type: number - name: Var161 type: number - name: Var162 type: number - name: Var163 type: number - name: Var164 type: number - name: Var165 type: number - name: Var166 type: number - name: Var167 type: number - name: Var168 type: number - name: Var169 type: number - name: Var170 type: number - name: Var171 type: number - name: Var172 type: number - name: Var173 type: number - name: Var174 type: number - name: Var175 type: number - name: Var176 type: number - name: Var177 type: number - name: Var178 type: number - name: Var179 type: number - name: Var180 type: number - name: Var181 type: number - name: Var182 type: number - name: Var183 type: number - name: Var184 type: number - name: Var185 type: number - name: Var186 type: number - name: Var187 type: number - name: Var188 type: number - name: Var189 type: number - name: Var190 type: number - name: Var191 type: category - name: Var192 type: category - name: Var193 type: category - name: Var194 type: category - name: Var195 type: category - name: Var196 type: category - name: Var197 type: category - name: Var198 type: category - name: Var199 type: category - name: Var200 type: category - name: Var201 type: category - name: Var202 type: category - name: Var203 type: category - name: Var204 type: category - name: Var205 type: category - name: Var206 type: category - name: Var207 type: category - name: Var208 type: category - name: Var209 type: number - name: Var210 type: category - name: Var211 type: category - name: Var212 type: category - name: Var213 type: category - name: Var214 type: category - name: Var215 type: category - name: Var216 type: category - name: Var217 type: category - name: Var218 type: category - name: Var219 type: category - name: Var220 type: category - name: Var221 type: category - name: Var222 type: category - name: Var223 type: category - name: Var224 type: category - name: Var225 type: category - name: Var226 type: category - name: Var227 type: category - name: Var228 type: category - name: Var229 type: category - name: Var230 type: number - name: target type: binary output_features: - name: target type: binary ================================================ FILE: ludwig/datasets/configs/kick_starter_funding.yaml ================================================ version: 1.0 name: kick_starter_funding download_urls: - https://automl-mm-bench.s3.amazonaws.com/kick_starter_funding/train.csv - https://automl-mm-bench.s3.amazonaws.com/kick_starter_funding/test.csv sha256: test.csv: 13c2d4b74ac8d1e258659b5f5fa74526b9d27e305f6c29ad7e853dfeeb01983c train.csv: 3120b69f30bbc08c68940ab9e5d85d6cc2fbc9a65e8a24c66739179b6a60150e train_filenames: train.csv test_filenames: test.csv description: | Funding Successful Projects on Kickstarter Predict if a project will get successfully funded or not using labeled data https://www.kaggle.com/codename007/funding-successful-projects columns: - name: name type: category - name: desc type: text - name: goal type: number - name: keywords type: category - name: disable_communication type: binary - name: country type: category - name: currency type: category - name: deadline type: number - name: created_at type: number - name: final_status type: binary output_features: - name: final_status type: binary ================================================ FILE: ludwig/datasets/configs/melbourne_airbnb.yaml ================================================ version: 1.0 name: melbourne_airbnb download_urls: - https://automl-mm-bench.s3.amazonaws.com/airbnb_melbourne/train.pq - https://automl-mm-bench.s3.amazonaws.com/airbnb_melbourne/test.pq sha256: test.pq: 9fe965cfdbd24ee9af7a004a7dc8c4e535a7ffceb722dce00f8ea90a54f95aa9 train.pq: c158c0f497ef355ba9d5de0de7556f6eb7f9bc343a67c4c681b014f6c7412e48 train_filenames: train.pq test_filenames: test.pq description: | Melbourne Airbnb Open Data Detailed and summarized data of Airbnb activity in Melbourne, VIC, Australia https://www.kaggle.com/tylerx/melbourne-airbnb-open-data columns: - name: id type: number - name: listing_url type: category - name: scrape_id type: number - name: last_scraped type: date - name: text type: category - name: summary type: text - name: space type: text - name: description type: text - name: neighborhood_overview type: text - name: notes type: text - name: transit type: text - name: access type: text - name: interaction type: text - name: house_rules type: text - name: picture_url type: category - name: host_id type: category - name: host_url type: category - name: host_name type: category - name: host_since type: date - name: host_location type: category - name: host_about type: text - name: host_response_time type: category - name: host_response_rate type: category - name: host_is_superhost type: binary - name: host_thumbnail_url type: category - name: host_picture_url type: category - name: host_neighborhood type: category - name: host_verifications type: set - name: host_has_profile_pic type: binary - name: host_identity_verified type: binary - name: street type: category - name: neighborhood type: category - name: city type: category - name: suburb type: category - name: state type: category - name: zipcode type: category - name: smart_location type: category - name: country_code type: category - name: country type: category - name: latitude type: number - name: longitude type: number - name: is_location_exact type: binary - name: property_type type: category - name: room_type type: category - name: accommodates type: number - name: bathrooms type: number - name: bedrooms type: number - name: beds type: number - name: bed_type type: category - name: amenities type: set - name: price type: number - name: weekly_price type: number - name: monthly_price type: number - name: security_deposit type: number - name: cleaning_fee type: number - name: guests_included type: number - name: extra_people type: number - name: minimum_nights type: number - name: maximum_nights type: number - name: calendar_updated type: category - name: has_availability type: binary - name: availability_30 type: number - name: availability_60 type: number - name: availability_90 type: number - name: availability_365 type: number - name: calendar_last_scraped type: date - name: number_of_reviews type: number - name: first_review type: date - name: last_review type: date - name: review_scores_rating type: number - name: review_scores_accuracy type: number - name: review_scores_cleanliness type: number - name: review_scores_checkin type: number - name: review_scores_communication type: number - name: review_scores_location type: number - name: review_scores_value type: number - name: requires_license type: binary - name: license type: category - name: instant_bookable type: binary - name: cancellation_policy type: category - name: require_guest_profile_picture type: binary - name: require_guest_phone_verification type: binary - name: calculated_host_listings_count type: number - name: reviews_per_month type: number - name: price_label type: number - name: host_verifications_jumio type: binary - name: host_verifications_government_id type: binary - name: host_verifications_kba type: binary - name: host_verifications_zhima_selfie type: binary - name: host_verifications_facebook type: binary - name: host_verifications_work_email type: binary - name: host_verifications_google type: binary - name: host_verifications_sesame type: binary - name: host_verifications_manual_online type: binary - name: host_verifications_manual_offline type: binary - name: host_verifications_offline_government_id type: binary - name: host_verifications_selfie type: binary - name: host_verifications_reviews type: binary - name: host_verifications_identity_manual type: binary - name: host_verifications_sesame_offline type: binary - name: host_verifications_weibo type: binary - name: host_verifications_email type: binary - name: host_verifications_sent_id type: binary - name: host_verifications_phone type: binary output_features: - name: price_label type: category ================================================ FILE: ludwig/datasets/configs/mercari_price_suggestion.yaml ================================================ version: 1.0 name: mercari_price_suggestion download_urls: - https://automl-mm-bench.s3.amazonaws.com/mercari_price_suggestion/train.pq - https://automl-mm-bench.s3.amazonaws.com/mercari_price_suggestion/dev.pq - https://automl-mm-bench.s3.amazonaws.com/mercari_price_suggestion/test.pq sha256: test.pq: 05fed940f5545e6a470ca595d014a02b173fd3362ca5bc5c458d02640b892a57 train.pq: a0613b77714ebb9f8927cf6bff2092af8143f4a66a64e45e9c3bf9d18604cfe3 dev.pq: f7284b86adde0354f30ee2c2b7a7a55dc895d202b4291138e807c8f3eaacb6b0 train_filenames: train.pq validation_filenames: dev.pq test_filenames: test.pq description: | Predict product price based on details like product category name, brand name, and item condition. We have converted price to log price by log(1 + price). https://www.kaggle.com/c/mercari-price-suggestion-challenge columns: - name: train_id type: category - name: name type: category - name: item_condition_id type: category - name: category_name type: category - name: brand_name type: category - name: price type: number - name: shipping type: binary - name: item_description type: text - name: log_price type: number - name: cat1 type: category - name: cat2 type: category - name: cat3 type: category output_features: - name: log_price type: number ================================================ FILE: ludwig/datasets/configs/mercari_price_suggestion100K.yaml ================================================ version: 1.0 name: mercari_price_suggestion100K download_urls: - https://automl-mm-bench.s3.amazonaws.com/mercari_price_suggestion100K/train.pq - https://automl-mm-bench.s3.amazonaws.com/mercari_price_suggestion100K/test.pq sha256: test.pq: 60431577bd6cb433bae287ced2edc7a557497b66b1fe90e2fbec6ffc24bf35eb train.pq: f60063847d9b828f1e9366eb69fa53774771b53291586d1cce506c931b7173f4 train_filenames: train.pq test_filenames: test.pq description: | Predict product price based on details like product category name, brand name, and item condition. We have converted price to log price by log(1 + price). https://www.kaggle.com/c/mercari-price-suggestion-challenge columns: - name: train_id type: category - name: name type: category - name: item_condition_id type: category - name: category_name type: category - name: brand_name type: category - name: price type: number - name: shipping type: binary - name: item_description type: text - name: log_price type: number - name: cat1 type: category - name: cat2 type: category - name: cat3 type: category output_features: - name: log_price type: number ================================================ FILE: ludwig/datasets/configs/mercedes_benz_greener.yaml ================================================ version: 1.0 name: mercedes_benz_greener kaggle_competition: mercedes-benz-greener-manufacturing archive_filenames: mercedes-benz-greener-manufacturing.zip sha256: mercedes-benz-greener-manufacturing.zip: 91143716085345a84dc4991b8eb1d5ff80d8aa134930de946b3b24be0f2e5d1a train_filenames: train.csv test_filenames: test.csv description: | The Mercedes-Benz Greener Manufacturing dataset. https://www.kaggle.com/c/mercedes-benz-greener-manufacturing output_features: - name: y type: number fallback_mirrors: - name: predibase download_paths: s3://ludwig-tests/ludwig_backup/mercedes-benz-greener-manufacturing.zip ================================================ FILE: ludwig/datasets/configs/mnist.yaml ================================================ version: 1.0 name: mnist download_urls: - https://ossci-datasets.s3.amazonaws.com/mnist/train-images-idx3-ubyte.gz - https://ossci-datasets.s3.amazonaws.com/mnist/train-labels-idx1-ubyte.gz - https://ossci-datasets.s3.amazonaws.com/mnist/t10k-images-idx3-ubyte.gz - https://ossci-datasets.s3.amazonaws.com/mnist/t10k-labels-idx1-ubyte.gz sha256: t10k-images-idx3-ubyte.gz: 8d422c7b0a1c1c79245a5bcf07fe86e33eeafee792b84584aec276f5a2dbc4e6 train-images-idx3-ubyte.gz: 440fcabf73cc546fa21475e81ea370265605f56be210a4024d2ca8f203523609 train-labels-idx1-ubyte.gz: 3552534a0a558bbed6aed32b30c495cca23d567ec52cac8be1a0730e8010255c t10k-labels-idx1-ubyte.gz: f7ae60f92e00ec6debd23a6088c31dbd2371eca3ffa0defaefb259924204aec6 preserve_paths: - training - testing loader: mnist.MNISTLoader description: | The MNIST database of handwritten digits, available from this page, has a training set of 60,000 examples, and a test set of 10,000 examples. It is a subset of a larger set available from NIST. The digits have been size-normalized and centered in a fixed-size image. It is a good database for people who want to try learning techniques and pattern recognition methods on real-world data while spending minimal efforts on preprocessing and formatting. http://yann.lecun.com/exdb/mnist/ columns: - name: image_path type: image - name: label type: category output_features: - name: label type: category ================================================ FILE: ludwig/datasets/configs/mushroom_edibility.yaml ================================================ version: 1.0 name: mushroom_edibility download_urls: http://archive.ics.uci.edu/ml/machine-learning-databases/mushroom/agaricus-lepiota.data sha256: agaricus-lepiota.data: e65d082030501a3ebcbcd7c9f7c71aa9d28fdfff463bf4cf4716a3fe13ac360e train_filenames: agaricus-lepiota.data description: | This data set includes descriptions of hypothetical samples corresponding to 23 species of gilled mushrooms in the Agaricus and Lepiota Family (pp. 500-525). Each species is identified as definitely edible, definitely poisonous, or of unknown edibility and not recommended. This latter class was combined with the poisonous one. columns: - name: class type: category - name: cap-shape type: category - name: cap-surface type: category - name: cap-color type: category - name: bruises? type: category - name: odor type: category - name: gill-attachment type: category - name: gill-spacing type: category - name: gill-size type: category - name: gill-color type: category - name: stalk-shape type: category - name: stalk-root type: category - name: stalk-surface-above-ring type: category - name: stalk-surface-below-ring type: category - name: stalk-color-above-ring type: category - name: stalk-color-below-ring type: category - name: veil-type type: category - name: veil-color type: category - name: ring-number type: category - name: ring-type type: category - name: spore-print-color type: category - name: population type: category - name: habitat type: category output_features: - name: class type: category ================================================ FILE: ludwig/datasets/configs/naval.yaml ================================================ version: 1.0 name: naval download_urls: http://archive.ics.uci.edu/ml/machine-learning-databases/00316/UCI%20CBM%20Dataset.zip sha256: UCI%20CBM%20Dataset.zip: 91a3815da80b5ab7e2d5b82ac82f1c2cbf89182c7a65bcdf240db1e014423cb9 dataset_filenames: UCI CBM Dataset/data.txt loader: naval.NavalLoader description: | Condition Based Maintenance of Naval Propulsion Plants Data Set http://archive.ics.uci.edu/ml/datasets/condition+based+maintenance+of+naval+propulsion+plants columns: - name: lp type: number - name: v type: number - name: gtt type: number - name: gtn type: number - name: ggn type: number - name: ts type: number - name: tp type: number - name: t48 type: number - name: t1 type: number - name: t2 type: number - name: p48 type: number - name: p1 type: number - name: p2 type: number - name: pexh type: number - name: tic type: number - name: mf type: number - name: gtcdsc type: number - name: gttdsc type: number output_features: - name: gtcdsc type: number ================================================ FILE: ludwig/datasets/configs/news_channel.yaml ================================================ version: 1.0 name: news_channel download_urls: - https://automl-mm-bench.s3.amazonaws.com/news_channel/train.csv - https://automl-mm-bench.s3.amazonaws.com/news_channel/test.csv sha256: test.csv: d48e7261dce69964eb1163c89e05261b8732c676b10de9b40339b2d95559c9c3 train.csv: 46e433fcf070ec684cfaf30bada482a73637e8dd954edc3e1fe860de8e661055 train_filenames: train.csv test_filenames: test.csv description: | Online News Popularity Data Set This dataset summarizes a heterogeneous set of features about articles published by Mashable in a period of two years. The goal is to predict the number of shares in social networks (popularity). https://archive.ics.uci.edu/ml/datasets/online+news+popularity columns: # Most lot of these columns have a leading space - name: n_tokens_content type: number - name: n_unique_tokens type: number - name: n_non_stop_words type: number - name: n_non_stop_unique_tokens type: number - name: num_hrefs type: number - name: num_self_hrefs type: number - name: num_imgs type: number - name: num_videos type: number - name: average_token_length type: number - name: num_keywords type: number - name: global_subjectivity type: number - name: global_sentiment_polarity type: number - name: global_rate_positive_words type: number - name: global_rate_negative_words type: number - name: rate_positive_words type: number - name: rate_negative_words type: number - name: article_title type: text - name: channel type: category output_features: - name: channel type: category ================================================ FILE: ludwig/datasets/configs/news_popularity2.yaml ================================================ version: 1.0 name: news_popularity2 download_urls: - https://automl-mm-bench.s3.amazonaws.com/news_popularity2/train.csv - https://automl-mm-bench.s3.amazonaws.com/news_popularity2/test.csv sha256: test.csv: 276effa981456e187fb1fc07abd8556d240e1a110fc5c096f2ad75a4082d1ccb train.csv: 3673a07b87dbe09a9073e5ab83241681f561984269a9dc5411018fd9bca70b71 train_filenames: train.csv test_filenames: test.csv description: | Online News Popularity Data Set This dataset summarizes a heterogeneous set of features about articles published by Mashable in a period of two years. The goal is to predict the number of shares in social networks (popularity). https://archive.ics.uci.edu/ml/datasets/online+news+popularity columns: - name: n_tokens_content type: number - name: average_token_length type: number - name: num_keywords type: number - name: log_shares type: number - name: article_title type: text output_features: - name: log_shares type: number ================================================ FILE: ludwig/datasets/configs/noshow_appointments.yaml ================================================ version: 1.0 name: noshow_appointments kaggle_dataset_id: joniarroba/noshowappointments archive_filenames: noshowappointments.zip sha256: noshowappointments.zip: 4b4f258837029bd4e61ed4c9bab2ce8a3b8a299d1a4f5bdabcc98967d5e29a43 loader: split_loaders.RandomSplitLoader description: | 110.527 medical appointments its 14 associated variables (characteristics). The most important one if the patient show-up or no-show to the appointment. https://www.kaggle.com/datasets/joniarroba/noshowappointments columns: - name: PatientId type: category - name: AppointmentID type: category - name: Gender type: binary - name: ScheduledDay type: date - name: AppointmentDay type: date - name: Age type: number - name: Neighbourhood type: category - name: Scholarship type: binary - name: Hipertension type: binary - name: Diabetes type: binary - name: Alcoholism type: binary - name: Handcap type: binary - name: SMS_received type: binary - name: No-show type: binary output_features: - name: No-show type: binary ================================================ FILE: ludwig/datasets/configs/numerai28pt6.yaml ================================================ version: 1.0 name: numerai28pt6 kaggle_dataset_id: numerai/encrypted-stock-market-data-from-numerai archive_filenames: encrypted-stock-market-data-from-numerai.zip sha256: encrypted-stock-market-data-from-numerai.zip: cc0714c5f4c8ac6b212f7569641c5110bd2296547af434cba77184ebb03f304b description: | Encrypted Stock Market Data from Numerai dataset from Kaggle. columns: - name: feature1 type: number - name: feature2 type: number - name: feature3 type: number - name: feature4 type: number - name: feature5 type: number - name: feature6 type: number - name: feature7 type: number - name: feature8 type: number - name: feature9 type: number - name: feature10 type: number - name: feature11 type: number - name: feature12 type: number - name: feature13 type: number - name: feature14 type: number - name: feature15 type: number - name: feature16 type: number - name: feature17 type: number - name: feature18 type: number - name: feature19 type: number - name: feature20 type: number - name: feature21 type: number - name: target type: binary output_features: - name: target type: binary ================================================ FILE: ludwig/datasets/configs/ohsumed_7400.yaml ================================================ version: 1.0 name: ohsumed_7400 kaggle_dataset_id: weipengfei/ohr8r52 archive_filenames: ohr8r52.zip sha256: ohr8r52.zip: 93c7a8817a32b994d93267506ad766281764ba9382e3f4f9d978544cebab6ca4 train_filenames: oh/oh-train-stemmed.csv validation_filenames: oh/oh-dev-stemmed.csv test_filenames: oh/oh-test-stemmed.csv description: | Ohsumed corpus is extracted from MEDLINE database. MEDLINE is designed for multi-label classification, we remove the text with two or more labels. https://www.kaggle.com/datasets/weipengfei/ohr8r52 columns: - name: text type: text - name: edge type: text - name: intent type: category output_features: - name: intent type: category ================================================ FILE: ludwig/datasets/configs/ohsumed_cmu.yaml ================================================ version: 1.0 name: ohsumed_cmu download_urls: http://boston.lti.cs.cmu.edu/classes/95-865-K/HW/HW2/ohsumed-allcats-6.zip sha256: ohsumed-allcats-6.zip: 3f2f6c4e27faaac1c8dc179a121bed92d6adbdf91a1e11d2d124f7bd963798da description: | OHSUMED is a well-known medical abstracts dataset. It contains 348,566 references, and is still used for research and development. This is a subset of OHSUMED containing 6 categories, from this CMU course: http://boston.lti.cs.cmu.edu/classes/95-865-K/HW/HW2/ columns: - name: text type: text - name: class type: category output_features: - name: class type: category ================================================ FILE: ludwig/datasets/configs/otto_group_product.yaml ================================================ version: 1.0 name: otto_group_product kaggle_competition: otto-group-product-classification-challenge archive_filenames: otto-group-product-classification-challenge.zip sha256: otto-group-product-classification-challenge.zip: 81d1fa5805036772b7a2a2425311fdc7b1568af4fbb42f0ec8f9661d0d21ce42 train_filenames: train.csv test_filenames: test.csv description: | The Otto Group Product Classification Challenge https://www.kaggle.com/c/otto-group-product-classification-challenge/overview output_features: - name: target type: category ================================================ FILE: ludwig/datasets/configs/poker_hand.yaml ================================================ version: 1.0 name: poker_hand download_urls: - http://archive.ics.uci.edu/ml/machine-learning-databases/poker/poker-hand-training-true.data - http://archive.ics.uci.edu/ml/machine-learning-databases/poker/poker-hand-testing.data train_filenames: poker-hand-training-true.data test_filenames: poker-hand-testing.data sha256: poker-hand-testing.data: 3cd75958e19dd321ed5ca3f7f154c0f6aad544aab9f37731ac545b5f66b232c7 poker-hand-training-true.data: 37becdf87d5f8cbf2b91d6471e965a25b86cb4a6d878c0f94a4025969fca464f description: | Each record is an example of a hand consisting of five playing cards drawn from a standard deck of 52. Each card is described using two attributes (suit and rank), for a total of 10 predictive attributes. There is one Class attribute that describes the "Poker Hand". The order of cards is important, which is why there are 480 possible Royal Flush hands as compared to 4. https://archive.ics.uci.edu/ml/datasets/Poker+Hand columns: - name: S1 type: number - name: C1 type: number - name: S2 type: number - name: C2 type: number - name: S3 type: number - name: C3 type: number - name: S4 type: number - name: C4 type: number - name: S5 type: number - name: C5 type: number - name: hand type: category output_features: - name: hand type: category ================================================ FILE: ludwig/datasets/configs/porto_seguro_safe_driver.yaml ================================================ version: 1.0 name: porto_seguro_safe_driver kaggle_competition: porto-seguro-safe-driver-prediction archive_filenames: porto-seguro-safe-driver-prediction.zip sha256: porto-seguro-safe-driver-prediction.zip: 53dd7b67b9b3df088c4e0814cba7317d3bc8f76094c726471c8f91e84f61ccdc train_filenames: train.csv test_filenames: test.csv description: | Predict the probability that an auto insurance policy holder files a claim. https://www.kaggle.com/competitions/porto-seguro-safe-driver-prediction output_features: - name: target type: binary ================================================ FILE: ludwig/datasets/configs/product_sentiment_machine_hack.yaml ================================================ version: 1.0 name: product_sentiment_machine_hack download_urls: - https://automl-mm-bench.s3.amazonaws.com/machine_hack_product_sentiment/train.csv - https://automl-mm-bench.s3.amazonaws.com/machine_hack_product_sentiment/dev.csv sha256: dev.csv: 33adff4dba7d9322397b398900c20f678d3fffc5d87b0ea825d9aa497a343150 train.csv: 85a229e162b6d8c4839d1b27f834c36ae5e244fd027534fe62a888d4f536f0ef train_filenames: train.csv test_filenames: dev.csv description: | We challenge the machinehackers community to develop a machine learning model to accurately classify various products into 4 different classes of sentiments based on the raw text review provided by the user. https://www.machinehack.com/hackathons/product_sentiment_classification_weekend_hackathon_19/overview columns: - name: Text_ID type: category - name: Product_Description type: text - name: Product_Type type: category - name: Sentiment type: category output_features: - name: Sentiment type: category ================================================ FILE: ludwig/datasets/configs/protein.yaml ================================================ version: 1.0 name: protein download_urls: http://archive.ics.uci.edu/ml/machine-learning-databases/00265/CASP.csv sha256: CASP.csv: 4277cfcb4e91a181746cbc654f001b57951c9e6a80f4f795fdb5c807e0848f40 description: | Physicochemical Properties of Protein Tertiary Structure Data Set. https://archive.ics.uci.edu/ml/datasets/Physicochemical+Properties+of+Protein+Tertiary+Structure columns: - name: RMSD type: number - name: F1 type: number - name: F2 type: number - name: F3 type: number - name: F4 type: number - name: F5 type: number - name: F6 type: number - name: F7 type: number - name: F8 type: number - name: F9 type: number output_features: - name: RMSD type: number ================================================ FILE: ludwig/datasets/configs/reuters_cmu.yaml ================================================ version: 1.0 name: reuters_cmu download_urls: http://boston.lti.cs.cmu.edu/classes/95-865-K/HW/HW2/reuters-allcats-6.zip sha256: reuters-allcats-6.zip: 304ae223f9ca35f7ce9066c9d31558c06ed5c72cd91faa885f82b928b2aa6f34 description: | Reuters-21578 is a well-known newswire dataset containing 21,578 documents. This is a subset of Reuters-21578 using only 6 categories, from this CMU course: http://boston.lti.cs.cmu.edu/classes/95-865-K/HW/HW2/ columns: - name: text type: text - name: class type: category output_features: - name: class type: category ================================================ FILE: ludwig/datasets/configs/reuters_r8.yaml ================================================ version: 1.0 name: reuters_r8 kaggle_dataset_id: weipengfei/ohr8r52 archive_filenames: ohr8r52.zip sha256: ohr8r52.zip: 93c7a8817a32b994d93267506ad766281764ba9382e3f4f9d978544cebab6ca4 train_filenames: r8/r8-train-stemmed.csv validation_filenames: r8/r8-dev-stemmed.csv test_filenames: r8/r8-test-stemmed.csv description: | Reuters R8 subset of Reuters 21578 dataset from Kaggle. columns: - name: text type: text - name: edge type: text - name: intent type: category output_features: - name: intent type: category ================================================ FILE: ludwig/datasets/configs/rossman_store_sales.yaml ================================================ version: 1.0 name: rossman_store_sales kaggle_competition: rossmann-store-sales archive_filenames: rossmann-store-sales.zip sha256: rossmann-store-sales.zip: 52ce715e02dc70cac16b14548580d656997f5d43ce3544220d5e574d26483cf3 loader: rossman_store_sales.RossmanStoreSalesLoader description: | The Rossmann Store Sales dataset. Using the time split from the catboost benchmark https://github.com/catboost/benchmarks/tree/master/kaggle/rossmann-store-sales that is used in the TabNet paper, because the test set does not contain sales ground truth. output_features: - name: Sales type: number ================================================ FILE: ludwig/datasets/configs/santander_customer_satisfaction.yaml ================================================ version: 1.0 name: santander_customer_satisfaction kaggle_competition: santander-customer-satisfaction archive_filenames: santander-customer-satisfaction.zip sha256: santander-customer-satisfaction.zip: d4c2d068d8041af168d82d0eef7ad0b53ddd1d7fca9aba4e5d88fa1f957ee594 train_filenames: train.csv test_filenames: test.csv description: | Santander Customer Satisfaction Prediction. https://www.kaggle.com/c/santander-customer-satisfaction/overview output_features: - name: TARGET type: binary ================================================ FILE: ludwig/datasets/configs/santander_customer_transaction.yaml ================================================ version: 1.0 name: santander_customer_transaction kaggle_competition: santander-customer-transaction-prediction archive_filenames: santander-customer-transaction-prediction.zip sha256: santander-customer-transaction-prediction.zip: b3a56d036b493a9cf0695018c968baba1ba7ef8c39d842cc5626e72f13c0ec69 train_filenames: train.csv test_filenames: test.csv description: | Santander Customer Transaction Prediction. https://www.kaggle.com/c/santander-customer-transaction-prediction/overview output_features: - name: target type: binary ================================================ FILE: ludwig/datasets/configs/santander_value_prediction.yaml ================================================ version: 1.0 name: santander_value_prediction kaggle_competition: santander-value-prediction-challenge archive_filenames: santander-value-prediction-challenge.zip sha256: santander-value-prediction-challenge.zip: a8b44a0403bff6ab42f2bd1da8d9cbaf98f1fd4b9ea7a86e47491ac996384bf4 train_filenames: train.csv loader: santander_value_prediction.SantanderValuePredictionLoader description: | The Santander Value Prediction Challenge dataset. https://www.kaggle.com/c/santander-value-prediction-challenge output_features: - name: target type: number ================================================ FILE: ludwig/datasets/configs/sarcastic_headlines.yaml ================================================ version: 1.0 name: sarcastic_headlines train_filenames: Sarcasm_Headlines_Dataset.json archive_filenames: news-headlines-dataset-for-sarcasm-detection.zip sha256: news-headlines-dataset-for-sarcasm-detection.zip: 3728f0fbce563536c3c67ab92e343e3ebcdc5cf1feaf4980c3abd4e54109eb51 kaggle_dataset_id: rmisra/news-headlines-dataset-for-sarcasm-detection description: A dataset to determine if a news headline is sarcastic or serious. loader: sarcastic_headlines.SarcasticHeadlinesLoader columns: - name: article_link type: category - name: headline type: text - name: is_sarcastic type: binary output_features: - name: is_sarcastic type: binary ================================================ FILE: ludwig/datasets/configs/sarcos.yaml ================================================ version: 1.0 name: sarcos download_urls: - http://www.gaussianprocess.org/gpml/data/sarcos_inv.mat - http://www.gaussianprocess.org/gpml/data/sarcos_inv_test.mat sha256: sarcos_inv_test.mat: 161a59b5c3b4f4b404584323f181607b2acbe620eb134dc720760dc3f38f5cec sarcos_inv.mat: b8a249733253ba6097372fedee7696833fcf30de42037d5b4a7227f21a6d1d97 train_filenames: sarcos_inv.mat test_filenames: sarcos_inv_test.mat loader: sarcos.SarcosLoader description: | The data relates to an inverse dynamics problem for a seven degrees-of-freedom SARCOS anthropomorphic robot arm. The task is to map from a 21-dimensional input space (7 joint positions, 7 joint velocities, 7 joint accelerations) to the corresponding 7 joint torques. http://gaussianprocess.org/gpml/data/ columns: - name: position_1 type: number - name: position_2 type: number - name: position_3 type: number - name: position_4 type: number - name: position_5 type: number - name: position_6 type: number - name: position_7 type: number - name: velocity_1 type: number - name: velocity_2 type: number - name: velocity_3 type: number - name: velocity_4 type: number - name: velocity_5 type: number - name: velocity_6 type: number - name: velocity_7 type: number - name: acceleration_1 type: number - name: acceleration_2 type: number - name: acceleration_3 type: number - name: acceleration_4 type: number - name: acceleration_5 type: number - name: acceleration_6 type: number - name: acceleration_7 type: number - name: torque_1 type: number - name: torque_2 type: number - name: torque_3 type: number - name: torque_4 type: number - name: torque_5 type: number - name: torque_6 type: number - name: torque_7 type: number output_features: - name: torque_1 type: number fallback_mirrors: - name: predibase download_paths: - s3://ludwig-tests/ludwig_backup/sarcos_inv.mat - s3://ludwig-tests/ludwig_backup/sarcos_inv_test.mat ================================================ FILE: ludwig/datasets/configs/sst2.yaml ================================================ version: 1.0 name: sst2 download_urls: https://nlp.stanford.edu/~socherr/stanfordSentimentTreebank.zip sha256: stanfordSentimentTreebank.zip: 3f5209483b46bbf129cacbbbe6ae02fe780407034f61cf6342b7833257c3f1db train_filenames: train.csv validation_filenames: dev.csv test_filenames: test.csv loader: sst.SST2Loader description: | The SST2 dataset. This dataset is constructed using the Stanford Sentiment Treebank Dataset. This dataset contains binary labels (positive or negative) for each sample. The original dataset specified 5 labels: very negative, negative, neutral, positive, very positive with the following cutoffs: [0, 0.2], (0.2, 0.4], (0.4, 0.6], (0.6, 0.8], (0.8, 1.0] In the construction of this dataset, we remove all neutral phrases and assign a negative label if the original rating falls into the following range: [0, 0.4] and a positive label if the original rating is between (0.6, 1.0]. columns: - name: sentence type: text - name: label type: binary output_features: - name: label type: binary ================================================ FILE: ludwig/datasets/configs/sst3.yaml ================================================ version: 1.0 name: sst3 download_urls: https://nlp.stanford.edu/~socherr/stanfordSentimentTreebank.zip sha256: stanfordSentimentTreebank.zip: 3f5209483b46bbf129cacbbbe6ae02fe780407034f61cf6342b7833257c3f1db train_filenames: train.csv validation_filenames: dev.csv test_filenames: test.csv loader: sst.SST3Loader description: | The SST3 dataset. This dataset is constructed using the Stanford Sentiment Treebank Dataset. The original dataset contains five labels (very negative, negative, neutral, positive, very positive) for each sample. In this dataset, the 3 labels negative, neutral, positive have the following cutoffs: [0, 0.4], (0.4, 0.6], (0.6, 1.0] columns: - name: sentence type: text - name: label type: category output_features: - name: label type: category ================================================ FILE: ludwig/datasets/configs/sst5.yaml ================================================ version: 1.0 name: sst5 download_urls: https://nlp.stanford.edu/~socherr/stanfordSentimentTreebank.zip sha256: stanfordSentimentTreebank.zip: 3f5209483b46bbf129cacbbbe6ae02fe780407034f61cf6342b7833257c3f1db train_filenames: train.csv validation_filenames: dev.csv test_filenames: test.csv loader: sst.SST5Loader description: | The SST5 dataset. This dataset is constructed using the Stanford Sentiment Treebank Dataset. This dataset contains five labels (very negative, negative, neutral, positive, very positive) for each sample. In the original dataset, the 5 labels: very negative, negative, neutral, positive, and very positive have the following cutoffs: [0, 0.2], (0.2, 0.4], (0.4, 0.6], (0.6, 0.8], (0.8, 1.0] columns: - name: sentence type: text - name: label type: category output_features: - name: label type: category ================================================ FILE: ludwig/datasets/configs/synthetic_fraud.yaml ================================================ version: 1.0 name: synthetic_fraud kaggle_dataset_id: ealaxi/paysim1 archive_filenames: paysim1.zip sha256: paysim1.zip: f7eef9ffad5cfa64a034143a5c9b30491d189420b273d5ad5723ca40b596613d description: | The Synthetic Financial Datasets For Fraud Detection dataset. https://www.kaggle.com/ealaxi/paysim1 columns: - name: step type: category - name: type type: category - name: amount type: number - name: nameOrig type: category - name: oldbalanceOrg type: number - name: newbalanceOrig type: number - name: nameDest type: category - name: oldbalanceDest type: number - name: newbalanceDest type: number - name: isFraud type: binary - name: isFlaggedFraud type: binary output_features: - name: isFraud type: binary ================================================ FILE: ludwig/datasets/configs/talkingdata_adtrack_fraud.yaml ================================================ version: 1.0 name: talkingdata_adtrack_fraud_detection kaggle_competition: talkingdata-adtracking-fraud-detection archive_filenames: talkingdata-adtracking-fraud-detection.zip sha256: talkingdata-adtracking-fraud-detection.zip: 4441bea984e936db153aba30627b222cb1685021efb887bd22d78771fb793735 train_filenames: train.csv description: | TalkingData AdTracking Fraud Detection Challenge. https://www.kaggle.com/competitions/talkingdata-adtracking-fraud-detection/overview output_features: - name: is_attributed type: binary ================================================ FILE: ludwig/datasets/configs/telco_customer_churn.yaml ================================================ version: 1.0 name: telco_customer_churn kaggle_dataset_id: blastchar/telco-customer-churn archive_filenames: telco-customer-churn.zip dataset_filenames: WA_Fn-UseC_-Telco-Customer-Churn.csv sha256: telco-customer-churn.zip: cf7e6dcd8a238ecaa841a7d133142525453992d8d5e3ef6d1e5f0d359e7bf444 description: | The Telco customer churn data contains information about a fictional telco company that provided home phone and Internet services to customers. Each row represents a customer, each column contains customer’s attributes described on the column Metadata. https://www.kaggle.com/datasets/blastchar/telco-customer-churn columns: - name: customerID type: category - name: gender type: binary - name: SeniorCitizen type: binary - name: Partner type: binary - name: Dependents type: binary - name: tenure type: number - name: PhoneService type: binary - name: MultipleLines type: category - name: InternetService type: category - name: OnlineSecurity type: category - name: OnlineBackup type: category - name: DeviceProtection type: category - name: TechSupport type: category - name: StreamingTV type: category - name: StreamingMovies type: category - name: Contract type: category - name: PaperlessBilling type: binary - name: PaymentMethod type: category - name: MonthlyCharges type: number - name: TotalCharges type: number - name: Churn type: binary output_features: - name: Churn type: binary ================================================ FILE: ludwig/datasets/configs/temperature.yaml ================================================ version: 1.0 name: temperature kaggle_dataset_id: selfishgene/historical-hourly-weather-data archive_filenames: historical-hourly-weather-data.zip sha256: historical-hourly-weather-data.zip: db40ffce67318f366115b82a6f693d6dc82c808f23514e2ddae56c0434f606d7 dataset_filenames: temperature.csv description: | Hourly temperature dataset from Kaggle https://www.kaggle.com/selfishgene/historical-hourly-weather-data columns: - name: datetime type: date - name: Vancouver type: number - name: Portland type: number - name: San Francisco type: number - name: Seattle type: number - name: Los Angeles type: number - name: San Diego type: number - name: Las Vegas type: number - name: Phoenix type: number - name: Albuquerque type: number - name: Denver type: number - name: San Antonio type: number - name: Dallas type: number - name: Houston type: number - name: Kansas City type: number - name: Minneapolis type: number - name: Saint Louis type: number - name: Chicago type: number - name: Nashville type: number - name: Indianapolis type: number - name: Atlanta type: number - name: Detroit type: number - name: Jacksonville type: number - name: Charlotte type: number - name: Miami type: number - name: Pittsburgh type: number - name: Toronto type: number - name: Philadelphia type: number - name: New York type: number - name: Montreal type: number - name: Boston type: number - name: Beersheba type: number - name: Tel Aviv District type: number - name: Eilat type: number - name: Haifa type: number - name: Nahariyya type: number - name: Jerusalem type: number output_features: - name: San Francisco type: number ================================================ FILE: ludwig/datasets/configs/titanic.yaml ================================================ version: 1.0 name: titanic kaggle_competition: titanic archive_filenames: titanic.zip sha256: titanic.zip: bb1bda464cc6819d412b41d34be69fd89d26b372dc24c09421c3dbca1b0dbe9f train_filenames: train.csv test_filenames: test.csv description: | The Titanic dataset: use machine learning to create a model that predicts which passengers survived the Titanic shipwreck. https://www.kaggle.com/c/titanic output_features: - name: Survived type: binary ================================================ FILE: ludwig/datasets/configs/twitter_bots.yaml ================================================ version: 1.0 name: twitter_bots kaggle_dataset_id: danieltreiman/twitter-human-bots-dataset archive_filenames: twitter-human-bots-dataset.zip dataset_filenames: twitter_human_bots_dataset.csv sha256: twitter-human-bots-dataset.zip: 16ffaad719ebb9688231844a80f92901c5efb1ff96eafeb869dc5de07b323cdd preserve_paths: - profile_images - profile_background_images description: | A dataset for Twitter Bot account detection. https://www.kaggle.com/datasets/davidmartngutirrez/twitter-bots-accounts columns: - name: created_at type: date - name: default_profile type: binary - name: default_profile_image type: binary - name: description type: text - name: favourites_count type: number - name: followers_count type: number - name: friends_count type: number - name: geo_enabled type: binary - name: id type: category - name: lang type: category - name: location type: category - name: profile_background_image_url type: category - name: profile_image_url type: category - name: screen_name type: category - name: statuses_count type: number - name: verified type: binary - name: average_tweets_per_day type: number - name: account_age_days type: number - name: account_type type: category - name: profile_image_path type: image - name: profile_background_image_path type: image output_features: - name: account_type type: binary ================================================ FILE: ludwig/datasets/configs/walmart_recruiting.yaml ================================================ version: 1.0 name: walmart_recruiting kaggle_competition: walmart-recruiting-trip-type-classification archive_filenames: walmart-recruiting-trip-type-classification.zip sha256: walmart-recruiting-trip-type-classification.zip: 4c0ad71034d0b907e018adcb00c7b2835d2c30abe770fde5ce8719d7b89d4de6 train_filenames: train.csv description: | Walmart Recruiting: Trip Type Classification https://www.kaggle.com/c/walmart-recruiting-trip-type-classification output_features: - name: TripType type: category ================================================ FILE: ludwig/datasets/configs/wine_reviews.yaml ================================================ version: 1.0 name: wine_reviews download_urls: - https://automl-mm-bench.s3.amazonaws.com/wine_reviews/train.csv - https://automl-mm-bench.s3.amazonaws.com/wine_reviews/test.csv sha256: test.csv: c862d1af572659406ab39356a25c7d5e9b7c8570a89e069311fca1abb6bf1849 train.csv: c54101bb07571a3df0723e93a5f7c48123dd792b316396db4404a04bcf1809cb train_filenames: train.csv test_filenames: test.csv description: | Wine Reviews 130k wine reviews with variety, location, winery, price, and description https://www.kaggle.com/datasets/zynicide/wine-reviews columns: - name: country type: category - name: description type: text - name: points type: number - name: price type: number - name: province type: category - name: variety type: category output_features: - name: points type: number ================================================ FILE: ludwig/datasets/configs/wmt15.yaml ================================================ version: 1.0 name: wmt15 kaggle_dataset_id: dhruvildave/en-fr-translation-dataset archive_filenames: en-fr-translation-dataset.zip sha256: en-fr-translation-dataset.zip: 5fb911b327f2f36ea32315b4754f6aef95e6830562eec7054d31d614dd53d93c description: | French/English parallel texts for training translation models. Over 22.5 million sentences in French and English. https://www.kaggle.com/dhruvildave/en-fr-translation-dataset output_features: - name: en type: text ================================================ FILE: ludwig/datasets/configs/women_clothing_review.yaml ================================================ version: 1.0 name: women_clothing_review download_urls: - https://automl-mm-bench.s3.amazonaws.com/women_clothing_review/train.pq - https://automl-mm-bench.s3.amazonaws.com/women_clothing_review/test.pq sha256: test.pq: 477de72fe7e672ef87e1eca00de312f55ba884a9b80fbd04fa79c0d0159e5593 train.pq: 1b3d248397cee76a6ccff814560f29ae3d66eeb26a6e97ac0837e021629bc740 train_filenames: train.pq test_filenames: test.pq description: | Women's E-Commerce Clothing Reviews 23,000 Customer Reviews and Ratings https://www.kaggle.com/nicapotato/womens-ecommerce-clothing-reviews columns: - name: Clothing ID type: category - name: Age type: number - name: Title type: text - name: Review Text type: text - name: Rating type: number - name: Recommended IND type: binary - name: Positive Feedback Count type: number - name: Division Name type: category - name: Department Name type: category - name: Class Name type: category output_features: - name: Rating type: number ================================================ FILE: ludwig/datasets/configs/yahoo_answers.yaml ================================================ version: 1.0 name: yahoo_answers download_urls: https://s3.amazonaws.com/fast-ai-nlp/yahoo_answers_csv.tgz sha256: yahoo_answers_csv.tgz: 2d4277855faf8b35259009425fa8f7fe1888b5644b47165508942d000f4c96ae train_filenames: yahoo_answers_csv/train.csv test_filenames: yahoo_answers_csv/test.csv description: | The Yahoo Answers dataset Details: The 10 largest main categories from the Yahoo! Answers \ Comprehensive Questions and Answers version 1.0 dataset. \ Each class contains 140,000 training samples and 5,000 \ testing samples. Dataset source: Character-level Convolutional Networks for Text Classification Xiang Zhang et al., 2015 https://arxiv.org/abs/1509.01626 columns: - name: label type: category - name: question_title type: text - name: question type: text - name: best_answer type: text output_features: - name: label type: category ================================================ FILE: ludwig/datasets/configs/yelp_review_polarity.yaml ================================================ version: 1.0 name: yelp_review_polarity download_urls: https://s3.amazonaws.com/fast-ai-nlp/yelp_review_polarity_csv.tgz sha256: yelp_review_polarity_csv.tgz: 528f22e286cad085948acbc3bea7e58188416546b0e364d0ae4ca0ce666abe35 train_filenames: yelp_review_polarity_csv/train.csv test_filenames: yelp_review_polarity_csv/test.csv description: | The Yelp Polarity dataset Details: 1,569,264 samples from the Yelp Dataset Challenge 2015. This subset has 280,000 training samples and 19,000 test samples in each polarity. Dataset source: Character-level Convolutional Networks for Text Classification Xiang Zhang et al., 2015 columns: - name: label type: binary - name: text type: text output_features: - name: label type: binary ================================================ FILE: ludwig/datasets/configs/yelp_reviews.yaml ================================================ version: 1.0 name: yelp_reviews download_urls: https://s3.amazonaws.com/fast-ai-nlp/yelp_review_full_csv.tgz sha256: yelp_review_full_csv.tgz: 56006b0a17a370f1e366504b1f2c3e3754e4a3dda17d3e718a885c552869a559 train_filenames: yelp_review_full_csv/train.csv test_filenames: yelp_review_full_csv/test.csv description: | The Yelp Reviews dataset Details: 1,569,264 samples from the Yelp Dataset Challenge 2015. This subset has 130,000 training samples and 10,000 testing samples in each star rating. Dataset source: Character-level Convolutional Networks for Text Classification Xiang Zhang et al., 2015 columns: - name: label type: category - name: text type: text output_features: - name: label type: category ================================================ FILE: ludwig/datasets/configs/yosemite.yaml ================================================ version: 1.0 name: yosemite download_urls: https://raw.githubusercontent.com/ourownstory/neuralprophet-data/main/datasets_raw/yosemite_temps.csv sha256: yosemite_temps.csv: c0ec9f2cb4bbf0bc53f7bfd2e39f88ae21e43b7b8912b2d1eb8185055f9510e2 description: | Yosemite temperatures dataset. As found in https://github.com/ourownstory/neural_prophet columns: - name: ds type: date - name: y type: number output_features: - name: y type: number ================================================ FILE: ludwig/datasets/dataset_config.py ================================================ # Copyright (c) 2022 Predibase, 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. # ============================================================================== from dataclasses import dataclass, field from dataclasses_json import dataclass_json @dataclass_json @dataclass class DatasetFallbackMirror: # Name of the mirror name: str # List of paths to download from. Must map 1:1 to DatasetConfig.download_urls or to the archive_filenames # that we get from Kaggle. download_paths: str | list[str] @dataclass_json @dataclass class DatasetConfig: """The configuration of a Ludwig dataset.""" # The version of the dataset. version: str # The name of the dataset. Make this a valid python module name, should not contain spaces or dashes. name: str # The readable description of the dataset description: str = "" # Fallback mirrors. Paths must be in local/remote filesystems. fallback_mirrors: list[DatasetFallbackMirror] | None = None # Optional. The (suggested) output features for this dataset. Helps users discover new datasets and filter for # relevance to a specific machine learning setting. output_features: list[dict] = field(default_factory=list) # The kaggle competition this dataset belongs to, or None if this dataset is not hosted by a Kaggle competition. kaggle_competition: str | None = None # The kaggle dataset ID, or None if this dataset if not hosted by Kaggle. kaggle_dataset_id: str | None = None # The list of URLs to download. download_urls: str | list[str] = field(default_factory=list) # The list of file archives which will be downloaded. If download_urls contains a filename with extension, for # example https://domain.com/archive.zip, then archive_filenames does not need to be specified. archive_filenames: str | list[str] = field(default_factory=list) # The names of files in the dataset (after extraction). Glob-style patterns are supported, see # https://docs.python.org/3/library/glob.html dataset_filenames: str | list[str] = field(default_factory=list) # If the dataset contains separate files for training, testing, or validation. Glob-style patterns are supported, # see https://docs.python.org/3/library/glob.html train_filenames: str | list[str] = field(default_factory=list) validation_filenames: str | list[str] = field(default_factory=list) test_filenames: str | list[str] = field(default_factory=list) # If the dataset contains additional referenced files or directories (ex. images or audio) list them here and they # will be copied to the same location as the processed dataset. Glob-style patterns are supported, # see https://docs.python.org/3/library/glob.html preserve_paths: str | list[str] = field(default_factory=list) # Optionally verify integrity of the dataset by providing sha256 checksums for important files. Maps filename to # sha256 digest. Use `sha256sum ` on linux, `shasum -a 256 ` on Mac to get checksums. # If verification fails, loading the dataset will fail with a ValueError. # If no sha256 digests are in the config, a warning is logged and the dataset will load without verification. sha256: dict[str, str] = field(default_factory=dict) # List of column names, for datasets which do not have column names. If specified, will override the column names # already present in the dataset. columns: list[dict] = field(default_factory=list) # Optional dictionary which maps column name to column type. Column's will be converted to the requested type, or # will be inferred from the dataset by default. column_types: dict[str, str] = field(default_factory=dict) # The loader module and class to use, relative to ludwig.datasets.loaders. Only change this if the dataset requires # processing which is not handled by the default loader. loader: str = "dataset_loader.DatasetLoader" ================================================ FILE: ludwig/datasets/kaggle.py ================================================ import os from contextlib import contextmanager from ludwig.utils.fs_utils import upload_output_directory def create_kaggle_client(): # Need to import here to prevent Kaggle from authenticating on import from kaggle import api return api @contextmanager def update_env(**kwargs): override_env = {k: v for k, v in kwargs.items() if v is not None} old = os.environ.copy() try: os.environ.update(override_env) yield finally: os.environ = old def download_kaggle_dataset( download_directory: str, kaggle_dataset_id: str | None = None, kaggle_competition: str | None = None, kaggle_username: str | None = None, kaggle_key: str | None = None, ): """Download all files in a kaggle dataset. One of kaggle_dataset_id, If the user has not specified creds in the kaggle.json file we lookup the passed in username and the api key and perform authentication. """ with update_env(KAGGLE_USERNAME=kaggle_username, KAGGLE_KEY=kaggle_key): # Call authenticate explicitly to pick up new credentials if necessary api = create_kaggle_client() api.authenticate() with upload_output_directory(download_directory) as (tmpdir, _): if kaggle_competition: api.competition_download_files(kaggle_competition, path=tmpdir) else: api.dataset_download_files(kaggle_dataset_id, path=tmpdir) return [os.path.join(download_directory, f) for f in os.listdir(download_directory)] ================================================ FILE: ludwig/datasets/loaders/__init__.py ================================================ ================================================ FILE: ludwig/datasets/loaders/adult_census_income.py ================================================ # Copyright (c) 2022 Predibase, 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. # ============================================================================== import pandas as pd from ludwig.datasets.loaders.dataset_loader import DatasetLoader class AdultCensusIncomeLoader(DatasetLoader): def load_file_to_dataframe(self, file_path: str) -> pd.DataFrame: if file_path.endswith(".test"): # The test file contains the line "|1x3 Cross validator" before the CSV content. return pd.read_csv(file_path, skiprows=1) return super().load_file_to_dataframe(file_path) def transform_dataframe(self, dataframe: pd.DataFrame) -> pd.DataFrame: processed_df = super().transform_dataframe(dataframe) processed_df["income"] = processed_df["income"].str.rstrip(".") processed_df["income"] = processed_df["income"].str.strip() return processed_df ================================================ FILE: ludwig/datasets/loaders/agnews.py ================================================ # Copyright (c) 2022 Predibase, 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. # ============================================================================== import pandas as pd from ludwig.datasets.loaders.dataset_loader import DatasetLoader class AGNewsLoader(DatasetLoader): def transform_dataframe(self, dataframe: pd.DataFrame) -> pd.DataFrame: processed_df = super().transform_dataframe(dataframe) # Maps class_index to class name. class_names = ["", "world", "sports", "business", "sci_tech"] # Adds new column 'class' by mapping class indexes to strings. processed_df["class"] = processed_df.class_index.apply(lambda i: class_names[i]) # Agnews has no validation split, only train and test (0, 2). For convenience, we'll designate the first 5% of # each class from the training set as the validation set. val_set_n = int((len(processed_df) * 0.05) // len(class_names)) # rows from each class in validation set. for ci in range(1, 5): # For each class, reassign the first val_set_n rows of the training set to validation set. train_rows = processed_df[(processed_df.split == 0) & (processed_df.class_index == ci)].index processed_df.loc[train_rows[:val_set_n], "split"] = 1 return processed_df ================================================ FILE: ludwig/datasets/loaders/allstate_claims_severity.py ================================================ # Copyright (c) 2022 Predibase, 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. # ============================================================================== import os import pandas as pd from ludwig.datasets.loaders.dataset_loader import DatasetLoader class AllstateClaimsSeverityLoader(DatasetLoader): def load_file_to_dataframe(self, file_path: str) -> pd.DataFrame: if os.path.basename(file_path) == "train.csv": # train.csv has been updated with quoted test rows at the end; don't load these, only load the original # training set. return pd.read_csv(file_path, nrows=188319) if os.path.basename(file_path) == "test.csv": # we limit the loaded rows for the same reason as the training set. return pd.read_csv(file_path, nrows=125547) super().load_file_to_dataframe(file_path) ================================================ FILE: ludwig/datasets/loaders/camseq.py ================================================ # Copyright (c) 2023 Aizen Corp. # # 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. # ============================================================================== import os import pandas as pd from ludwig.datasets.loaders.dataset_loader import DatasetLoader from ludwig.utils.fs_utils import makedirs class CamseqLoader(DatasetLoader): def transform_files(self, file_paths: list[str]) -> list[str]: if not os.path.exists(self.processed_dataset_dir): os.makedirs(self.processed_dataset_dir) # move images and masks into separate directories source_dir = self.raw_dataset_dir images_dir = os.path.join(source_dir, "images") masks_dir = os.path.join(source_dir, "masks") makedirs(images_dir, exist_ok=True) makedirs(masks_dir, exist_ok=True) data_files = [] for f in os.listdir(source_dir): if f.endswith("_L.png"): # masks dest_file = os.path.join(masks_dir, f) elif f.endswith(".png"): # images dest_file = os.path.join(images_dir, f) else: continue source_file = os.path.join(source_dir, f) os.replace(source_file, dest_file) data_files.append(dest_file) return super().transform_files(data_files) def load_unprocessed_dataframe(self, file_paths: list[str]) -> pd.DataFrame: """Creates a dataframe of image paths and mask paths.""" images_dir = os.path.join(self.processed_dataset_dir, "images") masks_dir = os.path.join(self.processed_dataset_dir, "masks") images = [] masks = [] for f in os.listdir(images_dir): images.append(os.path.join(images_dir, f)) mask_f = f[:-4] + "_L.png" masks.append(os.path.join(masks_dir, mask_f)) return pd.DataFrame({"image_path": images, "mask_path": masks}) ================================================ FILE: ludwig/datasets/loaders/code_alpaca_loader.py ================================================ # Copyright (c) 2022 Predibase, 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. # ============================================================================== import pandas as pd from ludwig.datasets.loaders.dataset_loader import DatasetLoader class CodeAlpacaLoader(DatasetLoader): """The Code Alpaca dataset.""" def load_file_to_dataframe(self, file_path: str) -> pd.DataFrame: """Loads a file into a dataframe.""" df = pd.read_json(file_path) return df ================================================ FILE: ludwig/datasets/loaders/consumer_complaints_loader.py ================================================ # Copyright (c) 2022 Predibase, 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. import pandas as pd from ludwig.datasets.loaders.dataset_loader import DatasetLoader class ConsumerComplaintsLoader(DatasetLoader): """The Consumer Complaints dataset.""" def load_file_to_dataframe(self, file_path: str) -> pd.DataFrame: """Loads a file into a dataframe.""" consumer_complaints_df = pd.read_csv(file_path) consumer_complaints_df = preprocess_df(consumer_complaints_df) return consumer_complaints_df def preprocess_df(df): """Preprocesses the dataframe. - Remove all rows with missing values in the following columns: - Consumer complaint narrative - Issue - Product Args: df (pd.DataFrame): The dataframe to preprocess. Returns: pd.DataFrame: The preprocessed dataframe. """ return df.dropna(subset=["Consumer complaint narrative", "Issue", "Product"]) ================================================ FILE: ludwig/datasets/loaders/creditcard_fraud.py ================================================ # Copyright (c) 2022 Predibase, 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. # ============================================================================== import pandas as pd from ludwig.datasets.loaders.dataset_loader import DatasetLoader class CreditCardFraudLoader(DatasetLoader): def transform_dataframe(self, dataframe: pd.DataFrame) -> pd.DataFrame: processed_df = super().transform_dataframe(dataframe) # Train/Test split like https://www.kaggle.com/competitions/1056lab-fraud-detection-in-credit-card/overview processed_df = processed_df.sort_values(by=["Time"]) processed_df.loc[:198365, "split"] = 0 processed_df.loc[198365:, "split"] = 2 processed_df.split = processed_df.split.astype(int) return processed_df ================================================ FILE: ludwig/datasets/loaders/dataset_loader.py ================================================ # Copyright (c) 2022 Predibase, 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. # ============================================================================== from __future__ import annotations import glob import hashlib import logging import os import shutil import urllib from enum import Enum from urllib.parse import urlparse import pandas as pd from tqdm import tqdm from ludwig.api_annotations import DeveloperAPI, PublicAPI from ludwig.constants import SPLIT from ludwig.datasets.archives import extract_archive, is_archive, list_archive from ludwig.datasets.dataset_config import DatasetConfig, DatasetFallbackMirror from ludwig.datasets.kaggle import download_kaggle_dataset from ludwig.datasets.utils import model_configs_for_dataset from ludwig.utils.fs_utils import get_default_cache_location, get_fs_and_path from ludwig.utils.strings_utils import make_safe_filename logger = logging.getLogger(__name__) @DeveloperAPI class TqdmUpTo(tqdm): """Provides progress bar for `urlretrieve`. Taken from: https://gist.github.com/leimao/37ff6e990b3226c2c9670a2cd1e4a6f5 """ def update_to(self, b=1, bsize=1, tsize=None): """ b : int, optional Number of blocks transferred so far [default: 1]. bsize : int, optional Size of each block (in tqdm units) [default: 1]. tsize : int, optional Total size (in tqdm units). If [default: None] remains unchanged. """ if tsize is not None: self.total = tsize # noqa W0201 self.update(b * bsize - self.n) # will also set self.n = b * bsize def _list_of_strings(list_or_string: str | list[str]) -> list[str]: """Helper function to accept single string or lists in config.""" return [list_or_string] if isinstance(list_or_string, str) else list_or_string def _glob_multiple(pathnames: list[str], root_dir: str = None, recursive: bool = True) -> set[str]: """Recursive glob multiple patterns, returns set of matches. Note: glob's root_dir argument was added in python 3.10, not using it for compatibility. """ if root_dir: pathnames = [os.path.join(root_dir, p) for p in pathnames] return set().union(*[glob.glob(p, recursive=recursive) for p in pathnames]) def _sha256_digest(file_path) -> str: """Returns the sha256 digest for the specified file.""" hash = hashlib.sha256() buffer = bytearray(hash.block_size * 1024) # Attempts to read in multiples of the hash block size (64KB). mv = memoryview(buffer) with open(file_path, "rb", buffering=0) as f: for bytes_read in iter(lambda: f.readinto(mv), 0): hash.update(mv[:bytes_read]) return hash.hexdigest() @PublicAPI class DatasetState(int, Enum): """The state of the dataset.""" NOT_LOADED = 0 DOWNLOADED = 1 EXTRACTED = 2 TRANSFORMED = 3 @PublicAPI class DatasetLoader: """Base class that defines the default pipeline for loading a ludwig dataset. Clients will typically call load(), which processes the dataset according to the config. A dataset is processed in 4 phases: 1. Download - The dataset files are downloaded to the cache. 2. Verify - Hashes of downloaded files are verified. 3. Extract - The dataset files are extracted from an archive (may be a no-op if data is not archived). 4. Transform - The dataset is transformed into a format usable for training and is ready to load. a. Transform Files (Files -> Files) b. Load Dataframe (Files -> DataFrame) c. Transform Dataframe (DataFrame -> DataFrame) d. Save Processed (DataFrame -> File) The download and extract phases are run for each URL based on the URL type and file extension. After extraction, the full set of downloaded and extracted files are collected and passed as a list to the transform stage. The transform phase offers customization points for datasets which require preprocessing before they are usable for training. """ def __init__(self, config: DatasetConfig, cache_dir: str | None = None): """Constructor.""" self.config = config self.cache_dir = cache_dir if cache_dir else get_default_cache_location() @property def name(self): """The name of the dataset.""" return self.config.name @property def version(self): """The version of the dataset.""" return self.config.version @property def is_kaggle_dataset(self) -> bool: return self.config.kaggle_dataset_id or self.config.kaggle_competition @property def download_dir(self) -> str: """Directory where all dataset artifacts are saved.""" return os.path.join(self.cache_dir, f"{self.name}_{self.version}") @property def raw_dataset_dir(self) -> str: """Save path for raw data downloaded from the web.""" return os.path.join(self.download_dir, "raw") @property def processed_dataset_dir(self) -> str: """Save path for processed data.""" return os.path.join(self.download_dir, "processed") @property def processed_dataset_filename(self) -> str: """Filename for processed data.""" return f"{make_safe_filename(self.config.name)}.parquet" @property def processed_dataset_path(self) -> str: """Save path to processed dataset file.""" return os.path.join(self.processed_dataset_dir, self.processed_dataset_filename) @property def processed_temp_dir(self) -> str: """Save path for processed temp data.""" return os.path.join(self.download_dir, "_processed") @property def state(self) -> DatasetState: """Dataset state.""" if os.path.exists(self.processed_dataset_path): return DatasetState.TRANSFORMED if all([os.path.exists(os.path.join(self.raw_dataset_dir, filename)) for filename in self.download_filenames]): archive_filenames = [f for f in self.download_filenames if is_archive(f)] if archive_filenames: # Check to see if archive has been extracted. extracted_files = [ f for a in archive_filenames for f in list_archive(os.path.join(self.raw_dataset_dir, a)) ] if all(os.path.exists(os.path.join(self.raw_dataset_dir, ef)) for ef in extracted_files): return DatasetState.EXTRACTED else: return DatasetState.DOWNLOADED # If none of the dataset download files are archives, skip extraction phase. return DatasetState.EXTRACTED return DatasetState.NOT_LOADED @property def download_urls(self) -> list[str]: return _list_of_strings(self.config.download_urls) @property def download_filenames(self) -> list[str]: """Filenames for downloaded files inferred from download_urls.""" if self.config.archive_filenames: return _list_of_strings(self.config.archive_filenames) return [os.path.basename(urlparse(url).path) for url in self.download_urls] @staticmethod def get_mirror_download_paths(mirror: DatasetFallbackMirror): """Filenames for downloaded files inferred from mirror download_paths.""" return _list_of_strings(mirror.download_paths) def get_mirror_download_filenames(self, mirror: DatasetFallbackMirror): """Filenames for downloaded files inferred from mirror download_paths.""" if self.config.archive_filenames: return _list_of_strings(self.config.archive_filenames) return [os.path.basename(path) for path in mirror.download_paths] def description(self) -> str: """Returns human-readable description of the dataset.""" return f"{self.config.name} {self.config.version}\n{self.config.description}" @property def model_configs(self) -> dict[str, dict]: """Returns a dictionary of built-in model configs for this dataset.""" return model_configs_for_dataset(self.config.name) @property def best_model_config(self) -> dict | None: """Returns the best built-in model config for this dataset, or None.""" return self.model_configs.get("best") @property def default_model_config(self) -> dict | None: """Returns the default built-in model config for this dataset. This is a good first model which should train in under 10m on a current laptop without GPU acceleration. """ return self.model_configs.get("default") def _get_preserved_paths(self, root_dir=None): """Gets list of files to preserve when exporting dataset, not including self.processed_dataset_path. Returns paths relative to the dataset root directory. """ root_dir = root_dir if root_dir else self.processed_dataset_dir preserved_paths = _glob_multiple(_list_of_strings(self.config.preserve_paths), root_dir=root_dir) return [os.path.relpath(p, start=root_dir) for p in preserved_paths] def export(self, output_directory: str) -> None: """Exports the dataset (and any files required by it) into the specified directory.""" self._download_and_process() os.makedirs(output_directory, exist_ok=True) shutil.copy2(self.processed_dataset_path, os.path.join(output_directory, self.processed_dataset_filename)) preserve_paths = self._get_preserved_paths() for relative_path in preserve_paths: source = os.path.join(self.processed_dataset_dir, relative_path) destination = os.path.join(output_directory, relative_path) if os.path.isdir(source): shutil.copytree(source, destination, symlinks=False, dirs_exist_ok=True) else: shutil.copy2(source, destination) def _download_and_process(self, kaggle_username: str | None = None, kaggle_key: str | None = None): """Loads the dataset, downloaded and processing it if needed. If dataset is already processed, does nothing. """ if self.state == DatasetState.NOT_LOADED: try: self.download(kaggle_username=kaggle_username, kaggle_key=kaggle_key) except Exception as e: logger.warning( f"Finding fallback mirrors to download the dataset. Downloading from " f"the original source failed with the following error {e}." ) if not self.config.fallback_mirrors: logger.exception(f"No fallback mirror found. Failed to download dataset {self.config.name}.") else: self.download_from_fallback_mirrors() self.verify() if self.state == DatasetState.DOWNLOADED: # Extract dataset try: self.extract() except Exception: logger.exception("Failed to extract dataset") if self.state == DatasetState.EXTRACTED: # Transform dataset try: self.transform() except Exception: logger.exception("Failed to transform dataset") def load( self, kaggle_username: str | None = None, kaggle_key: str | None = None, split: bool = False ) -> pd.DataFrame | list[pd.DataFrame, pd.DataFrame, pd.DataFrame]: """Loads the dataset, downloaded and processing it if needed. Note: This method is also responsible for splitting the data, returning a single dataframe if split=False, and a 3-tuple of train, val, test if split=True. :param kaggle_username: (str) username on Kaggle platform :param kaggle_key: (str) dataset key on Kaggle platform :param split: (bool) splits dataset along 'split' column if present. The split column should always have values 0: train, 1: validation, 2: test. """ self._download_and_process(kaggle_username=kaggle_username, kaggle_key=kaggle_key) if self.state == DatasetState.TRANSFORMED: dataset_df = self.load_transformed_dataset() if split: return self.split(dataset_df) else: return dataset_df def download(self, kaggle_username: str | None = None, kaggle_key: str | None = None) -> list[str]: if not os.path.exists(self.raw_dataset_dir): os.makedirs(self.raw_dataset_dir) if self.is_kaggle_dataset: return download_kaggle_dataset( self.raw_dataset_dir, kaggle_dataset_id=self.config.kaggle_dataset_id, kaggle_competition=self.config.kaggle_competition, kaggle_username=kaggle_username, kaggle_key=kaggle_key, ) else: for url, filename in zip(self.download_urls, self.download_filenames): downloaded_file_path = os.path.join(self.raw_dataset_dir, filename) with TqdmUpTo(unit="B", unit_scale=True, unit_divisor=1024, miniters=1, desc=filename) as t: urllib.request.urlretrieve(url, downloaded_file_path, t.update_to) def download_from_fallback_mirrors(self): for mirror in self.config.fallback_mirrors: logger.info(f"Attempting download from mirror {mirror.name}.") try: download_paths = self.get_mirror_download_paths(mirror) filenames = self.get_mirror_download_filenames(mirror) for path, filename in zip(download_paths, filenames): downloaded_file_path = os.path.join(self.raw_dataset_dir, filename) with TqdmUpTo(unit="B", unit_scale=True, unit_divisor=1024, miniters=1, desc=filename): fs, path = get_fs_and_path(path) fs.get(path, downloaded_file_path) return except Exception: logger.exception(f"Download from mirror `{mirror.name}` failed.") def verify(self) -> None: """Verifies checksums for dataset.""" for filename, sha256sum in self.config.sha256.items(): digest = _sha256_digest(os.path.join(self.raw_dataset_dir, filename)) if digest != sha256sum: raise ValueError(f"Checksum mismatch for file {filename} of {self.config.name} dataset") if not self.config.sha256: logger.warning(f"No sha256 digest provided for dataset {self.config.name}, cannot verify.") logger.info("Contents:") for filename in os.listdir(self.raw_dataset_dir): path = os.path.join(self.raw_dataset_dir, filename) if not os.path.isdir(path): digest = _sha256_digest(path) logger.info(f" {filename}: {digest}") def extract(self) -> list[str]: extracted_files = set() for download_filename in self.download_filenames: download_path = os.path.join(self.raw_dataset_dir, download_filename) if is_archive(download_path): extracted_files.update(extract_archive(download_path)) # If the archive contains archives, extract those too. For example, bnp_claims_management. archive_contents = extracted_files.copy() for extracted_file in archive_contents: extracted_path = os.path.join(self.raw_dataset_dir, extracted_file) if is_archive(extracted_path): try: extracted_files.update(extract_archive(extracted_path)) except RuntimeError as e: logger.warning(f"Error extracting {extracted_file}" + str(e)) return list(extracted_files) def transform(self) -> None: data_filenames = [ os.path.join(self.raw_dataset_dir, f) for f in os.listdir(self.raw_dataset_dir) if not is_archive(f) ] transformed_files = self.transform_files(data_filenames) unprocessed_dataframe = self.load_unprocessed_dataframe(transformed_files) transformed_dataframe = self.transform_dataframe(unprocessed_dataframe) self.save_processed(transformed_dataframe) def transform_files(self, file_paths: list[str]) -> list[str]: """Transform data files before loading to dataframe. Subclasses should override this method to process files before loading dataframe, calling the base class implementation after transformation if the results of transformation are needed by preserve_paths. """ data_files = [p for p in file_paths if not os.path.isdir(p)] if not os.path.exists(self.processed_dataset_dir): os.makedirs(self.processed_dataset_dir) # Moves any preserved paths (ex. image directories) into processed directory to avoid unnecessary copy. for rel_path in self._get_preserved_paths(self.raw_dataset_dir): source_path = os.path.join(self.raw_dataset_dir, rel_path) dest_path = os.path.join(self.processed_dataset_dir, rel_path) if os.path.exists(source_path) and not os.path.exists(dest_path): os.replace(source_path, dest_path) return data_files def load_file_to_dataframe(self, file_path: str) -> pd.DataFrame: """Loads a file into a dataframe. Subclasses may override this method to support other input formats (json, jsonl, tsv, csv, parquet) """ file_extension = os.path.splitext(file_path)[-1].lower() if file_extension == ".json": return pd.read_json(file_path) elif file_extension == ".jsonl": return pd.read_json(file_path, lines=True) elif file_extension == ".tsv": return pd.read_table(file_path) elif file_extension in {".csv", ".data"}: return pd.read_csv(file_path) elif file_extension in {".parquet", ".pq", ".pqt"}: return pd.read_parquet(file_path) else: raise ValueError(f"Unsupported dataset file type: {file_extension}") def load_files_to_dataframe(self, file_paths: list[str], root_dir=None) -> pd.DataFrame: """Loads a file or list of files and returns a dataframe. Subclasses may override this method to change the loader's behavior for groups of files. """ if root_dir: file_paths = [os.path.join(root_dir, path) for path in file_paths] dataframes = [self.load_file_to_dataframe(path) for path in file_paths] try: if self.config.columns: column_names = [column["name"] for column in self.config.columns] set_cols_dfs = [] for df in dataframes: # Split column is not included in configs, add in if pre-set split is present if SPLIT in df.columns: column_names.append(SPLIT) # If the number of columns in the dataframe does not match the number of columns in the config, # then the dataframe likely has an extra column that we don't want - i.e. "Unnamed: 0". if len(column_names) != len(df.columns): df = df[column_names] set_cols_dfs.append(df.set_axis(column_names, axis=1)) return pd.concat(set_cols_dfs, ignore_index=True) else: return pd.concat(dataframes, ignore_index=True) except ValueError as e: logger.warning(f"Error setting column names: {e}") return pd.concat(dataframes, ignore_index=True) def load_unprocessed_dataframe(self, file_paths: list[str]) -> pd.DataFrame: """Load dataset files into a dataframe. Will use the list of data files in the dataset directory as a default if all of config's dataset_filenames, train_filenames, validation_filenames, test_filenames are empty. """ dataset_paths = _glob_multiple(_list_of_strings(self.config.dataset_filenames), root_dir=self.raw_dataset_dir) train_paths = _glob_multiple(_list_of_strings(self.config.train_filenames), root_dir=self.raw_dataset_dir) validation_paths = _glob_multiple( _list_of_strings(self.config.validation_filenames), root_dir=self.raw_dataset_dir ) test_paths = _glob_multiple(_list_of_strings(self.config.test_filenames), root_dir=self.raw_dataset_dir) if self.config.name == "hugging_face": dataframes = self._get_dataframe_with_fixed_splits_from_hf() else: dataframes = self._get_dataframe_with_fixed_splits( train_paths, validation_paths, test_paths, dataset_paths, file_paths ) return pd.concat(dataframes, ignore_index=True) def _get_dataframe_with_fixed_splits_from_hf(self): dataframes = [] splits = ["train", "validation", "test"] data_dict = self.load_hf_to_dict( self.config.huggingface_dataset_id, self.config.huggingface_subset ) # This function is defined in the Hugging Face dataloader for split_type in splits: if split_type in data_dict: # We don't have to do anything if split not in data_dict because we just concatenate the dataframes # in the end anyway. data_dict[split_type][SPLIT] = splits.index(split_type) # Add "split" column (0, 1, or 2) dataframes.append(data_dict[split_type]) return dataframes def _get_dataframe_with_fixed_splits(self, train_paths, validation_paths, test_paths, dataset_paths, file_paths): dataframes = [] if len(train_paths) > 0: train_df = self.load_files_to_dataframe(train_paths) train_df[SPLIT] = 0 dataframes.append(train_df) if len(validation_paths) > 0: validation_df = self.load_files_to_dataframe(validation_paths) validation_df[SPLIT] = 1 dataframes.append(validation_df) if len(test_paths) > 0: test_df = self.load_files_to_dataframe(test_paths) test_df[SPLIT] = 2 dataframes.append(test_df) # If we have neither train/validation/test files nor dataset_paths in the config, # use data files in root dir. if len(dataset_paths) == len(dataframes) == 0: dataset_paths = file_paths if len(dataset_paths) > 0: dataframes.append(self.load_files_to_dataframe(dataset_paths)) return dataframes def transform_dataframe(self, dataframe: pd.DataFrame) -> pd.DataFrame: """Transforms a dataframe of the entire dataset. Subclasses should override this method if transformation of the dataframe is needed. """ for column_name, type in self.config.column_types.items(): dataframe[column_name] = dataframe[column_name].astype(type) return dataframe def save_processed(self, dataframe: pd.DataFrame) -> None: """Saves transformed dataframe as a flat file ludwig can load for training.""" if not os.path.exists(self.processed_dataset_dir): os.makedirs(self.processed_dataset_dir) dataframe.to_parquet(self.processed_dataset_path, engine="pyarrow") def load_transformed_dataset(self) -> pd.DataFrame: """Load processed dataset into a dataframe.""" return pd.read_parquet(self.processed_dataset_path) def get_mtime(self) -> float: """Last modified time of the processed dataset after downloading successfully.""" return os.path.getmtime(self.processed_dataset_path) @staticmethod def split(dataset: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]: if SPLIT in dataset: dataset[SPLIT] = pd.to_numeric(dataset[SPLIT]) training_set = dataset[dataset[SPLIT] == 0].drop(columns=[SPLIT]) val_set = dataset[dataset[SPLIT] == 1].drop(columns=[SPLIT]) test_set = dataset[dataset[SPLIT] == 2].drop(columns=[SPLIT]) return training_set, test_set, val_set else: raise ValueError(f"The dataset does not a '{SPLIT}' column, load with `split=False`") ================================================ FILE: ludwig/datasets/loaders/ethos_binary.py ================================================ # Copyright (c) 2022 Predibase, 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. # ============================================================================== import pandas as pd from ludwig.datasets.loaders.dataset_loader import DatasetLoader class EthosBinaryLoader(DatasetLoader): def load_file_to_dataframe(self, file_path: str) -> pd.DataFrame: # This dataset uses ; seperator instead of , return pd.read_csv(file_path, sep=";") def transform_dataframe(self, dataframe: pd.DataFrame) -> pd.DataFrame: processed_df = super().transform_dataframe(dataframe) # convert float labels (0.0, 1.0) to binary labels processed_df["isHate"] = processed_df["isHate"] >= 0.5 processed_df["isHate"] = processed_df["isHate"].astype(int) return processed_df ================================================ FILE: ludwig/datasets/loaders/flickr8k.py ================================================ # Copyright (c) 2022 Predibase, 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. # ============================================================================== import os import re from collections import defaultdict from ludwig.datasets.loaders.dataset_loader import DatasetLoader class Flickr8kLoader(DatasetLoader): def transform_files(self, file_paths: list[str]) -> list[str]: # create a dictionary matching image_path --> list of captions image_to_caption = defaultdict(list) with open(f"{self.raw_dataset_dir}/Flickr8k.token.txt") as captions_file: image_to_caption = defaultdict(list) for line in captions_file: line = line.split("#") # the regex is to format the string to fit properly in a csv line[1] = line[1].strip("\n01234.\t ") line[1] = re.sub('"', '""', line[1]) line[1] = '"' + line[1] + '"' image_to_caption[line[0]].append(line[1]) # create csv file with 7 columns: image_path, 5 captions, and split with open(os.path.join(self.raw_dataset_dir, "flickr8k_dataset.csv"), "w") as output_file: output_file.write("image_path,caption0,caption1,caption2,") output_file.write("caption3,caption4,split\n") splits = ["train", "dev", "test"] for i in range(len(splits)): split = splits[i] with open(f"{self.raw_dataset_dir}/Flickr_8k.{split}Images.txt") as split_file: for image_name in split_file: image_name = image_name.strip("\n") if image_name in image_to_caption: output_file.write( "{},{},{},{},{},{},{}\n".format( # Note: image folder is named Flicker8k_Dataset f"{self.raw_dataset_dir}/Flicker8k_Dataset/{image_name}", *image_to_caption[image_name], i, ) ) return super().transform_files(file_paths) ================================================ FILE: ludwig/datasets/loaders/forest_cover.py ================================================ # Copyright (c) 2022 Predibase, 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. # ============================================================================== import pandas as pd from sklearn.model_selection import train_test_split from ludwig.datasets.dataset_config import DatasetConfig from ludwig.datasets.loaders.dataset_loader import DatasetLoader class ForestCoverLoader(DatasetLoader): def __init__(self, config: DatasetConfig, cache_dir: str | None = None, use_tabnet_split=True): super().__init__(config, cache_dir=cache_dir) self.use_tabnet_split = use_tabnet_split def transform_dataframe(self, dataframe: pd.DataFrame) -> pd.DataFrame: df = super().transform_dataframe(dataframe) # Elevation quantitative meters Elevation in meters # Aspect quantitative azimuth Aspect in degrees azimuth # Slope quantitative degrees Slope in degrees # Horizontal_Distance_To_Hydrology quantitative meters Horz Dist to nearest surface water features # noqa: E501 # Vertical_Distance_To_Hydrology quantitative meters Vert Dist to nearest surface water features # noqa: E501 # Horizontal_Distance_To_Roadways quantitative meters Horz Dist to nearest roadway # noqa: E501 # Hillshade_9am quantitative 0 to 255 index Hillshade index at 9am, summer solstice # noqa: E501 # Hillshade_Noon quantitative 0 to 255 index Hillshade index at noon, summer soltice # noqa: E501 # Hillshade_3pm quantitative 0 to 255 index Hillshade index at 3pm, summer solstice # noqa: E501 # Horizontal_Distance_To_Fire_Points quantitative meters Horz Dist to nearest wildfire ignition points # noqa: E501 # Wilderness_Area (4 binary columns) qualitative 0 (absence) or 1 (presence) Wilderness area designation # noqa: E501 # Soil_Type (40 binary columns) qualitative 0 (absence) or 1 (presence) Soil Type designation # Cover_Type (7 types) integer 1 to 7 Forest Cover Type designation # noqa: E501 # Map the 40 soil types to a single integer instead of 40 binary columns st_cols = [ "Soil_Type_1", "Soil_Type_2", "Soil_Type_3", "Soil_Type_4", "Soil_Type_5", "Soil_Type_6", "Soil_Type_7", "Soil_Type_8", "Soil_Type_9", "Soil_Type_10", "Soil_Type_11", "Soil_Type_12", "Soil_Type_13", "Soil_Type_14", "Soil_Type_15", "Soil_Type_16", "Soil_Type_17", "Soil_Type_18", "Soil_Type_19", "Soil_Type_20", "Soil_Type_21", "Soil_Type_22", "Soil_Type_23", "Soil_Type_24", "Soil_Type_25", "Soil_Type_26", "Soil_Type_27", "Soil_Type_28", "Soil_Type_29", "Soil_Type_30", "Soil_Type_31", "Soil_Type_32", "Soil_Type_33", "Soil_Type_34", "Soil_Type_35", "Soil_Type_36", "Soil_Type_37", "Soil_Type_38", "Soil_Type_39", "Soil_Type_40", ] st_vals = [] for _, row in df[st_cols].iterrows(): st_vals.append(row.to_numpy().nonzero()[0].item(0)) df = df.drop(columns=st_cols) df["Soil_Type"] = st_vals # Map the 4 wilderness areas to a single integer # instead of 4 binary columns wa_cols = ["Wilderness_Area_1", "Wilderness_Area_2", "Wilderness_Area_3", "Wilderness_Area_4"] wa_vals = [] for _, row in df[wa_cols].iterrows(): wa_vals.append(row.to_numpy().nonzero()[0].item(0)) df = df.drop(columns=wa_cols) df["Wilderness_Area"] = wa_vals if not self.use_tabnet_split: # first 11340 records used for training data subset # next 3780 records used for validation data subset # last 565892 records used for testing data subset df["split"] = [0] * 11340 + [1] * 3780 + [2] * 565892 else: # Split used in the tabNet paper # https://github.com/google-research/google-research/blob/master/tabnet/download_prepare_covertype.py train_val_indices, test_indices = train_test_split(range(len(df)), test_size=0.2, random_state=0) train_indices, val_indices = train_test_split(train_val_indices, test_size=0.2 / 0.6, random_state=0) df["split"] = 0 df.loc[val_indices, "split"] = 1 df.loc[test_indices, "split"] = 2 return df ================================================ FILE: ludwig/datasets/loaders/goemotions.py ================================================ # Copyright (c) 2022 Predibase, 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. # ============================================================================== import pandas as pd from ludwig.datasets.loaders.dataset_loader import DatasetLoader class GoEmotionsLoader(DatasetLoader): def transform_dataframe(self, dataframe: pd.DataFrame) -> pd.DataFrame: processed_df = super().transform_dataframe(dataframe) # Format emotion IDs as space-delimited string (Set). processed_df["emotion_ids"] = processed_df["emotion_ids"].apply(lambda e_id: " ".join(e_id.split(","))) return processed_df ================================================ FILE: ludwig/datasets/loaders/higgs.py ================================================ # Copyright (c) 2022 Predibase, 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. # ============================================================================== import pandas as pd from ludwig.datasets.dataset_config import DatasetConfig from ludwig.datasets.loaders.dataset_loader import DatasetLoader class HiggsLoader(DatasetLoader): def __init__(self, config: DatasetConfig, cache_dir: str | None = None, add_validation_set=True): super().__init__(config, cache_dir) self.add_validation_set = add_validation_set def load_file_to_dataframe(self, file_path: str) -> pd.DataFrame: """Loads a file into a dataframe.""" return pd.read_csv(file_path, header=None) def transform_dataframe(self, dataframe: pd.DataFrame) -> pd.DataFrame: processed_df = super().transform_dataframe(dataframe) if self.add_validation_set: processed_df["split"] = [0] * 10000000 + [1] * 500000 + [2] * 500000 else: processed_df["split"] = [0] * 10500000 + [2] * 500000 return processed_df ================================================ FILE: ludwig/datasets/loaders/hugging_face.py ================================================ # Copyright (c) 2022 Predibase, 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. # ============================================================================== from __future__ import annotations import logging import datasets import pandas as pd from ludwig.constants import TEST, TRAIN, VALIDATION from ludwig.datasets.loaders.dataset_loader import DatasetLoader SPLITS = [TRAIN, VALIDATION, TEST] logger = logging.getLogger(__name__) class HFLoader(DatasetLoader): """HFLoader differs from all other DatasetLoaders because of how it loads data through the Hugging Face datasets API instead of saving any files to the cache. The config for HFLoader contains two unique parameters, huggingface_dataset_id and huggingface_subsample, that identify which dataset and which subsample of that dataset to load in. """ @staticmethod def load_hf_to_dict(hf_id: str | None = None, hf_subsample: str | None = None) -> dict[str, pd.DataFrame]: """Returns a map of split -> pd.DataFrame for the given HF dataset. :param hf_id: (str) path to dataset on HuggingFace platform :param hf_subsample: (str) name of dataset configuration on HuggingFace platform """ dataset_dict: dict[str, datasets.Dataset] = datasets.load_dataset(path=hf_id, name=hf_subsample) pandas_dict = {} for split in dataset_dict: # Convert from HF DatasetDict type to a dictionary of pandas dataframes pandas_dict[split] = dataset_dict[split].to_pandas() return pandas_dict # TODO(Alex): Standardize load() signature as interface method in DatasetLoader and adhere to it in all subclasses. def load( self, hf_id: str | None = None, hf_subsample: str | None = None, split: bool = False ) -> pd.DataFrame | list[pd.DataFrame, pd.DataFrame, pd.DataFrame]: """When load() is called, HFLoader calls the datasets API to return all of the data in a HuggingFace DatasetDict, converts it to a dictionary of pandas dataframes, and returns either three dataframes containing train, validation, and test data or one dataframe that is the concatenation of all three depending on whether `split` is set to True or False. :param split: (bool) directive for how to interpret if dataset contains validation or test set (see below) Note that some datasets may not provide a validation set or a test set. In this case: - If split is True, the DataFrames corresponding to the missing sets are initialized to be empty - If split is False, the "split" column in the resulting DataFrame will reflect the fact that there is no validation/test split (i.e., there will be no 1s/2s) A train set should always be provided by Hugging Face. :param hf_id: (str) path to dataset on HuggingFace platform :param hf_subsample: (str) name of dataset configuration on HuggingFace platform """ self.config.huggingface_dataset_id = hf_id self.config.huggingface_subsample = hf_subsample pandas_dict = self.load_hf_to_dict( hf_id=hf_id, hf_subsample=hf_subsample, ) if split: # For each split, either return the appropriate dataframe or an empty dataframe for spl in SPLITS: if spl not in pandas_dict: logger.warning(f"No {spl} set found in provided Hugging Face dataset. Skipping {spl} set.") train_df = pandas_dict[TRAIN] if TRAIN in pandas_dict else pd.DataFrame() validation_df = pandas_dict[VALIDATION] if VALIDATION in pandas_dict else pd.DataFrame() test_df = pandas_dict[TEST] if TEST in pandas_dict else pd.DataFrame() return train_df, validation_df, test_df else: dataset_list = [] for spl in pandas_dict: pandas_dict[spl]["split"] = SPLITS.index(spl) # Add a column containing 0s, 1s, and 2s denoting splits dataset_list.append(pandas_dict[spl]) return pd.concat(dataset_list) ================================================ FILE: ludwig/datasets/loaders/ieee_fraud.py ================================================ # Copyright (c) 2022 Predibase, 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. # ============================================================================== import os import pandas as pd from ludwig.datasets.loaders.dataset_loader import DatasetLoader class IEEEFraudLoader(DatasetLoader): """The IEEE-CIS Fraud Detection Dataset https://www.kaggle.com/c/ieee-fraud-detection/overview.""" def load_unprocessed_dataframe(self, file_paths: list[str]) -> pd.DataFrame: """Load dataset files into a dataframe.""" train_files = {"train_identity.csv", "train_transaction.csv"} test_files = {"test_identity.csv", "test_transaction.csv"} train_dfs, test_dfs = {}, {} for filename in train_files.union(test_files): split_name = os.path.splitext(filename)[0] file_df = self.load_file_to_dataframe(os.path.join(self.raw_dataset_dir, filename)) if filename in train_files: train_dfs[split_name] = file_df elif filename in test_files: test_dfs[split_name] = file_df # Merge on TransactionID final_train = pd.merge( train_dfs["train_transaction"], train_dfs["train_identity"], on="TransactionID", how="left" ) return final_train ================================================ FILE: ludwig/datasets/loaders/insurance_lite.py ================================================ # Copyright (c) 2022 Predibase, 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. # ============================================================================== import os import pandas as pd from ludwig.datasets.loaders.dataset_loader import DatasetLoader class InsuranceLiteLoader(DatasetLoader): """Health Insurance Cross Sell Prediction Predict Health Insurance Owners' who will be interested in Vehicle Insurance https://www.kaggle.com/datasets/arashnic/imbalanced-data-practice.""" def transform_dataframe(self, dataframe: pd.DataFrame) -> pd.DataFrame: df = super().transform_dataframe(dataframe) # Make image paths relative to dataset root directory df["image_path"] = df["image_path"].apply( lambda x: os.path.join("Fast_Furious_Insured", "trainImages", os.path.basename(x)) ) return df ================================================ FILE: ludwig/datasets/loaders/kdd_loader.py ================================================ # Copyright (c) 2022 Predibase, 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. # ============================================================================== import os import pandas as pd from ludwig.datasets.dataset_config import DatasetConfig from ludwig.datasets.loaders.dataset_loader import DatasetLoader class KDDCup2009Loader(DatasetLoader): def __init__(self, config: DatasetConfig, cache_dir: str | None = None, task_name="", include_test_download=False): super().__init__(config, cache_dir=cache_dir) self.task_name = task_name self.include_test_download = include_test_download def load_file_to_dataframe(self, file_path: str) -> pd.DataFrame: """Loads a file into a dataframe.""" return pd.read_csv(file_path, sep="\t") def transform_dataframe(self, dataframe: pd.DataFrame) -> pd.DataFrame: train_df = super().transform_dataframe(dataframe) train_df = process_categorical_features(train_df, categorical_features) train_df = process_number_features(train_df, categorical_features) targets = ( pd.read_csv(os.path.join(self.raw_dataset_dir, f"orange_small_train_{self.task_name}.labels"), header=None)[ 0 ] .astype(str) .apply(lambda x: "true" if x == "1" else "false") ) train_idcs = pd.read_csv( os.path.join(self.raw_dataset_dir, f"stratified_train_idx_{self.task_name}.txt"), header=None )[0] val_idcs = pd.read_csv( os.path.join(self.raw_dataset_dir, f"stratified_test_idx_{self.task_name}.txt"), header=None )[0] processed_train_df = train_df.iloc[train_idcs].copy() processed_train_df["target"] = targets.iloc[train_idcs] processed_train_df["split"] = 0 processed_val_df = train_df.iloc[val_idcs].copy() processed_val_df["target"] = targets.iloc[val_idcs] processed_val_df["split"] = 1 if self.include_test_download: test_df = self.load_file_to_dataframe(os.path.join(self.raw_dataset_dir, "orange_small_test.data")) test_df["target"] = "" # no ground truth labels for test download test_df["split"] = 2 df = pd.concat([processed_train_df, processed_val_df, test_df]) else: df = pd.concat([processed_train_df, processed_val_df]) return df def process_categorical_features(df, categorical_features): for i in categorical_features: df.iloc[:, i].fillna("", inplace=True) return df def process_number_features(df, categorical_features): for i, column in enumerate(df.columns): if i not in categorical_features: df[column].astype(float, copy=False) return df categorical_features = { 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223, 224, 225, 226, 227, 228, } class KDDAppetencyLoader(KDDCup2009Loader): """The KDD Cup 2009 Appetency dataset. https://www.kdd.org/kdd-cup/view/kdd-cup-2009/Data """ def __init__(self, config: DatasetConfig, cache_dir: str | None = None, include_test_download=False): super().__init__( config, cache_dir=cache_dir, task_name="appetency", include_test_download=include_test_download ) class KDDChurnLoader(KDDCup2009Loader): """The KDD Cup 2009 Churn dataset. https://www.kdd.org/kdd-cup/view/kdd-cup-2009/Data """ def __init__(self, config: DatasetConfig, cache_dir: str | None = None, include_test_download=False): super().__init__(config, cache_dir=cache_dir, task_name="churn", include_test_download=include_test_download) class KDDUpsellingLoader(KDDCup2009Loader): """The KDD Cup 2009 Upselling dataset. https://www.kdd.org/kdd-cup/view/kdd-cup-2009/Data """ def __init__(self, config: DatasetConfig, cache_dir: str | None = None, include_test_download=False): super().__init__( config, cache_dir=cache_dir, task_name="upselling", include_test_download=include_test_download ) ================================================ FILE: ludwig/datasets/loaders/mnist.py ================================================ # Copyright (c) 2022 Predibase, 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. # ============================================================================== import logging import os import struct from multiprocessing.pool import ThreadPool import numpy as np import pandas as pd import torch from ludwig.datasets.dataset_config import DatasetConfig from ludwig.datasets.loaders.dataset_loader import DatasetLoader from ludwig.utils.fs_utils import makedirs logger = logging.getLogger(__name__) NUM_LABELS = 10 class MNISTLoader(DatasetLoader): def __init__(self, config: DatasetConfig, cache_dir: str | None = None): try: from torchvision.io import write_png self.write_png = write_png except ImportError: logger.error( "torchvision is not installed. " "In order to install all image feature dependencies run " "pip install ludwig[image]" ) raise super().__init__(config, cache_dir) def transform_files(self, file_paths: list[str]) -> list[str]: for dataset in ["training", "testing"]: labels, images = self.read_source_dataset(dataset, self.raw_dataset_dir) self.write_output_dataset(labels, images, os.path.join(self.raw_dataset_dir, dataset)) return super().transform_files(file_paths) def load_unprocessed_dataframe(self, file_paths: list[str]) -> pd.DataFrame: """Load dataset files into a dataframe.""" return self.output_training_and_test_data() def read_source_dataset(self, dataset="training", path="."): """Create a directory for training and test and extract all the images and labels to this destination. :args: dataset (str) : the label for the dataset path (str): the raw dataset path :returns: A tuple of the label for the image, the file array, the size and rows and columns for the image """ if dataset == "training": fname_img = os.path.join(path, "train-images-idx3-ubyte") fname_lbl = os.path.join(path, "train-labels-idx1-ubyte") elif dataset == "testing": fname_img = os.path.join(path, "t10k-images-idx3-ubyte") fname_lbl = os.path.join(path, "t10k-labels-idx1-ubyte") else: raise ValueError("dataset must be 'testing' or 'training'") with open(fname_lbl, "rb") as flbl: struct.unpack(">II", flbl.read(8)) lbl = np.frombuffer(flbl.read(), dtype=np.uint8) with open(fname_img, "rb") as fimg: magic_nr, size, rows, cols = struct.unpack(">IIII", fimg.read(16)) img = np.frombuffer(fimg.read(), dtype=np.uint8) img = img.reshape((size, rows, cols)) return lbl, img def write_output_dataset(self, labels, images, output_dir): """Create output directories where we write out the images. :args: labels (str) : the labels for the image data (np.array) : the binary array corresponding to the image output_dir (str) : the output directory that we need to write to path (str): the raw dataset path :returns: A tuple of the label for the image, the file array, the size and rows and columns for the image """ # create child image output directories output_dirs = [os.path.join(output_dir, str(i)) for i in range(NUM_LABELS)] for output_dir in output_dirs: makedirs(output_dir, exist_ok=True) def write_processed_image(t): i, label = t output_filename = os.path.join(output_dirs[label], str(i) + ".png") torch_image = torch.from_numpy(images[i].copy()).view(1, 28, 28) self.write_png(torch_image, output_filename) # write out image data tasks = list(enumerate(labels)) pool = ThreadPool(NUM_LABELS) pool.map(write_processed_image, tasks) pool.close() pool.join() def output_training_and_test_data(self): """Creates a combined (training and test) dataframe by iterating through all the images and labels.""" dataframes = [] for name in ["training", "testing"]: labels = [] paths = [] splits = [] for i in range(NUM_LABELS): label_dir = f"{name}/{i}" img_dir = os.path.join(self.processed_dataset_dir, label_dir) for file in os.listdir(img_dir): if file.endswith(".png"): labels.append(str(i)) paths.append(os.path.join(img_dir, file)) splits.append(0 if name == "training" else 2) dataframes.append(pd.DataFrame({"image_path": paths, "label": labels, "split": splits})) return pd.concat(dataframes, ignore_index=True) ================================================ FILE: ludwig/datasets/loaders/naval.py ================================================ # Copyright (c) 2022 Predibase, 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. # ============================================================================== import pandas as pd from ludwig.datasets.loaders.dataset_loader import DatasetLoader class NavalLoader(DatasetLoader): def load_file_to_dataframe(self, file_path: str) -> pd.DataFrame: """Loads a file into a dataframe.""" return pd.read_csv(file_path, header=None, sep=" ") ================================================ FILE: ludwig/datasets/loaders/rossman_store_sales.py ================================================ # Copyright (c) 2022 Predibase, 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. # ============================================================================== import calendar import os import numpy as np import pandas as pd from ludwig.datasets.loaders.dataset_loader import DatasetLoader class RossmanStoreSalesLoader(DatasetLoader): """The Rossmann Store Sales dataset.""" def load_unprocessed_dataframe(self, file_paths: list[str]) -> pd.DataFrame: """Load dataset files into a dataframe.""" stores_df = pd.read_csv(os.path.join(self.raw_dataset_dir, "store.csv")) train_df = pd.read_csv(os.path.join(self.raw_dataset_dir, "train.csv"), low_memory=False) train_df = preprocess_df(train_df, stores_df) train_df["split"] = -1 train_df.loc[train_df["Year"] == 2014, "split"] = 0 train_df.loc[train_df["Year"] == 2015, "split"] = 2 train_df.drop(train_df[train_df["split"] == -1].index, inplace=True) return train_df def preprocess_dates(df): # Make integer Year,Month,Day columns instead of Date dates = np.array([[int(v) for v in s.split("-")] for s in df["Date"]]) df = df.drop(["Date"], axis=1) df["Year"] = dates[:, 0] df["Month"] = dates[:, 1] df["Day"] = dates[:, 2] return df month_abbrs = calendar.month_abbr[1:] month_abbrs[8] = "Sept" def preprocess_stores(df, stores_df): # join data in df with stores df df = df.join(stores_df, on="Store", rsuffix="_right") df = df.drop(["Store_right"], axis=1) promo2_start_months = [(s.split(",") if not pd.isnull(s) else []) for s in df["PromoInterval"]] for month_abbr in month_abbrs: df["Promo2Start_" + month_abbr] = np.array( [(1 if month_abbr in s else 0) for s in promo2_start_months], dtype=np.int8 ) df = df.drop(["PromoInterval"], axis=1) return df int_columns = [ "Store", "DayOfWeek", "Sales", "Customers", "Open", "Promo", "SchoolHoliday", "Year", "Month", "Day", "CompetitionDistance", "CompetitionOpenSinceMonth", "CompetitionOpenSinceYear", "Promo2", "Promo2SinceWeek", "Promo2SinceYear", ] def preprocess_df(df, stores_df): df = preprocess_dates(df) df = preprocess_stores(df, stores_df) for column in int_columns: df[column] = pd.to_numeric(df[column].fillna(0), downcast="integer") df["StateHoliday"] = df["StateHoliday"].astype(str) df.loc[df["StateHoliday"] == "0", "StateHoliday"] = "No" return df ================================================ FILE: ludwig/datasets/loaders/santander_value_prediction.py ================================================ # Copyright (c) 2022 Predibase, 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. # ============================================================================== import pandas as pd from ludwig.datasets.loaders.dataset_loader import DatasetLoader class SantanderValuePredictionLoader(DatasetLoader): """The Santander Value Prediction Challenge dataset. https://www.kaggle.com/c/santander-value-prediction-challenge """ def transform_dataframe(self, dataframe: pd.DataFrame) -> pd.DataFrame: processed_df = super().transform_dataframe(dataframe) # Ensure feature column names are strings (some are numeric); keep special names as is processed_df.columns = ["C" + str(col) for col in processed_df.columns] processed_df.rename(columns={"CID": "ID", "Ctarget": "target", "Csplit": "split"}, inplace=True) return processed_df ================================================ FILE: ludwig/datasets/loaders/sarcastic_headlines.py ================================================ # Copyright (c) 2022 Predibase, 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. # ============================================================================== import pandas as pd from ludwig.datasets.loaders.dataset_loader import DatasetLoader class SarcasticHeadlinesLoader(DatasetLoader): def load_file_to_dataframe(self, file_path: str) -> pd.DataFrame: """Loads a file into a dataframe.""" return pd.read_json(file_path, lines=True) ================================================ FILE: ludwig/datasets/loaders/sarcos.py ================================================ # Copyright (c) 2022 Predibase, 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. # ============================================================================== import os import pandas as pd from scipy.io import loadmat from ludwig.datasets.loaders.dataset_loader import DatasetLoader from ludwig.utils.fs_utils import open_file class SarcosLoader(DatasetLoader): """The Sarcos dataset. Details: The data relates to an inverse dynamics problem for a seven degrees-of-freedom SARCOS anthropomorphic robot arm. The task is to map from a 21-dimensional input space (7 joint positions, 7 joint velocities, 7 joint accelerations) to the corresponding 7 joint torques. There are 44,484 training examples and 4,449 test examples. The first 21 columns are the input variables, and the 22nd column is used as the target variable. Dataset source: Locally Weighted Projection RegressionL: An O(n) Algorithm for Incremental Real Time Learning in High Dimensional Space, S. Vijayakumar and S. Schaal, Proc ICML 2000. http://www.gaussianprocess.org/gpml/data/ """ def load_file_to_dataframe(self, file_path: str) -> pd.DataFrame: """Loads a file into a dataframe.""" with open_file(file_path) as f: mat = loadmat(f) file_df = pd.DataFrame(mat[os.path.basename(file_path).split(".")[0]]) return file_df def transform_dataframe(self, dataframe: pd.DataFrame) -> pd.DataFrame: processed_df = super().transform_dataframe(dataframe) columns = [] columns += [f"position_{i}" for i in range(1, 8)] columns += [f"velocity_{i}" for i in range(1, 8)] columns += [f"acceleration_{i}" for i in range(1, 8)] columns += [f"torque_{i}" for i in range(1, 8)] columns += ["split"] processed_df.columns = columns return processed_df ================================================ FILE: ludwig/datasets/loaders/split_loaders.py ================================================ # Copyright (c) 2022 Predibase, 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. # ============================================================================== import numpy as np import pandas as pd from ludwig.constants import SPLIT from ludwig.datasets.loaders.dataset_loader import DatasetLoader class RandomSplitLoader(DatasetLoader): """Adds a random split column to the dataset, with fixed proportions of: train: 70% validation: 10% test: 20% . """ def transform_dataframe(self, dataframe: pd.DataFrame) -> pd.DataFrame: df = super().transform_dataframe(dataframe) df[SPLIT] = np.random.choice(3, len(df), p=(0.7, 0.1, 0.2)).astype(np.int8) return df ================================================ FILE: ludwig/datasets/loaders/sst.py ================================================ # Copyright (c) 2022 Predibase, 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. # ============================================================================== import os import pandas as pd from ludwig.datasets.dataset_config import DatasetConfig from ludwig.datasets.loaders.dataset_loader import DatasetLoader class SSTLoader(DatasetLoader): """The SST dataset. This dataset is constructed using the Stanford Sentiment Treebank Dataset. This dataset contains binary labels (positive or negative) for each sample. The original dataset specified 5 labels: very negative, negative, neutral, positive, very positive with the following cutoffs: [0, 0.2], (0.2, 0.4], (0.4, 0.6], (0.6, 0.8], (0.8, 1.0] """ def __init__( self, config: DatasetConfig, cache_dir: str | None = None, include_subtrees=False, discard_neutral=False, convert_parentheses=True, remove_duplicates=False, ): super().__init__(config, cache_dir=cache_dir) self.include_subtrees = include_subtrees self.discard_neutral = discard_neutral self.convert_parentheses = convert_parentheses self.remove_duplicates = remove_duplicates @staticmethod def get_sentiment_label(id2sent, phrase_id): raise NotImplementedError def transform_files(self, file_paths: list[str]) -> list[str]: # maybe this should be """Load dataset files into a dataframe.""" sentences_df = pd.read_csv( os.path.join(self.raw_dataset_dir, "stanfordSentimentTreebank/datasetSentences.txt"), sep="\t", ) sentences_df["sentence"] = sentences_df["sentence"].apply(format_text) datasplit_df = pd.read_csv( os.path.join(self.raw_dataset_dir, "stanfordSentimentTreebank/datasetSplit.txt"), sep="," ) phrase2id = {} with open(os.path.join(self.raw_dataset_dir, "stanfordSentimentTreebank/dictionary.txt")) as f: Lines = f.readlines() for line in Lines: if line: split_line = line.split("|") phrase = split_line[0] phrase2id[phrase] = int(split_line[1]) id2sent = {} with open(os.path.join(self.raw_dataset_dir, "stanfordSentimentTreebank/sentiment_labels.txt")) as f: Lines = f.readlines() for line in Lines: if line: split_line = line.split("|") try: id2sent[int(split_line[0])] = float(split_line[1]) except ValueError: pass trees_pointers = None trees_phrases = None if self.include_subtrees: trees_pointers = [] with open(os.path.join(self.raw_dataset_dir, "stanfordSentimentTreebank/STree.txt")) as f: Lines = f.readlines() for line in Lines: if line: trees_pointers.append([int(s.strip()) for s in line.split("|")]) trees_phrases = [] with open(os.path.join(self.raw_dataset_dir, "stanfordSentimentTreebank/SOStr.txt")) as f: Lines = f.readlines() for line in Lines: if line: trees_phrases.append([s.strip() for s in line.split("|")]) splits = {"train": 1, "test": 2, "dev": 3} generated_csv_filenames = [] for split_name, split_id in splits.items(): sentence_idcs = get_sentence_idcs_in_split(datasplit_df, split_id) pairs = [] if split_name == "train" and self.include_subtrees: phrases = [] for sentence_idx in sentence_idcs: # trees_pointers and trees_phrases are 0 indexed # while sentence_idx starts from 1 # so we need to decrease sentence_idx value sentence_idx -= 1 subtrees = sentence_subtrees(sentence_idx, trees_pointers, trees_phrases) sentence_idx += 1 sentence_phrase = list(sentences_df[sentences_df["sentence_index"] == sentence_idx]["sentence"])[0] sentence_phrase = convert_parentheses(sentence_phrase) label = self.get_sentiment_label(id2sent, phrase2id[sentence_phrase]) # filter @ sentence level # For SST-2, check subtrees only if sentence is not neutral if not self.discard_neutral or label != -1: for phrase in subtrees: label = self.get_sentiment_label(id2sent, phrase2id[phrase]) if not self.discard_neutral or label != -1: if not self.convert_parentheses: phrase = convert_parentheses_back(phrase) phrase = phrase.replace("\xa0", " ") pairs.append([phrase, label]) else: phrases = get_sentences_with_idcs(sentences_df, sentence_idcs) for phrase in phrases: phrase = convert_parentheses(phrase) label = self.get_sentiment_label(id2sent, phrase2id[phrase]) if not self.discard_neutral or label != -1: if not self.convert_parentheses: phrase = convert_parentheses_back(phrase) phrase = phrase.replace("\xa0", " ") pairs.append([phrase, label]) final_csv = pd.DataFrame(pairs) final_csv.columns = ["sentence", "label"] if self.remove_duplicates: final_csv = final_csv.drop_duplicates(subset=["sentence"]) csv_filename = os.path.join(self.raw_dataset_dir, f"{split_name}.csv") generated_csv_filenames.append(csv_filename) final_csv.to_csv(csv_filename, index=False) return super().transform_files(generated_csv_filenames) class SST2Loader(SSTLoader): """The SST2 dataset. This dataset is constructed using the Stanford Sentiment Treebank Dataset. This dataset contains binary labels (positive or negative) for each sample. The original dataset specified 5 labels: very negative, negative, neutral, positive, very positive with the following cutoffs: [0, 0.2], (0.2, 0.4], (0.4, 0.6], (0.6, 0.8], (0.8, 1.0] In the construction of this dataset, we remove all neutral phrases and assign a negative label if the original rating falls into the following range: [0, 0.4] and a positive label if the original rating is between (0.6, 1.0]. """ def __init__( self, config: DatasetConfig, cache_dir: str | None = None, include_subtrees=False, convert_parentheses=True, remove_duplicates=False, ): super().__init__( config, cache_dir=cache_dir, include_subtrees=include_subtrees, discard_neutral=True, convert_parentheses=convert_parentheses, remove_duplicates=remove_duplicates, ) def get_sentiment_label(self, id2sent, phrase_id): sentiment = id2sent[phrase_id] if sentiment <= 0.4: # negative return 0 elif sentiment > 0.6: # positive return 1 return -1 # neutral class SST3Loader(SSTLoader): """The SST3 dataset. This dataset is constructed using the Stanford Sentiment Treebank Dataset. This dataset contains five labels (very negative, negative, neutral, positive, very positive) for each sample. In the original dataset, the 5 labels: very negative, negative, neutral, positive, and very positive have the following cutoffs: [0, 0.4], (0.4, 0.6], (0.6, 1.0] This class pulls in an array of mixins for different types of functionality which belongs in the workflow for ingesting and transforming training data into a destination dataframe that can be use by Ludwig. """ def __init__( self, config: DatasetConfig, cache_dir: str | None = None, include_subtrees=False, convert_parentheses=True, remove_duplicates=False, ): super().__init__( config, cache_dir=cache_dir, include_subtrees=include_subtrees, convert_parentheses=convert_parentheses, remove_duplicates=remove_duplicates, ) def get_sentiment_label(self, id2sent, phrase_id): sentiment = id2sent[phrase_id] if sentiment <= 0.4: return "negative" elif sentiment <= 0.6: return "neutral" elif sentiment <= 1.0: return "positive" return "neutral" class SST5Loader(SSTLoader): """The SST5 dataset. This dataset is constructed using the Stanford Sentiment Treebank Dataset. This dataset contains five labels (very negative, negative, neutral, positive, very positive) for each sample. In the original dataset, the 5 labels: very negative, negative, neutral, positive, and very positive have the following cutoffs: [0, 0.2], (0.2, 0.4], (0.4, 0.6], (0.6, 0.8], (0.8, 1.0] This class pulls in an array of mixins for different types of functionality which belongs in the workflow for ingesting and transforming training data into a destination dataframe that can be use by Ludwig. """ def __init__( self, config: DatasetConfig, cache_dir: str | None = None, include_subtrees=False, convert_parentheses=True, remove_duplicates=False, ): super().__init__( config, cache_dir=cache_dir, include_subtrees=include_subtrees, convert_parentheses=convert_parentheses, remove_duplicates=remove_duplicates, ) def get_sentiment_label(self, id2sent, phrase_id): sentiment = id2sent[phrase_id] if sentiment <= 0.2: return "very_negative" elif sentiment <= 0.4: return "negative" elif sentiment <= 0.6: return "neutral" elif sentiment <= 0.8: return "positive" elif sentiment <= 1.0: return "very_positive" return "neutral" def format_text(text: str): """Formats text by decoding into utf-8.""" return " ".join([w.encode("latin1").decode("utf-8") for w in text.strip().split(" ")]) def convert_parentheses(text: str): """Replaces -LRB- and -RRB- tokens present in SST with ( and )""" return text.replace("-LRB-", "(").replace("-RRB-", ")") def convert_parentheses_back(text: str): """Replaces ( and ) tokens with -LRB- and -RRB-""" return text.replace("(", "-LRB-").replace(")", "-RRB-") def get_sentence_idcs_in_split(datasplit: pd.DataFrame, split_id: int): """Given a dataset split is (1 for train, 2 for test, 3 for dev), returns the set of corresponding sentence indices in sentences_df.""" return set(datasplit[datasplit["splitset_label"] == split_id]["sentence_index"]) def get_sentences_with_idcs(sentences: pd.DataFrame, sentences_idcs: set[int]): """Given a set of sentence indices, returns the corresponding sentences texts in sentences.""" criterion = sentences["sentence_index"].map(lambda x: x in sentences_idcs) return sentences[criterion]["sentence"].tolist() def sentence_subtrees(sentence_idx, trees_pointers, trees_phrases): tree_pointers = trees_pointers[sentence_idx] tree_phrases = trees_phrases[sentence_idx] tree = SSTTree(tree_pointers, tree_phrases) return tree.subtrees() def visit_postorder(node, visit_list): if node: visit_postorder(node.left, visit_list) visit_postorder(node.right, visit_list) visit_list.append(node.val) class SSTTree: class Node: def __init__(self, key, val=None): self.left = None self.right = None self.key = key self.val = val def create_node(self, parent, i): if self.nodes[i] is not None: # already created return self.nodes[i] = self.Node(i) if parent[i] == -1: # is root self.root = self.nodes[i] return if self.nodes[parent[i]] is None: # parent not yet created self.create_node(parent, parent[i]) # assign current node to parent parent = self.nodes[parent[i]] if parent.left is None: parent.left = self.nodes[i] else: parent.right = self.nodes[i] def create_tree(self, parents, tree_phrases): n = len(parents) self.nodes = [None for i in range(n)] self.root = [None] for i in range(n): self.create_node(parents, i) for i, phrase in enumerate(tree_phrases): self.nodes[i].val = phrase for node in self.nodes: if node.val is None: node.val = " ".join((node.left.val, node.right.val)) def __init__(self, tree_pointers, tree_phrases): self.create_tree([int(elem) - 1 for elem in tree_pointers], tree_phrases) def subtrees(self): visit_list = [] visit_postorder(self.root, visit_list) return visit_list ================================================ FILE: ludwig/datasets/model_configs/__init__.py ================================================ ================================================ FILE: ludwig/datasets/model_configs/adult_census_income_default.yaml ================================================ output_features: - name: income type: category input_features: - name: age type: number - name: workclass type: category - name: fnlwgt type: number - name: education type: category - name: education-num type: number - name: marital-status type: category - name: occupation type: category - name: relationship type: category - name: race type: category - name: sex type: category - name: capital-gain type: number - name: capital-loss type: number - name: hours-per-week type: number - name: native-country type: category combiner: type: concat num_fc_layers: 3 fc_size: 128 dropout: 0.1 training: batch_size: 256 learning_rate: .001 epochs: 1 steps_per_checkpoint: 1 ================================================ FILE: ludwig/datasets/model_configs/allstate_claims_severity_default.yaml ================================================ output_features: - name: loss type: number input_features: - name: cat1 type: category - name: cat2 type: category - name: cat3 type: category - name: cat4 type: category - name: cat5 type: category - name: cat6 type: category - name: cat7 type: category - name: cat8 type: category - name: cat9 type: category - name: cat10 type: category - name: cat11 type: category - name: cat12 type: category - name: cat13 type: category - name: cat14 type: category - name: cat15 type: category - name: cat16 type: category - name: cat17 type: category - name: cat18 type: category - name: cat19 type: category - name: cat20 type: category - name: cat21 type: category - name: cat22 type: category - name: cat23 type: category - name: cat24 type: category - name: cat25 type: category - name: cat26 type: category - name: cat27 type: category - name: cat28 type: category - name: cat29 type: category - name: cat30 type: category - name: cat31 type: category - name: cat32 type: category - name: cat33 type: category - name: cat34 type: category - name: cat35 type: category - name: cat36 type: category - name: cat37 type: category - name: cat38 type: category - name: cat39 type: category - name: cat40 type: category - name: cat41 type: category - name: cat42 type: category - name: cat43 type: category - name: cat44 type: category - name: cat45 type: category - name: cat46 type: category - name: cat47 type: category - name: cat48 type: category - name: cat49 type: category - name: cat50 type: category - name: cat51 type: category - name: cat52 type: category - name: cat53 type: category - name: cat54 type: category - name: cat55 type: category - name: cat56 type: category - name: cat57 type: category - name: cat58 type: category - name: cat59 type: category - name: cat60 type: category - name: cat61 type: category - name: cat62 type: category - name: cat63 type: category - name: cat64 type: category - name: cat65 type: category - name: cat66 type: category - name: cat67 type: category - name: cat68 type: category - name: cat69 type: category - name: cat70 type: category - name: cat71 type: category - name: cat72 type: category - name: cat73 type: category - name: cat74 type: category - name: cat75 type: category - name: cat76 type: category - name: cat77 type: category - name: cat78 type: category - name: cat79 type: category - name: cat80 type: category - name: cat81 type: category - name: cat82 type: category - name: cat83 type: category - name: cat84 type: category - name: cat85 type: category - name: cat86 type: category - name: cat87 type: category - name: cat88 type: category - name: cat89 type: category - name: cat90 type: category - name: cat91 type: category - name: cat92 type: category - name: cat93 type: category - name: cat94 type: category - name: cat95 type: category - name: cat96 type: category - name: cat97 type: category - name: cat98 type: category - name: cat99 type: category - name: cat100 type: category - name: cat101 type: category - name: cat102 type: category - name: cat103 type: category - name: cat104 type: category - name: cat105 type: category - name: cat106 type: category - name: cat107 type: category - name: cat108 type: category - name: cat109 type: category - name: cat110 type: category - name: cat111 type: category - name: cat112 type: category - name: cat113 type: category - name: cat114 type: category - name: cat115 type: category - name: cat116 type: category - name: cont1 type: number - name: cont2 type: number - name: cont3 type: number - name: cont4 type: number - name: cont5 type: number - name: cont6 type: number - name: cont7 type: number - name: cont8 type: number - name: cont9 type: number - name: cont10 type: number - name: cont11 type: number - name: cont12 type: number - name: cont13 type: number - name: cont14 type: number combiner: type: concat num_fc_layers: 3 fc_size: 128 dropout: 0.1 training: batch_size: 256 learning_rate: .001 epochs: 1 ================================================ FILE: ludwig/datasets/model_configs/ames_housing_default.yaml ================================================ output_features: - name: SalePrice type: number input_features: - name: MSSubClass type: category - name: MSZoning type: category - name: LotFrontage type: number - name: LotArea type: number - name: Street type: category - name: Alley type: category - name: LotShape type: category - name: LandContour type: category - name: Utilities type: category - name: LotConfig type: category - name: LandSlope type: category - name: Neighborhood type: category - name: Condition1 type: category - name: Condition2 type: category - name: BldgType type: category - name: HouseStyle type: category - name: OverallQual type: category - name: OverallCond type: category - name: YearBuilt type: number - name: YearRemodAdd type: number - name: RoofStyle type: category - name: RoofMatl type: category - name: Exterior1st type: category - name: Exterior2nd type: category - name: MasVnrType type: category - name: MasVnrArea type: number - name: ExterQual type: category - name: ExterCond type: category - name: Foundation type: category - name: BsmtQual type: category - name: BsmtCond type: category - name: BsmtExposure type: category - name: BsmtFinType1 type: category - name: BsmtFinSF1 type: number - name: BsmtFinType2 type: category - name: BsmtFinSF2 type: number - name: BsmtUnfSF type: number - name: TotalBsmtSF type: number - name: Heating type: category - name: HeatingQC type: category - name: CentralAir type: binary - name: Electrical type: category - name: 1stFlrSF type: number - name: 2ndFlrSF type: number - name: LowQualFinSF type: number - name: GrLivArea type: number - name: BsmtFullBath type: number - name: BsmtHalfBath type: number - name: FullBath type: number - name: HalfBath type: number - name: BedroomAbvGr type: number - name: KitchenAbvGr type: number - name: KitchenQual type: category - name: TotRmsAbvGrd type: number - name: Functional type: category - name: Fireplaces type: number - name: FireplaceQu type: category - name: GarageType type: category - name: GarageYrBlt type: number - name: GarageFinish type: category - name: GarageCars type: number - name: GarageArea type: number - name: GarageQual type: category - name: GarageCond type: category - name: PavedDrive type: category - name: WoodDeckSF type: number - name: OpenPorchSF type: number - name: EnclosedPorch type: number - name: 3SsnPorch type: number - name: ScreenPorch type: number - name: PoolArea type: number - name: PoolQC type: category - name: Fence type: category - name: MiscFeature type: category - name: MiscVal type: number - name: MoSold type: category - name: YrSold type: number - name: SaleType type: category - name: SaleCondition type: category combiner: type: concat num_fc_layers: 3 fc_size: 128 dropout: 0.1 training: batch_size: 256 learning_rate: .001 epochs: 1 ================================================ FILE: ludwig/datasets/model_configs/bnp_claims_management_default.yaml ================================================ output_features: - name: target type: binary input_features: - name: v1 type: number - name: v2 type: number - name: v3 type: category - name: v4 type: number - name: v5 type: number - name: v6 type: number - name: v7 type: number - name: v8 type: number - name: v9 type: number - name: v10 type: number - name: v11 type: number - name: v12 type: number - name: v13 type: number - name: v14 type: number - name: v15 type: number - name: v16 type: number - name: v17 type: number - name: v18 type: number - name: v19 type: number - name: v20 type: number - name: v21 type: number - name: v22 type: category - name: v23 type: number - name: v24 type: category - name: v25 type: number - name: v26 type: number - name: v27 type: number - name: v28 type: number - name: v29 type: number - name: v30 type: category - name: v31 type: category - name: v32 type: number - name: v33 type: number - name: v34 type: number - name: v35 type: number - name: v36 type: number - name: v37 type: number - name: v38 type: number - name: v39 type: number - name: v40 type: number - name: v41 type: number - name: v42 type: number - name: v43 type: number - name: v44 type: number - name: v45 type: number - name: v46 type: number - name: v47 type: category - name: v48 type: number - name: v49 type: number - name: v50 type: number - name: v51 type: number - name: v52 type: category - name: v53 type: number - name: v54 type: number - name: v55 type: number - name: v56 type: category - name: v57 type: number - name: v58 type: number - name: v59 type: number - name: v60 type: number - name: v61 type: number - name: v62 type: number - name: v63 type: number - name: v64 type: number - name: v65 type: number - name: v66 type: category - name: v67 type: number - name: v68 type: number - name: v69 type: number - name: v70 type: number - name: v71 type: category - name: v72 type: number - name: v73 type: number - name: v74 type: category - name: v75 type: category - name: v76 type: number - name: v77 type: number - name: v78 type: number - name: v79 type: category - name: v80 type: number - name: v81 type: number - name: v82 type: number - name: v83 type: number - name: v84 type: number - name: v85 type: number - name: v86 type: number - name: v87 type: number - name: v88 type: number - name: v89 type: number - name: v90 type: number - name: v91 type: category - name: v92 type: number - name: v93 type: number - name: v94 type: number - name: v95 type: number - name: v96 type: number - name: v97 type: number - name: v98 type: number - name: v99 type: number - name: v100 type: number - name: v101 type: number - name: v102 type: number - name: v103 type: number - name: v104 type: number - name: v105 type: number - name: v106 type: number - name: v107 type: category - name: v108 type: number - name: v109 type: number - name: v110 type: category - name: v111 type: number - name: v112 type: category - name: v113 type: category - name: v114 type: number - name: v115 type: number - name: v116 type: number - name: v117 type: number - name: v118 type: number - name: v119 type: number - name: v120 type: number - name: v121 type: number - name: v122 type: number - name: v123 type: number - name: v124 type: number - name: v125 type: category - name: v126 type: number - name: v127 type: number - name: v128 type: number - name: v129 type: number - name: v130 type: number - name: v131 type: number combiner: type: concat num_fc_layers: 3 fc_size: 128 dropout: 0.1 training: batch_size: 256 learning_rate: .001 epochs: 1 ================================================ FILE: ludwig/datasets/model_configs/forest_cover_default.yaml ================================================ output_features: - name: Cover_Type type: category input_features: - name: Elevation type: number - name: Aspect type: number - name: Slope type: number - name: Horizontal_Distance_To_Hydrology type: number - name: Vertical_Distance_To_Hydrology type: number - name: Horizontal_Distance_To_Roadways type: number - name: Hillshade_9am type: number - name: Hillshade_Noon type: number - name: Hillshade_3pm type: number - name: Horizontal_Distance_To_Fire_Points type: number - name: Wilderness_Area type: category - name: Soil_Type type: category combiner: type: concat num_fc_layers: 3 fc_size: 128 dropout: 0.1 training: batch_size: 256 learning_rate: .001 epochs: 1 ================================================ FILE: ludwig/datasets/model_configs/higgs_best.yaml ================================================ output_features: - name: label type: binary weight_regularization: null input_features: - name: lepton_pT type: number - name: lepton_eta type: number - name: lepton_phi type: number - name: missing_energy_magnitude type: number - name: missing_energy_phi type: number - name: jet_1_pt type: number - name: jet_1_eta type: number - name: jet_1_phi type: number - name: jet_1_b-tag type: number - name: jet_2_pt type: number - name: jet_2_eta type: number - name: jet_2_phi type: number - name: jet_2_b-tag type: number - name: jet_3_pt type: number - name: jet_3_eta type: number - name: jet_3_phi type: number - name: jet_3_b-tag type: number - name: jet_4_pt type: number - name: jet_4_eta type: number - name: jet_4_phi type: number - name: jet_4_b-tag type: number - name: m_jj type: number - name: m_jjj type: number - name: m_lv type: number - name: m_jlv type: number - name: m_bb type: number - name: m_wbb type: number - name: m_wwbb type: number combiner: type: tabnet bn_momentum: 0.95 bn_virtual_bs: 1024 dropout: 0.05252744300130521 fc_size: 128 num_fc_layers: 3 num_steps: 3 output_size: 128 relaxation_factor: 1.5 size: 32 sparsity: 0.0001 training: batch_size: 8192 learning_rate: 0.01 shuffle_buffer_size: 1000000 should_shuffle: true eval_batch_size: 500000 #4096 # 65536 131072 262144 524288 epochs: 300 early_stop: 30 optimizer: type: adam learning_rate_scheduler: decay: exponential decay_rate: 0.8 decay_steps: 20000 regularization_lambda: 1 validation_field: label ================================================ FILE: ludwig/datasets/model_configs/higgs_default.yaml ================================================ output_features: - name: label type: binary weight_regularization: null input_features: - name: lepton_pT type: number - name: lepton_eta type: number - name: lepton_phi type: number - name: missing_energy_magnitude type: number - name: missing_energy_phi type: number - name: jet_1_pt type: number - name: jet_1_eta type: number - name: jet_1_phi type: number - name: jet_1_b-tag type: number - name: jet_2_pt type: number - name: jet_2_eta type: number - name: jet_2_phi type: number - name: jet_2_b-tag type: number - name: jet_3_pt type: number - name: jet_3_eta type: number - name: jet_3_phi type: number - name: jet_3_b-tag type: number - name: jet_4_pt type: number - name: jet_4_eta type: number - name: jet_4_phi type: number - name: jet_4_b-tag type: number - name: m_jj type: number - name: m_jjj type: number - name: m_lv type: number - name: m_jlv type: number - name: m_bb type: number - name: m_wbb type: number - name: m_wwbb type: number combiner: type: concat num_fc_layers: 3 fc_size: 128 dropout: 0.1 training: batch_size: 256 learning_rate: .001 epochs: 1 ================================================ FILE: ludwig/datasets/model_configs/ieee_fraud_default.yaml ================================================ output_features: - name: isFraud type: binary input_features: - name: TransactionDT type: number - name: TransactionAmt type: number - name: ProductCD type: category - name: card1 type: number - name: card2 type: number - name: card3 type: number - name: card4 type: category - name: card5 type: number - name: card6 type: category - name: addr1 type: number - name: addr2 type: number - name: dist1 type: number - name: dist2 type: number - name: P_emaildomain type: category - name: R_emaildomain type: number - name: C1 type: number - name: C2 type: number - name: C3 type: number - name: C4 type: number - name: C5 type: number - name: C6 type: number - name: C7 type: number - name: C8 type: number - name: C9 type: number - name: C10 type: number - name: C11 type: number - name: C12 type: number - name: C13 type: number - name: C14 type: number - name: D1 type: number - name: D2 type: number - name: D3 type: number - name: D4 type: number - name: D5 type: number - name: D6 type: number - name: D7 type: number - name: D8 type: number - name: D9 type: number - name: D10 type: number - name: D11 type: number - name: D12 type: number - name: D13 type: number - name: D14 type: number - name: D15 type: number - name: M1 type: category - name: M2 type: category - name: M3 type: category - name: M4 type: category - name: M5 type: category - name: M6 type: category - name: M7 type: category - name: M8 type: category - name: M9 type: category - name: V1 type: number - name: V2 type: number - name: V3 type: number - name: V4 type: number - name: V5 type: number - name: V6 type: number - name: V7 type: number - name: V8 type: number - name: V9 type: number - name: V10 type: number - name: V11 type: number - name: V12 type: number - name: V13 type: number - name: V14 type: number - name: V15 type: number - name: V16 type: number - name: V17 type: number - name: V18 type: number - name: V19 type: number - name: V20 type: number - name: V21 type: number - name: V22 type: number - name: V23 type: number - name: V24 type: number - name: V25 type: number - name: V26 type: number - name: V27 type: number - name: V28 type: number - name: V29 type: number - name: V30 type: number - name: V31 type: number - name: V32 type: number - name: V33 type: number - name: V34 type: number - name: V35 type: number - name: V36 type: number - name: V37 type: number - name: V38 type: number - name: V39 type: number - name: V40 type: number - name: V41 type: number - name: V42 type: number - name: V43 type: number - name: V44 type: number - name: V45 type: number - name: V46 type: number - name: V47 type: number - name: V48 type: number - name: V49 type: number - name: V50 type: number - name: V51 type: number - name: V52 type: number - name: V53 type: number - name: V54 type: number - name: V55 type: number - name: V56 type: number - name: V57 type: number - name: V58 type: number - name: V59 type: number - name: V60 type: number - name: V61 type: number - name: V62 type: number - name: V63 type: number - name: V64 type: number - name: V65 type: number - name: V66 type: number - name: V67 type: number - name: V68 type: number - name: V69 type: number - name: V70 type: number - name: V71 type: number - name: V72 type: number - name: V73 type: number - name: V74 type: number - name: V75 type: number - name: V76 type: number - name: V77 type: number - name: V78 type: number - name: V79 type: number - name: V80 type: number - name: V81 type: number - name: V82 type: number - name: V83 type: number - name: V84 type: number - name: V85 type: number - name: V86 type: number - name: V87 type: number - name: V88 type: number - name: V89 type: number - name: V90 type: number - name: V91 type: number - name: V92 type: number - name: V93 type: number - name: V94 type: number - name: V95 type: number - name: V96 type: number - name: V97 type: number - name: V98 type: number - name: V99 type: number - name: V100 type: number - name: V101 type: number - name: V102 type: number - name: V103 type: number - name: V104 type: number - name: V105 type: number - name: V106 type: number - name: V107 type: number - name: V108 type: number - name: V109 type: number - name: V110 type: number - name: V111 type: number - name: V112 type: number - name: V113 type: number - name: V114 type: number - name: V115 type: number - name: V116 type: number - name: V117 type: number - name: V118 type: number - name: V119 type: number - name: V120 type: number - name: V121 type: number - name: V122 type: number - name: V123 type: number - name: V124 type: number - name: V125 type: number - name: V126 type: number - name: V127 type: number - name: V128 type: number - name: V129 type: number - name: V130 type: number - name: V131 type: number - name: V132 type: number - name: V133 type: number - name: V134 type: number - name: V135 type: number - name: V136 type: number - name: V137 type: number - name: V138 type: number - name: V139 type: number - name: V140 type: number - name: V141 type: number - name: V142 type: number - name: V143 type: number - name: V144 type: number - name: V145 type: number - name: V146 type: number - name: V147 type: number - name: V148 type: number - name: V149 type: number - name: V150 type: number - name: V151 type: number - name: V152 type: number - name: V153 type: number - name: V154 type: number - name: V155 type: number - name: V156 type: number - name: V157 type: number - name: V158 type: number - name: V159 type: number - name: V160 type: number - name: V161 type: number - name: V162 type: number - name: V163 type: number - name: V164 type: number - name: V165 type: number - name: V166 type: number - name: V167 type: number - name: V168 type: number - name: V169 type: number - name: V170 type: number - name: V171 type: number - name: V172 type: number - name: V173 type: number - name: V174 type: number - name: V175 type: number - name: V176 type: number - name: V177 type: number - name: V178 type: number - name: V179 type: number - name: V180 type: number - name: V181 type: number - name: V182 type: number - name: V183 type: number - name: V184 type: number - name: V185 type: number - name: V186 type: number - name: V187 type: number - name: V188 type: number - name: V189 type: number - name: V190 type: number - name: V191 type: number - name: V192 type: number - name: V193 type: number - name: V194 type: number - name: V195 type: number - name: V196 type: number - name: V197 type: number - name: V198 type: number - name: V199 type: number - name: V200 type: number - name: V201 type: number - name: V202 type: number - name: V203 type: number - name: V204 type: number - name: V205 type: number - name: V206 type: number - name: V207 type: number - name: V208 type: number - name: V209 type: number - name: V210 type: number - name: V211 type: number - name: V212 type: number - name: V213 type: number - name: V214 type: number - name: V215 type: number - name: V216 type: number - name: V217 type: number - name: V218 type: number - name: V219 type: number - name: V220 type: number - name: V221 type: number - name: V222 type: number - name: V223 type: number - name: V224 type: number - name: V225 type: number - name: V226 type: number - name: V227 type: number - name: V228 type: number - name: V229 type: number - name: V230 type: number - name: V231 type: number - name: V232 type: number - name: V233 type: number - name: V234 type: number - name: V235 type: number - name: V236 type: number - name: V237 type: number - name: V238 type: number - name: V239 type: number - name: V240 type: number - name: V241 type: number - name: V242 type: number - name: V243 type: number - name: V244 type: number - name: V245 type: number - name: V246 type: number - name: V247 type: number - name: V248 type: number - name: V249 type: number - name: V250 type: number - name: V251 type: number - name: V252 type: number - name: V253 type: number - name: V254 type: number - name: V255 type: number - name: V256 type: number - name: V257 type: number - name: V258 type: number - name: V259 type: number - name: V260 type: number - name: V261 type: number - name: V262 type: number - name: V263 type: number - name: V264 type: number - name: V265 type: number - name: V266 type: number - name: V267 type: number - name: V268 type: number - name: V269 type: number - name: V270 type: number - name: V271 type: number - name: V272 type: number - name: V273 type: number - name: V274 type: number - name: V275 type: number - name: V276 type: number - name: V277 type: number - name: V278 type: number - name: V279 type: number - name: V280 type: number - name: V281 type: number - name: V282 type: number - name: V283 type: number - name: V284 type: number - name: V285 type: number - name: V286 type: number - name: V287 type: number - name: V288 type: number - name: V289 type: number - name: V290 type: number - name: V291 type: number - name: V292 type: number - name: V293 type: number - name: V294 type: number - name: V295 type: number - name: V296 type: number - name: V297 type: number - name: V298 type: number - name: V299 type: number - name: V300 type: number - name: V301 type: number - name: V302 type: number - name: V303 type: number - name: V304 type: number - name: V305 type: number - name: V306 type: number - name: V307 type: number - name: V308 type: number - name: V309 type: number - name: V310 type: number - name: V311 type: number - name: V312 type: number - name: V313 type: number - name: V314 type: number - name: V315 type: number - name: V316 type: number - name: V317 type: number - name: V318 type: number - name: V319 type: number - name: V320 type: number - name: V321 type: number - name: V322 type: number - name: V323 type: number - name: V324 type: number - name: V325 type: number - name: V326 type: number - name: V327 type: number - name: V328 type: number - name: V329 type: number - name: V330 type: number - name: V331 type: number - name: V332 type: number - name: V333 type: number - name: V334 type: number - name: V335 type: number - name: V336 type: number - name: V337 type: number - name: V338 type: number - name: V339 type: number - name: id_01 type: number - name: id_02 type: number - name: id_03 type: number - name: id_04 type: number - name: id_05 type: number - name: id_06 type: number - name: id_07 type: number - name: id_08 type: number - name: id_09 type: number - name: id_10 type: number - name: id_11 type: number - name: id_12 type: number - name: id_13 type: number - name: id_14 type: number - name: id_15 type: number - name: id_16 type: number - name: id_17 type: number - name: id_18 type: number - name: id_19 type: number - name: id_20 type: number - name: id_21 type: number - name: id_22 type: number - name: id_23 type: number - name: id_24 type: number - name: id_25 type: number - name: id_26 type: number - name: id_27 type: number - name: id_28 type: number - name: id_29 type: number - name: id_30 type: number - name: id_31 type: number - name: id_32 type: number - name: id_33 type: number - name: id_34 type: number - name: id_35 type: number - name: id_36 type: number - name: id_37 type: number - name: id_38 type: number - name: DeviceType type: number - name: DeviceInfo type: number combiner: type: concat num_fc_layers: 3 fc_size: 128 dropout: 0.1 training: batch_size: 256 learning_rate: .001 epochs: 1 ================================================ FILE: ludwig/datasets/model_configs/mercedes_benz_greener_default.yaml ================================================ output_features: - name: y type: number input_features: - name: X0 type: category - name: X1 type: category - name: X2 type: category - name: X3 type: category - name: X4 type: category - name: X5 type: category - name: X6 type: category - name: X8 type: category - name: X10 type: binary - name: X11 type: binary - name: X12 type: binary - name: X13 type: binary - name: X14 type: binary - name: X15 type: binary - name: X16 type: binary - name: X17 type: binary - name: X18 type: binary - name: X19 type: binary - name: X20 type: binary - name: X21 type: binary - name: X22 type: binary - name: X23 type: binary - name: X24 type: binary - name: X26 type: binary - name: X27 type: binary - name: X28 type: binary - name: X29 type: binary - name: X30 type: binary - name: X31 type: binary - name: X32 type: binary - name: X33 type: binary - name: X34 type: binary - name: X35 type: binary - name: X36 type: binary - name: X37 type: binary - name: X38 type: binary - name: X39 type: binary - name: X40 type: binary - name: X41 type: binary - name: X42 type: binary - name: X43 type: binary - name: X44 type: binary - name: X45 type: binary - name: X46 type: binary - name: X47 type: binary - name: X48 type: binary - name: X49 type: binary - name: X50 type: binary - name: X51 type: binary - name: X52 type: binary - name: X53 type: binary - name: X54 type: binary - name: X55 type: binary - name: X56 type: binary - name: X57 type: binary - name: X58 type: binary - name: X59 type: binary - name: X60 type: binary - name: X61 type: binary - name: X62 type: binary - name: X63 type: binary - name: X64 type: binary - name: X65 type: binary - name: X66 type: binary - name: X67 type: binary - name: X68 type: binary - name: X69 type: binary - name: X70 type: binary - name: X71 type: binary - name: X73 type: binary - name: X74 type: binary - name: X75 type: binary - name: X76 type: binary - name: X77 type: binary - name: X78 type: binary - name: X79 type: binary - name: X80 type: binary - name: X81 type: binary - name: X82 type: binary - name: X83 type: binary - name: X84 type: binary - name: X85 type: binary - name: X86 type: binary - name: X87 type: binary - name: X88 type: binary - name: X89 type: binary - name: X90 type: binary - name: X91 type: binary - name: X92 type: binary - name: X93 type: binary - name: X94 type: binary - name: X95 type: binary - name: X96 type: binary - name: X97 type: binary - name: X98 type: binary - name: X99 type: binary - name: X100 type: binary - name: X101 type: binary - name: X102 type: binary - name: X103 type: binary - name: X104 type: binary - name: X105 type: binary - name: X106 type: binary - name: X107 type: binary - name: X108 type: binary - name: X109 type: binary - name: X110 type: binary - name: X111 type: binary - name: X112 type: binary - name: X113 type: binary - name: X114 type: binary - name: X115 type: binary - name: X116 type: binary - name: X117 type: binary - name: X118 type: binary - name: X119 type: binary - name: X120 type: binary - name: X122 type: binary - name: X123 type: binary - name: X124 type: binary - name: X125 type: binary - name: X126 type: binary - name: X127 type: binary - name: X128 type: binary - name: X129 type: binary - name: X130 type: binary - name: X131 type: binary - name: X132 type: binary - name: X133 type: binary - name: X134 type: binary - name: X135 type: binary - name: X136 type: binary - name: X137 type: binary - name: X138 type: binary - name: X139 type: binary - name: X140 type: binary - name: X141 type: binary - name: X142 type: binary - name: X143 type: binary - name: X144 type: binary - name: X145 type: binary - name: X146 type: binary - name: X147 type: binary - name: X148 type: binary - name: X150 type: binary - name: X151 type: binary - name: X152 type: binary - name: X153 type: binary - name: X154 type: binary - name: X155 type: binary - name: X156 type: binary - name: X157 type: binary - name: X158 type: binary - name: X159 type: binary - name: X160 type: binary - name: X161 type: binary - name: X162 type: binary - name: X163 type: binary - name: X164 type: binary - name: X165 type: binary - name: X166 type: binary - name: X167 type: binary - name: X168 type: binary - name: X169 type: binary - name: X170 type: binary - name: X171 type: binary - name: X172 type: binary - name: X173 type: binary - name: X174 type: binary - name: X175 type: binary - name: X176 type: binary - name: X177 type: binary - name: X178 type: binary - name: X179 type: binary - name: X180 type: binary - name: X181 type: binary - name: X182 type: binary - name: X183 type: binary - name: X184 type: binary - name: X185 type: binary - name: X186 type: binary - name: X187 type: binary - name: X189 type: binary - name: X190 type: binary - name: X191 type: binary - name: X192 type: binary - name: X194 type: binary - name: X195 type: binary - name: X196 type: binary - name: X197 type: binary - name: X198 type: binary - name: X199 type: binary - name: X200 type: binary - name: X201 type: binary - name: X202 type: binary - name: X203 type: binary - name: X204 type: binary - name: X205 type: binary - name: X206 type: binary - name: X207 type: binary - name: X208 type: binary - name: X209 type: binary - name: X210 type: binary - name: X211 type: binary - name: X212 type: binary - name: X213 type: binary - name: X214 type: binary - name: X215 type: binary - name: X216 type: binary - name: X217 type: binary - name: X218 type: binary - name: X219 type: binary - name: X220 type: binary - name: X221 type: binary - name: X222 type: binary - name: X223 type: binary - name: X224 type: binary - name: X225 type: binary - name: X226 type: binary - name: X227 type: binary - name: X228 type: binary - name: X229 type: binary - name: X230 type: binary - name: X231 type: binary - name: X232 type: binary - name: X233 type: binary - name: X234 type: binary - name: X235 type: binary - name: X236 type: binary - name: X237 type: binary - name: X238 type: binary - name: X239 type: binary - name: X240 type: binary - name: X241 type: binary - name: X242 type: binary - name: X243 type: binary - name: X244 type: binary - name: X245 type: binary - name: X246 type: binary - name: X247 type: binary - name: X248 type: binary - name: X249 type: binary - name: X250 type: binary - name: X251 type: binary - name: X252 type: binary - name: X253 type: binary - name: X254 type: binary - name: X255 type: binary - name: X256 type: binary - name: X257 type: binary - name: X258 type: binary - name: X259 type: binary - name: X260 type: binary - name: X261 type: binary - name: X262 type: binary - name: X263 type: binary - name: X264 type: binary - name: X265 type: binary - name: X266 type: binary - name: X267 type: binary - name: X268 type: binary - name: X269 type: binary - name: X270 type: binary - name: X271 type: binary - name: X272 type: binary - name: X273 type: binary - name: X274 type: binary - name: X275 type: binary - name: X276 type: binary - name: X277 type: binary - name: X278 type: binary - name: X279 type: binary - name: X280 type: binary - name: X281 type: binary - name: X282 type: binary - name: X283 type: binary - name: X284 type: binary - name: X285 type: binary - name: X286 type: binary - name: X287 type: binary - name: X288 type: binary - name: X289 type: binary - name: X290 type: binary - name: X291 type: binary - name: X292 type: binary - name: X293 type: binary - name: X294 type: binary - name: X295 type: binary - name: X296 type: binary - name: X297 type: binary - name: X298 type: binary - name: X299 type: binary - name: X300 type: binary - name: X301 type: binary - name: X302 type: binary - name: X304 type: binary - name: X305 type: binary - name: X306 type: binary - name: X307 type: binary - name: X308 type: binary - name: X309 type: binary - name: X310 type: binary - name: X311 type: binary - name: X312 type: binary - name: X313 type: binary - name: X314 type: binary - name: X315 type: binary - name: X316 type: binary - name: X317 type: binary - name: X318 type: binary - name: X319 type: binary - name: X320 type: binary - name: X321 type: binary - name: X322 type: binary - name: X323 type: binary - name: X324 type: binary - name: X325 type: binary - name: X326 type: binary - name: X327 type: binary - name: X328 type: binary - name: X329 type: binary - name: X330 type: binary - name: X331 type: binary - name: X332 type: binary - name: X333 type: binary - name: X334 type: binary - name: X335 type: binary - name: X336 type: binary - name: X337 type: binary - name: X338 type: binary - name: X339 type: binary - name: X340 type: binary - name: X341 type: binary - name: X342 type: binary - name: X343 type: binary - name: X344 type: binary - name: X345 type: binary - name: X346 type: binary - name: X347 type: binary - name: X348 type: binary - name: X349 type: binary - name: X350 type: binary - name: X351 type: binary - name: X352 type: binary - name: X353 type: binary - name: X354 type: binary - name: X355 type: binary - name: X356 type: binary - name: X357 type: binary - name: X358 type: binary - name: X359 type: binary - name: X360 type: binary - name: X361 type: binary - name: X362 type: binary - name: X363 type: binary - name: X364 type: binary - name: X365 type: binary - name: X366 type: binary - name: X367 type: binary - name: X368 type: binary - name: X369 type: binary - name: X370 type: binary - name: X371 type: binary - name: X372 type: binary - name: X373 type: binary - name: X374 type: binary - name: X375 type: binary - name: X376 type: binary - name: X377 type: binary - name: X378 type: binary - name: X379 type: binary - name: X380 type: binary - name: X382 type: binary - name: X383 type: binary - name: X384 type: binary - name: X385 type: binary combiner: type: concat num_fc_layers: 3 fc_size: 128 dropout: 0.1 training: batch_size: 256 learning_rate: .001 epochs: 1 ================================================ FILE: ludwig/datasets/model_configs/mnist_default.yaml ================================================ output_features: - name: label type: category input_features: - name: image_path type: image preprocessing: num_processes: 4 encoder: stacked_cnn conv_layers: - num_filters: 32 filter_size: 3 pool_size: 2 pool_stride: 2 - num_filters: 64 filter_size: 3 pool_size: 2 pool_stride: 2 dropout: 0.4 fc_layers: - output_size: 128 dropout: 0.4 trainer: epochs: 1 ================================================ FILE: ludwig/datasets/model_configs/mushroom_edibility_default.yaml ================================================ output_features: - name: class type: category input_features: - name: cap-shape type: category - name: cap-surface type: category - name: cap-color type: category - name: bruises? type: category - name: odor type: category - name: gill-attachment type: category - name: gill-spacing type: category - name: gill-size type: category - name: gill-color type: category - name: stalk-shape type: category - name: stalk-root type: category - name: stalk-surface-above-ring type: category - name: stalk-surface-below-ring type: category - name: stalk-color-above-ring type: category - name: stalk-color-below-ring type: category - name: veil-type type: category - name: veil-color type: category - name: ring-number type: category - name: ring-type type: category - name: spore-print-color type: category - name: population type: category - name: habitat type: category combiner: type: concat num_fc_layers: 3 fc_size: 128 dropout: 0.1 training: batch_size: 256 learning_rate: .001 epochs: 1 ================================================ FILE: ludwig/datasets/model_configs/otto_group_product_default.yaml ================================================ output_features: - name: target type: category input_features: - name: feat_1 type: number - name: feat_2 type: number - name: feat_3 type: number - name: feat_4 type: number - name: feat_5 type: number - name: feat_6 type: number - name: feat_7 type: number - name: feat_8 type: number - name: feat_9 type: number - name: feat_10 type: number - name: feat_11 type: number - name: feat_12 type: number - name: feat_13 type: number - name: feat_14 type: number - name: feat_15 type: number - name: feat_16 type: number - name: feat_17 type: number - name: feat_18 type: number - name: feat_19 type: number - name: feat_20 type: number - name: feat_21 type: number - name: feat_22 type: number - name: feat_23 type: number - name: feat_24 type: number - name: feat_25 type: number - name: feat_26 type: number - name: feat_27 type: number - name: feat_28 type: number - name: feat_29 type: number - name: feat_30 type: number - name: feat_31 type: number - name: feat_32 type: number - name: feat_33 type: number - name: feat_34 type: number - name: feat_35 type: number - name: feat_36 type: number - name: feat_37 type: number - name: feat_38 type: number - name: feat_39 type: number - name: feat_40 type: number - name: feat_41 type: number - name: feat_42 type: number - name: feat_43 type: number - name: feat_44 type: number - name: feat_45 type: number - name: feat_46 type: number - name: feat_47 type: number - name: feat_48 type: number - name: feat_49 type: number - name: feat_50 type: number - name: feat_51 type: number - name: feat_52 type: number - name: feat_53 type: number - name: feat_54 type: number - name: feat_55 type: number - name: feat_56 type: number - name: feat_57 type: number - name: feat_58 type: number - name: feat_59 type: number - name: feat_60 type: number - name: feat_61 type: number - name: feat_62 type: number - name: feat_63 type: number - name: feat_64 type: number - name: feat_65 type: number - name: feat_66 type: number - name: feat_67 type: number - name: feat_68 type: number - name: feat_69 type: number - name: feat_70 type: number - name: feat_71 type: number - name: feat_72 type: number - name: feat_73 type: number - name: feat_74 type: number - name: feat_75 type: number - name: feat_76 type: number - name: feat_77 type: number - name: feat_78 type: number - name: feat_79 type: number - name: feat_80 type: number - name: feat_81 type: number - name: feat_82 type: number - name: feat_83 type: number - name: feat_84 type: number - name: feat_85 type: number - name: feat_86 type: number - name: feat_87 type: number - name: feat_88 type: number - name: feat_89 type: number - name: feat_90 type: number - name: feat_91 type: number - name: feat_92 type: number - name: feat_93 type: number combiner: type: concat num_fc_layers: 3 fc_size: 128 dropout: 0.1 training: batch_size: 256 learning_rate: .001 epochs: 1 ================================================ FILE: ludwig/datasets/model_configs/poker_hand_default.yaml ================================================ output_features: - name: hand type: category input_features: - name: S1 type: category - name: C1 type: category - name: S2 type: category - name: C2 type: category - name: S3 type: category - name: C3 type: category - name: S4 type: category - name: C4 type: category - name: S5 type: category - name: C5 type: category combiner: type: concat num_fc_layers: 3 fc_size: 128 dropout: 0.1 training: batch_size: 256 learning_rate: .001 epochs: 1 ================================================ FILE: ludwig/datasets/model_configs/porto_seguro_safe_driver_default.yaml ================================================ output_features: - name: target type: binary input_features: - name: ps_ind_01 type: number - name: ps_ind_02_cat type: category - name: ps_ind_03 type: number - name: ps_ind_04_cat type: category - name: ps_ind_05_cat type: category - name: ps_ind_06_bin type: binary - name: ps_ind_07_bin type: binary - name: ps_ind_08_bin type: binary - name: ps_ind_09_bin type: binary - name: ps_ind_10_bin type: binary - name: ps_ind_11_bin type: binary - name: ps_ind_12_bin type: binary - name: ps_ind_13_bin type: binary - name: ps_ind_14 type: number - name: ps_ind_15 type: number - name: ps_ind_16_bin type: binary - name: ps_ind_17_bin type: binary - name: ps_ind_18_bin type: binary - name: ps_reg_01 type: number - name: ps_reg_02 type: number - name: ps_reg_03 type: number - name: ps_car_01_cat type: category - name: ps_car_02_cat type: category - name: ps_car_03_cat type: category - name: ps_car_04_cat type: category - name: ps_car_05_cat type: category - name: ps_car_06_cat type: category - name: ps_car_07_cat type: category - name: ps_car_08_cat type: category - name: ps_car_09_cat type: category - name: ps_car_10_cat type: category - name: ps_car_11_cat type: category - name: ps_car_11 type: number - name: ps_car_12 type: number - name: ps_car_13 type: number - name: ps_car_14 type: number - name: ps_car_15 type: number - name: ps_calc_01 type: number - name: ps_calc_02 type: number - name: ps_calc_03 type: number - name: ps_calc_04 type: number - name: ps_calc_05 type: number - name: ps_calc_06 type: number - name: ps_calc_07 type: number - name: ps_calc_08 type: number - name: ps_calc_09 type: number - name: ps_calc_10 type: number - name: ps_calc_11 type: number - name: ps_calc_12 type: number - name: ps_calc_13 type: number - name: ps_calc_14 type: number - name: ps_calc_15_bin type: binary - name: ps_calc_16_bin type: binary - name: ps_calc_17_bin type: binary - name: ps_calc_18_bin type: binary - name: ps_calc_19_bin type: binary - name: ps_calc_20_bin type: binary combiner: type: concat num_fc_layers: 3 fc_size: 128 dropout: 0.1 training: batch_size: 256 learning_rate: .001 epochs: 1 ================================================ FILE: ludwig/datasets/model_configs/synthetic_fraud_default.yaml ================================================ output_features: - name: isFraud type: binary input_features: - name: step type: number - name: type type: category - name: amount type: number - name: oldbalanceOrg type: number - name: newbalanceOrig type: number - name: oldbalanceDest type: number - name: newbalanceDest type: number combiner: type: concat num_fc_layers: 3 fc_size: 128 dropout: 0.1 training: batch_size: 256 learning_rate: .001 epochs: 1 ================================================ FILE: ludwig/datasets/model_configs/titanic_default.yaml ================================================ output_features: - name: Survived type: binary input_features: - name: Pclass type: category - name: Sex type: category - name: Age type: number preprocessing: missing_value_strategy: fill_with_mean - name: SibSp type: number - name: Parch type: number - name: Fare type: number preprocessing: missing_value_strategy: fill_with_mean - name: Embarked type: category training: batch_size: 256 epochs: 1 ================================================ FILE: ludwig/datasets/utils.py ================================================ import os from functools import lru_cache import yaml from ludwig.api_annotations import PublicAPI from ludwig.datasets import model_configs @PublicAPI def model_configs_for_dataset(dataset_name: str) -> dict[str, dict]: """Returns a dictionary of built-in model configs for the specified dataset. Maps config name to ludwig config dict. """ return _get_model_configs(dataset_name) @lru_cache(maxsize=3) def _get_model_configs(dataset_name: str) -> dict[str, dict]: """Returns all model configs for the specified dataset. Model configs are named _.yaml """ import importlib.resources config_filenames = [ f.name for f in importlib.resources.files(model_configs).iterdir() if f.name.endswith(".yaml") and f.name.startswith(dataset_name) ] configs = {} for config_filename in config_filenames: basename = os.path.splitext(config_filename)[0] config_name = basename[len(dataset_name) + 1 :] configs[config_name] = _load_model_config(config_filename) return configs def _load_model_config(model_config_filename: str): """Loads a model config.""" model_config_path = os.path.join(os.path.dirname(model_configs.__file__), model_config_filename) with open(model_config_path) as f: return yaml.safe_load(f) ================================================ FILE: ludwig/decoders/__init__.py ================================================ # register all decoders import ludwig.decoders.generic_decoders # noqa import ludwig.decoders.image_decoders # noqa import ludwig.decoders.llm_decoders # noqa import ludwig.decoders.sequence_decoders # noqa import ludwig.decoders.sequence_tagger # noqa ================================================ FILE: ludwig/decoders/base.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 2020 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. # ============================================================================== from abc import ABC, abstractmethod from ludwig.api_annotations import DeveloperAPI from ludwig.utils.torch_utils import LudwigModule @DeveloperAPI class Decoder(LudwigModule, ABC): @abstractmethod def forward(self, inputs, mask=None): raise NotImplementedError @property def name(self): return self.__class__.__name__ ================================================ FILE: ludwig/decoders/generic_decoders.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import logging from functools import partial import torch from ludwig.api_annotations import DeveloperAPI from ludwig.constants import BINARY, CATEGORY, CATEGORY_DISTRIBUTION, LOSS, NUMBER, SET, TIMESERIES, TYPE, VECTOR from ludwig.decoders.base import Decoder from ludwig.decoders.registry import register_decoder from ludwig.schema.decoders.base import ClassifierConfig, PassthroughDecoderConfig, ProjectorConfig, RegressorConfig from ludwig.utils.torch_utils import Dense, get_activation logger = logging.getLogger(__name__) @DeveloperAPI # TODO(Arnav): Re-enable once we add DotProduct Combiner: https://github.com/ludwig-ai/ludwig/issues/3150 # @register_decoder("passthrough", [BINARY, CATEGORY, NUMBER, SET, VECTOR, SEQUENCE, TEXT]) class PassthroughDecoder(Decoder): def __init__(self, input_size: int = 1, num_classes: int = None, decoder_config=None, **kwargs): super().__init__() self.config = decoder_config logger.debug(f" {self.name}") self.input_size = input_size self.num_classes = num_classes def forward(self, inputs, **kwargs): return inputs @staticmethod def get_schema_cls(): return PassthroughDecoderConfig @property def input_shape(self) -> torch.Size: return torch.Size([self.input_size]) @property def output_shape(self) -> torch.Size: return self.input_shape @DeveloperAPI @register_decoder("regressor", [BINARY, NUMBER]) class Regressor(Decoder): def __init__( self, input_size, use_bias=True, weights_initializer="xavier_uniform", bias_initializer="zeros", decoder_config=None, **kwargs, ): super().__init__() self.config = decoder_config logger.debug(f" {self.name}") logger.debug(" Dense") self.dense = Dense( input_size=input_size, output_size=1, use_bias=use_bias, weights_initializer=weights_initializer, bias_initializer=bias_initializer, ) @staticmethod def get_schema_cls(): return RegressorConfig @property def input_shape(self): return self.dense.input_shape def forward(self, inputs, **kwargs): return self.dense(inputs) @DeveloperAPI @register_decoder("projector", [VECTOR, TIMESERIES]) class Projector(Decoder): def __init__( self, input_size, output_size, use_bias=True, weights_initializer="xavier_uniform", bias_initializer="zeros", activation=None, multiplier=1.0, clip=None, decoder_config=None, **kwargs, ): super().__init__() self.config = decoder_config logger.debug(f" {self.name}") logger.debug(" Dense") self.dense = Dense( input_size=input_size, output_size=output_size, use_bias=use_bias, weights_initializer=weights_initializer, bias_initializer=bias_initializer, ) self.activation = get_activation(activation) self.multiplier = multiplier if clip is not None: if isinstance(clip, (list, tuple)) and len(clip) == 2: self.clip = partial(torch.clip, min=clip[0], max=clip[1]) else: raise ValueError( "The clip parameter of {} is {}. " "It must be a list or a tuple of length 2.".format(self.feature_name, self.clip) ) else: self.clip = None @staticmethod def get_schema_cls(): return ProjectorConfig @property def input_shape(self): return self.dense.input_shape def forward(self, inputs, **kwargs): values = self.activation(self.dense(inputs)) * self.multiplier if self.clip: values = self.clip(values) return values @DeveloperAPI @register_decoder("classifier", [CATEGORY, CATEGORY_DISTRIBUTION, SET]) class Classifier(Decoder): def __init__( self, input_size, num_classes, use_bias=True, weights_initializer="xavier_uniform", bias_initializer="zeros", decoder_config=None, **kwargs, ): super().__init__() self.config = decoder_config logger.debug(f" {self.name}") logger.debug(" Dense") self.num_classes = num_classes self.dense = Dense( input_size=input_size, output_size=num_classes, use_bias=use_bias, weights_initializer=weights_initializer, bias_initializer=bias_initializer, ) self.sampled_loss = False if LOSS in kwargs and TYPE in kwargs[LOSS] and kwargs[LOSS][TYPE] is not None: self.sampled_loss = kwargs[LOSS][TYPE].startswith("sampled") @staticmethod def get_schema_cls(): return ClassifierConfig @property def input_shape(self): return self.dense.input_shape def forward(self, inputs, **kwargs): return self.dense(inputs) ================================================ FILE: ludwig/decoders/image_decoders.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Aizen Corp. # # 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. # ============================================================================== import logging import torch from ludwig.api_annotations import DeveloperAPI from ludwig.constants import ENCODER_OUTPUT_STATE, HIDDEN, IMAGE, LOGITS, PREDICTIONS from ludwig.decoders.base import Decoder from ludwig.decoders.registry import register_decoder from ludwig.modules.convolutional_modules import UNetUpStack from ludwig.schema.decoders.image_decoders import ImageDecoderConfig, UNetDecoderConfig logger = logging.getLogger(__name__) @DeveloperAPI @register_decoder("unet", IMAGE) class UNetDecoder(Decoder): def __init__( self, input_size: int, height: int, width: int, num_channels: int = 1, num_classes: int = 2, conv_norm: str | None = None, decoder_config=None, **kwargs, ): super().__init__() self.config = decoder_config self.num_classes = num_classes logger.debug(f" {self.name}") if num_classes < 2: raise ValueError(f"Invalid `num_classes` {num_classes} for unet decoder") if height % 16 or width % 16: raise ValueError(f"Invalid `height` {height} or `width` {width} for unet decoder") self.unet = UNetUpStack( img_height=height, img_width=width, out_channels=num_classes, norm=conv_norm, ) self.input_reshape = list(self.unet.input_shape) self.input_reshape.insert(0, -1) self._output_shape = (height, width) def forward(self, combiner_outputs: dict[str, torch.Tensor], target: torch.Tensor): hidden = combiner_outputs[HIDDEN] skips = combiner_outputs[ENCODER_OUTPUT_STATE] # unflatten combiner outputs hidden = hidden.reshape(self.input_reshape) logits = self.unet(hidden, skips) predictions = logits.argmax(dim=1).squeeze(1).byte() return {LOGITS: logits, PREDICTIONS: predictions} def get_prediction_set(self): return {LOGITS, PREDICTIONS} @staticmethod def get_schema_cls() -> type[ImageDecoderConfig]: return UNetDecoderConfig @property def output_shape(self) -> torch.Size: return torch.Size(self._output_shape) @property def input_shape(self) -> torch.Size: return self.unet.input_shape ================================================ FILE: ludwig/decoders/llm_decoders.py ================================================ import logging import re from typing import Any import torch from ludwig.api_annotations import DeveloperAPI from ludwig.constants import CATEGORY, LOGITS, PREDICTIONS, PROBABILITIES, TEXT from ludwig.decoders.base import Decoder from ludwig.decoders.registry import register_decoder from ludwig.decoders.utils import extract_generated_tokens from ludwig.schema.decoders.llm_decoders import CategoryExtractorDecoderConfig, TextExtractorDecoderConfig from ludwig.utils.strings_utils import get_tokenizer logger = logging.getLogger(__name__) # TODO(Arnav): Refactor to split into strategies like splitters class Matcher: def __init__(self, match: dict[str, dict[str, Any]]): self.match = match def contains(self, decoded_input: str, value: str) -> bool: return value in decoded_input def regex(self, decoded_input: str, regex_pattern: str) -> bool: """Perform a regex match on a given text using a specified regex pattern. Parameters: text (str): The text to perform the match on. regex_pattern (str): The regex pattern to use for the match. Returns: A list of match objects. """ # Compile the regex pattern matches = [] try: regex = re.compile(regex_pattern) # Perform the match matches = regex.findall(decoded_input) except Exception: logger.warning(f"Regex pattern {regex_pattern} could not be compiled.") # If there is a match, matches is a non-empty list, so we can use this # to infer if there was a match or not and return a bool return len(matches) > 0 def __call__(self, decoded_input: str) -> str | None: # Greedy match on first label that matches the input for label, label_def in self.match.items(): label_def_type = label_def["type"] label_def_value = label_def["value"] if label_def_type == "contains": is_match = self.contains(decoded_input, label_def_value) elif label_def_type == "regex": is_match = self.regex(decoded_input, label_def_value) else: raise ValueError( f"{label_def_type} is not a valid match `type`. Ludwig " "currently supports `contains` and `regex` match types." ) if is_match: return label return None @DeveloperAPI @register_decoder("text_extractor", [TEXT]) class TextExtractorDecoder(Decoder): def __init__( self, input_size: int, decoder_config=None, **kwargs, ): super().__init__() self.config = decoder_config self.input_size = input_size # Tokenizer self.tokenizer_type = self.config.tokenizer self.pretrained_model_name_or_path = self.config.pretrained_model_name_or_path self.vocab_file = self.config.vocab_file # Load tokenizer required for decoding the output from the generate # function of the text input feature for LLMs. self.tokenizer = get_tokenizer(self.tokenizer_type, self.vocab_file, self.pretrained_model_name_or_path) if hasattr(self.tokenizer, "tokenizer"): # Transformer Tokenizers self.tokenizer_vocab_size = self.tokenizer.tokenizer.vocab_size else: # TorchText Tokenizers self.tokenizer_vocab_size = len(self.tokenizer.vocab) # Maximum number of new tokens that will be generated # TODO(geoffrey): figure out where self.max_sequence_length is used– if not used, we might consider removing it. # It's confusing to have both this and `max_new_tokens` as a mandatory param in the `forward` function. self.max_sequence_length = self.config.max_new_tokens @staticmethod def get_schema_cls(): return TextExtractorDecoderConfig @property def input_shape(self): return self.input_size def get_prediction_set(self): return {LOGITS, PREDICTIONS, PROBABILITIES} def forward(self, inputs: list[torch.Tensor], input_lengths: list[int], max_new_tokens: int): # Extract the sequences tensor from the LLMs forward pass generated_outputs = extract_generated_tokens( raw_generated_output_sequences=inputs, input_lengths=input_lengths, max_new_tokens=max_new_tokens, pad_sequence=True, ) # Stack the predictions for each example in the batch. The padding should ensure they are all the same shape. for output in generated_outputs: if output.shape[0] > max_new_tokens: raise ValueError( f"Output {output} is longer than the max_new_tokens {max_new_tokens} during decoding. " f"This should never happen– please file an issue on GitHub." ) generated_outputs = torch.stack(generated_outputs, dim=0) outputs_device = generated_outputs.device return { PREDICTIONS: generated_outputs, # TODO(Arnav): Add support for probabilities and logits PROBABILITIES: torch.zeros((len(generated_outputs), max_new_tokens, self.tokenizer_vocab_size)).to( outputs_device ), LOGITS: torch.zeros((len(generated_outputs), max_new_tokens, self.tokenizer_vocab_size)).to(outputs_device), } @DeveloperAPI @register_decoder("category_extractor", [CATEGORY]) class CategoryExtractorDecoder(Decoder): def __init__( self, decoder_config=None, **kwargs, ): super().__init__() self.config = decoder_config self.input_size = self.config.input_size self.fallback_label = self.config.fallback_label self.str2idx = self.config.str2idx self.vocab_size = len(self.config.str2idx) # Create Matcher object to perform matching on the decoded output self.matcher = Matcher(self.config.match) # Tokenizer self.tokenizer_type = self.config.tokenizer self.pretrained_model_name_or_path = self.config.pretrained_model_name_or_path self.vocab_file = self.config.vocab_file # Load tokenizer required for decoding the output from the generate # function of the text input feature for LLMs. self.tokenizer = get_tokenizer(self.tokenizer_type, self.vocab_file, self.pretrained_model_name_or_path) @staticmethod def get_schema_cls(): return CategoryExtractorDecoderConfig @property def input_shape(self): return self.input_size def get_prediction_set(self): return {LOGITS, PREDICTIONS, PROBABILITIES} def forward(self, inputs: list[torch.Tensor], input_lengths: list[int], max_new_tokens: int): # Extract the sequences tensor from the LLMs forward pass generated_outputs = extract_generated_tokens( raw_generated_output_sequences=inputs, input_lengths=input_lengths, max_new_tokens=max_new_tokens, pad_sequence=False, ) outputs_device = generated_outputs[0].device # Decode generated outputs from the LLM's generate function. decoded_outputs = self.tokenizer.tokenizer.batch_decode(generated_outputs, skip_special_tokens=True) # Parse labels based on matching criteria and return probability vectors matched_labels = [] probabilities = [] logits = [] for output in decoded_outputs: output = output.lower() # Convert to lowercase for matching matched_label = self.matcher(output) idx = self.str2idx[matched_label] if matched_label in self.str2idx else self.str2idx[self.fallback_label] # Append the index of the matched label matched_labels.append(idx) # Append the probability vector for the matched label probability_vec = [0] * self.vocab_size probability_vec[idx] = 1 probabilities.append(probability_vec) # TODO(Arnav): Figure out how to compute logits. For now, we return # a tensor of zeros. logits.append([0] * self.vocab_size) return { PREDICTIONS: torch.tensor(matched_labels, device=outputs_device), PROBABILITIES: torch.tensor(probabilities, dtype=torch.float32, device=outputs_device), LOGITS: torch.tensor(logits, dtype=torch.float32, device=outputs_device), } ================================================ FILE: ludwig/decoders/registry.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.decoders.base import Decoder from ludwig.utils.registry import Registry _decoder_registry = Registry() @DeveloperAPI def get_decoder_registry() -> Registry: return _decoder_registry @DeveloperAPI def register_decoder(name: str, features: str | list[str]): if isinstance(features, str): features = [features] def wrap(cls): for feature in features: feature_registry = get_decoder_registry().get(feature, {}) feature_registry[name] = cls get_decoder_registry()[feature] = feature_registry return cls return wrap @DeveloperAPI def get_decoder_cls(feature: str, name: str) -> type[Decoder]: return get_decoder_registry()[feature][name] @DeveloperAPI def get_decoder_classes(feature: str) -> dict[str, type[Decoder]]: return get_decoder_registry()[feature] ================================================ FILE: ludwig/decoders/sequence_decoder_utils.py ================================================ """Utility functions related to sequence decoders.""" import torch from ludwig.constants import ENCODER_OUTPUT_STATE, HIDDEN from ludwig.modules.reduction_modules import SequenceReducer def repeat_2D_tensor(tensor, k): """Repeats a 2D-tensor k times over the first dimension. For example: Input: Tensor of [batch_size, state_size], k=2 Output: Tensor of [k, batch_size, state_size] """ if len(tensor.size()) > 2: raise ValueError("Cannot repeat a non-2D tensor with this method.") return tensor.repeat(k, 1, 1) def get_rnn_init_state( combiner_outputs: dict[str, torch.Tensor], sequence_reducer: SequenceReducer, num_layers: int ) -> torch.Tensor: """Computes the hidden state that the RNN decoder should start with. Args: combiner_outputs: Dictionary of tensors from the outputs of the combiner and other output features. sequence_reducer: SequenceReducer to reduce rank-3 to rank-2. num_layers: Number of layers the decoder uses. Returns: Tensor of [num_layers, batch_size, hidden_size]. """ if ENCODER_OUTPUT_STATE not in combiner_outputs: # Use the combiner's hidden state. encoder_output_state = combiner_outputs[HIDDEN] else: # Use the encoder's output state. encoder_output_state = combiner_outputs[ENCODER_OUTPUT_STATE] if isinstance(encoder_output_state, tuple): if len(encoder_output_state) == 2: # LSTM encoder. Use the hidden state and ignore the cell state. encoder_output_state = encoder_output_state[0] elif len(encoder_output_state) == 4: # Bi-directional LSTM encoder. Use the average of hidden states and ignore cell state. encoder_output_state = torch.mean([encoder_output_state[0], encoder_output_state[2]]) else: raise ValueError( f"Invalid sequence decoder inputs with keys: {combiner_outputs.keys()} with extracted encoder " + f"state: {encoder_output_state.size()} that was invalid. Please double check the compatibility " + "of your encoder and decoder." ) if len(encoder_output_state.size()) > 3: raise ValueError("Init state for RNN decoders only works for 1d or 2d tensors (encoder_output).") if len(encoder_output_state.size()) == 3: # Reduce to [batch_size, hidden_size]. encoder_output_state = sequence_reducer(encoder_output_state) return repeat_2D_tensor(encoder_output_state, num_layers) def get_lstm_init_state( combiner_outputs: dict[str, torch.Tensor], sequence_reducer: SequenceReducer, num_layers: int ) -> tuple[torch.Tensor, torch.Tensor]: """Returns the states that the LSTM decoder should start with. Args: combiner_outputs: Dictionary of tensors from the outputs of the combiner and other output features. sequence_reducer: SequenceReducer to reduce rank-3 to rank-2. num_layers: Number of layers the decoder uses. Returns: Tuple of 2 tensors (decoder hidden state, decoder cell state), each [num_layers, batch_size, hidden_size]. """ if ENCODER_OUTPUT_STATE not in combiner_outputs: # Use the combiner's hidden state. decoder_hidden_state = combiner_outputs[HIDDEN] decoder_cell_state = torch.clone(decoder_hidden_state) else: # Use the encoder's output state. encoder_output_state = combiner_outputs[ENCODER_OUTPUT_STATE] if not isinstance(encoder_output_state, tuple): decoder_hidden_state = encoder_output_state decoder_cell_state = decoder_hidden_state else: if len(encoder_output_state) == 2: # The encoder was probably an LSTM. decoder_hidden_state, decoder_cell_state = encoder_output_state elif len(encoder_output_state) == 4: # The encoder was probably a bi-LSTM. # Use the average of the encoder's hidden states for hidden state. # Use the average of the encoder's cell states for cell state. decoder_hidden_state = torch.mean([encoder_output_state[0], encoder_output_state[2]]) decoder_cell_state = torch.mean([encoder_output_state[1], encoder_output_state[3]]) else: raise ValueError( f"Invalid sequence decoder inputs with keys: {combiner_outputs.keys()} with extracted encoder " + f"state: {encoder_output_state} that was invalid. Please double check the compatibility of your " + "encoder and decoder." ) # Check rank and reduce if necessary. if len(decoder_hidden_state.size()) > 3 or len(decoder_cell_state.size()) > 3: raise ValueError( f"Invalid sequence decoder inputs with keys: {combiner_outputs.keys()} with extracted encoder " + f"state: {decoder_hidden_state.size()} that was invalid. Please double check the compatibility " + "of your encoder and decoder." ) if len(decoder_hidden_state.size()) == 3: decoder_hidden_state = sequence_reducer(decoder_hidden_state) if len(decoder_cell_state.size()) == 3: decoder_cell_state = sequence_reducer(decoder_cell_state) # Repeat over the number of layers. return repeat_2D_tensor(decoder_hidden_state, num_layers), repeat_2D_tensor(decoder_cell_state, num_layers) ================================================ FILE: ludwig/decoders/sequence_decoders.py ================================================ # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import logging import torch import torch.nn as nn from ludwig.api_annotations import DeveloperAPI from ludwig.constants import LOGITS, PREDICTIONS, PROBABILITIES, SEQUENCE, TEXT from ludwig.decoders.base import Decoder from ludwig.decoders.registry import register_decoder from ludwig.decoders.sequence_decoder_utils import get_lstm_init_state, get_rnn_init_state from ludwig.modules.reduction_modules import SequenceReducer from ludwig.schema.decoders.sequence_decoders import SequenceGeneratorDecoderConfig from ludwig.utils import strings_utils logger = logging.getLogger(__name__) @DeveloperAPI class RNNDecoder(nn.Module): """GRU or RNN-based decoder.""" def __init__(self, hidden_size: int, vocab_size: int, cell_type: str, num_layers: int = 1): super().__init__() self.hidden_size = hidden_size self.vocab_size = vocab_size self.embedding = nn.Embedding(vocab_size, hidden_size) if cell_type == "gru": self.rnn = nn.GRU(hidden_size, hidden_size, num_layers=num_layers, batch_first=True) else: self.rnn = nn.RNN(hidden_size, hidden_size, num_layers=num_layers, batch_first=True) self.out = nn.Linear(hidden_size, vocab_size) # Have the embedding and projection share weights. # This is a trick used by the Transformer, and seems to attain better loss. # See section 3.4 of https://arxiv.org/pdf/1706.03762.pdf. self.out.weight = self.embedding.weight def forward(self, input: torch.Tensor, hidden: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: """Runs a single decoding time step. Modeled off of https://pytorch.org/tutorials/intermediate/seq2seq_translation_tutorial.html. Args: input: [batch_size] tensor with the previous step's predicted symbol. hidden: [batch_size, hidden_size] tensor with the previous step's hidden state. Returns: Tuple of two tensors: - output: [batch_size, 1, vocab_size] tensor with the logits. - hidden: [num_layers, batch_size, hidden_size] tensor with the hidden state for the next time step. """ # Unsqueeze predicted tokens. input = input.unsqueeze(1).to(torch.int) output = self.embedding(input) output, hidden = self.rnn(output, hidden) output_logits = self.out(output) return output_logits, hidden @DeveloperAPI class LSTMDecoder(nn.Module): """LSTM-based decoder.""" def __init__(self, hidden_size: int, vocab_size: int, num_layers: int = 1): super().__init__() self.hidden_size = hidden_size self.vocab_size = vocab_size self.embedding = nn.Embedding(vocab_size, hidden_size) self.lstm = nn.LSTM(hidden_size, hidden_size, batch_first=True, num_layers=num_layers) self.out = nn.Linear(hidden_size, vocab_size) # Have the embedding and projection share weights. # This is a trick used by the Transformer, and seems to attain better loss. # See section 3.4 of https://arxiv.org/pdf/1706.03762.pdf. self.out.weight = self.embedding.weight def forward( self, input: torch.Tensor, hidden_state: torch.Tensor, cell_state: torch.Tensor ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: """Runs a single decoding time step. Modeled off of https://pytorch.org/tutorials/intermediate/seq2seq_translation_tutorial.html. Args: input: [batch_size] tensor with the previous step's predicted symbol. hidden_state: [batch_size, hidden_size] tensor with the previous step's hidden state. cell_state: [batch_size, hidden_size] tensor with the previous step's cell state. Returns: Tuple of 3 tensors: - output: [batch_size, vocab_size] tensor with the logits. - hidden_state: [batch_size, hidden_size] tensor with the hidden state for the next time step. - cell_state: [batch_size, hidden_size] tensor with the cell state for the next time step. """ # Unsqueeze predicted tokens. input = input.unsqueeze(1).to(torch.int) output = self.embedding(input) output, (hidden_state, cell_state) = self.lstm(output, (hidden_state, cell_state)) output_logits = self.out(output) return output_logits, hidden_state, cell_state @DeveloperAPI class SequenceRNNDecoder(nn.Module): """RNN-based decoder over multiple time steps.""" def __init__( self, hidden_size: int, vocab_size: int, max_sequence_length: int, cell_type: str, num_layers: int = 1, reduce_input="sum", ): super().__init__() self.hidden_size = hidden_size self.vocab_size = vocab_size self.rnn_decoder = RNNDecoder(hidden_size, vocab_size, cell_type, num_layers=num_layers) self.max_sequence_length = max_sequence_length self.reduce_sequence = SequenceReducer(reduce_mode=reduce_input) self.num_layers = num_layers self.register_buffer("logits", torch.zeros([max_sequence_length, vocab_size])) self.register_buffer("decoder_input", torch.Tensor([strings_utils.SpecialSymbol.START.value])) def forward(self, combiner_outputs: dict[str, torch.Tensor], target: torch.Tensor): """Runs max_sequence_length RNN decoding time steps. Args: combiner_outputs: Dictionary of tensors from the outputs of the combiner and other output features. target: Tensor [batch_size, max_sequence_length] with target symbols. Returns: Tensor of logits [batch_size, max_sequence_length, vocab_size]. """ # Prepare the encoder output state. decoder_hidden = get_rnn_init_state(combiner_outputs, self.reduce_sequence, self.num_layers) batch_size = decoder_hidden.size()[1] # Tensor to store decoder output logits. logits = self.logits.unsqueeze(0).repeat(batch_size, 1, 1) # Initialize the decoder with start symbols. decoder_input = self.decoder_input.repeat(batch_size) # Unsqueeze to account for extra multilayer dimension. # decoder_hidden = encoder_output_state.unsqueeze(0) # Decode until max length. for di in range(self.max_sequence_length): decoder_output, decoder_hidden = self.rnn_decoder(decoder_input, decoder_hidden) # decoder_output: [batch_size, 1, vocab_size] # Squeeze out the multilayer dimension and save logits. logits[:, di, :] = decoder_output.squeeze(1) # Determine inputs for next time step. # Using teacher forcing causes the model to converge faster but when the trained network is exploited, it # may be unstable: http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.378.4095&rep=rep1&type=pdf. # TODO: Use a configurable ratio for how often to use teacher forcing during training. if target is None: _, topi = decoder_output.topk(1) # Squeeze out multilayer and vocabulary dimensions. decoder_input = topi.squeeze(1).squeeze(1).detach() # detach from history as input else: # Teacher forcing. decoder_input = target[:, di] return logits @DeveloperAPI class SequenceLSTMDecoder(nn.Module): """LSTM-based decoder over multiple time steps.""" def __init__( self, hidden_size: int, vocab_size: int, max_sequence_length: int, reduce_input: str = "sum", num_layers: int = 1, ): super().__init__() self.hidden_size = hidden_size self.vocab_size = vocab_size self.lstm_decoder = LSTMDecoder(hidden_size, vocab_size, num_layers) self.max_sequence_length = max_sequence_length self.reduce_sequence = SequenceReducer(reduce_mode=reduce_input) self.num_layers = num_layers self.register_buffer("logits", torch.zeros([max_sequence_length, vocab_size])) self.register_buffer("decoder_input", torch.Tensor([strings_utils.SpecialSymbol.START.value])) def forward(self, combiner_outputs: dict[str, torch.Tensor], target: torch.Tensor) -> torch.Tensor: """Runs max_sequence_length LSTM decoding time steps. Args: combiner_outputs: Dictionary of tensors from the outputs of the combiner and other output features. target: Tensor [batch_size, max_sequence_length] with target symbols. Returns: Tensor of logits [batch_size, max_sequence_length, vocab_size]. """ # Prepare the decoder initial state. decoder_hidden, decoder_cell_state = get_lstm_init_state( combiner_outputs, self.reduce_sequence, self.num_layers ) batch_size = decoder_hidden.size()[1] # Initialize the decoder with start symbols. decoder_input = self.decoder_input.repeat(batch_size) # Tensor to store decoder output logits. logits = self.logits.unsqueeze(0).repeat(batch_size, 1, 1) # Decode until max length. for di in range(self.max_sequence_length): decoder_output, decoder_hidden, decoder_cell_state = self.lstm_decoder( decoder_input, decoder_hidden, decoder_cell_state ) # decoder_output: [batch_size, 1, vocab_size] # Squeeze out the multilayer dimension and save logits. logits[:, di, :] = decoder_output.squeeze(1) # Determine inputs for next time step. # Using teacher forcing causes the model to converge faster but when the trained network is exploited, it # may be unstable: http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.378.4095&rep=rep1&type=pdf. # TODO: Use a configurable ratio for how often to use teacher forcing during training. if target is None: _, topi = decoder_output.topk(1) # Squeeze out multilayer and vocabulary dimensions. decoder_input = topi.squeeze(1).squeeze(1).detach() # detach from history as input else: # Teacher forcing. decoder_input = target[:, di] return logits @DeveloperAPI @register_decoder("generator", [SEQUENCE, TEXT]) class SequenceGeneratorDecoder(Decoder): """Dispatcher for different sequence generator decoders.""" def __init__( self, vocab_size: int, max_sequence_length: int, cell_type: str = "gru", input_size: int = 256, reduce_input: str = "sum", num_layers: int = 1, decoder_config=None, **kwargs, ): """ Args: vocab_size: Vocab size. max_sequence_length: Maximum sequence length. cell_type: Type of RNN cell to use. 'rnn', 'gru', or 'lstm'. input_size: Size of incoming combiner output. reduce_input: Mode with which to reduce incoming combiner output, if needed. num_layers: Number of layers for the RNN deecoders. """ super().__init__() self.config = decoder_config self.vocab_size = vocab_size self.input_size = input_size self.max_sequence_length = max_sequence_length if cell_type == "lstm": self.rnn_decoder = SequenceLSTMDecoder( hidden_size=input_size, vocab_size=vocab_size, max_sequence_length=max_sequence_length, reduce_input=reduce_input, num_layers=num_layers, ) else: self.rnn_decoder = SequenceRNNDecoder( hidden_size=input_size, vocab_size=vocab_size, max_sequence_length=max_sequence_length, cell_type=cell_type, reduce_input=reduce_input, num_layers=num_layers, ) def forward( self, combiner_outputs: dict[str, torch.Tensor], target: torch.Tensor = None ) -> dict[str, torch.Tensor]: """Decodes combiner_outputs into a sequence. Args: combiner_outputs: Dictionary of tensors from the outputs of the combiner and other output features. target: Tensor [batch_size, max_sequence_length] with target symbols. Returns: Dictionary of tensors of logits [batch_size, max_sequence_length, vocab_size]. """ logits = self.rnn_decoder(combiner_outputs, target) return {LOGITS: logits} def get_prediction_set(self): return {LOGITS, PREDICTIONS, PROBABILITIES} @staticmethod def get_schema_cls(): return SequenceGeneratorDecoderConfig @property def input_shape(self): # Dummy implementation. return torch.Size([1]) @property def output_shape(self): return torch.Size([self.max_sequence_length, self.vocab_size]) ================================================ FILE: ludwig/decoders/sequence_tagger.py ================================================ import logging import torch from ludwig.api_annotations import DeveloperAPI from ludwig.constants import HIDDEN, LOGITS, PREDICTIONS, PROBABILITIES, SEQUENCE, TEXT from ludwig.decoders.base import Decoder from ludwig.decoders.registry import register_decoder from ludwig.modules.attention_modules import MultiHeadSelfAttention from ludwig.schema.decoders.sequence_decoders import SequenceTaggerDecoderConfig from ludwig.utils.torch_utils import Dense logger = logging.getLogger(__name__) @DeveloperAPI @register_decoder("tagger", [SEQUENCE, TEXT]) class SequenceTaggerDecoder(Decoder): def __init__( self, input_size: int, vocab_size: int, max_sequence_length: int, use_attention: bool = False, use_bias: bool = True, attention_embedding_size: int = 256, attention_num_heads: int = 8, decoder_config=None, **kwargs, ): super().__init__() self.config = decoder_config self.vocab_size = vocab_size self.max_sequence_length = max_sequence_length self.input_size = input_size self.use_attention = use_attention if use_attention: logger.debug(" MultiHeadSelfAttention") self.self_attention = MultiHeadSelfAttention( input_size=input_size, hidden_size=attention_embedding_size, num_heads=attention_num_heads ) # Adjust the input size to the final projection layer. input_size = self.self_attention.output_shape[0] self.projection_layer = Dense(input_size=input_size, output_size=vocab_size, use_bias=use_bias) def forward(self, inputs: dict[str, torch.Tensor], target: torch.Tensor = None) -> dict[str, torch.Tensor]: """Decodes the inputs into a sequence. Args: inputs: Dictionary of tensors from the outputs of the combiner and other output features. target: Tensor [batch_size, max_sequence_length] with predictions. Returns: Dictionary of tensors with logits [batch_size, max_sequence_length, vocab_size]. """ hidden = inputs[HIDDEN] if len(hidden.size()) != 3: raise ValueError( f"Decoder inputs rank is {len(hidden.size())}, but should be 3: " + "[batch_size x max_sequence_length x hidden_size] in when using a tagger sequential decoder. " + "Consider setting reduce_output to None if a sequential encoder / combiner is used." ) if list(hidden.shape[1:]) != [self.max_sequence_length, self.input_size]: raise ValueError( "Sequence tagger decoder inputs (hidden) should be [batch_size, self.max_sequence_length, " + f"input_size], or [batch_size, {self.max_sequence_length}, {self.input_size}]. However, the " + f"inputs (hidden) was instead: {list(hidden.size())}. " + "The encoder is not length preserving. Please check its configuration." ) if self.use_attention: hidden = self.self_attention(hidden) logits = self.projection_layer(hidden) return {LOGITS: logits} def get_prediction_set(self): return {LOGITS, PROBABILITIES, PREDICTIONS} @staticmethod def get_schema_cls(): return SequenceTaggerDecoderConfig @property def input_shape(self): # Dummy implementation. return torch.Size([1]) @property def output_shape(self): return torch.Size([self.max_sequence_length, self.vocab_size]) ================================================ FILE: ludwig/decoders/utils.py ================================================ import torch from torch import Tensor def extract_generated_tokens( raw_generated_output_sequences: list[Tensor], input_lengths: list[int], max_new_tokens: int, pad_sequence: bool, ) -> list[Tensor]: """Extracts the generated tokens from the raw output sequences of the language model. Args: raw_generated_output_sequences: The raw output sequences of the language model. Represented as a list to handle variable length sequences. input_lengths: The length of the inputs to the language model. max_new_tokens: The maximum number of new tokens that were generated. Used to pad the generated sequences to the max_new_tokens. pad_sequence: Whether to pad the generated sequences to the max_new_tokens. Returns: The generated tokens. """ if len(raw_generated_output_sequences) != len(input_lengths): raise ValueError( f"The number of raw_generated_output_sequences ({len(raw_generated_output_sequences)}) " f"must be the same as the number of input_lengths ({len(input_lengths)})." ) generated_outputs = [] for idx, input_length in enumerate(input_lengths): # Remove the input sequence from the generated sequence generated_sequence = raw_generated_output_sequences[idx][input_length:] # Pad the sequence if it is shorter than the max_new_tokens for downstream metric computation if pad_sequence and generated_sequence.size()[0] < max_new_tokens: generated_sequence = torch.nn.functional.pad( generated_sequence, (0, max_new_tokens - generated_sequence.size()[0]), "constant", 0 ) generated_outputs.append(generated_sequence) return generated_outputs ================================================ FILE: ludwig/distributed/__init__.py ================================================ from typing import Any from ludwig.distributed.base import DistributedStrategy, LocalStrategy def load_ddp(): from ludwig.distributed.ddp import DDPStrategy return DDPStrategy def load_fsdp(): from ludwig.distributed.fsdp import FSDPStrategy return FSDPStrategy def load_deepspeed(): from ludwig.distributed.deepspeed import DeepSpeedStrategy return DeepSpeedStrategy def load_local(): return LocalStrategy STRATEGIES = { "ddp": load_ddp, "fsdp": load_fsdp, "deepspeed": load_deepspeed, "local": load_local, } _current_strategy: DistributedStrategy = None def init_dist_strategy(strategy: str | dict[str, Any], **kwargs) -> DistributedStrategy: global _current_strategy if isinstance(strategy, dict): dtype = strategy.pop("type", None) obj = get_dist_strategy(dtype)(**strategy) else: obj = get_dist_strategy(strategy)(**kwargs) _current_strategy = obj return obj def get_current_dist_strategy() -> DistributedStrategy: if _current_strategy is None: raise RuntimeError("Distributed strategy not initialized") return _current_strategy def get_dist_strategy(strategy: str | dict[str, Any]) -> type[DistributedStrategy]: name = strategy if isinstance(strategy, dict): name = strategy["type"] return STRATEGIES[name]() def get_default_strategy_name() -> str: return "ddp" ================================================ FILE: ludwig/distributed/base.py ================================================ from __future__ import annotations import contextlib from abc import ABC, abstractmethod from collections.abc import Callable from typing import Any, TYPE_CHECKING import torch from torch import nn from torch.optim import Optimizer from ludwig.modules.optimization_modules import create_optimizer from ludwig.utils.torch_utils import get_torch_device if TYPE_CHECKING: from ray.train.backend import BackendConfig from ray.train.data_parallel_trainer import DataParallelTrainer from ludwig.models.base import BaseModel from ludwig.modules.lr_scheduler import LRScheduler from ludwig.schema.trainer import ECDTrainerConfig from ludwig.utils.checkpoint_utils import Checkpoint class DistributedStrategy(ABC): """Interface that wraps a distributed training framework (DDP, FSDP, DeepSpeed). Distributed strategies modify the model and/or optimizer to coordinate gradient updates among multiple workers running in parallel. In most cases, these are using collective communication libraries pass messages between processes. """ @abstractmethod def prepare( self, model: nn.Module, trainer_config: ECDTrainerConfig, base_learning_rate: float, ) -> tuple[nn.Module, Optimizer]: """Modifies the model to support distributed training and creates the optimizer. Args: model: The model to wrap for distributed training. trainer_config: The trainer configuration, which includes optimizer params. base_learning_rate: The base learning rate to init the optimizer, which may be scaled by the strategy. Returns: A tuple of the wrapped model and the optimizer. """ def prepare_for_inference(self, model: nn.Module) -> nn.Module: return model def to_device(self, model: BaseModel, device: torch.device | None = None) -> nn.Module: return model.to_device(device if device is not None else get_torch_device()) def backward(self, loss: torch.Tensor, model: nn.Module): loss.backward() def step(self, optimizer: Optimizer, *args, **kwargs): optimizer.step(*args, **kwargs) def zero_grad(self, optimizer: Optimizer): optimizer.zero_grad() def set_batch_size(self, model: nn.Module, batch_size: int): pass @abstractmethod def size(self) -> int: pass @abstractmethod def rank(self) -> int: pass @abstractmethod def local_size(self) -> int: pass @abstractmethod def local_rank(self) -> int: pass def is_coordinator(self) -> bool: return self.rank() == 0 @abstractmethod def barrier(self): pass @abstractmethod def allreduce(self, t: torch.Tensor) -> torch.Tensor: pass @abstractmethod def broadcast(self, t: torch.Tensor) -> torch.Tensor: pass @abstractmethod def sync_model(self, model: nn.Module): pass @abstractmethod def sync_optimizer(self, optimizer: Optimizer): pass @abstractmethod def broadcast_object(self, v: Any, name: str | None = None) -> Any: pass @abstractmethod def wait_optimizer_synced(self, optimizer: Optimizer): pass @abstractmethod @contextlib.contextmanager def prepare_model_update(self, model: nn.Module, should_step: bool): pass @abstractmethod @contextlib.contextmanager def prepare_optimizer_update(self, optimizer: Optimizer): pass @classmethod @abstractmethod def is_available(cls) -> bool: pass @classmethod @abstractmethod def gather_all_tensors_fn(cls) -> Callable | None: pass @classmethod @abstractmethod def get_ray_trainer_backend(cls, **kwargs) -> Any | None: pass @classmethod @abstractmethod def get_trainer_cls(cls, backend_config: BackendConfig) -> tuple[type[DataParallelTrainer], dict[str, Any]]: pass @abstractmethod def shutdown(self): pass def return_first(self, fn: Callable) -> Callable: """Wraps function so results are only returned by the first (coordinator) rank. The purpose of this function is to reduce network overhead. """ def wrapped(*args, **kwargs): res = fn(*args, **kwargs) return res if self.rank() == 0 else None return wrapped def allow_gradient_accumulation(self) -> bool: return True def allow_mixed_precision(self) -> bool: return True def allow_clip_gradients(self) -> bool: return True def prepare_before_load(self) -> bool: """True if we need to call `prepare` again before loading a checkpoint.""" return False @classmethod def is_model_parallel(cls) -> bool: return False def create_checkpoint_handle( self, dist_model: nn.Module, model: nn.Module, optimizer: Optimizer | None = None, scheduler: LRScheduler | None = None, ) -> Checkpoint: from ludwig.utils.checkpoint_utils import MultiNodeCheckpoint return MultiNodeCheckpoint(self, model, optimizer, scheduler) @classmethod def extract_model_for_serialization(cls, model: nn.Module) -> nn.Module | tuple[nn.Module, list[dict]]: return model @classmethod def replace_model_from_serialization(cls, state: nn.Module | tuple[nn.Module, list[dict]]) -> nn.Module: assert isinstance(state, nn.Module) return state class LocalStrategy(DistributedStrategy): def prepare( self, model: nn.Module, trainer_config: ECDTrainerConfig, base_learning_rate: float, ) -> tuple[nn.Module, Optimizer]: return model, create_optimizer(model, trainer_config.optimizer, base_learning_rate) def size(self) -> int: return 1 def rank(self) -> int: return 0 def local_size(self) -> int: return 0 def local_rank(self) -> int: return 0 def barrier(self): pass def allreduce(self, t: torch.Tensor) -> torch.Tensor: return t def broadcast(self, t: torch.Tensor) -> torch.Tensor: return t def sync_model(self, model: nn.Module): pass def sync_optimizer(self, optimizer: Optimizer): pass def broadcast_object(self, v: Any, name: str | None = None) -> Any: return v def wait_optimizer_synced(self, optimizer: Optimizer): pass @contextlib.contextmanager def prepare_model_update(self, model: nn.Module, should_step: bool): yield @contextlib.contextmanager def prepare_optimizer_update(self, optimizer: Optimizer): yield @classmethod def is_available(cls) -> bool: # While this strategy is always an option, it is not "distributed" which is the meaning of availability # in this context. return False @classmethod def gather_all_tensors_fn(cls) -> Callable | None: return None @classmethod def get_ray_trainer_backend(cls, **kwargs) -> Any | None: return None @classmethod def get_trainer_cls(cls, backend_config: BackendConfig) -> tuple[type[DataParallelTrainer], dict[str, Any]]: raise ValueError("Cannot construct a trainer from a local strategy.") def shutdown(self): pass ================================================ FILE: ludwig/distributed/ddp.py ================================================ import contextlib import logging import os import socket from collections.abc import Callable from typing import Any, Optional, TYPE_CHECKING, Union import torch import torch.distributed as dist from ray.train.backend import BackendConfig from ray.train.data_parallel_trainer import DataParallelTrainer from ray.train.torch import TorchTrainer from torch import nn from torch.nn.parallel import DistributedDataParallel as DDP from torch.optim import Optimizer from torchmetrics.utilities.distributed import gather_all_tensors from ludwig.distributed.base import DistributedStrategy from ludwig.modules.optimization_modules import create_optimizer from ludwig.utils.torch_utils import get_torch_device if TYPE_CHECKING: from ludwig.models.base import BaseModel from ludwig.modules.lr_scheduler import LRScheduler from ludwig.schema.trainer import ECDTrainerConfig from ludwig.utils.checkpoint_utils import Checkpoint class DDPStrategy(DistributedStrategy): def __init__(self): self._local_rank, self._local_size = local_rank_and_size() self._log_on_init() def _log_on_init(self): logging.info("Using DDP strategy") def prepare( self, model: nn.Module, trainer_config: "ECDTrainerConfig", base_learning_rate: float, ) -> tuple[nn.Module, Optimizer]: return DDP(model), create_optimizer(model, trainer_config.optimizer, base_learning_rate) def size(self) -> int: return dist.get_world_size() def rank(self) -> int: return dist.get_rank() def local_size(self) -> int: return self._local_size def local_rank(self) -> int: return self._local_rank def barrier(self): return dist.barrier() def allreduce(self, t: torch.Tensor) -> torch.Tensor: dist.all_reduce(t) return t def broadcast(self, t: torch.Tensor) -> torch.Tensor: dist.broadcast(t) return t def sync_model(self, model: nn.Module): # TODO(travis): open question if this is needed to ensure all workers using same weights pass def sync_optimizer(self, optimizer: Optimizer): # TODO(travis): open question if this is needed to ensure all workers using same optimizer state pass def broadcast_object(self, v: Any, name: str | None = None) -> Any: output = [v] dist.broadcast_object_list(output) return output[0] def wait_optimizer_synced(self, optimizer: Optimizer): pass @contextlib.contextmanager def prepare_model_update(self, model: nn.Module, should_step: bool): if should_step: yield else: # Prevents DDP from syncing gradients during accumulation step with model.no_sync(): yield @contextlib.contextmanager def prepare_optimizer_update(self, optimizer: Optimizer): yield @classmethod def is_available(cls) -> bool: return dist.is_available() and dist.is_initialized() @classmethod def gather_all_tensors_fn(cls) -> Callable | None: return gather_all_tensors @classmethod def get_ray_trainer_backend(cls, **kwargs) -> Any | None: from ray.train.torch import TorchConfig return TorchConfig() @classmethod def get_trainer_cls(cls, backend_config: BackendConfig) -> tuple[type[DataParallelTrainer], dict[str, Any]]: return TorchTrainer, dict(torch_config=backend_config) def shutdown(self): # TODO(travis): currently Ray handles this for us, but is subject to hangs if one of the workers raises an # exception and the other makes a collective op. We should figure out a way to make this safe to call # multiple times. It looks like there is a fix we can make use of when we upgrade to Ray 2.1: # https://discuss.ray.io/t/torchtrainer-hangs-when-only-1-worker-raises-error/7447/11 # dist.destroy_process_group() pass def create_checkpoint_handle( self, dist_model: nn.Module, model: nn.Module, optimizer: Optimizer | None = None, scheduler: Optional["LRScheduler"] = None, ) -> "Checkpoint": from ludwig.utils.checkpoint_utils import MultiNodeCheckpoint return MultiNodeCheckpoint(self, model, optimizer, scheduler) def to_device(self, model: Union["BaseModel", DDP], device: torch.device | None = None) -> nn.Module: try: return model.to_device(device if device is not None else get_torch_device()) except AttributeError: # Model is already wrapped in DistributedDataParallel, so it has already been moved to device return model def local_rank_and_size() -> tuple[int, int]: # DeepSpeed CLI and other tools may set these environment variables for us. local_rank, local_size = os.environ.get("LOCAL_RANK"), os.environ.get("LOCAL_SIZE") if local_rank is not None and local_size is not None: return int(local_rank), int(local_size) # Gather the rank and hostnames from every worker so we can count up how many belong to the same host, which # constitutes the local group. rank = dist.get_rank() host = socket.gethostname() output = [None for _ in range(dist.get_world_size())] dist.all_gather_object(output, (rank, host)) # Every time we find a worker with the same host, we increment the size counter. # The local rank is determined by the world rank relative to the other workers on the same host, so every time # we see a worker on our host with a lower rank, we increment the rank counter. local_size = 0 local_rank = 0 for other_rank, other_host in output: if other_host == host: local_size += 1 if other_rank < rank: local_rank += 1 return local_rank, local_size ================================================ FILE: ludwig/distributed/deepspeed.py ================================================ import logging import os import warnings from collections.abc import Mapping from typing import Any, Optional, TYPE_CHECKING import deepspeed import deepspeed.comm import torch from deepspeed.utils.zero_to_fp32 import get_fp32_state_dict_from_zero_checkpoint from packaging import version from torch import nn from torch.optim.optimizer import Optimizer from ludwig.constants import MIN_POSSIBLE_BATCH_SIZE from ludwig.distributed.ddp import DDPStrategy from ludwig.modules.optimization_modules import get_optimizer_class_and_kwargs from ludwig.utils.checkpoint_utils import Checkpoint from ludwig.utils.model_utils import extract_tensors, replace_tensors _deepspeed_0101 = version.parse(deepspeed.__version__) >= version.parse("0.10.1") if TYPE_CHECKING: from ludwig.modules.lr_scheduler import LRScheduler from ludwig.schema.trainer import ECDTrainerConfig DEFAULT_ZERO_OPTIMIZATION = { "stage": "auto", "stage3_gather_16bit_weights_on_model_save": "auto", "offload_optimizer": {"device": "auto"}, "offload_param": {"device": "auto"}, } # Filter out warnings about DeepSpeed use of deprecated methods. Can remove on upgrade to DeepSpeed 0.9. warnings.filterwarnings( action="ignore", category=UserWarning, module="torch.distributed.distributed_c10d", ) class DeepSpeedStrategy(DDPStrategy): def __init__( self, zero_optimization: dict[str, Any] | None = None, fp16: dict[str, Any] | None = None, bf16: dict[str, Any] | None = None, compression_training: dict[str, Any] | None = None, **kwargs ): # If we're initializing from a `deepspeed` CLI command, deepspeed will have already been initialized, as # indicated by the presence of the LOCAL_RANK var. Otherwise, we're initializing from Ray / torchrun, and will # need to set this var ourselves, then init DeepSpeed here. local_rank, local_size = os.environ.get("LOCAL_RANK"), os.environ.get("LOCAL_SIZE") init_deepspeed = local_rank is None or local_size is None super().__init__(**kwargs) self.zero_optimization = zero_optimization or DEFAULT_ZERO_OPTIMIZATION self.fp16 = fp16 self.bf16 = bf16 self.compression_training = compression_training if init_deepspeed: os.environ["LOCAL_RANK"] = str(self.local_rank()) os.environ["LOCAL_SIZE"] = str(self.local_size()) os.environ["RANK"] = str(self.rank()) os.environ["WORLD_SIZE"] = str(self.size()) deepspeed.init_distributed() def _log_on_init(self): logging.info("Using DeepSpeed strategy") def prepare( self, model: nn.Module, trainer_config: "ECDTrainerConfig", base_learning_rate: float, ) -> tuple[nn.Module, Optimizer]: # If `batch_size=auto`, we set to MIN_POSSIBLE_BATCH_SIZE temporarily until auto-tuning adjusts it` # We can really set it to be whatever we want, as it will be overridden by the auto-tuning. batch_size = ( trainer_config.batch_size if isinstance(trainer_config.batch_size, int) else MIN_POSSIBLE_BATCH_SIZE ) # Paged and 8-bit optimizers are not supported by Deepspeed - just whatever is supported # by torch.optim.Optimizer. https://www.deepspeed.ai/docs/config-json/#optimizer-parameters. if trainer_config.optimizer.is_paged or trainer_config.optimizer.is_8bit: raise ValueError("Cannot use a paged or 8-bit optimizer with DeepSpeed.") optimizer_cls, optimizer_kwargs = get_optimizer_class_and_kwargs(trainer_config.optimizer, base_learning_rate) ds_config = { "amp": { "enabled": trainer_config.use_mixed_precision, }, "optimizer": {"type": optimizer_cls.__name__, "params": optimizer_kwargs}, "zero_optimization": self.zero_optimization, "gradient_clipping": trainer_config.gradient_clipping.clipglobalnorm, "train_micro_batch_size_per_gpu": batch_size, "gradient_accumulation_steps": trainer_config.gradient_accumulation_steps, "steps_per_print": trainer_config.steps_per_checkpoint or 10000, } # DeepSpeed doesn't like passing these params as None values if self.fp16 is not None: ds_config["fp16"] = self.fp16 if self.bf16 is not None: ds_config["bf16"] = self.bf16 if self.compression_training is not None: ds_config["compression_training"] = self.compression_training model_engine, optimizer, _, _ = deepspeed.initialize( model=model, model_parameters=model.parameters(), lr_scheduler=None, # Don't let DeepSpeed manage the learning rate scheduler config=ds_config, dist_init_required=False, ) if hasattr(optimizer, "optimizer"): # Zero-3 wraps the optimizer optimizer = optimizer.optimizer return model_engine, optimizer def prepare_for_inference(self, model: nn.Module) -> nn.Module: ds_config = {} model_engine = deepspeed.init_inference(model=model, config=ds_config) return model_engine def to_device(self, model: nn.Module, device: torch.device | None = None) -> nn.Module: return model def backward(self, loss: torch.Tensor, model: nn.Module): # See: https://github.com/huggingface/accelerate/blob/main/src/accelerate/utils/deepspeed.py # runs backpropagation and handles mixed precision model.backward(loss) # Deepspeed's `engine.step` performs the following operations: # - gradient accumulation check # - gradient clipping # - optimizer step # - zero grad # - checking overflow # - lr_scheduler step (only if engine.lr_scheduler is not None) model.step() # and this plugin overrides the above calls with no-ops when Accelerate runs under # Deepspeed, but allows normal functionality for non-Deepspeed cases thus enabling a simple # training loop that works transparently under many training regimes. def step(self, optimizer: Optimizer, *args, **kwargs): # Handled by `self.backward(loss)` pass def zero_grad(self, optimizer: Optimizer): # Handled by `self.backward(loss)` pass def set_batch_size(self, model: nn.Module, batch_size: int): # Adapted from: # https://github.com/microsoft/DeepSpeed/blob/7ce371b139521b1ebbf052f0496b1a16397c1d19/deepspeed/runtime/engine.py#L422 # noqa: E501 model._config.micro_batch_size_per_gpu = batch_size model._config.train_batch_size = batch_size * self.size() * model._config.gradient_accumulation_steps def barrier(self): deepspeed.comm.barrier() def allow_gradient_accumulation(self) -> bool: """DeepSpeed handles gradient accumulation internally.""" return False def allow_mixed_precision(self) -> bool: """DeepSpeed handles mixed precision internally.""" return False def allow_clip_gradients(self) -> bool: """DeepSpeed handles gradient clipping internally.""" return False def prepare_before_load(self) -> bool: """DeepSpeed requires the engine to be re-initialized before loading. https://deepspeed.readthedocs.io/en/latest/model-checkpointing.html#loading-training-checkpoints """ return True @classmethod def is_model_parallel(cls) -> bool: return True def create_checkpoint_handle( self, dist_model: nn.Module, model: nn.Module, optimizer: Optimizer | None = None, scheduler: Optional["LRScheduler"] = None, ) -> Checkpoint: return DeepSpeedCheckpoint(self, dist_model, optimizer, scheduler) @classmethod def extract_model_for_serialization(cls, model: nn.Module) -> nn.Module | tuple[nn.Module, list[dict]]: return extract_tensors(model) @classmethod def replace_model_from_serialization(cls, state: nn.Module | tuple[nn.Module, list[dict]]) -> nn.Module: assert isinstance(state, tuple) model, model_weights = state replace_tensors(model, model_weights, torch.device("cpu")) return model class DeepSpeedCheckpoint(Checkpoint): def prepare(self, directory: str): if self.distributed.local_rank() == 0: # Checkpoints need to be written on every rank, but the directory only needs to be created once per node. super().prepare(directory) def load(self, save_path: str, device: torch.device | None = None) -> bool: """Load a checkpoint. For DeepSpeed, we need every worker to independently load back the model weights, as the checkpoints themselves may be sharded (when using DeepSpeed Zero3). https://deepspeed.readthedocs.io/en/latest/model-checkpointing.html#loading-training-checkpoints """ # NOTE(geoffrey): `load_module_strict=False` because this code path is frequently used to load models trained # using adapter-based fine-tuning, where the checkpoints only contain the adapter weights, and not the full # model weights. This may lead to silent, unexpected behavior for resuming full model fine-tuning, # where all the model weights *must* be loaded in. # TODO(geoffrey): Add a boolean arg to function to control load_module_strict behavior. _, client_state = self.model.load_checkpoint( save_path, load_lr_scheduler_states=False, load_module_strict=False ) self.global_step = self._get_global_step(client_state, save_path) if self.scheduler is not None and "scheduler_state" in client_state: self.scheduler.load_state_dict(client_state["scheduler_state"]) return True def save(self, save_path: str, global_step: int): client_state = { "global_step": global_step, } if self.scheduler is not None: client_state["scheduler_state"] = self.scheduler.state_dict() kwargs = {} if _deepspeed_0101: kwargs["exclude_frozen_parameters"] = True self.model.save_checkpoint(save_path, client_state=client_state, **kwargs) def get_state_for_inference(self, save_path: str, device: torch.device | None = None) -> Mapping[str, Any]: if self.model.zero_optimization_stage() == 3: return get_fp32_state_dict_from_zero_checkpoint(save_path) self.model.load_checkpoint( save_path, load_optimizer_states=False, load_lr_scheduler_states=False, load_module_only=True ) return self.model.module.cpu().state_dict() ================================================ FILE: ludwig/distributed/fsdp.py ================================================ import logging from typing import TYPE_CHECKING import torch from torch import nn from torch.distributed.fsdp import FullyShardedDataParallel as FSDP from torch.optim import Optimizer from ludwig.distributed.ddp import DDPStrategy from ludwig.modules.optimization_modules import create_optimizer if TYPE_CHECKING: from ludwig.schema.trainer import ECDTrainerConfig class FSDPStrategy(DDPStrategy): def _log_on_init(self): logging.info("Using FSDP strategy") def prepare( self, model: nn.Module, trainer_config: "ECDTrainerConfig", base_learning_rate: float, ) -> tuple[nn.Module, Optimizer]: return FSDP(model), create_optimizer(model, trainer_config.optimizer, base_learning_rate) def to_device(self, model: nn.Module, device: torch.device | None = None) -> nn.Module: return model @classmethod def is_model_parallel(cls) -> bool: return True ================================================ FILE: ludwig/encoders/__init__.py ================================================ # register all encoders import ludwig.encoders.bag_encoders import ludwig.encoders.category_encoders import ludwig.encoders.date_encoders import ludwig.encoders.generic_encoders import ludwig.encoders.h3_encoders import ludwig.encoders.image import ludwig.encoders.sequence_encoders import ludwig.encoders.set_encoders import ludwig.encoders.text_encoders # noqa ================================================ FILE: ludwig/encoders/bag_encoders.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import logging from typing import Any import torch from ludwig.api_annotations import DeveloperAPI from ludwig.constants import BAG, ENCODER_OUTPUT from ludwig.encoders.base import Encoder from ludwig.encoders.registry import register_encoder from ludwig.encoders.types import EncoderOutputDict from ludwig.modules.embedding_modules import EmbedWeighted from ludwig.modules.fully_connected_modules import FCStack from ludwig.schema.encoders.bag_encoders import BagEmbedWeightedConfig from ludwig.schema.encoders.base import BaseEncoderConfig logger = logging.getLogger(__name__) @DeveloperAPI @register_encoder("embed", BAG) class BagEmbedWeightedEncoder(Encoder): def __init__( self, vocab: list[str], embedding_size: int = 50, representation: str = "dense", embeddings_trainable: bool = True, pretrained_embeddings: str | None = None, force_embedding_size: bool = False, embeddings_on_cpu: bool = False, fc_layers=None, num_fc_layers: int = 0, output_size: int = 10, use_bias: bool = True, weights_initializer: str = "xavier_uniform", bias_initializer: str = "zeros", norm: str | None = None, norm_params: dict[str, Any] | None = None, activation: str = "relu", dropout: float = 0.0, encoder_config=None, **kwargs, ): super().__init__() self.config = encoder_config logger.debug(f" {self.name}") logger.debug(" EmbedWeighted") self.embed_weighted = EmbedWeighted( vocab, embedding_size, representation=representation, embeddings_trainable=embeddings_trainable, pretrained_embeddings=pretrained_embeddings, force_embedding_size=force_embedding_size, embeddings_on_cpu=embeddings_on_cpu, dropout=dropout, embedding_initializer=weights_initializer, ) logger.debug(" FCStack") self.fc_stack = FCStack( self.embed_weighted.output_shape[-1], layers=fc_layers, num_layers=num_fc_layers, default_output_size=output_size, default_use_bias=use_bias, default_weights_initializer=weights_initializer, default_bias_initializer=bias_initializer, default_norm=norm, default_norm_params=norm_params, default_activation=activation, default_dropout=dropout, ) @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return BagEmbedWeightedConfig @property def input_shape(self) -> torch.Size: return torch.Size([len(self.vocab)]) @property def output_shape(self) -> torch.Size: return self.fc_stack.output_shape def forward(self, inputs: torch.Tensor) -> EncoderOutputDict: """ :param inputs: The inputs fed into the encoder. Shape: [batch x vocab size], type torch.int32 :param return: embeddings of shape [batch x embed size], type torch.float32 """ hidden = self.embed_weighted(inputs) hidden = self.fc_stack(hidden) return {ENCODER_OUTPUT: hidden} ================================================ FILE: ludwig/encoders/base.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 2020 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. # ============================================================================== from abc import ABC, abstractmethod from torch import nn from ludwig.api_annotations import DeveloperAPI from ludwig.utils.torch_utils import LudwigModule @DeveloperAPI class Encoder(LudwigModule, ABC): @abstractmethod def forward(self, inputs, training=None, mask=None): raise NotImplementedError def get_embedding_layer(self) -> nn.Module: """Returns layer that embeds inputs, used for computing explanations. Captum adds an evaluation hook to this module returned by this function. The hook copies the module's return with .clone(). The module returned by this function must return a tensor, not a dictionary of tensors. """ return next(self.children()) @property def name(self) -> str: return self.__class__.__name__ ================================================ FILE: ludwig/encoders/category_encoders.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import logging import torch from torch import nn from ludwig.api_annotations import DeveloperAPI from ludwig.constants import CATEGORY, ENCODER_OUTPUT from ludwig.encoders.base import Encoder from ludwig.encoders.registry import register_encoder from ludwig.encoders.types import EncoderOutputDict from ludwig.modules.embedding_modules import Embed from ludwig.schema.encoders.base import BaseEncoderConfig from ludwig.schema.encoders.category_encoders import ( CategoricalEmbedConfig, CategoricalOneHotEncoderConfig, CategoricalPassthroughEncoderConfig, CategoricalSparseConfig, ) logger = logging.getLogger(__name__) @DeveloperAPI @register_encoder("passthrough", [CATEGORY]) class CategoricalPassthroughEncoder(Encoder): def __init__(self, input_size=1, encoder_config=None, **kwargs): super().__init__() self.config = encoder_config logger.debug(f" {self.name}") self.input_size = input_size self.identity = nn.Identity() def forward(self, inputs: torch.Tensor, mask: torch.Tensor | None = None) -> EncoderOutputDict: """ :param inputs: The inputs fed into the encoder. Shape: [batch x 1] """ return {"encoder_output": self.identity(inputs.float())} @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return CategoricalPassthroughEncoderConfig @property def input_shape(self) -> torch.Size: return torch.Size([self.input_size]) @property def output_shape(self) -> torch.Size: return self.input_shape def get_embedding_layer(self) -> nn.Module: return self.identity @DeveloperAPI @register_encoder("dense", CATEGORY) class CategoricalEmbedEncoder(Encoder): def __init__( self, vocab: list[str], embedding_size: int = 50, embeddings_trainable: bool = True, pretrained_embeddings: str | None = None, embeddings_on_cpu: bool = False, dropout: float = 0.0, embedding_initializer: str | dict | None = None, encoder_config=None, **kwargs, ): super().__init__() self.config = encoder_config logger.debug(f" {self.name}") logger.debug(" Embed") self.embed = Embed( vocab=vocab, embedding_size=embedding_size, representation="dense", embeddings_trainable=embeddings_trainable, pretrained_embeddings=pretrained_embeddings, embeddings_on_cpu=embeddings_on_cpu, dropout=dropout, embedding_initializer=embedding_initializer, ) self.embedding_size = self.embed.embedding_size def forward(self, inputs: torch.Tensor) -> EncoderOutputDict: """ :param inputs: The inputs fed into the encoder. Shape: [batch x 1], type torch.int32 :param return: embeddings of shape [batch x embed size], type torch.float32 """ embedded = self.embed(inputs) return {ENCODER_OUTPUT: embedded} @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return CategoricalEmbedConfig @property def output_shape(self) -> torch.Size: return torch.Size([self.embedding_size]) @property def input_shape(self) -> torch.Size: return torch.Size([1]) @DeveloperAPI @register_encoder("sparse", CATEGORY) class CategoricalSparseEncoder(Encoder): def __init__( self, vocab: list[str], embeddings_trainable: bool = False, pretrained_embeddings: str | None = None, embeddings_on_cpu: bool = False, dropout: float = 0.0, embedding_initializer: str | dict | None = None, encoder_config=None, **kwargs, ): super().__init__() self.config = encoder_config logger.debug(f" {self.name}") logger.debug(" Embed") self.embed = Embed( vocab=vocab, embedding_size=len(vocab), representation="sparse", embeddings_trainable=embeddings_trainable, pretrained_embeddings=pretrained_embeddings, embeddings_on_cpu=embeddings_on_cpu, dropout=dropout, embedding_initializer=embedding_initializer, ) self.embedding_size = self.embed.embedding_size def forward(self, inputs: torch.Tensor) -> EncoderOutputDict: """ :param inputs: The inputs fed into the encoder. Shape: [batch x 1], type torch.int32 :param return: embeddings of shape [batch x embed size], type torch.float32 """ embedded = self.embed(inputs) return {ENCODER_OUTPUT: embedded} @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return CategoricalSparseConfig @property def output_shape(self) -> torch.Size: return torch.Size([self.embedding_size]) @property def input_shape(self) -> torch.Size: return torch.Size([1]) @DeveloperAPI @register_encoder("onehot", [CATEGORY]) class CategoricalOneHotEncoder(Encoder): def __init__( self, vocab: list[str], encoder_config=None, **kwargs, ): super().__init__() self.config = encoder_config logger.debug(f" {self.name}") self.vocab_size = len(vocab) self.identity = nn.Identity() def forward(self, inputs: torch.Tensor, mask: torch.Tensor | None = None) -> EncoderOutputDict: """ :param inputs: The inputs fed into the encoder. Shape: [batch, 1] or [batch] """ t = inputs.reshape(-1).long() # the output of this must be a float so that it can be concatenated with other # encoder outputs and passed to dense layers in the combiner, decoder, etc. outputs = self.identity(torch.nn.functional.one_hot(t, num_classes=self.vocab_size).float()) return {"encoder_output": outputs} @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return CategoricalOneHotEncoderConfig @property def input_shape(self) -> torch.Size: return torch.Size([1]) @property def output_shape(self) -> torch.Size: return torch.Size([self.vocab_size]) def get_embedding_layer(self) -> nn.Module: return self.identity ================================================ FILE: ludwig/encoders/date_encoders.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import logging import torch from ludwig.api_annotations import DeveloperAPI from ludwig.constants import DATE, ENCODER_OUTPUT from ludwig.encoders.base import Encoder from ludwig.encoders.registry import register_encoder from ludwig.encoders.types import EncoderOutputDict from ludwig.modules.embedding_modules import Embed from ludwig.modules.fully_connected_modules import FCStack from ludwig.schema.encoders.base import BaseEncoderConfig from ludwig.schema.encoders.date_encoders import DateEmbedConfig, DateWaveConfig from ludwig.utils import torch_utils logger = logging.getLogger(__name__) # Year, month, day, weekday, yearday, hour, minute, seconds, second_of_day. # TODO: Share this constant with date_feature.DATE_VECTOR_SIZE. DATE_INPUT_SIZE = 9 @DeveloperAPI @register_encoder("embed", DATE) class DateEmbed(Encoder): def __init__( self, embedding_size: int = 10, embeddings_on_cpu: bool = False, fc_layers: list[dict] | None = None, num_fc_layers: int = 0, output_size: int = 10, use_bias: bool = True, weights_initializer: str = "xavier_uniform", bias_initializer: str = "zeros", norm: str | None = None, norm_params: dict | None = None, activation: str = "relu", dropout: float = 0, encoder_config=None, **kwargs, ): """ :param embedding_size: The maximum embedding size, the actual size will be `min(vocabulary_size, embedding_size)` for `dense` representations and exactly `vocabulary_size` for the `sparse` encoding, where `vocabulary_size` is the number of different strings appearing in the training set in the column the feature is named after (plus 1 for ``). :type embedding_size: Integer :param embeddings_on_cpu: by default embeddings matrices are stored on GPU memory if a GPU is used, as it allows for faster access, but in some cases the embedding matrix may be really big and this parameter forces the placement of the embedding matrix in regular memory and the CPU is used to resolve them, slightly slowing down the process as a result of data transfer between CPU and GPU memory. :param fc_layers: list of dictionaries containing the parameters of all the fully connected layers. :type fc_layers: List :param num_fc_layers: Number of stacked fully connected layers. :type num_fc_layers: Integer :param output_size: Size of each layer. :type output_size: Integer :param use_bias: bool determines where to use a bias vector. :type use_bias: bool :param weights_initializer: Initializer for the weights (aka kernel) matrix. :type weights_initializer: string :param bias_initializer: Initializer for the bias vector. :type bias_initializer: string :param norm: type of normalization to use 'batch' or 'layer'. :type norm: string, default None :param norm_params: parameters to pass to normalization function. :type norm_params: dictionary :param activation: Activation function to use. :type activation: string :param dropout: determines if there should be a dropout layer before returning the encoder output. :type dropout: float """ super().__init__() self.config = encoder_config logger.debug(f" {self.name}") logger.debug(" year FCStack") self.year_fc = FCStack( first_layer_input_size=1, num_layers=1, default_output_size=1, default_use_bias=use_bias, default_weights_initializer=weights_initializer, default_bias_initializer=bias_initializer, default_norm=None, default_norm_params=None, default_activation=None, default_dropout=dropout, ) logger.debug(" month Embed") self.embed_month = Embed( [str(i) for i in range(12)], embedding_size, representation="dense", embeddings_trainable=True, pretrained_embeddings=None, embeddings_on_cpu=embeddings_on_cpu, dropout=dropout, embedding_initializer=weights_initializer, ) logger.debug(" day Embed") self.embed_day = Embed( [str(i) for i in range(31)], embedding_size, representation="dense", embeddings_trainable=True, pretrained_embeddings=None, embeddings_on_cpu=embeddings_on_cpu, dropout=dropout, embedding_initializer=weights_initializer, ) logger.debug(" weekday Embed") self.embed_weekday = Embed( [str(i) for i in range(7)], embedding_size, representation="dense", embeddings_trainable=True, pretrained_embeddings=None, embeddings_on_cpu=embeddings_on_cpu, dropout=dropout, embedding_initializer=weights_initializer, ) logger.debug(" yearday Embed") self.embed_yearday = Embed( [str(i) for i in range(366)], embedding_size, representation="dense", embeddings_trainable=True, pretrained_embeddings=None, embeddings_on_cpu=embeddings_on_cpu, dropout=dropout, embedding_initializer=weights_initializer, ) logger.debug(" hour Embed") self.embed_hour = Embed( [str(i) for i in range(24)], embedding_size, representation="dense", embeddings_trainable=True, pretrained_embeddings=None, embeddings_on_cpu=embeddings_on_cpu, dropout=dropout, embedding_initializer=weights_initializer, ) logger.debug(" minute Embed") self.embed_minute = Embed( [str(i) for i in range(60)], embedding_size, representation="dense", embeddings_trainable=True, pretrained_embeddings=None, embeddings_on_cpu=embeddings_on_cpu, dropout=dropout, embedding_initializer=weights_initializer, ) logger.debug(" second Embed") self.embed_second = Embed( [str(i) for i in range(60)], embedding_size, representation="dense", embeddings_trainable=True, pretrained_embeddings=None, embeddings_on_cpu=embeddings_on_cpu, dropout=dropout, embedding_initializer=weights_initializer, ) # Summed sizes of all of the embeddings. fc_layer_input_size = ( self.year_fc.output_shape[0] + self.embed_month.output_shape[0] + self.embed_day.output_shape[0] + self.embed_weekday.output_shape[0] + self.embed_yearday.output_shape[0] + self.embed_hour.output_shape[0] + self.embed_minute.output_shape[0] + self.embed_second.output_shape[0] + 1 # for periodic_second_of_day. ) logger.debug(" FCStack") self.fc_stack = FCStack( first_layer_input_size=fc_layer_input_size, layers=fc_layers, num_layers=num_fc_layers, default_output_size=output_size, default_use_bias=use_bias, default_weights_initializer=weights_initializer, default_bias_initializer=bias_initializer, default_norm=norm, default_norm_params=norm_params, default_activation=activation, default_dropout=dropout, ) def forward(self, inputs: torch.Tensor) -> EncoderOutputDict: """ :param inputs: The input vector fed into the encoder. Shape: [batch x DATE_INPUT_SIZE], type torch.int8 :type inputs: Tensor """ # ================ Embeddings ================ input_vector = inputs.type(torch.int) scaled_year = self.year_fc(input_vector[:, 0:1].type(torch.float)) embedded_month = self.embed_month(input_vector[:, 1:2] - 1) embedded_day = self.embed_day(input_vector[:, 2:3] - 1) embedded_weekday = self.embed_weekday(input_vector[:, 3:4]) embedded_yearday = self.embed_yearday(input_vector[:, 4:5] - 1) embedded_hour = self.embed_hour(input_vector[:, 5:6]) embedded_minute = self.embed_minute(input_vector[:, 6:7]) embedded_second = self.embed_second(input_vector[:, 7:8]) periodic_second_of_day = torch_utils.periodic(input_vector[:, 8:9].type(torch.float), 86400) hidden = torch.cat( [ scaled_year, embedded_month, embedded_day, embedded_weekday, embedded_yearday, embedded_hour, embedded_minute, embedded_second, periodic_second_of_day, ], dim=1, ) # ================ FC Stack ================ # logger.debug(' flatten hidden: {0}'.format(hidden)) hidden = self.fc_stack(hidden) return {ENCODER_OUTPUT: hidden} @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return DateEmbedConfig @property def input_shape(self) -> torch.Size: return torch.Size([DATE_INPUT_SIZE]) @property def output_shape(self) -> torch.Size: return self.fc_stack.output_shape @DeveloperAPI @register_encoder("wave", DATE) class DateWave(Encoder): def __init__( self, fc_layers: list[FCStack] | None = None, num_fc_layers: int = 1, output_size: int = 10, use_bias: bool = True, weights_initializer: str = "xavier_uniform", bias_initializer: str = "zeros", norm: str | None = None, norm_params: dict | None = None, activation: str = "relu", dropout: float = 0, encoder_config=None, **kwargs, ): """ :param fc_layers: list of dictionaries containing the parameters of all the fully connected layers. :type fc_layers: List :param num_fc_layers: Number of stacked fully connected layers. :type num_fc_layers: Integer :param output_size: Size of each layer. :type output_size: Integer :param use_bias: bool determines where to use a bias vector. :type use_bias: bool :param weights_initializer: Initializer for the weights (aka kernel) matrix. :type weights_initializer: string :param bias_initializer: Initializer for the bias vector. :type bias_initializer: string :param norm: type of normalization to use 'batch' or 'layer'. :type norm: string, default None :param norm_params: parameters to pass to normalization function. :type norm_params: dictionary :param activation: Activation function to use. :type activation: string :param dropout: determines if there should be a dropout layer before returning the encoder output. :type dropout: float """ super().__init__() self.config = encoder_config logger.debug(f" {self.name}") logger.debug(" year FCStack") self.year_fc = FCStack( first_layer_input_size=1, num_layers=1, default_output_size=1, default_use_bias=use_bias, default_weights_initializer=weights_initializer, default_bias_initializer=bias_initializer, default_norm=None, default_norm_params=None, default_activation=None, default_dropout=dropout, ) # Summed sizes of all of the embeddings. # Additional 8 for periodic_[month, day, ..., second_of_day]. fc_layer_input_size = self.year_fc.output_shape[0] + 8 logger.debug(" FCStack") self.fc_stack = FCStack( first_layer_input_size=fc_layer_input_size, layers=fc_layers, num_layers=num_fc_layers, default_output_size=output_size, default_use_bias=use_bias, default_weights_initializer=weights_initializer, default_bias_initializer=bias_initializer, default_norm=norm, default_norm_params=norm_params, default_activation=activation, default_dropout=dropout, ) def forward(self, inputs: torch.Tensor) -> EncoderOutputDict: """ :param inputs: The input vector fed into the encoder. Shape: [batch x DATE_INPUT_SIZE], type torch.int8 :type inputs: Tensor """ # ================ Embeddings ================ input_vector = inputs.type(torch.float) scaled_year = self.year_fc(input_vector[:, 0:1]) periodic_month = torch_utils.periodic(input_vector[:, 1:2], 12) periodic_day = torch_utils.periodic(input_vector[:, 2:3], 31) periodic_weekday = torch_utils.periodic(input_vector[:, 3:4], 7) periodic_yearday = torch_utils.periodic(input_vector[:, 4:5], 366) periodic_hour = torch_utils.periodic(input_vector[:, 5:6], 24) periodic_minute = torch_utils.periodic(input_vector[:, 6:7], 60) periodic_second = torch_utils.periodic(input_vector[:, 7:8], 60) periodic_second_of_day = torch_utils.periodic(input_vector[:, 8:9], 86400) hidden = torch.cat( [ scaled_year, periodic_month, periodic_day, periodic_weekday, periodic_yearday, periodic_hour, periodic_minute, periodic_second, periodic_second_of_day, ], dim=1, ) # ================ FC Stack ================ # logger.debug(' flatten hidden: {0}'.format(hidden)) hidden = self.fc_stack(hidden) return {ENCODER_OUTPUT: hidden} @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return DateWaveConfig @property def input_shape(self) -> torch.Size: return torch.Size([DATE_INPUT_SIZE]) @property def output_shape(self) -> torch.Size: return self.fc_stack.output_shape ================================================ FILE: ludwig/encoders/generic_encoders.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import logging import torch from ludwig.api_annotations import DeveloperAPI from ludwig.constants import BINARY, ENCODER_OUTPUT, NUMBER, TEXT, TIMESERIES, VECTOR from ludwig.encoders.base import Encoder from ludwig.encoders.registry import register_encoder from ludwig.encoders.types import EncoderOutputDict from ludwig.modules.fully_connected_modules import FCStack from ludwig.schema.encoders.base import BaseEncoderConfig, DenseEncoderConfig, PassthroughEncoderConfig logger = logging.getLogger(__name__) @DeveloperAPI @register_encoder("passthrough", [BINARY, NUMBER, TEXT, VECTOR]) class PassthroughEncoder(Encoder): def __init__(self, input_size=1, encoder_config=None, **kwargs): super().__init__() self.config = encoder_config logger.debug(f" {self.name}") self.input_size = input_size def forward(self, inputs: torch.Tensor, mask: torch.Tensor | None = None) -> EncoderOutputDict: """ :param inputs: The inputs fed into the encoder. Shape: [batch x 1], type tf.float32 """ return {ENCODER_OUTPUT: inputs} @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return PassthroughEncoderConfig @property def input_shape(self) -> torch.Size: return torch.Size([self.input_size]) @property def output_shape(self) -> torch.Size: return self.input_shape @DeveloperAPI @register_encoder("dense", [BINARY, NUMBER, VECTOR, TIMESERIES]) class DenseEncoder(Encoder): def __init__( self, input_size, fc_layers=None, num_layers=1, output_size=256, use_bias=True, weights_initializer="xavier_uniform", bias_initializer="zeros", norm=None, norm_params=None, activation="relu", dropout=0, encoder_config=None, **kwargs, ): super().__init__() self.config = encoder_config logger.debug(f" {self.name}") self.input_size = input_size logger.debug(" FCStack") self.fc_stack = FCStack( first_layer_input_size=input_size, layers=fc_layers, num_layers=num_layers, default_output_size=output_size, default_use_bias=use_bias, default_weights_initializer=weights_initializer, default_bias_initializer=bias_initializer, default_norm=norm, default_norm_params=norm_params, default_activation=activation, default_dropout=dropout, ) def forward(self, inputs: torch.Tensor, mask: torch.Tensor | None = None) -> EncoderOutputDict: """ :param inputs: The inputs fed into the encoder. Shape: [batch x 1], type tf.float32 """ return {ENCODER_OUTPUT: self.fc_stack(inputs)} @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return DenseEncoderConfig @property def input_shape(self) -> torch.Size: return torch.Size([self.input_size]) @property def output_shape(self) -> torch.Size: return torch.Size([self.fc_stack.layers[-1]["output_size"]]) ================================================ FILE: ludwig/encoders/h3_encoders.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import logging import torch from ludwig.api_annotations import DeveloperAPI from ludwig.constants import ENCODER_OUTPUT, ENCODER_OUTPUT_STATE, H3 from ludwig.encoders.base import Encoder from ludwig.encoders.registry import register_encoder from ludwig.encoders.types import EncoderOutputDict from ludwig.modules.embedding_modules import Embed, EmbedSequence from ludwig.modules.fully_connected_modules import FCStack from ludwig.modules.initializer_modules import get_initializer from ludwig.modules.recurrent_modules import RecurrentStack from ludwig.modules.reduction_modules import SequenceReducer from ludwig.schema.encoders.base import BaseEncoderConfig from ludwig.schema.encoders.h3_encoders import H3EmbedConfig, H3RNNConfig, H3WeightedSumConfig from ludwig.utils import torch_utils logger = logging.getLogger(__name__) # TODO: Share this with h3_feature.H3_VECTOR_LENGTH H3_INPUT_SIZE = 19 @DeveloperAPI @register_encoder("embed", H3) class H3Embed(Encoder): def __init__( self, embedding_size: int = 10, embeddings_on_cpu: bool = False, fc_layers: list | None = None, num_fc_layers: int = 0, output_size: int = 10, use_bias: bool = True, weights_initializer: str = "xavier_uniform", bias_initializer: str = "zeros", norm: str = None, norm_params: dict = None, activation: str = "relu", dropout: float = 0, reduce_output: str = "sum", encoder_config=None, **kwargs, ): """ :param embedding_size: it is the maximum embedding size, the actual size will be `min(vocabulary_size, embedding_size)` for `dense` representations and exactly `vocabulary_size` for the `sparse` encoding, where `vocabulary_size` is the number of different strings appearing in the training set in the column the feature is named after (plus 1 for ``). :type embedding_size: Integer :param embeddings_on_cpu: by default embeddings matrices are stored on GPU memory if a GPU is used, as it allows for faster access, but in some cases the embedding matrix may be really big and this parameter forces the placement of the embedding matrix in regular memory and the CPU is used to resolve them, slightly slowing down the process as a result of data transfer between CPU and GPU memory. :param dropout: determines if there should be a dropout layer before returning the encoder output. :type dropout: Boolean """ super().__init__() self.config = encoder_config logger.debug(f" {self.name}") self.embedding_size = embedding_size self.reduce_output = reduce_output self.reduce_sequence = SequenceReducer(reduce_mode=reduce_output) logger.debug(" mode Embed") self.embed_mode = Embed( [str(i) for i in range(3)], embedding_size, representation="dense", embeddings_trainable=True, pretrained_embeddings=None, force_embedding_size=True, embeddings_on_cpu=embeddings_on_cpu, dropout=dropout, embedding_initializer=weights_initializer, ) logger.debug(" edge Embed") self.embed_edge = Embed( [str(i) for i in range(7)], embedding_size, representation="dense", embeddings_trainable=True, pretrained_embeddings=None, force_embedding_size=True, embeddings_on_cpu=embeddings_on_cpu, dropout=dropout, embedding_initializer=weights_initializer, ) logger.debug(" resolution Embed") self.embed_resolution = Embed( [str(i) for i in range(16)], embedding_size, representation="dense", embeddings_trainable=True, pretrained_embeddings=None, force_embedding_size=True, embeddings_on_cpu=embeddings_on_cpu, dropout=dropout, embedding_initializer=weights_initializer, ) logger.debug(" base cell Embed") self.embed_base_cell = Embed( [str(i) for i in range(122)], embedding_size, representation="dense", embeddings_trainable=True, pretrained_embeddings=None, force_embedding_size=True, embeddings_on_cpu=embeddings_on_cpu, dropout=dropout, embedding_initializer=weights_initializer, ) logger.debug(" cells Embed") self.embed_cells = EmbedSequence( [str(i) for i in range(8)], embedding_size, max_sequence_length=(H3_INPUT_SIZE - 4), representation="dense", embeddings_trainable=True, pretrained_embeddings=None, force_embedding_size=True, embeddings_on_cpu=embeddings_on_cpu, dropout=dropout, embedding_initializer=weights_initializer, ) logger.debug(" FCStack") self.fc_stack = FCStack( first_layer_input_size=embedding_size, layers=fc_layers, num_layers=num_fc_layers, default_output_size=output_size, default_use_bias=use_bias, default_weights_initializer=weights_initializer, default_bias_initializer=bias_initializer, default_norm=norm, default_norm_params=norm_params, default_activation=activation, default_dropout=dropout, ) def forward(self, inputs: torch.Tensor) -> EncoderOutputDict: """ :param inputs: The input vector fed into the encoder. Shape: [batch x H3_INPUT_SIZE], type torch.int8 :type inputs: Tensor """ input_vector = inputs.int() # ================ Embeddings ================ embedded_mode = self.embed_mode(input_vector[:, 0:1]).unsqueeze(1) embedded_edge = self.embed_edge(input_vector[:, 1:2]).unsqueeze(1) embedded_resolution = self.embed_resolution(input_vector[:, 2:3]).unsqueeze(1) embedded_base_cell = self.embed_base_cell(input_vector[:, 3:4]).unsqueeze(1) embedded_cells = self.embed_cells(input_vector[:, 4:]) # ================ Masking ================ # Mask out cells beyond the resolution of interest. resolution = input_vector[:, 2] mask = torch.unsqueeze(torch_utils.sequence_mask(resolution, 15), dim=-1).float() # Batch size X 15(max resolution) X embedding size masked_embedded_cells = embedded_cells * mask # ================ Reduce ================ # Batch size X H3_INPUT_SIZE X embedding size concatenated = torch.cat( [embedded_mode, embedded_edge, embedded_resolution, embedded_base_cell, masked_embedded_cells], dim=1 ) hidden = self.reduce_sequence(concatenated) # ================ FC Stack ================ # logger.debug(' flatten hidden: {0}'.format(hidden)) hidden = self.fc_stack(hidden) return {ENCODER_OUTPUT: hidden} @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return H3EmbedConfig @property def input_shape(self) -> torch.Size: return torch.Size([H3_INPUT_SIZE]) @property def output_shape(self) -> torch.Size: return self.fc_stack.output_shape @DeveloperAPI @register_encoder("weighted_sum", H3) class H3WeightedSum(Encoder): def __init__( self, embedding_size: int = 10, embeddings_on_cpu: bool = False, should_softmax: bool = False, fc_layers: list | None = None, num_fc_layers: int = 0, output_size: int = 10, use_bias: bool = True, weights_initializer: str = "xavier_uniform", bias_initializer: str = "zeros", norm: str | None = None, norm_params: dict = None, activation: str = "relu", dropout: float = 0, encoder_config=None, **kwargs, ): """ :param embedding_size: it is the maximum embedding size, the actual size will be `min(vocabulary_size, embedding_size)` for `dense` representations and exactly `vocabulary_size` for the `sparse` encoding, where `vocabulary_size` is the number of different strings appearing in the training set in the column the feature is named after (plus 1 for ``). :type embedding_size: Integer :param embeddings_on_cpu: by default embeddings matrices are stored on GPU memory if a GPU is used, as it allows for faster access, but in some cases the embedding matrix may be really big and this parameter forces the placement of the embedding matrix in regular memory and the CPU is used to resolve them, slightly slowing down the process as a result of data transfer between CPU and GPU memory. :param dropout: determines if there should be a dropout layer before returning the encoder output. :type dropout: Boolean """ super().__init__() self.config = encoder_config logger.debug(f" {self.name}") self.should_softmax = should_softmax self.sum_sequence_reducer = SequenceReducer(reduce_mode="sum") self.h3_embed = H3Embed( embedding_size, embeddings_on_cpu=embeddings_on_cpu, dropout=dropout, weights_initializer=weights_initializer, bias_initializer=bias_initializer, reduce_output="None", ) self.register_buffer( "aggregation_weights", torch.Tensor(get_initializer(weights_initializer)([H3_INPUT_SIZE, 1])) ) logger.debug(" FCStack") self.fc_stack = FCStack( first_layer_input_size=self.h3_embed.output_shape[0], layers=fc_layers, num_layers=num_fc_layers, default_output_size=output_size, default_use_bias=use_bias, default_weights_initializer=weights_initializer, default_bias_initializer=bias_initializer, default_norm=norm, default_norm_params=norm_params, default_activation=activation, default_dropout=dropout, ) def forward(self, inputs: torch.Tensor) -> EncoderOutputDict: """ :param inputs: The input vector fed into the encoder. Shape: [batch x H3_INPUT_SIZE], type torch.int8 :type inputs: Tensor """ # ================ Embeddings ================ input_vector = inputs embedded_h3 = self.h3_embed(input_vector) # ================ Weighted Sum ================ if self.should_softmax: weights = torch.softmax(self.aggregation_weights, dim=None) else: weights = self.aggregation_weights hidden = self.sum_sequence_reducer(embedded_h3[ENCODER_OUTPUT] * weights) # ================ FC Stack ================ # logger.debug(' flatten hidden: {0}'.format(hidden)) hidden = self.fc_stack(hidden) return {ENCODER_OUTPUT: hidden} @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return H3WeightedSumConfig @property def input_shape(self) -> torch.Size: return torch.Size([H3_INPUT_SIZE]) @property def output_shape(self) -> torch.Size: return self.fc_stack.output_shape @DeveloperAPI @register_encoder("rnn", H3) class H3RNN(Encoder): def __init__( self, embedding_size: int = 10, embeddings_on_cpu: bool = False, num_layers: int = 1, hidden_size: int = 10, cell_type: str = "rnn", bidirectional: bool = False, activation: str = "tanh", recurrent_activation: str = "sigmoid", use_bias: bool = True, unit_forget_bias: bool = True, weights_initializer: str = "xavier_uniform", recurrent_initializer: str = "orthogonal", bias_initializer: str = "zeros", dropout: float = 0.0, recurrent_dropout: float = 0.0, reduce_output: str = "last", encoder_config=None, **kwargs, ): """ :param embedding_size: it is the maximum embedding size, the actual size will be `min(vocabulary_size, embedding_size)` for `dense` representations and exactly `vocabulary_size` for the `sparse` encoding, where `vocabulary_size` is the number of different strings appearing in the training set in the column the feature is named after (plus 1 for ``). :type embedding_size: Integer :param embeddings_on_cpu: by default embeddings matrices are stored on GPU memory if a GPU is used, as it allows for faster access, but in some cases the embedding matrix may be really big and this parameter forces the placement of the embedding matrix in regular memory and the CPU is used to resolve them, slightly slowing down the process as a result of data transfer between CPU and GPU memory. :param num_layers: the number of stacked recurrent layers. :type num_layers: Integer :param cell_type: the type of recurrent cell to use. Available values are: `rnn`, `lstm`, `lstm_block`, `lstm`, `ln`, `lstm_cudnn`, `gru`, `gru_block`, `gru_cudnn`. For reference about the differences between the cells please refer to PyTorch's documentation. We suggest to use the `block` variants on CPU and the `cudnn` variants on GPU because of their increased speed. :type cell_type: str :param hidden_size: the size of the state of the rnn. :type hidden_size: Integer :param bidirectional: if `True` two recurrent networks will perform encoding in the forward and backward direction and their outputs will be concatenated. :type bidirectional: Boolean :param activation: Activation function to use. :type activation: string :param recurrent_activation: Activation function to use for the recurrent step. :type recurrent_activation: string :param use_bias: bool determines where to use a bias vector :type use_bias: bool :param unit_forget_bias: if True add 1 to the bias forget gate at initialization. :type unit_forget_bias: bool :param weights_initializer: Initializer for the weights (aka kernel) matrix :type weights_initializer: string :param recurrent_initializer: Initializer for the recurrent weights matrix :type recurrent_initializer: string :param bias_initializer: Initializer for the bias vector :type bias_initializer: string :param dropout: determines if there should be a dropout layer before returning the encoder output. :type dropout: float :param recurrent_dropout: Dropout rate for the RNN encoder of the H3 embeddings. :type recurrent_dropout: float """ super().__init__() self.config = encoder_config logger.debug(f" {self.name}") self.embedding_size = embedding_size self.h3_embed = H3Embed( embedding_size, embeddings_on_cpu=embeddings_on_cpu, dropout=dropout, weights_initializer=weights_initializer, bias_initializer=bias_initializer, reduce_output="None", ) logger.debug(" RecurrentStack") self.recurrent_stack = RecurrentStack( input_size=self.h3_embed.output_shape[0], max_sequence_length=H3_INPUT_SIZE, hidden_size=hidden_size, cell_type=cell_type, num_layers=num_layers, bidirectional=bidirectional, use_bias=use_bias, dropout=recurrent_dropout, ) def forward(self, inputs: torch.Tensor) -> EncoderOutputDict: """ :param inputs: The input vector fed into the encoder. Shape: [batch x H3_INPUT_SIZE], type torch.int8 :type inputs: Tensor """ # ================ Embeddings ================ embedded_h3 = self.h3_embed(inputs) # ================ RNN ================ hidden, final_state = self.recurrent_stack(embedded_h3[ENCODER_OUTPUT]) return {ENCODER_OUTPUT: hidden, ENCODER_OUTPUT_STATE: final_state} @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return H3RNNConfig @property def input_shape(self) -> torch.Size: return torch.Size([H3_INPUT_SIZE]) @property def output_shape(self) -> torch.Size: return self.recurrent_stack.output_shape ================================================ FILE: ludwig/encoders/image/__init__.py ================================================ import ludwig.encoders.image.base import ludwig.encoders.image.timm # noqa import ludwig.encoders.image.torchvision # noqa ================================================ FILE: ludwig/encoders/image/base.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import logging from typing import Any import torch from ludwig.api_annotations import DeveloperAPI from ludwig.constants import ENCODER_OUTPUT, ENCODER_OUTPUT_STATE, IMAGE from ludwig.encoders.base import Encoder from ludwig.encoders.registry import register_encoder from ludwig.encoders.types import EncoderOutputDict from ludwig.modules.convolutional_modules import Conv2DStack, ResNet, UNetDownStack from ludwig.modules.fully_connected_modules import FCStack from ludwig.modules.mlp_mixer_modules import MLPMixer from ludwig.schema.encoders.image.base import ( ImageEncoderConfig, MLPMixerConfig, ResNetConfig, Stacked2DCNNConfig, UNetEncoderConfig, ViTConfig, ) from ludwig.utils.torch_utils import FreezeModule logger = logging.getLogger(__name__) @DeveloperAPI class ImageEncoder(Encoder): pass @DeveloperAPI @register_encoder("stacked_cnn", IMAGE) class Stacked2DCNN(ImageEncoder): def __init__( self, height: int, width: int, conv_layers: list[dict] | None = None, num_conv_layers: int | None = None, num_channels: int = None, out_channels: int = 32, kernel_size: int | tuple[int] = 3, stride: int | tuple[int] = 1, padding: int | tuple[int] | str = "valid", dilation: int | tuple[int] = 1, conv_use_bias: bool = True, padding_mode: str = "zeros", conv_norm: str | None = None, conv_norm_params: dict[str, Any] | None = None, conv_activation: str = "relu", conv_dropout: int = 0, pool_function: str = "max", pool_kernel_size: int | tuple[int] = 2, pool_stride: int | tuple[int] = None, pool_padding: int | tuple[int] = 0, pool_dilation: int | tuple[int] = 1, groups: int = 1, fc_layers: list[dict] | None = None, num_fc_layers: int | None = 1, output_size: int = 128, fc_use_bias: bool = True, fc_weights_initializer: str = "xavier_uniform", fc_bias_initializer: str = "zeros", fc_norm: str | None = None, fc_norm_params: dict[str, Any] | None = None, fc_activation: str = "relu", fc_dropout: float = 0, encoder_config=None, **kwargs, ): super().__init__() self.config = encoder_config logger.debug(f" {self.name}") # map parameter input feature config names to internal names img_height = height img_width = width first_in_channels = num_channels self._input_shape = (first_in_channels, img_height, img_width) if first_in_channels is None: raise ValueError("first_in_channels must not be None.") logger.debug(" Conv2DStack") self.conv_stack_2d = Conv2DStack( img_height=img_height, img_width=img_width, layers=conv_layers, num_layers=num_conv_layers, first_in_channels=first_in_channels, default_out_channels=out_channels, default_kernel_size=kernel_size, default_stride=stride, default_padding=padding, default_dilation=dilation, default_groups=groups, default_use_bias=conv_use_bias, default_padding_mode=padding_mode, default_norm=conv_norm, default_norm_params=conv_norm_params, default_activation=conv_activation, default_dropout=conv_dropout, default_pool_function=pool_function, default_pool_kernel_size=pool_kernel_size, default_pool_stride=pool_stride, default_pool_padding=pool_padding, default_pool_dilation=pool_dilation, ) out_channels, img_height, img_width = self.conv_stack_2d.output_shape first_fc_layer_input_size = out_channels * img_height * img_width self.flatten = torch.nn.Flatten() logger.debug(" FCStack") self.fc_stack = FCStack( first_layer_input_size=first_fc_layer_input_size, layers=fc_layers, num_layers=num_fc_layers, default_output_size=output_size, default_use_bias=fc_use_bias, default_weights_initializer=fc_weights_initializer, default_bias_initializer=fc_bias_initializer, default_norm=fc_norm, default_norm_params=fc_norm_params, default_activation=fc_activation, default_dropout=fc_dropout, ) def forward(self, inputs: torch.Tensor) -> EncoderOutputDict: """ :param inputs: The inputs fed into the encoder. Shape: [batch x channels x height x width], type torch.uint8 """ hidden = self.conv_stack_2d(inputs) hidden = self.flatten(hidden) outputs = self.fc_stack(hidden) return {ENCODER_OUTPUT: outputs} @staticmethod def get_schema_cls() -> type[ImageEncoderConfig]: return Stacked2DCNNConfig @property def output_shape(self) -> torch.Size: return self.fc_stack.output_shape @property def input_shape(self) -> torch.Size: return torch.Size(self._input_shape) @DeveloperAPI @register_encoder("_resnet_legacy", IMAGE) class ResNetEncoder(ImageEncoder): def __init__( self, height: int, width: int, resnet_size: int = 50, num_channels: int = 3, out_channels: int = 16, kernel_size: int | tuple[int] = 3, conv_stride: int | tuple[int] = 1, first_pool_kernel_size: int | tuple[int] = None, first_pool_stride: int | tuple[int] = None, batch_norm_momentum: float = 0.1, batch_norm_epsilon: float = 0.001, fc_layers: list[dict] | None = None, num_fc_layers: int | None = 1, output_size: int = 256, use_bias: bool = True, weights_initializer: str = "xavier_uniform", bias_initializer: str = "zeros", norm: str | None = None, norm_params: dict[str, Any] | None = None, activation: str = "relu", dropout: float = 0, encoder_config=None, **kwargs, ): super().__init__() self.config = encoder_config logger.debug(f" {self.name}") # map parameter input feature config names to internal names img_height = height img_width = width first_in_channels = num_channels self._input_shape = (first_in_channels, img_height, img_width) logger.debug(" ResNet") self.resnet = ResNet( img_height=img_height, img_width=img_width, first_in_channels=first_in_channels, out_channels=out_channels, resnet_size=resnet_size, kernel_size=kernel_size, conv_stride=conv_stride, first_pool_kernel_size=first_pool_kernel_size, first_pool_stride=first_pool_stride, batch_norm_momentum=batch_norm_momentum, batch_norm_epsilon=batch_norm_epsilon, ) first_fc_layer_input_size = self.resnet.output_shape[0] logger.debug(" FCStack") self.fc_stack = FCStack( first_layer_input_size=first_fc_layer_input_size, layers=fc_layers, num_layers=num_fc_layers, default_output_size=output_size, default_use_bias=use_bias, default_weights_initializer=weights_initializer, default_bias_initializer=bias_initializer, default_norm=norm, default_norm_params=norm_params, default_activation=activation, default_dropout=dropout, ) def forward(self, inputs: torch.Tensor) -> EncoderOutputDict: hidden = self.resnet(inputs) axes = [2, 3] hidden = torch.mean(hidden, axes) hidden = self.fc_stack(hidden) return {ENCODER_OUTPUT: hidden} @staticmethod def get_schema_cls() -> type[ImageEncoderConfig]: return ResNetConfig @property def output_shape(self) -> torch.Size: return self.fc_stack.output_shape @property def input_shape(self) -> torch.Size: return torch.Size(self._input_shape) @DeveloperAPI @register_encoder("mlp_mixer", IMAGE) class MLPMixerEncoder(ImageEncoder): def __init__( self, height: int, width: int, num_channels: int = None, patch_size: int = 16, embed_size: int = 512, token_size: int = 2048, channel_dim: int = 256, num_layers: int = 8, dropout: float = 0.0, avg_pool: bool = True, encoder_config=None, **kwargs, ): super().__init__() self.config = encoder_config logger.debug(f" {self.name}") # map parameter input feature config names to internal names img_height = height img_width = width in_channels = num_channels if num_channels is None: raise RuntimeError("num_channels must not be None") self._input_shape = (in_channels, img_height, img_width) logger.debug(" MLPMixer") self.mlp_mixer = MLPMixer( img_height=img_height, img_width=img_width, in_channels=in_channels, patch_size=patch_size, embed_size=embed_size, token_size=token_size, channel_dim=channel_dim, num_layers=num_layers, dropout=dropout, avg_pool=avg_pool, ) self._output_shape = self.mlp_mixer.output_shape def forward(self, inputs: torch.Tensor) -> EncoderOutputDict: hidden = self.mlp_mixer(inputs) return {ENCODER_OUTPUT: hidden} @staticmethod def get_schema_cls() -> type[ImageEncoderConfig]: return MLPMixerConfig @property def input_shape(self) -> torch.Size: return torch.Size(self._input_shape) @property def output_shape(self) -> torch.Size: return self._output_shape @DeveloperAPI @register_encoder("_vit_legacy", IMAGE) class ViTEncoder(ImageEncoder): def __init__( self, height: int, width: int, num_channels: int = 3, use_pretrained: bool = True, pretrained_model: str = "google/vit-base-patch16-224", saved_weights_in_checkpoint: bool = False, hidden_size: int = 768, num_hidden_layers: int = 12, num_attention_heads: int = 12, intermediate_size: int = 3072, hidden_act: str = "gelu", hidden_dropout_prob: float = 0.1, attention_probs_dropout_prob: float = 0.1, initializer_range: float = 0.02, layer_norm_eps: float = 1e-12, gradient_checkpointing: bool = False, patch_size: int = 16, trainable: bool = True, encoder_config=None, **kwargs, ): """Creates a ViT encoder using transformers.ViTModel. use_pretrained: If True, uses a pretrained transformer based on the pretrained_model argument. pretrained: If str, expects the path to a pretrained model or the id of a model on huggingface.co, and ignores the configuration provided in the arguments. """ super().__init__() self.config = encoder_config try: from transformers import ViTConfig, ViTModel except ModuleNotFoundError: raise RuntimeError( " transformers is not installed. " "In order to install all image feature dependencies run " "pip install ludwig[image]" ) # map parameter input feature config names to internal names img_height = height img_width = width in_channels = num_channels img_width = img_width or img_height if img_width != img_height: raise ValueError("img_height and img_width should be identical.") self._input_shape = (in_channels, img_height, img_width) config_dict: dict if use_pretrained and not saved_weights_in_checkpoint: config_dict = { "pretrained_model_name_or_path": pretrained_model, } transformer = ViTModel.from_pretrained(**config_dict) else: config_dict = { "image_size": img_height, "num_channels": in_channels, "patch_size": patch_size, "hidden_size": hidden_size, "num_hidden_layers": num_hidden_layers, "num_attention_heads": num_attention_heads, "intermediate_size": intermediate_size, "hidden_act": hidden_act, "hidden_dropout_prob": hidden_dropout_prob, "attention_probs_dropout_prob": attention_probs_dropout_prob, "initializer_range": initializer_range, "layer_norm_eps": layer_norm_eps, "gradient_checkpointing": gradient_checkpointing, } config = ViTConfig(**config_dict) transformer = ViTModel(config) self.transformer = FreezeModule(transformer, frozen=not trainable) self._output_shape = (transformer.config.hidden_size,) def forward(self, inputs: torch.Tensor, head_mask: torch.Tensor | None = None) -> EncoderOutputDict: output = self.transformer.module(inputs, head_mask=head_mask) return {ENCODER_OUTPUT: output.pooler_output} @staticmethod def get_schema_cls() -> type[ImageEncoderConfig]: return ViTConfig @property def input_shape(self) -> torch.Size: return torch.Size(self._input_shape) @property def output_shape(self) -> torch.Size: return torch.Size(self._output_shape) @DeveloperAPI @register_encoder("unet", IMAGE) class UNetEncoder(ImageEncoder): def __init__( self, height: int, width: int, num_channels: int = 3, conv_norm: str | None = None, encoder_config=None, **kwargs, ): super().__init__() self.config = encoder_config logger.debug(f" {self.name}") if height % 16 or width % 16: raise ValueError(f"Invalid `height` {height} or `width` {width} for unet encoder") self.unet = UNetDownStack( img_height=height, img_width=width, in_channels=num_channels, norm=conv_norm, ) def forward(self, inputs: torch.Tensor) -> EncoderOutputDict: hidden, skips = self.unet(inputs) return {ENCODER_OUTPUT: hidden, ENCODER_OUTPUT_STATE: skips} @staticmethod def get_schema_cls() -> type[ImageEncoderConfig]: return UNetEncoderConfig @property def output_shape(self) -> torch.Size: return self.unet.output_shape @property def input_shape(self) -> torch.Size: return self.unet.input_shape ================================================ FILE: ludwig/encoders/image/timm.py ================================================ import logging import torch from ludwig.api_annotations import DeveloperAPI from ludwig.constants import ENCODER_OUTPUT, IMAGE from ludwig.encoders.image.base import ImageEncoder from ludwig.encoders.registry import register_encoder from ludwig.encoders.types import EncoderOutputDict from ludwig.schema.encoders.base import BaseEncoderConfig from ludwig.schema.encoders.image.timm import ( TimmCAFormerEncoderConfig, TimmConvFormerEncoderConfig, TimmEncoderConfig, TimmPoolFormerEncoderConfig, ) logger = logging.getLogger(__name__) def _get_timm(): try: import timm except ImportError: raise ImportError("timm is required for this encoder. Install it with: pip install timm") return timm @DeveloperAPI @register_encoder("timm", IMAGE) class TimmEncoder(ImageEncoder): """Wraps any model from the timm (pytorch-image-models) library as a Ludwig image encoder. This provides access to hundreds of pretrained vision models including MetaFormer variants (CAFormer, ConvFormer, PoolFormer), ConvNeXt V2, EfficientFormer, and many more. Usage in Ludwig config: encoder: type: timm model_name: caformer_s18.sail_in22k_ft_in1k use_pretrained: true trainable: true """ def __init__( self, model_name: str = "caformer_s18", use_pretrained: bool = True, trainable: bool = True, saved_weights_in_checkpoint: bool = False, encoder_config=None, **kwargs, ): super().__init__() self.config = encoder_config logger.debug(f" {self.name}") timm = _get_timm() pretrained = use_pretrained and not saved_weights_in_checkpoint if pretrained: logger.info(f"Instantiating timm image encoder '{model_name}' with pretrained weights.") else: logger.info(f"Instantiating timm image encoder '{model_name}' without pretrained weights.") # num_classes=0 removes the classification head, returning pooled features self.model = timm.create_model(model_name, pretrained=pretrained, num_classes=0) # Get the model's expected input config for input_shape data_config = timm.data.resolve_model_data_config(self.model) self._input_size = data_config["input_size"] # (C, H, W) # Compute output dim by running a dummy forward with torch.no_grad(): dummy = torch.zeros(1, *self._input_size) out = self.model(dummy) self._output_dim = out.shape[-1] for p in self.model.parameters(): p.requires_grad_(trainable) def forward(self, inputs: torch.Tensor) -> EncoderOutputDict: return {ENCODER_OUTPUT: self.model(inputs)} @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return TimmEncoderConfig @property def output_shape(self) -> torch.Size: return torch.Size([self._output_dim]) @property def input_shape(self) -> torch.Size: return torch.Size(self._input_size) @DeveloperAPI @register_encoder("caformer", IMAGE) class TimmCAFormerEncoder(TimmEncoder): """CAFormer encoder — hybrid Conv+Attention MetaFormer achieving SOTA accuracy on ImageNet. Variants: s18 (26M, 83.6%), s36 (39M, 84.5%), m36 (56M, 85.2%), b36 (99M, 85.5%). """ def __init__(self, model_name: str = "caformer_s18", **kwargs): super().__init__(model_name=model_name, **kwargs) @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return TimmCAFormerEncoderConfig @DeveloperAPI @register_encoder("convformer", IMAGE) class TimmConvFormerEncoder(TimmEncoder): """ConvFormer encoder — pure CNN MetaFormer that outperforms ConvNeXt.""" def __init__(self, model_name: str = "convformer_s18", **kwargs): super().__init__(model_name=model_name, **kwargs) @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return TimmConvFormerEncoderConfig @DeveloperAPI @register_encoder("poolformer", IMAGE) class TimmPoolFormerEncoder(TimmEncoder): """PoolFormer encoder — MetaFormer using simple average pooling as token mixer.""" def __init__(self, model_name: str = "poolformerv2_s12", **kwargs): super().__init__(model_name=model_name, **kwargs) @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return TimmPoolFormerEncoderConfig ================================================ FILE: ludwig/encoders/image/torchvision.py ================================================ import logging import os from abc import abstractmethod import torch import torchvision.models as tvm from ludwig.api_annotations import DeveloperAPI from ludwig.constants import ENCODER_OUTPUT, IMAGE from ludwig.encoders.image.base import ImageEncoder from ludwig.encoders.registry import register_encoder from ludwig.encoders.types import EncoderOutputDict from ludwig.schema.encoders.base import BaseEncoderConfig from ludwig.schema.encoders.image.torchvision import ( TVAlexNetEncoderConfig, TVConvNeXtEncoderConfig, TVDenseNetEncoderConfig, TVEfficientNetEncoderConfig, TVGoogLeNetEncoderConfig, TVInceptionV3EncoderConfig, TVMaxVitEncoderConfig, TVMNASNetEncoderConfig, TVMobileNetV2EncoderConfig, TVMobileNetV3EncoderConfig, TVRegNetEncoderConfig, TVResNetEncoderConfig, TVResNeXtEncoderConfig, TVShuffleNetV2EncoderConfig, TVSqueezeNetEncoderConfig, TVSwinTransformerEncoderConfig, TVVGGEncoderConfig, TVViTEncoderConfig, TVWideResNetEncoderConfig, ) from ludwig.utils.image_utils import register_torchvision_model_variants, torchvision_model_registry, TVModelVariant logger = logging.getLogger(__name__) @DeveloperAPI class TVBaseEncoder(ImageEncoder): def __init__( self, model_variant: str | int = None, use_pretrained: bool = True, saved_weights_in_checkpoint: bool = False, model_cache_dir: str | None = None, trainable: bool = True, **kwargs, ): super().__init__() logger.debug(f" {self.name}") # map parameter input feature config names to internal names self.model_variant = model_variant self.use_pretrained = use_pretrained self.model_cache_dir = model_cache_dir # remove any Ludwig specific keyword parameters kwargs.pop("encoder_config", None) kwargs.pop("type", None) kwargs.pop("skip", None) # cache pre-trained models if requested # based on https://github.com/pytorch/vision/issues/616#issuecomment-428637564 if self.model_cache_dir is not None: os.environ["TORCH_HOME"] = self.model_cache_dir # retrieve function to create requested model self.create_model = torchvision_model_registry[self.torchvision_model_type][ self.model_variant ].create_model_function # get weight specification if use_pretrained and not saved_weights_in_checkpoint: weights_specification = torchvision_model_registry[self.torchvision_model_type][ self.model_variant ].model_weights.DEFAULT logger.info( f"Instantiating torchvision image encoder '{self.torchvision_model_type}' with pretrained weights: " f"{weights_specification}." ) else: weights_specification = None if saved_weights_in_checkpoint: logger.info( f"Instantiating torchvision image encoder: '{self.torchvision_model_type}' " "with weights saved in the checkpoint." ) else: logger.info( f"Instantiating torchvision image encoder: '{self.torchvision_model_type}' " "with no pretrained weights." ) # get torchvision transforms object transforms_obj = torchvision_model_registry[self.torchvision_model_type][ self.model_variant ].model_weights.DEFAULT.transforms() # capture key attributes from torchvision transform for later use self.num_channels = len(transforms_obj.mean) self.normalize_mean = transforms_obj.mean self.normalize_std = transforms_obj.std self.crop_size = transforms_obj.crop_size logger.debug(f" {self.torchvision_model_type}") # create pretrained model with pretrained weights or None for untrained model self.model = self.create_model(weights=weights_specification, **kwargs) # remove final classification layer self._remove_softmax_layer() # freeze parameters if requested for p in self.model.parameters(): p.requires_grad_(trainable) def forward(self, inputs: torch.Tensor) -> EncoderOutputDict: return {ENCODER_OUTPUT: self.model(inputs)} @abstractmethod def _remove_softmax_layer(self): """Model specific method that allows the final softmax layer to be implemented in the Ludwig Decoder component. The model specific implementation should change the final softmax layer in the torchvision model architecture to torch.nn.Identity(). This allows the output tensor from the preceding layer to be passed to the Ludwig Combiner and then to the Decoder. Returns: None """ raise NotImplementedError() @property def output_shape(self) -> torch.Size: # create synthetic image and run through forward method inputs = torch.randn([1, *self.input_shape]) output = self.model(inputs) return torch.Size(output.shape[1:]) @property def input_shape(self) -> torch.Size: # expected shape after all pre-processing # len(transforms_obj.mean) determines the number of channels # transforms_obj.crop_size determines the height and width of image # [num_channels, height, width] return torch.Size([self.num_channels, *(2 * self.crop_size)]) @DeveloperAPI @register_torchvision_model_variants( [ TVModelVariant(variant_id="base", create_model_function=tvm.alexnet, model_weights=tvm.AlexNet_Weights), ] ) @register_encoder("alexnet", IMAGE) class TVAlexNetEncoder(TVBaseEncoder): # specify base model type torchvision_model_type: str = "alexnet" def __init__( self, **kwargs, ): logger.debug(f" {self.name}") super().__init__(**kwargs) # TODO: discussion w/ justin # @property # def get_torchvision_model_type(self): # return "alexnet" def _remove_softmax_layer(self): self.model.classifier[-1] = torch.nn.Identity() @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return TVAlexNetEncoderConfig @DeveloperAPI @register_torchvision_model_variants( [ TVModelVariant( variant_id="tiny", create_model_function=tvm.convnext_tiny, model_weights=tvm.ConvNeXt_Tiny_Weights ), TVModelVariant( variant_id="small", create_model_function=tvm.convnext_small, model_weights=tvm.ConvNeXt_Small_Weights ), TVModelVariant( variant_id="base", create_model_function=tvm.convnext_base, model_weights=tvm.ConvNeXt_Base_Weights ), TVModelVariant( variant_id="large", create_model_function=tvm.convnext_large, model_weights=tvm.ConvNeXt_Large_Weights ), ] ) @register_encoder("convnext", IMAGE) class TVConvNeXtEncoder(TVBaseEncoder): # specify base torchvision model torchvision_model_type: str = "convnext" def __init__( self, **kwargs, ): logger.debug(f" {self.name}") super().__init__(**kwargs) def _remove_softmax_layer(self) -> None: self.model.classifier[-1] = torch.nn.Identity() @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return TVConvNeXtEncoderConfig @DeveloperAPI @register_torchvision_model_variants( [ TVModelVariant(121, tvm.densenet121, tvm.DenseNet121_Weights), TVModelVariant(161, tvm.densenet161, tvm.DenseNet161_Weights), TVModelVariant(169, tvm.densenet169, tvm.DenseNet169_Weights), TVModelVariant(201, tvm.densenet201, tvm.DenseNet201_Weights), ] ) @register_encoder("densenet", IMAGE) class TVDenseNetEncoder(TVBaseEncoder): # specify base torchvision model torchvision_model_type: str = "densenet" def __init__( self, **kwargs, ): logger.debug(f" {self.name}") super().__init__(**kwargs) def _remove_softmax_layer(self) -> None: self.model.classifier = torch.nn.Identity() @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return TVDenseNetEncoderConfig @DeveloperAPI @register_torchvision_model_variants( [ TVModelVariant("b0", tvm.efficientnet_b0, tvm.EfficientNet_B0_Weights), TVModelVariant("b1", tvm.efficientnet_b1, tvm.EfficientNet_B1_Weights), TVModelVariant("b2", tvm.efficientnet_b2, tvm.EfficientNet_B2_Weights), TVModelVariant("b3", tvm.efficientnet_b3, tvm.EfficientNet_B3_Weights), TVModelVariant("b4", tvm.efficientnet_b4, tvm.EfficientNet_B4_Weights), TVModelVariant("b5", tvm.efficientnet_b5, tvm.EfficientNet_B5_Weights), TVModelVariant("b6", tvm.efficientnet_b6, tvm.EfficientNet_B6_Weights), TVModelVariant("b7", tvm.efficientnet_b7, tvm.EfficientNet_B7_Weights), TVModelVariant("v2_s", tvm.efficientnet_v2_s, tvm.EfficientNet_V2_S_Weights), TVModelVariant("v2_m", tvm.efficientnet_v2_m, tvm.EfficientNet_V2_M_Weights), TVModelVariant("v2_l", tvm.efficientnet_v2_l, tvm.EfficientNet_V2_L_Weights), ] ) @register_encoder("efficientnet", IMAGE) class TVEfficientNetEncoder(TVBaseEncoder): # specify base torchvision model torchvision_model_type: str = "efficientnet" def __init__( self, **kwargs, ): logger.debug(f" {self.name}") super().__init__(**kwargs) def _remove_softmax_layer(self) -> None: self.model.classifier[-1] = torch.nn.Identity() @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return TVEfficientNetEncoderConfig @DeveloperAPI @register_torchvision_model_variants( [ TVModelVariant("base", tvm.googlenet, tvm.GoogLeNet_Weights), ] ) @register_encoder("googlenet", IMAGE) class TVGoogLeNetEncoder(TVBaseEncoder): # specify base torchvision model torchvision_model_type: str = "googlenet" def __init__( self, **kwargs, ): logger.debug(f" {self.name}") super().__init__(**kwargs) # if auxiliary network exists, eliminate auxiliary network # to resolve issue when loading a saved model which does not # contain the auxiliary network if self.model.aux_logits: self.model.aux_logits = False self.model.aux1 = None self.model.aux2 = None def _remove_softmax_layer(self) -> None: self.model.fc = torch.nn.Identity() @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return TVGoogLeNetEncoderConfig @DeveloperAPI @register_torchvision_model_variants( [ TVModelVariant("base", tvm.inception_v3, tvm.Inception_V3_Weights), ] ) @register_encoder("inceptionv3", IMAGE) class TVInceptionV3Encoder(TVBaseEncoder): # specify base torchvision model torchvision_model_type: str = "inceptionv3" def __init__( self, **kwargs, ): logger.debug(f" {self.name}") super().__init__(**kwargs) # if auxiliary network exists, eliminate auxiliary network # to resolve issue when loading a saved model which does not # contain the auxiliary network if self.model.aux_logits: self.model.aux_logits = False self.model.AuxLogits = None def _remove_softmax_layer(self) -> None: self.model.fc = torch.nn.Identity() @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return TVInceptionV3EncoderConfig @DeveloperAPI @register_torchvision_model_variants( [ TVModelVariant("t", tvm.maxvit_t, tvm.MaxVit_T_Weights), ] ) @register_encoder("maxvit", IMAGE) class TVMaxVitEncoder(TVBaseEncoder): # specify base torchvision model torchvision_model_type: str = "maxvit" def __init__( self, **kwargs, ): logger.debug(f" {self.name}") super().__init__(**kwargs) def _remove_softmax_layer(self) -> None: self.model.classifier[-1] = torch.nn.Identity() @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return TVMaxVitEncoderConfig @DeveloperAPI @register_torchvision_model_variants( [ TVModelVariant("0_5", tvm.mnasnet0_5, tvm.mnasnet.MNASNet0_5_Weights), TVModelVariant("0_75", tvm.mnasnet0_75, tvm.mnasnet.MNASNet0_75_Weights), TVModelVariant("1_0", tvm.mnasnet1_0, tvm.mnasnet.MNASNet1_0_Weights), TVModelVariant("1_3", tvm.mnasnet1_3, tvm.mnasnet.MNASNet1_3_Weights), ] ) @register_encoder("mnasnet", IMAGE) class TVMNASNetEncoder(TVBaseEncoder): # specify base torchvision model torchvision_model_type: str = "mnasnet" def __init__( self, **kwargs, ): logger.debug(f" {self.name}") super().__init__(**kwargs) def _remove_softmax_layer(self) -> None: self.model.classifier[-1] = torch.nn.Identity() @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return TVMNASNetEncoderConfig @DeveloperAPI @register_torchvision_model_variants( [ TVModelVariant("base", tvm.mobilenet_v2, tvm.MobileNet_V2_Weights), ] ) @register_encoder("mobilenetv2", IMAGE) class TVMobileNetV2Encoder(TVBaseEncoder): # specify base torchvision model torchvision_model_type: str = "mobilenetv2" def __init__( self, **kwargs, ): logger.debug(f" {self.name}") super().__init__(**kwargs) def _remove_softmax_layer(self) -> None: self.model.classifier[-1] = torch.nn.Identity() @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return TVMobileNetV2EncoderConfig @DeveloperAPI @register_torchvision_model_variants( [ TVModelVariant("small", tvm.mobilenet_v3_small, tvm.MobileNet_V3_Small_Weights), TVModelVariant("large", tvm.mobilenet_v3_large, tvm.MobileNet_V3_Large_Weights), ] ) @register_encoder("mobilenetv3", IMAGE) class TVMobileNetV3Encoder(TVBaseEncoder): # specify base torchvision model torchvision_model_type: str = "mobilenetv3" def __init__( self, **kwargs, ): logger.debug(f" {self.name}") super().__init__(**kwargs) def _remove_softmax_layer(self) -> None: self.model.classifier[-1] = torch.nn.Identity() @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return TVMobileNetV3EncoderConfig @DeveloperAPI @register_torchvision_model_variants( [ TVModelVariant("x_16gf", tvm.regnet_x_16gf, tvm.RegNet_X_16GF_Weights), TVModelVariant("x_1_6gf", tvm.regnet_x_1_6gf, tvm.RegNet_X_1_6GF_Weights), TVModelVariant("x_32gf", tvm.regnet_x_32gf, tvm.RegNet_X_32GF_Weights), TVModelVariant("x_3_2gf", tvm.regnet_x_3_2gf, tvm.RegNet_X_3_2GF_Weights), TVModelVariant("x_400mf", tvm.regnet_x_400mf, tvm.RegNet_X_400MF_Weights), TVModelVariant("x_800mf", tvm.regnet_x_800mf, tvm.RegNet_X_800MF_Weights), TVModelVariant("x_8gf", tvm.regnet_x_8gf, tvm.RegNet_X_8GF_Weights), TVModelVariant("y_128gf", tvm.regnet_y_128gf, tvm.RegNet_Y_128GF_Weights), TVModelVariant("y_16gf", tvm.regnet_y_16gf, tvm.RegNet_Y_16GF_Weights), TVModelVariant("y_1_6gf", tvm.regnet_y_1_6gf, tvm.RegNet_Y_1_6GF_Weights), TVModelVariant("y_32gf", tvm.regnet_y_32gf, tvm.RegNet_Y_32GF_Weights), TVModelVariant("y_3_2gf", tvm.regnet_y_3_2gf, tvm.RegNet_Y_3_2GF_Weights), TVModelVariant("y_400mf", tvm.regnet_y_400mf, tvm.RegNet_Y_400MF_Weights), TVModelVariant("y_800mf", tvm.regnet_y_800mf, tvm.RegNet_Y_800MF_Weights), TVModelVariant("y_8gf", tvm.regnet_y_8gf, tvm.RegNet_Y_8GF_Weights), ] ) @register_encoder("regnet", IMAGE) class TVRegNetEncoder(TVBaseEncoder): # specify base torchvision model torchvision_model_type: str = "regnet" def __init__( self, **kwargs, ): logger.debug(f" {self.name}") super().__init__(**kwargs) def _remove_softmax_layer(self) -> None: self.model.fc = torch.nn.Identity() @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return TVRegNetEncoderConfig @DeveloperAPI @register_torchvision_model_variants( [ TVModelVariant(18, tvm.resnet18, tvm.ResNet18_Weights), TVModelVariant(34, tvm.resnet34, tvm.ResNet34_Weights), TVModelVariant(50, tvm.resnet50, tvm.ResNet50_Weights), TVModelVariant(101, tvm.resnet101, tvm.ResNet101_Weights), TVModelVariant(152, tvm.resnet152, tvm.ResNet152_Weights), ] ) @register_encoder("resnet", IMAGE) class TVResNetEncoder(TVBaseEncoder): # specify base torchvision model torchvision_model_type: str = "resnet" def __init__( self, **kwargs, ): logger.debug(f" {self.name}") super().__init__(**kwargs) def _remove_softmax_layer(self) -> None: self.model.fc = torch.nn.Identity() @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return TVResNetEncoderConfig @DeveloperAPI @register_torchvision_model_variants( [ TVModelVariant("50_32x4d", tvm.resnext50_32x4d, tvm.ResNeXt50_32X4D_Weights), TVModelVariant("101_328xd", tvm.resnext101_32x8d, tvm.ResNeXt101_32X8D_Weights), TVModelVariant("101_64x4d", tvm.resnext101_64x4d, tvm.ResNeXt101_64X4D_Weights), ] ) @register_encoder("resnext", IMAGE) class TVResNeXtEncoder(TVBaseEncoder): # specify base torchvision model torchvision_model_type: str = "resnext" def __init__( self, **kwargs, ): logger.debug(f" {self.name}") super().__init__(**kwargs) def _remove_softmax_layer(self) -> None: self.model.fc = torch.nn.Identity() @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return TVResNeXtEncoderConfig @DeveloperAPI @register_torchvision_model_variants( [ TVModelVariant("x0_5", tvm.shufflenet_v2_x0_5, tvm.ShuffleNet_V2_X0_5_Weights), TVModelVariant("x1_0", tvm.shufflenet_v2_x1_0, tvm.ShuffleNet_V2_X1_0_Weights), TVModelVariant("x1_5", tvm.shufflenet_v2_x1_5, tvm.ShuffleNet_V2_X1_5_Weights), TVModelVariant("x2_0", tvm.shufflenet_v2_x2_0, tvm.ShuffleNet_V2_X2_0_Weights), ] ) @register_encoder("shufflenet_v2", IMAGE) class TVShuffleNetV2Encoder(TVBaseEncoder): # specify base torchvision model torchvision_model_type: str = "shufflenet_v2" def __init__( self, **kwargs, ): logger.debug(f" {self.name}") super().__init__(**kwargs) def _remove_softmax_layer(self) -> None: self.model.fc = torch.nn.Identity() @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return TVShuffleNetV2EncoderConfig @DeveloperAPI @register_torchvision_model_variants( [ TVModelVariant("1_0", tvm.squeezenet1_0, tvm.SqueezeNet1_0_Weights), TVModelVariant("1_1", tvm.squeezenet1_1, tvm.SqueezeNet1_1_Weights), ] ) @register_encoder("squeezenet", IMAGE) class TVSqueezeNetEncoder(TVBaseEncoder): # specify base torchvision model torchvision_model_type: str = "squeezenet" def __init__( self, **kwargs, ): logger.debug(f" {self.name}") super().__init__(**kwargs) def _remove_softmax_layer(self) -> None: # SqueezeNet does not have a final nn.Linear() layer # Use flatten output from last AdaptiveAvgPool2d layer # as encoder output. pass @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return TVSqueezeNetEncoderConfig @DeveloperAPI @register_torchvision_model_variants( [ TVModelVariant("t", tvm.swin_t, tvm.Swin_T_Weights), TVModelVariant("s", tvm.swin_s, tvm.Swin_S_Weights), TVModelVariant("b", tvm.swin_b, tvm.Swin_B_Weights), ] ) @register_encoder("swin_transformer", IMAGE) class TVSwinTransformerEncoder(TVBaseEncoder): # specify base torchvision model torchvision_model_type: str = "swin_transformer" def __init__( self, **kwargs, ): logger.debug(f" {self.name}") super().__init__(**kwargs) def _remove_softmax_layer(self) -> None: self.model.head = torch.nn.Identity() @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return TVSwinTransformerEncoderConfig @DeveloperAPI @register_torchvision_model_variants( [ TVModelVariant(11, tvm.vgg11, tvm.VGG11_Weights), TVModelVariant("11_bn", tvm.vgg11_bn, tvm.VGG11_BN_Weights), TVModelVariant(13, tvm.vgg13, tvm.VGG13_Weights), TVModelVariant("13_bn", tvm.vgg13_bn, tvm.VGG13_BN_Weights), TVModelVariant(16, tvm.vgg16, tvm.VGG16_Weights), TVModelVariant("16_bn", tvm.vgg16_bn, tvm.VGG16_BN_Weights), TVModelVariant(19, tvm.vgg19, tvm.VGG19_Weights), TVModelVariant("19_bn", tvm.vgg19_bn, tvm.VGG19_BN_Weights), ] ) @register_encoder("vgg", IMAGE) class TVVGGEncoder(TVBaseEncoder): # specify base torchvison model torchvision_model_type: str = "vgg" def __init__( self, **kwargs, ): logger.debug(f" {self.name}") super().__init__(**kwargs) def _remove_softmax_layer(self) -> None: self.model.classifier[-1] = torch.nn.Identity() @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return TVVGGEncoderConfig @DeveloperAPI @register_torchvision_model_variants( [ TVModelVariant("b_16", tvm.vit_b_16, tvm.ViT_B_16_Weights), TVModelVariant("b_32", tvm.vit_b_32, tvm.ViT_B_32_Weights), TVModelVariant("l_16", tvm.vit_l_16, tvm.ViT_L_16_Weights), TVModelVariant("l_32", tvm.vit_l_32, tvm.ViT_L_32_Weights), TVModelVariant("h_14", tvm.vit_h_14, tvm.ViT_H_14_Weights), ] ) @register_encoder("vit", IMAGE) class TVViTEncoder(TVBaseEncoder): # specify base torchvision model torchvision_model_type: str = "vit" def __init__( self, **kwargs, ): logger.debug(f" {self.name}") # Depending on model variant and weight specification, the expected image size # will vary. This code determines at run time what the expected image size will be # and adds to the kwargs dictionary the parameter that specifies the image size. # this is needed only if not using pretrained weights. If pre-trained weights are # specified, then the correct image size is set. if not kwargs["use_pretrained"]: weights_specification = torchvision_model_registry[self.torchvision_model_type][ kwargs["model_variant"] ].model_weights.DEFAULT kwargs["image_size"] = weights_specification.transforms.keywords["crop_size"] super().__init__(**kwargs) def _remove_softmax_layer(self) -> None: self.model.heads[-1] = torch.nn.Identity() @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return TVViTEncoderConfig @DeveloperAPI @register_torchvision_model_variants( [ TVModelVariant("50_2", tvm.wide_resnet50_2, tvm.Wide_ResNet50_2_Weights), TVModelVariant("101_2", tvm.wide_resnet101_2, tvm.Wide_ResNet101_2_Weights), ] ) @register_encoder("wide_resnet", IMAGE) class TVWideResNetEncoder(TVBaseEncoder): # specify base torchvision model torchvision_model_type: str = "wide_resnet" def __init__( self, **kwargs, ): logger.debug(f" {self.name}") super().__init__(**kwargs) def _remove_softmax_layer(self) -> None: self.model.fc = torch.nn.Identity() @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return TVWideResNetEncoderConfig ================================================ FILE: ludwig/encoders/registry.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.encoders.base import Encoder from ludwig.utils.registry import Registry _encoder_registry = Registry() _sequence_encoder_registry = Registry() @DeveloperAPI def get_encoder_registry() -> Registry: return _encoder_registry @DeveloperAPI def get_sequence_encoder_registry() -> Registry: return _sequence_encoder_registry def register_sequence_encoder(name: str): def wrap(cls): get_sequence_encoder_registry()[name] = cls return cls return wrap def register_encoder(name: str, features: str | list[str]): if isinstance(features, str): features = [features] def update_registry(registry_getter_fn, cls, feature): feature_registry = registry_getter_fn().get(feature, {}) feature_registry[name] = cls registry_getter_fn()[feature] = feature_registry def wrap(cls): for feature in features: update_registry(get_encoder_registry, cls, feature) return cls return wrap def get_encoder_cls(feature: str, name: str) -> type[Encoder]: return get_encoder_registry()[feature][name] def get_encoder_classes(feature: str) -> dict[str, type[Encoder]]: return get_encoder_registry()[feature] ================================================ FILE: ludwig/encoders/sequence_encoders.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import logging import torch from torch import nn from ludwig.api_annotations import DeveloperAPI from ludwig.constants import AUDIO, ENCODER_OUTPUT, ENCODER_OUTPUT_STATE, SEQUENCE, TEXT, TIMESERIES from ludwig.encoders.base import Encoder from ludwig.encoders.registry import register_encoder, register_sequence_encoder from ludwig.encoders.types import EncoderOutputDict from ludwig.modules.attention_modules import TransformerStack from ludwig.modules.convolutional_modules import Conv1DStack, ParallelConv1D, ParallelConv1DStack from ludwig.modules.embedding_modules import EmbedSequence, TokenAndPositionEmbedding from ludwig.modules.fully_connected_modules import FCStack from ludwig.modules.recurrent_modules import RecurrentStack from ludwig.modules.reduction_modules import SequenceReducer from ludwig.schema.encoders.sequence_encoders import ( ParallelCNNConfig, SequenceEmbedConfig, SequenceEncoderConfig, SequencePassthroughConfig, StackedCNNConfig, StackedCNNRNNConfig, StackedParallelCNNConfig, StackedRNNConfig, StackedTransformerConfig, ) logger = logging.getLogger(__name__) class SequenceEncoder(Encoder): pass @DeveloperAPI @register_encoder("passthrough", [SEQUENCE, TEXT, TIMESERIES]) class SequencePassthroughEncoder(SequenceEncoder): def __init__( self, reduce_output: str = None, max_sequence_length: int = 256, encoding_size: int = None, encoder_config=None, **kwargs, ): """ :param reduce_output: defines how to reduce the output tensor along the `s` sequence length dimension if the rank of the tensor is greater than 2. Available values are: `sum`, `mean` or `avg`, `max`, `concat` (concatenates along the first dimension), `last` (returns the last vector of the first dimension) and `None` or `null` (which does not reduce and returns the full tensor). :param max_sequence_length: The maximum sequence length. :param encoding_size: The size of the encoding vector, or None if sequence elements are scalars. """ super().__init__() self.config = encoder_config self.max_sequence_length = max_sequence_length logger.debug(f" {self.name}") self.reduce_output = reduce_output self.reduce_sequence = SequenceReducer( reduce_mode=reduce_output, max_sequence_length=max_sequence_length, encoding_size=encoding_size ) if self.reduce_output is None: self.supports_masking = True def forward(self, input_sequence: torch.Tensor, mask: torch.Tensor | None = None) -> EncoderOutputDict: """ :param input_sequence: The input sequence fed into the encoder. Shape: [batch x sequence length], type torch.int32 or [batch x sequence length x encoding size], type torch.float32 :type input_sequence: Tensor :param mask: Sequence mask (not yet implemented). Shape: [batch x sequence length] :type mask: Tensor """ input_sequence = input_sequence.type(torch.float32) while len(input_sequence.shape) < 3: input_sequence = input_sequence.unsqueeze(-1) hidden = self.reduce_sequence(input_sequence) return {ENCODER_OUTPUT: hidden} @staticmethod def get_schema_cls() -> type[SequenceEncoderConfig]: return SequencePassthroughConfig @property def input_shape(self) -> torch.Size: return torch.Size([self.max_sequence_length]) @property def output_shape(self) -> torch.Size: return self.input_shape @DeveloperAPI @register_encoder("embed", [SEQUENCE, TEXT]) class SequenceEmbedEncoder(SequenceEncoder): def __init__( self, vocab, max_sequence_length, representation="dense", embedding_size=256, embeddings_trainable=True, pretrained_embeddings=None, embeddings_on_cpu=False, weights_initializer=None, dropout=0, reduce_output="sum", encoder_config=None, **kwargs, ): """ :param vocab: Vocabulary of the input feature to encode :type vocab: List :param max_sequence_length: The maximum sequence length. :type max_sequence_length: int :param representation: the possible values are `dense` and `sparse`. `dense` means the embeddings are initialized randomly, `sparse` means they are initialized to be one-hot encodings. :type representation: str (one of 'dense' or 'sparse') :param embedding_size: it is the maximum embedding size, the actual size will be `min(vocabulary_size, embedding_size)` for `dense` representations and exactly `vocabulary_size` for the `sparse` encoding, where `vocabulary_size` is the number of different strings appearing in the training set in the column the feature is named after (plus 1 for ``). :type embedding_size: Integer :param embeddings_trainable: If `True` embeddings are trained during the training process, if `False` embeddings are fixed. It may be useful when loading pretrained embeddings for avoiding finetuning them. This parameter has effect only for `representation` is `dense` as `sparse` one-hot encodings are not trainable. :type embeddings_trainable: Boolean :param pretrained_embeddings: by default `dense` embeddings are initialized randomly, but this parameter allows to specify a path to a file containing embeddings in the GloVe format. When the file containing the embeddings is loaded, only the embeddings with labels present in the vocabulary are kept, the others are discarded. If the vocabulary contains strings that have no match in the embeddings file, their embeddings are initialized with the average of all other embedding plus some random noise to make them different from each other. This parameter has effect only if `representation` is `dense`. :type pretrained_embeddings: str (filepath) :param embeddings_on_cpu: by default embeddings matrices are stored on GPU memory if a GPU is used, as it allows for faster access, but in some cases the embedding matrix may be really big and this parameter forces the placement of the embedding matrix in regular memory and the CPU is used to resolve them, slightly slowing down the process as a result of data transfer between CPU and GPU memory. :type embeddings_on_cpu: Boolean :param weights_initializer: the initializer to use. If `None`, the default initialized of each variable is used (`xavier_uniform` in most cases). Options are: `constant`, `identity`, `zeros`, `ones`, `orthogonal`, `normal`, `uniform`, `truncated_normal`, `variance_scaling`, `xavier_normal`, `xavier_uniform`, `xavier_normal`, `he_normal`, `he_uniform`, `lecun_normal`, `lecun_uniform`. Alternatively it is possible to specify a dictionary with a key `type` that identifies the type of initializer and other keys for its parameters, e.g. `{type: normal, mean: 0, stddev: 0}`. To know the parameters of each initializer, please refer to PyTorch's documentation. :type weights_initializer: str :param dropout: Tensor (torch.float) The dropout probability. :type dropout: Tensor :param reduce_output: defines how to reduce the output tensor along the `s` sequence length dimension if the rank of the tensor is greater than 2. Available values are: `sum`, `mean` or `avg`, `max`, `concat` (concatenates along the first dimension), `last` (returns the last vector of the first dimension) and `None` or `null` (which does not reduce and returns the full tensor). :type reduce_output: str """ super().__init__() self.config = encoder_config logger.debug(f" {self.name}") self.embedding_size = embedding_size self.max_sequence_length = max_sequence_length self.reduce_output = reduce_output if self.reduce_output is None: self.supports_masking = True logger.debug(" EmbedSequence") self.embed_sequence = EmbedSequence( vocab, embedding_size, max_sequence_length=max_sequence_length, representation=representation, embeddings_trainable=embeddings_trainable, pretrained_embeddings=pretrained_embeddings, embeddings_on_cpu=embeddings_on_cpu, dropout=dropout, embedding_initializer=weights_initializer, ) self.reduce_sequence = SequenceReducer( reduce_mode=reduce_output, max_sequence_length=max_sequence_length, encoding_size=self.embed_sequence.output_shape[-1], ) def forward(self, inputs: torch.Tensor, mask: torch.Tensor | None = None) -> EncoderOutputDict: """ :param inputs: The input sequence fed into the encoder. Shape: [batch x sequence length], type torch.int32 :param mask: Input mask (unused, not yet implemented in EmbedSequence) """ embedded_sequence = self.embed_sequence(inputs, mask=mask) hidden = self.reduce_sequence(embedded_sequence) return {ENCODER_OUTPUT: hidden} @staticmethod def get_schema_cls() -> type[SequenceEncoderConfig]: return SequenceEmbedConfig @property def input_shape(self) -> torch.Size: return torch.Size([self.max_sequence_length]) @property def output_shape(self) -> torch.Size: return self.reduce_sequence.output_shape @DeveloperAPI @register_sequence_encoder("parallel_cnn") @register_encoder("parallel_cnn", [AUDIO, SEQUENCE, TEXT, TIMESERIES]) class ParallelCNN(SequenceEncoder): def __init__( self, should_embed=True, vocab=None, representation="dense", embedding_size=256, max_sequence_length=None, embeddings_trainable=True, pretrained_embeddings=None, embeddings_on_cpu=False, conv_layers=None, num_conv_layers=None, filter_size=3, num_filters=256, pool_function="max", pool_size=None, fc_layers=None, num_fc_layers=None, output_size=256, use_bias=True, weights_initializer="xavier_uniform", bias_initializer="zeros", norm=None, norm_params=None, activation="relu", dropout=0, reduce_output="max", encoder_config=None, **kwargs, ): # todo: revise docstring """ :param should_embed: If True the input sequence is expected to be made of integers and will be mapped into embeddings :type should_embed: Boolean :param vocab: Vocabulary of the input feature to encode :type vocab: List :param representation: the possible values are `dense` and `sparse`. `dense` means the embeddings are initialized randomly, `sparse` means they are initialized to be one-hot encodings. :type representation: Str (one of 'dense' or 'sparse') :param embedding_size: it is the maximum embedding size, the actual size will be `min(vocabulary_size, embedding_size)` for `dense` representations and exactly `vocabulary_size` for the `sparse` encoding, where `vocabulary_size` is the number of different strings appearing in the training set in the column the feature is named after (plus 1 for ``). :type embedding_size: Integer :param embeddings_trainable: If `True` embeddings are trained during the training process, if `False` embeddings are fixed. It may be useful when loading pretrained embeddings for avoiding finetuning them. This parameter has effect only for `representation` is `dense` as `sparse` one-hot encodings are not trainable. :type embeddings_trainable: Boolean :param pretrained_embeddings: by default `dense` embeddings are initialized randomly, but this parameter allows to specify a path to a file containing embeddings in the GloVe format. When the file containing the embeddings is loaded, only the embeddings with labels present in the vocabulary are kept, the others are discarded. If the vocabulary contains strings that have no match in the embeddings file, their embeddings are initialized with the average of all other embedding plus some random noise to make them different from each other. This parameter has effect only if `representation` is `dense`. :type pretrained_embeddings: str (filepath) :param embeddings_on_cpu: by default embeddings matrices are stored on GPU memory if a GPU is used, as it allows for faster access, but in some cases the embedding matrix may be really big and this parameter forces the placement of the embedding matrix in regular memroy and the CPU is used to resolve them, slightly slowing down the process as a result of data transfer between CPU and GPU memory. :param conv_layers: it is a list of dictionaries containing the parameters of all the convolutional layers. The length of the list determines the number of parallel convolutional layers and the content of each dictionary determines the parameters for a specific layer. The available parameters for each layer are: `filter_size`, `num_filters`, `pool`, `norm`, and `activation`. If any of those values is missing from the dictionary, the default one specified as a parameter of the encoder will be used instead. If both `conv_layers` and `num_conv_layers` are `None`, a default list will be assigned to `conv_layers` with the value `[{filter_size: 2}, {filter_size: 3}, {filter_size: 4}, {filter_size: 5}]`. :type conv_layers: List :param num_conv_layers: if `conv_layers` is `None`, this is the number of parallel convolutional layers. :type num_conv_layers: Integer :param filter_size: if a `filter_size` is not already specified in `conv_layers` this is the default `filter_size` that will be used for each layer. It indicates how wide is the 1d convolutional filter. :type filter_size: Integer :param num_filters: if a `num_filters` is not already specified in `conv_layers` this is the default `num_filters` that will be used for each layer. It indicates the number of filters, and by consequence the output channels of the 1d convolution. :type num_filters: Integer :param pool_size: if a `pool_size` is not already specified in `conv_layers` this is the default `pool_size` that will be used for each layer. It indicates the size of the max pooling that will be performed along the `s` sequence dimension after the convolution operation. :type pool_size: Integer :param fc_layers: it is a list of dictionaries containing the parameters of all the fully connected layers. The length of the list determines the number of stacked fully connected layers and the content of each dictionary determines the parameters for a specific layer. The available parameters for each layer are: `output_size`, `norm` and `activation`. If any of those values is missing from the dictionary, the default one specified as a parameter of the encoder will be used instead. If both `fc_layers` and `num_fc_layers` are `None`, a default list will be assigned to `fc_layers` with the value `[{output_size: 512}, {output_size: 256}]` (only applies if `reduce_output` is not `None`). :type fc_layers: List :param num_fc_layers: if `fc_layers` is `None`, this is the number of stacked fully connected layers (only applies if `reduce_output` is not `None`). :type num_fc_layers: Integer :param output_size: if a `output_size` is not already specified in `fc_layers` this is the default `output_size` that will be used for each layer. It indicates the size of the output of a fully connected layer. :type output_size: Integer :param norm: if a `norm` is not already specified in `conv_layers` or `fc_layers` this is the default `norm` that will be used for each layer. It indicates the norm of the output. :type norm: str :param activation: Default activation function to use :type activation: Str :param dropout: determines if there should be a dropout layer before returning the encoder output. :type dropout: Boolean :param initializer: the initializer to use. If `None` it uses `xavier_uniform`. Options are: `constant`, `identity`, `zeros`, `ones`, `orthogonal`, `normal`, `uniform`, `truncated_normal`, `variance_scaling`, `xavier_normal`, `xavier_uniform`, `xavier_normal`, `he_normal`, `he_uniform`, `lecun_normal`, `lecun_uniform`. Alternatively it is possible to specify a dictionary with a key `type` that identifies the type of initializer and other keys for its parameters, e.g. `{type: normal, mean: 0, stddev: 0}`. To know the parameters of each initializer, please refer to PyTorch's documentation. :type initializer: str :param reduce_output: defines how to reduce the output tensor of the convolutional layers along the `s` sequence length dimension if the rank of the tensor is greater than 2. Available values are: `sum`, `mean` or `avg`, `max`, `concat` (concatenates along the first dimension), `last` (returns the last vector of the first dimension) and `None` or `null` (which does not reduce and returns the full tensor). :type reduce_output: str """ super().__init__() self.config = encoder_config logger.debug(f" {self.name}") self.max_sequence_length = max_sequence_length if conv_layers is not None and num_conv_layers is None: # use custom-defined layers self.conv_layers = conv_layers self.num_conv_layers = len(conv_layers) elif conv_layers is None and num_conv_layers is not None: # generate num_conv_layers with default parameters self.conv_layers = None self.num_conv_layers = num_conv_layers elif conv_layers is None and num_conv_layers is None: # use default layers with varying filter sizes self.conv_layers = [{"filter_size": 2}, {"filter_size": 3}, {"filter_size": 4}, {"filter_size": 5}] self.num_conv_layers = 4 else: raise ValueError("Invalid layer parametrization, use either conv_layers or num_conv_layers") # The user is expected to provide fc_layers or num_fc_layers # The following logic handles the case where the user either provides # both or neither. if fc_layers is None and num_fc_layers is None: # use default layers with varying filter sizes fc_layers = [{"output_size": 512}, {"output_size": 256}] num_fc_layers = 2 elif fc_layers is not None and num_fc_layers is not None: raise ValueError("Invalid layer parametrization, use either fc_layers or num_fc_layers only. Not both.") self.should_embed = should_embed self.embed_sequence = None if self.should_embed: logger.debug(" EmbedSequence") self.embed_sequence = EmbedSequence( vocab, embedding_size, max_sequence_length=max_sequence_length, representation=representation, embeddings_trainable=embeddings_trainable, pretrained_embeddings=pretrained_embeddings, embeddings_on_cpu=embeddings_on_cpu, dropout=dropout, embedding_initializer=weights_initializer, ) logger.debug(" ParallelConv1D") in_channels = self.embed_sequence.output_shape[-1] if self.should_embed else embedding_size self.parallel_conv1d = ParallelConv1D( in_channels=in_channels, max_sequence_length=self.max_sequence_length, layers=self.conv_layers, default_num_filters=num_filters, default_filter_size=filter_size, default_use_bias=use_bias, default_weights_initializer=weights_initializer, default_bias_initializer=bias_initializer, default_norm=norm, default_norm_params=norm_params, default_activation=activation, default_dropout=dropout, default_pool_function=pool_function, default_pool_size=pool_size, default_pool_padding="same", ) self.reduce_output = reduce_output self.reduce_sequence = SequenceReducer( reduce_mode=reduce_output, max_sequence_length=max_sequence_length, encoding_size=self.parallel_conv1d.output_shape[-1], ) if self.reduce_output is not None: logger.debug(" FCStack") self.fc_stack = FCStack( self.reduce_sequence.output_shape[-1], layers=fc_layers, num_layers=num_fc_layers, default_output_size=output_size, default_use_bias=use_bias, default_weights_initializer=weights_initializer, default_bias_initializer=bias_initializer, default_norm=norm, default_norm_params=norm_params, default_activation=activation, default_dropout=dropout, ) def forward(self, inputs: torch.Tensor, mask: torch.Tensor | None = None) -> EncoderOutputDict: """ :param inputs: The input sequence fed into the encoder. Shape: [batch x sequence length], type torch.int32 :param mask: Input mask (unused, not yet implemented) """ # ================ Embeddings ================ if self.should_embed: embedded_sequence = self.embed_sequence(inputs, mask=mask) else: embedded_sequence = inputs while len(embedded_sequence.shape) < 3: embedded_sequence = embedded_sequence.unsqueeze(-1) embedded_sequence = embedded_sequence.to(dtype=torch.float) # shape=(?, sequence_length, embedding_size) hidden = embedded_sequence # ================ Conv Layers ================ hidden = self.parallel_conv1d(hidden, mask=mask) # ================ Sequence Reduction ================ if self.reduce_output is not None: hidden = self.reduce_sequence(hidden) # ================ FC Layers ================ hidden = self.fc_stack(hidden, mask=mask) return {ENCODER_OUTPUT: hidden} @staticmethod def get_schema_cls() -> type[SequenceEncoderConfig]: return ParallelCNNConfig @property def input_shape(self) -> torch.Size: return torch.Size([self.max_sequence_length]) @property def output_shape(self) -> torch.Size: if self.reduce_output is not None: return self.fc_stack.output_shape return self.parallel_conv1d.output_shape @DeveloperAPI @register_sequence_encoder("stacked_cnn") @register_encoder("stacked_cnn", [AUDIO, SEQUENCE, TEXT, TIMESERIES]) class StackedCNN(SequenceEncoder): def __init__( self, should_embed=True, vocab=None, representation="dense", embedding_size=256, max_sequence_length=None, embeddings_trainable=True, pretrained_embeddings=None, embeddings_on_cpu=False, conv_layers=None, num_conv_layers=None, num_filters=256, filter_size=5, strides=1, # todo: assess how to specify padding for equivalent to 'same' padding="same", dilation_rate=1, pool_function="max", pool_size=None, pool_strides=None, # todo: determine how to pool_padding equivalent of 'same' pool_padding="same", fc_layers=None, num_fc_layers=None, output_size=256, use_bias=True, weights_initializer="xavier_uniform", bias_initializer="zeros", norm=None, norm_params=None, activation="relu", dropout=0, reduce_output="max", encoder_config=None, **kwargs, ): # todo: fixup docstring """ :param should_embed: If True the input sequence is expected to be made of integers and will be mapped into embeddings :type should_embed: Boolean :param vocab: Vocabulary of the input feature to encode :type vocab: List :param representation: the possible values are `dense` and `sparse`. `dense` means the embeddings are initialized randomly, `sparse` means they are initialized to be one-hot encodings. :type representation: Str (one of 'dense' or 'sparse') :param embedding_size: it is the maximum embedding size, the actual size will be `min(vocabulary_size, embedding_size)` for `dense` representations and exactly `vocabulary_size` for the `sparse` encoding, where `vocabulary_size` is the number of different strings appearing in the training set in the column the feature is named after (plus 1 for ``). :type embedding_size: Integer :param embeddings_trainable: If `True` embeddings are trained during the training process, if `False` embeddings are fixed. It may be useful when loading pretrained embeddings for avoiding finetuning them. This parameter has effect only for `representation` is `dense` as `sparse` one-hot encodings are not trainable. :type embeddings_trainable: Boolean :param pretrained_embeddings: by default `dense` embeddings are initialized randomly, but this parameter allows to specify a path to a file containing embeddings in the GloVe format. When the file containing the embeddings is loaded, only the embeddings with labels present in the vocabulary are kept, the others are discarded. If the vocabulary contains strings that have no match in the embeddings file, their embeddings are initialized with the average of all other embedding plus some random noise to make them different from each other. This parameter has effect only if `representation` is `dense`. :type pretrained_embeddings: str (filepath) :param embeddings_on_cpu: by default embeddings matrices are stored on GPU memory if a GPU is used, as it allows for faster access, but in some cases the embedding matrix may be really big and this parameter forces the placement of the embedding matrix in regular memroy and the CPU is used to resolve them, slightly slowing down the process as a result of data transfer between CPU and GPU memory. :param conv_layers: it is a list of dictionaries containing the parameters of all the convolutional layers. The length of the list determines the number of parallel convolutional layers and the content of each dictionary determines the parameters for a specific layer. The available parameters for each layer are: `filter_size`, `num_filters`, `pool`, `norm` and `activation`. If any of those values is missing from the dictionary, the default one specified as a parameter of the encoder will be used instead. If both `conv_layers` and `num_conv_layers` are `None`, a default list will be assigned to `conv_layers` with the value `[{filter_size: 2}, {filter_size: 3}, {filter_size: 4}, {filter_size: 5}]`. :type conv_layers: List :param num_conv_layers: if `conv_layers` is `None`, this is the number of stacked convolutional layers. :type num_conv_layers: Integer :param filter_size: if a `filter_size` is not already specified in `conv_layers` this is the default `filter_size` that will be used for each layer. It indicates how wide is the 1d convolutional filter. :type filter_size: Integer :param num_filters: if a `num_filters` is not already specified in `conv_layers` this is the default `num_filters` that will be used for each layer. It indicates the number of filters, and by consequence the output channels of the 1d convolution. :type num_filters: Integer :param pool_size: if a `pool_size` is not already specified in `conv_layers` this is the default `pool_size` that will be used for each layer. It indicates the size of the max pooling that will be performed along the `s` sequence dimension after the convolution operation. :type pool_size: Integer :param fc_layers: it is a list of dictionaries containing the parameters of all the fully connected layers. The length of the list determines the number of stacked fully connected layers and the content of each dictionary determines the parameters for a specific layer. The available parameters for each layer are: `output_size`, `norm` and `activation`. If any of those values is missing from the dictionary, the default one specified as a parameter of the encoder will be used instead. If both `fc_layers` and `num_fc_layers` are `None`, a default list will be assigned to `fc_layers` with the value `[{output_size: 512}, {output_size: 256}]` (only applies if `reduce_output` is not `None`). :type fc_layers: List :param num_fc_layers: if `fc_layers` is `None`, this is the number of stacked fully connected layers (only applies if `reduce_output` is not `None`). :type num_fc_layers: Integer :param output_size: if a `output_size` is not already specified in `fc_layers` this is the default `output_size` that will be used for each layer. It indicates the size of the output of a fully connected layer. :type output_size: Integer :param norm: if a `norm` is not already specified in `conv_layers` or `fc_layers` this is the default `norm` that will be used for each layer. It indicates the norm of the output. :type norm: str :param activation: Default activation function to use :type activation: Str :param dropout: determines if there should be a dropout layer before returning the encoder output. :type dropout: Boolean :param initializer: the initializer to use. If `None` it uses `xavier_uniform`. Options are: `constant`, `identity`, `zeros`, `ones`, `orthogonal`, `normal`, `uniform`, `truncated_normal`, `variance_scaling`, `xavier_normal`, `xavier_uniform`, `xavier_normal`, `he_normal`, `he_uniform`, `lecun_normal`, `lecun_uniform`. Alternatively it is possible to specify a dictionary with a key `type` that identifies the type of initializer and other keys for its parameters, e.g. `{type: normal, mean: 0, stddev: 0}`. To know the parameters of each initializer, please refer to PyTorch's documentation. :type initializer: str :param reduce_output: defines how to reduce the output tensor of the convolutional layers along the `s` sequence length dimension if the rank of the tensor is greater than 2. Available values are: `sum`, `mean` or `avg`, `max`, `concat` (concatenates along the first dimension), `last` (returns the last vector of the first dimension) and `None` or `null` (which does not reduce and returns the full tensor). :type reduce_output: str """ super().__init__() self.config = encoder_config logger.debug(f" {self.name}") if conv_layers is not None and num_conv_layers is None: # use custom-defined layers self.conv_layers = conv_layers self.num_conv_layers = len(conv_layers) elif conv_layers is None and num_conv_layers is not None: # generate num_conv_layers with default parameters self.conv_layers = None self.num_conv_layers = num_conv_layers elif conv_layers is None and num_conv_layers is None: # use default layers with varying filter sizes self.conv_layers = [ { "filter_size": 7, "pool_size": 3, }, { "filter_size": 7, "pool_size": 3, }, { "filter_size": 3, "pool_size": None, }, { "filter_size": 3, "pool_size": None, }, { "filter_size": 3, "pool_size": None, }, { "filter_size": 3, "pool_size": 3, }, ] self.num_conv_layers = 6 else: raise ValueError("Invalid layer parametrization, use either conv_layers or " "num_conv_layers") # The user is expected to provide fc_layers or num_fc_layers # The following logic handles the case where the user either provides # both or neither. if fc_layers is None and num_fc_layers is None: # use default layers with varying filter sizes fc_layers = [{"output_size": 512}, {"output_size": 256}] num_fc_layers = 2 elif fc_layers is not None and num_fc_layers is not None: raise ValueError("Invalid layer parametrization, use either fc_layers or " "num_fc_layers only. Not both.") self.max_sequence_length = max_sequence_length self.num_filters = num_filters self.should_embed = should_embed self.embed_sequence = None if self.should_embed: logger.debug(" EmbedSequence") self.embed_sequence = EmbedSequence( vocab, embedding_size, max_sequence_length=self.max_sequence_length, representation=representation, embeddings_trainable=embeddings_trainable, pretrained_embeddings=pretrained_embeddings, embeddings_on_cpu=embeddings_on_cpu, dropout=dropout, embedding_initializer=weights_initializer, ) logger.debug(" Conv1DStack") in_channels = self.embed_sequence.output_shape[-1] if self.should_embed else embedding_size self.conv1d_stack = Conv1DStack( in_channels=in_channels, max_sequence_length=max_sequence_length, layers=self.conv_layers, default_num_filters=num_filters, default_filter_size=filter_size, default_strides=strides, default_padding=padding, default_dilation_rate=dilation_rate, default_use_bias=use_bias, default_weights_initializer=weights_initializer, default_bias_initializer=bias_initializer, default_norm=norm, default_norm_params=norm_params, default_activation=activation, default_dropout=dropout, default_pool_function=pool_function, default_pool_size=pool_size, default_pool_strides=pool_strides, default_pool_padding=pool_padding, ) self.reduce_output = reduce_output self.reduce_sequence = SequenceReducer( reduce_mode=reduce_output, max_sequence_length=self.conv1d_stack.output_shape[-2], encoding_size=self.conv1d_stack.output_shape[-1], ) if self.reduce_output is not None: logger.debug(" FCStack") self.fc_stack = FCStack( self.reduce_sequence.output_shape[-1], layers=fc_layers, num_layers=num_fc_layers, default_output_size=output_size, default_use_bias=use_bias, default_weights_initializer=weights_initializer, default_bias_initializer=bias_initializer, default_norm=norm, default_norm_params=norm_params, default_activation=activation, default_dropout=dropout, ) @staticmethod def get_schema_cls() -> type[SequenceEncoderConfig]: return StackedCNNConfig @property def input_shape(self) -> torch.Size: return torch.Size([self.max_sequence_length]) @property def output_shape(self) -> torch.Size: if self.reduce_output is None: return self.conv1d_stack.output_shape return self.fc_stack.output_shape def forward(self, inputs: torch.Tensor, mask: torch.Tensor | None = None) -> EncoderOutputDict: """ :param inputs: The input sequence fed into the encoder. Shape: [batch x sequence length], type torch.int32 :param mask: Input mask (unused, not yet implemented) """ # ================ Embeddings ================ if self.should_embed: embedded_sequence = self.embed_sequence(inputs, mask=mask) else: embedded_sequence = inputs while len(embedded_sequence.shape) < 3: embedded_sequence = embedded_sequence.unsqueeze(-1) # shape=(?, sequence_length, embedding_size) hidden = embedded_sequence # ================ Conv Layers ================ hidden = self.conv1d_stack(hidden, mask=mask) # ================ Sequence Reduction ================ if self.reduce_output is not None: hidden = self.reduce_sequence(hidden) # ================ FC Layers ================ hidden = self.fc_stack(hidden, mask=mask) # no reduction: hidden [batch_size, seq_size, num_filters] # with reduction: hidden [batch_size, output_size] return {ENCODER_OUTPUT: hidden} @DeveloperAPI @register_sequence_encoder("stacked_parallel_cnn") @register_encoder("stacked_parallel_cnn", [AUDIO, SEQUENCE, TEXT, TIMESERIES]) class StackedParallelCNN(SequenceEncoder): def __init__( self, should_embed=True, vocab=None, representation="dense", embedding_size=256, max_sequence_length=None, embeddings_trainable=True, pretrained_embeddings=None, embeddings_on_cpu=False, stacked_layers=None, num_stacked_layers=None, filter_size=3, num_filters=256, pool_function="max", pool_size=None, fc_layers=None, num_fc_layers=None, output_size=256, use_bias=True, weights_initializer="xavier_uniform", bias_initializer="zeros", norm=None, norm_params=None, activation="relu", dropout=0, reduce_output="max", encoder_config=None, **kwargs, ): # todo: review docstring """ :param should_embed: If True the input sequence is expected to be made of integers and will be mapped into embeddings :type should_embed: Boolean :param vocab: Vocabulary of the input feature to encode :type vocab: List :param representation: the possible values are `dense` and `sparse`. `dense` means the embeddings are initialized randomly, `sparse` means they are initialized to be one-hot encodings. :type representation: Str (one of 'dense' or 'sparse') :param embedding_size: it is the maximum embedding size, the actual size will be `min(vocabulary_size, embedding_size)` for `dense` representations and exactly `vocabulary_size` for the `sparse` encoding, where `vocabulary_size` is the number of different strings appearing in the training set in the column the feature is named after (plus 1 for ``). :type embedding_size: Integer :param embeddings_trainable: If `True` embeddings are trained during the training process, if `False` embeddings are fixed. It may be useful when loading pretrained embeddings for avoiding finetuning them. This parameter has effect only for `representation` is `dense` as `sparse` one-hot encodings are not trainable. :type embeddings_trainable: Boolean :param pretrained_embeddings: by default `dense` embeddings are initialized randomly, but this parameter allows to specify a path to a file containing embeddings in the GloVe format. When the file containing the embeddings is loaded, only the embeddings with labels present in the vocabulary are kept, the others are discarded. If the vocabulary contains strings that have no match in the embeddings file, their embeddings are initialized with the average of all other embedding plus some random noise to make them different from each other. This parameter has effect only if `representation` is `dense`. :type pretrained_embeddings: str (filepath) :param embeddings_on_cpu: by default embeddings matrices are stored on GPU memory if a GPU is used, as it allows for faster access, but in some cases the embedding matrix may be really big and this parameter forces the placement of the embedding matrix in regular memroy and the CPU is used to resolve them, slightly slowing down the process as a result of data transfer between CPU and GPU memory. :param stacked_layers: it is a of lists of list of dictionaries containing the parameters of the stack of parallel convolutional layers. The length of the list determines the number of stacked parallel convolutional layers, length of the sub-lists determines the number of parallel conv layers and the content of each dictionary determines the parameters for a specific layer. The available parameters for each layer are: `filter_size`, `num_filters`, `pool_size`, `norm` and `activation`. If any of those values is missing from the dictionary, the default one specified as a parameter of the encoder will be used instead. If both `stacked_layers` and `num_stacked_layers` are `None`, a default list will be assigned to `stacked_layers` with the value `[[{filter_size: 2}, {filter_size: 3}, {filter_size: 4}, {filter_size: 5}], [{filter_size: 2}, {filter_size: 3}, {filter_size: 4}, {filter_size: 5}], [{filter_size: 2}, {filter_size: 3}, {filter_size: 4}, {filter_size: 5}]]`. :type stacked_layers: List :param num_stacked_layers: if `stacked_layers` is `None`, this is the number of elements in the stack of parallel convolutional layers. :type num_stacked_layers: Integer :param filter_size: if a `filter_size` is not already specified in `conv_layers` this is the default `filter_size` that will be used for each layer. It indicates how wide is the 1d convolutional filter. :type filter_size: Integer :param num_filters: if a `num_filters` is not already specified in `conv_layers` this is the default `num_filters` that will be used for each layer. It indicates the number of filters, and by consequence the output channels of the 1d convolution. :type num_filters: Integer :param pool_size: if a `pool_size` is not already specified in `conv_layers` this is the default `pool_size` that will be used for each layer. It indicates the size of the max pooling that will be performed along the `s` sequence dimension after the convolution operation. :type pool_size: Integer :param fc_layers: it is a list of dictionaries containing the parameters of all the fully connected layers. The length of the list determines the number of stacked fully connected layers and the content of each dictionary determines the parameters for a specific layer. The available parameters for each layer are: `output_size`, `norm` and `activation`. If any of those values is missing from the dictionary, the default one specified as a parameter of the encoder will be used instead. If both `fc_layers` and `num_fc_layers` are `None`, a default list will be assigned to `fc_layers` with the value `[{output_size: 512}, {output_size: 256}]` (only applies if `reduce_output` is not `None`). :type fc_layers: List :param num_fc_layers: if `fc_layers` is `None`, this is the number of stacked fully connected layers (only applies if `reduce_output` is not `None`). :type num_fc_layers: Integer :param output_size: if a `output_size` is not already specified in `fc_layers` this is the default `output_size` that will be used for each layer. It indicates the size of the output of a fully connected layer. :type output_size: Integer :param norm: if a `norm` is not already specified in `conv_layers` or `fc_layers` this is the default `norm` that will be used for each layer. It indicates the norm of the output. :type norm: str :param activation: Default activation function to use :type activation: Str :param dropout: determines if there should be a dropout layer before returning the encoder output. :type dropout: Boolean :param initializer: the initializer to use. If `None` it uses `xavier_uniform`. Options are: `constant`, `identity`, `zeros`, `ones`, `orthogonal`, `normal`, `uniform`, `truncated_normal`, `variance_scaling`, `xavier_normal`, `xavier_uniform`, `xavier_normal`, `he_normal`, `he_uniform`, `lecun_normal`, `lecun_uniform`. Alternatively it is possible to specify a dictionary with a key `type` that identifies the type of initializer and other keys for its parameters, e.g. `{type: normal, mean: 0, stddev: 0}`. To know the parameters of each initializer, please refer to PyTorch's documentation. :type initializer: str :param reduce_output: defines how to reduce the output tensor of the convolutional layers along the `s` sequence length dimension if the rank of the tensor is greater than 2. Available values are: `sum`, `mean` or `avg`, `max`, `concat` (concatenates along the first dimension), `last` (returns the last vector of the first dimension) and `None` or `null` (which does not reduce and returns the full tensor). :type reduce_output: str """ super().__init__() self.config = encoder_config logger.debug(f" {self.name}") self.max_sequence_length = max_sequence_length self.embedding_size = embedding_size if stacked_layers is not None and num_stacked_layers is None: # use custom-defined layers self.stacked_layers = stacked_layers self.num_stacked_layers = len(stacked_layers) elif stacked_layers is None and num_stacked_layers is not None: # generate num_conv_layers with default parameters self.stacked_layers = None self.num_stacked_layers = num_stacked_layers elif stacked_layers is None and num_stacked_layers is None: # use default layers with varying filter sizes self.stacked_layers = [ [{"filter_size": 2}, {"filter_size": 3}, {"filter_size": 4}, {"filter_size": 5}], [{"filter_size": 2}, {"filter_size": 3}, {"filter_size": 4}, {"filter_size": 5}], [{"filter_size": 2}, {"filter_size": 3}, {"filter_size": 4}, {"filter_size": 5}], ] self.num_stacked_layers = 6 else: raise ValueError("Invalid layer parametrization, use either stacked_layers or" " num_stacked_layers") # The user is expected to provide fc_layers or num_fc_layers # The following logic handles the case where the user either provides # both or neither. if fc_layers is None and num_fc_layers is None: # use default layers with varying filter sizes fc_layers = [{"output_size": 512}, {"output_size": 256}] num_fc_layers = 2 elif fc_layers is not None and num_fc_layers is not None: raise ValueError("Invalid layer parametrization, use either fc_layers or " "num_fc_layers only. Not both.") self.should_embed = should_embed self.embed_sequence = None if self.should_embed: logger.debug(" EmbedSequence") self.embed_sequence = EmbedSequence( vocab, embedding_size, max_sequence_length=self.max_sequence_length, representation=representation, embeddings_trainable=embeddings_trainable, pretrained_embeddings=pretrained_embeddings, embeddings_on_cpu=embeddings_on_cpu, dropout=dropout, embedding_initializer=weights_initializer, ) in_channels = self.embed_sequence.output_shape[-1] if self.should_embed else embedding_size logger.debug(" ParallelConv1DStack") self.parallel_conv1d_stack = ParallelConv1DStack( in_channels=in_channels, stacked_layers=self.stacked_layers, max_sequence_length=max_sequence_length, default_num_filters=num_filters, default_filter_size=filter_size, default_use_bias=use_bias, default_weights_initializer=weights_initializer, default_bias_initializer=bias_initializer, default_norm=norm, default_norm_params=norm_params, default_activation=activation, default_dropout=dropout, default_pool_function=pool_function, default_pool_size=pool_size, ) self.reduce_output = reduce_output self.reduce_sequence = SequenceReducer( reduce_mode=reduce_output, max_sequence_length=self.parallel_conv1d_stack.output_shape[-2], encoding_size=self.parallel_conv1d_stack.output_shape[-1], ) if self.reduce_output is not None: logger.debug(" FCStack") self.fc_stack = FCStack( self.reduce_sequence.output_shape[-1], layers=fc_layers, num_layers=num_fc_layers, default_output_size=output_size, default_use_bias=use_bias, default_weights_initializer=weights_initializer, default_bias_initializer=bias_initializer, default_norm=norm, default_norm_params=norm_params, default_activation=activation, default_dropout=dropout, ) @staticmethod def get_schema_cls() -> type[SequenceEncoderConfig]: return StackedParallelCNNConfig @property def input_shape(self) -> torch.Size: return torch.Size([self.max_sequence_length]) @property def output_shape(self) -> torch.Size: if self.reduce_output is not None: return self.fc_stack.output_shape return self.parallel_conv1d_stack.output_shape def forward(self, inputs: torch.Tensor, mask: torch.Tensor | None = None) -> EncoderOutputDict: """ :param inputs: The input sequence fed into the encoder. Shape: [batch x sequence length], type torch.int32 :param mask: Input mask (unused, not yet implemented) """ # ================ Embeddings ================ if self.should_embed: embedded_sequence = self.embed_sequence(inputs, mask=mask) else: embedded_sequence = inputs while len(embedded_sequence.shape) < 3: embedded_sequence = embedded_sequence.unsqueeze(-1) # shape=(?, sequence_length, embedding_size) hidden = embedded_sequence # ================ Conv Layers ================ hidden = self.parallel_conv1d_stack(hidden, mask=mask) # ================ Sequence Reduction ================ if self.reduce_output is not None: hidden = self.reduce_sequence(hidden) # ================ FC Layers ================ hidden = self.fc_stack(hidden, mask=mask) # no reduction: hidden [batch_size, seq_size, num_filter] # with reduction: hidden [batch_size, output_size] return {ENCODER_OUTPUT: hidden} @DeveloperAPI @register_sequence_encoder("rnn") @register_encoder("rnn", [AUDIO, SEQUENCE, TEXT, TIMESERIES]) class StackedRNN(SequenceEncoder): def __init__( self, should_embed=True, vocab=None, representation="dense", embedding_size=256, embeddings_trainable=True, pretrained_embeddings=None, embeddings_on_cpu=False, num_layers=1, max_sequence_length=None, state_size=256, cell_type="rnn", bidirectional=False, activation="tanh", recurrent_activation="sigmoid", unit_forget_bias=True, recurrent_initializer="orthogonal", dropout=0.0, recurrent_dropout=0.0, fc_layers=None, num_fc_layers=0, output_size=256, use_bias=True, weights_initializer="xavier_uniform", bias_initializer="zeros", norm=None, norm_params=None, fc_activation="relu", fc_dropout=0, reduce_output="last", encoder_config=None, **kwargs, ): # todo: fix up docstring """ :param should_embed: If True the input sequence is expected to be made of integers and will be mapped into embeddings :type should_embed: Boolean :param vocab: Vocabulary of the input feature to encode :type vocab: List :param representation: the possible values are `dense` and `sparse`. `dense` means the embeddings are initialized randomly, `sparse` means they are initialized to be one-hot encodings. :type representation: Str (one of 'dense' or 'sparse') :param embedding_size: it is the maximum embedding size, the actual size will be `min(vocabulary_size, embedding_size)` for `dense` representations and exactly `vocabulary_size` for the `sparse` encoding, where `vocabulary_size` is the number of different strings appearing in the training set in the column the feature is named after (plus 1 for ``). :type embedding_size: Integer :param embeddings_trainable: If `True` embeddings are trained during the training process, if `False` embeddings are fixed. It may be useful when loading pretrained embeddings for avoiding finetuning them. This parameter has effect only for `representation` is `dense` as `sparse` one-hot encodings are not trainable. :type embeddings_trainable: Boolean :param pretrained_embeddings: by default `dense` embeddings are initialized randomly, but this parameter allows to specify a path to a file containing embeddings in the GloVe format. When the file containing the embeddings is loaded, only the embeddings with labels present in the vocabulary are kept, the others are discarded. If the vocabulary contains strings that have no match in the embeddings file, their embeddings are initialized with the average of all other embedding plus some random noise to make them different from each other. This parameter has effect only if `representation` is `dense`. :type pretrained_embeddings: str (filepath) :param embeddings_on_cpu: by default embeddings matrices are stored on GPU memory if a GPU is used, as it allows for faster access, but in some cases the embedding matrix may be really big and this parameter forces the placement of the embedding matrix in regular memroy and the CPU is used to resolve them, slightly slowing down the process as a result of data transfer between CPU and GPU memory. :param conv_layers: it is a list of dictionaries containing the parameters of all the convolutional layers. The length of the list determines the number of parallel convolutional layers and the content of each dictionary determines the parameters for a specific layer. The available parameters for each layer are: `filter_size`, `num_filters`, `pool`, `norm`, `activation` and `regularize`. If any of those values is missing from the dictionary, the default one specified as a parameter of the encoder will be used instead. If both `conv_layers` and `num_conv_layers` are `None`, a default list will be assigned to `conv_layers` with the value `[{filter_size: 2}, {filter_size: 3}, {filter_size: 4}, {filter_size: 5}]`. :type conv_layers: List :param num_conv_layers: if `conv_layers` is `None`, this is the number of stacked convolutional layers. :type num_conv_layers: Integer :param filter_size: if a `filter_size` is not already specified in `conv_layers` this is the default `filter_size` that will be used for each layer. It indicates how wide is the 1d convolutional filter. :type filter_size: Integer :param num_filters: if a `num_filters` is not already specified in `conv_layers` this is the default `num_filters` that will be used for each layer. It indicates the number of filters, and by consequence the output channels of the 1d convolution. :type num_filters: Integer :param pool_size: if a `pool_size` is not already specified in `conv_layers` this is the default `pool_size` that will be used for each layer. It indicates the size of the max pooling that will be performed along the `s` sequence dimension after the convolution operation. :type pool_size: Integer :param num_rec_layers: the number of stacked recurrent layers. :type num_rec_layers: Integer :param cell_type: the type of recurrent cell to use. Available values are: `rnn`, `lstm`, `gru`. For reference about the differences between the cells please refer to PyTorch's documentation. :type cell_type: str :param state_size: the size of the state of the rnn. :type state_size: Integer :param bidirectional: if `True` two recurrent networks will perform encoding in the forward and backward direction and their outputs will be concatenated. :type bidirectional: Boolean :param dropout: determines if there should be a dropout layer before returning the encoder output. :type dropout: Boolean :param recurrent_dropout: Dropout rate for the recurrent stack. :type recurrent_dropout: float :param initializer: the initializer to use. If `None` it uses `xavier_uniform`. Options are: `constant`, `identity`, `zeros`, `ones`, `orthogonal`, `normal`, `uniform`, `truncated_normal`, `variance_scaling`, `xavier_normal`, `xavier_uniform`, `xavier_normal`, `he_normal`, `he_uniform`, `lecun_normal`, `lecun_uniform`. Alternatively it is possible to specify a dictionary with a key `type` that identifies the type of initializer and other keys for its parameters, e.g. `{type: normal, mean: 0, stddev: 0}`. To know the parameters of each initializer, please refer to PyTorch's documentation. :type initializer: str :param reduce_output: defines how to reduce the output tensor of the convolutional layers along the `s` sequence length dimension if the rank of the tensor is greater than 2. Available values are: `sum`, `mean` or `avg`, `max`, `concat` (concatenates along the first dimension), `last` (returns the last vector of the first dimension) and `None` or `null` (which does not reduce and returns the full tensor). :type reduce_output: str """ super().__init__() self.config = encoder_config logger.debug(f" {self.name}") self.max_sequence_length = max_sequence_length self.hidden_size = state_size self.embedding_size = embedding_size self.should_embed = should_embed self.embed_sequence = None if self.should_embed: logger.debug(" EmbedSequence") self.embed_sequence = EmbedSequence( vocab, embedding_size, max_sequence_length=self.max_sequence_length, representation=representation, embeddings_trainable=embeddings_trainable, pretrained_embeddings=pretrained_embeddings, embeddings_on_cpu=embeddings_on_cpu, dropout=dropout, embedding_initializer=weights_initializer, ) logger.debug(" RecurrentStack") input_size = self.embed_sequence.output_shape[-1] if self.should_embed else embedding_size self.recurrent_stack = RecurrentStack( input_size=input_size, hidden_size=state_size, cell_type=cell_type, max_sequence_length=max_sequence_length, num_layers=num_layers, bidirectional=bidirectional, activation=activation, recurrent_activation=recurrent_activation, use_bias=use_bias, unit_forget_bias=unit_forget_bias, weights_initializer=weights_initializer, recurrent_initializer=recurrent_initializer, bias_initializer=bias_initializer, dropout=recurrent_dropout, ) self.reduce_output = reduce_output self.reduce_sequence = SequenceReducer( reduce_mode=reduce_output, max_sequence_length=self.recurrent_stack.output_shape[-2], encoding_size=self.recurrent_stack.output_shape[-1], # state_size ) if self.reduce_output is None: self.supports_masking = True else: logger.debug(" FCStack") self.fc_stack = FCStack( self.reduce_sequence.output_shape[-1], layers=fc_layers, num_layers=num_fc_layers, default_output_size=output_size, default_use_bias=use_bias, default_weights_initializer=weights_initializer, default_bias_initializer=bias_initializer, default_norm=norm, default_norm_params=norm_params, default_activation=fc_activation, default_dropout=fc_dropout, ) @staticmethod def get_schema_cls() -> type[SequenceEncoderConfig]: return StackedRNNConfig @property def input_shape(self) -> torch.Size: return torch.Size([self.max_sequence_length]) @property def output_shape(self) -> torch.Size: if self.reduce_output is not None: return self.fc_stack.output_shape return self.recurrent_stack.output_shape def input_dtype(self): return torch.int32 def forward(self, inputs: torch.Tensor, mask: torch.Tensor | None = None) -> EncoderOutputDict: """ :param inputs: The input sequence fed into the encoder. Shape: [batch x sequence length], type torch.int32 :param mask: Input mask (unused, not yet implemented) """ # ================ Embeddings ================ if self.should_embed: embedded_sequence = self.embed_sequence(inputs, mask=mask) else: embedded_sequence = inputs while len(embedded_sequence.shape) < 3: embedded_sequence = embedded_sequence.unsqueeze(-1) # shape=(?, sequence_length, embedding_size) hidden = embedded_sequence # ================ Recurrent Layers ================ hidden, final_state = self.recurrent_stack(hidden, mask=mask) # ================ Sequence Reduction ================ if self.reduce_output is not None: hidden = self.reduce_sequence(hidden) # ================ FC Layers ================ hidden = self.fc_stack(hidden, mask=mask) return {ENCODER_OUTPUT: hidden, ENCODER_OUTPUT_STATE: final_state} @DeveloperAPI @register_sequence_encoder("cnnrnn") @register_encoder("cnnrnn", [AUDIO, SEQUENCE, TEXT, TIMESERIES]) class StackedCNNRNN(SequenceEncoder): def __init__( self, should_embed=True, vocab=None, max_sequence_length=None, representation="dense", embedding_size=256, embeddings_trainable=True, pretrained_embeddings=None, embeddings_on_cpu=False, conv_layers=None, num_conv_layers=None, num_filters=256, filter_size=5, strides=1, padding="same", dilation_rate=1, conv_activation="relu", conv_dropout=0.0, pool_function="max", pool_size=2, pool_strides=None, pool_padding="same", num_rec_layers=1, state_size=256, cell_type="rnn", bidirectional=False, activation="tanh", recurrent_activation="sigmoid", unit_forget_bias=True, recurrent_initializer="orthogonal", dropout=0.0, recurrent_dropout=0.0, fc_layers=None, num_fc_layers=0, output_size=256, use_bias=True, weights_initializer="xavier_uniform", bias_initializer="zeros", norm=None, norm_params=None, fc_activation="relu", fc_dropout=0, reduce_output="last", encoder_config=None, **kwargs, ): # todo: fix up docstring """ :param should_embed: If True the input sequence is expected to be made of integers and will be mapped into embeddings :type should_embed: Boolean :param vocab: Vocabulary of the input feature to encode :type vocab: List :param representation: the possible values are `dense` and `sparse`. `dense` means the embeddings are initialized randomly, `sparse` means they are initialized to be one-hot encodings. :type representation: Str (one of 'dense' or 'sparse') :param embedding_size: it is the maximum embedding size, the actual size will be `min(vocabulary_size, embedding_size)` for `dense` representations and exactly `vocabulary_size` for the `sparse` encoding, where `vocabulary_size` is the number of different strings appearing in the training set in the column the feature is named after (plus 1 for ``). :type embedding_size: Integer :param embeddings_trainable: If `True` embeddings are trained during the training process, if `False` embeddings are fixed. It may be useful when loading pretrained embeddings for avoiding finetuning them. This parameter has effect only for `representation` is `dense` as `sparse` one-hot encodings are not trainable. :type embeddings_trainable: Boolean :param pretrained_embeddings: by default `dense` embeddings are initialized randomly, but this parameter allows to specify a path to a file containing embeddings in the GloVe format. When the file containing the embeddings is loaded, only the embeddings with labels present in the vocabulary are kept, the others are discarded. If the vocabulary contains strings that have no match in the embeddings file, their embeddings are initialized with the average of all other embedding plus some random noise to make them different from each other. This parameter has effect only if `representation` is `dense`. :type pretrained_embeddings: str (filepath) :param embeddings_on_cpu: by default embeddings matrices are stored on GPU memory if a GPU is used, as it allows for faster access, but in some cases the embedding matrix may be really big and this parameter forces the placement of the embedding matrix in regular memroy and the CPU is used to resolve them, slightly slowing down the process as a result of data transfer between CPU and GPU memory. :param num_layers: the number of stacked recurrent layers. :type num_layers: Integer :param cell_type: the type of recurrent cell to use. Available values are: `rnn`, `lstm`, `gru`. For reference about the differences between the cells please refer to PyTorch's documentation. :type cell_type: str :param state_size: the size of the state of the rnn. :type state_size: Integer :param bidirectional: if `True` two recurrent networks will perform encoding in the forward and backward direction and their outputs will be concatenated. :type bidirectional: Boolean :param dropout: determines if there should be a dropout layer before returning the encoder output. :type dropout: Boolean :param recurrent_dropout: Dropout rate for the recurrent stack. :type recurrent_dropout: float :param initializer: the initializer to use. If `None` it uses `xavier_uniform`. Options are: `constant`, `identity`, `zeros`, `ones`, `orthogonal`, `normal`, `uniform`, `truncated_normal`, `variance_scaling`, `xavier_normal`, `xavier_uniform`, `xavier_normal`, `he_normal`, `he_uniform`, `lecun_normal`, `lecun_uniform`. Alternatively it is possible to specify a dictionary with a key `type` that identifies the type of initializer and other keys for its parameters, e.g. `{type: normal, mean: 0, stddev: 0}`. To know the parameters of each initializer, please refer to PyTorch's documentation. :type initializer: str :param reduce_output: defines how to reduce the output tensor of the convolutional layers along the `s` sequence length dimension if the rank of the tensor is greater than 2. Available values are: `sum`, `mean` or `avg`, `max`, `concat` (concatenates along the first dimension), `last` (returns the last vector of the first dimension) and `None` or `null` (which does not reduce and returns the full tensor). :type reduce_output: str """ super().__init__() self.config = encoder_config logger.debug(f" {self.name}") if conv_layers is not None and num_conv_layers is None: # use custom-defined layers self.conv_layers = conv_layers self.num_conv_layers = len(conv_layers) elif conv_layers is None and num_conv_layers is not None: # generate num_conv_layers with default parameters self.conv_layers = None self.num_conv_layers = num_conv_layers elif conv_layers is None and num_conv_layers is None: # use default layers with varying filter sizes self.conv_layers = [{"pool_size": 3}, {"pool_size": None}] self.num_conv_layers = 2 else: raise ValueError("Invalid layer parametrization, use either conv_layers or " "num_conv_layers") self.max_sequence_length = max_sequence_length self.should_embed = should_embed self.embed_sequence = None if self.should_embed: logger.debug(" EmbedSequence") self.embed_sequence = EmbedSequence( vocab, embedding_size, max_sequence_length=self.max_sequence_length, representation=representation, embeddings_trainable=embeddings_trainable, pretrained_embeddings=pretrained_embeddings, embeddings_on_cpu=embeddings_on_cpu, dropout=dropout, embedding_initializer=weights_initializer, ) logger.debug(" Conv1DStack") in_channels = self.embed_sequence.output_shape[-1] if self.should_embed else embedding_size self.conv1d_stack = Conv1DStack( in_channels=in_channels, max_sequence_length=max_sequence_length, layers=self.conv_layers, default_num_filters=num_filters, default_filter_size=filter_size, default_strides=strides, default_padding=padding, default_dilation_rate=dilation_rate, default_use_bias=use_bias, default_weights_initializer=weights_initializer, default_bias_initializer=bias_initializer, default_norm=norm, default_norm_params=norm_params, default_activation=conv_activation, default_dropout=conv_dropout, default_pool_function=pool_function, default_pool_size=pool_size, default_pool_strides=pool_strides, default_pool_padding=pool_padding, ) logger.debug(" RecurrentStack") self.recurrent_stack = RecurrentStack( input_size=self.conv1d_stack.output_shape[1], hidden_size=state_size, max_sequence_length=self.conv1d_stack.output_shape[0], cell_type=cell_type, num_layers=num_rec_layers, bidirectional=bidirectional, activation=activation, recurrent_activation=recurrent_activation, use_bias=use_bias, unit_forget_bias=unit_forget_bias, weights_initializer=weights_initializer, recurrent_initializer=recurrent_initializer, bias_initializer=bias_initializer, dropout=recurrent_dropout, ) self.reduce_output = reduce_output self.reduce_sequence = SequenceReducer( reduce_mode=reduce_output, max_sequence_length=self.recurrent_stack.output_shape[-2], encoding_size=self.recurrent_stack.output_shape[-1], # State size ) if self.reduce_output is not None: logger.debug(" FCStack") self.fc_stack = FCStack( self.reduce_sequence.output_shape[-1], layers=fc_layers, num_layers=num_fc_layers, default_output_size=output_size, default_use_bias=use_bias, default_weights_initializer=weights_initializer, default_bias_initializer=bias_initializer, default_norm=norm, default_norm_params=norm_params, default_activation=fc_activation, default_dropout=fc_dropout, ) @staticmethod def get_schema_cls() -> type[SequenceEncoderConfig]: return StackedCNNRNNConfig @property def input_shape(self) -> torch.Size: return torch.Size([self.max_sequence_length]) @property def output_shape(self) -> torch.Size: if self.reduce_output is not None: return self.fc_stack.output_shape return self.recurrent_stack.output_shape def forward(self, inputs: torch.Tensor, mask: torch.Tensor | None = None) -> EncoderOutputDict: """ :param inputs: The input sequence fed into the encoder. Shape: [batch x sequence length], type torch.int32 :param mask: Input mask (unused, not yet implemented) """ # ================ Embeddings ================ if self.should_embed: embedded_sequence = self.embed_sequence(inputs, mask=mask) else: embedded_sequence = inputs while len(embedded_sequence.shape) < 3: embedded_sequence = embedded_sequence.unsqueeze(-1) # shape=(?, sequence_length, embedding_size) hidden = embedded_sequence # ================ Conv Layers ================ hidden = self.conv1d_stack(hidden, mask=mask) # ================ Recurrent Layers ================ hidden, final_state = self.recurrent_stack(hidden) # ================ Sequence Reduction ================ if self.reduce_output is not None: hidden = self.reduce_sequence(hidden) # ================ FC Layers ================ hidden = self.fc_stack(hidden, mask=mask) # no reduction: hidden [batch_size, seq_size, state_size] # with reduction: hidden [batch_size, seq_size, output_size] # final_state: if rnn/gru [batch_size, state_size] # lstm ([batch_size, state_size], [batch_size, state_size]) return {ENCODER_OUTPUT: hidden, ENCODER_OUTPUT_STATE: final_state} @DeveloperAPI @register_sequence_encoder("transformer") @register_encoder("transformer", [SEQUENCE, TEXT, TIMESERIES]) class StackedTransformer(SequenceEncoder): def __init__( self, max_sequence_length, should_embed=True, vocab=None, representation="dense", embedding_size=256, embeddings_trainable=True, pretrained_embeddings=None, embeddings_on_cpu=False, num_layers=1, hidden_size=256, num_heads=8, transformer_output_size=256, dropout=0.1, fc_layers=None, num_fc_layers=0, output_size=256, use_bias=True, weights_initializer="xavier_uniform", bias_initializer="zeros", norm=None, norm_params=None, fc_activation="relu", fc_dropout=0, reduce_output="last", encoder_config=None, **kwargs, ): # todo: update docstring as needed """ :param should_embed: If True the input sequence is expected to be made of integers and will be mapped into embeddings :type should_embed: Boolean :param vocab: Vocabulary of the input feature to encode :type vocab: List :param representation: the possible values are `dense` and `sparse`. `dense` means the embeddings are initialized randomly, `sparse` means they are initialized to be one-hot encodings. :type representation: Str (one of 'dense' or 'sparse') :param embedding_size: it is the maximum embedding size, the actual size will be `min(vocabulary_size, embedding_size)` for `dense` representations and exactly `vocabulary_size` for the `sparse` encoding, where `vocabulary_size` is the number of different strings appearing in the training set in the column the feature is named after (plus 1 for ``). :type embedding_size: Integer :param embeddings_trainable: If `True` embeddings are trained during the training process, if `False` embeddings are fixed. It may be useful when loading pretrained embeddings for avoiding finetuning them. This parameter has effect only for `representation` is `dense` as `sparse` one-hot encodings are not trainable. :type embeddings_trainable: Boolean :param pretrained_embeddings: by default `dense` embeddings are initialized randomly, but this parameter allows to specify a path to a file containing embeddings in the GloVe format. When the file containing the embeddings is loaded, only the embeddings with labels present in the vocabulary are kept, the others are discarded. If the vocabulary contains strings that have no match in the embeddings file, their embeddings are initialized with the average of all other embedding plus some random noise to make them different from each other. This parameter has effect only if `representation` is `dense`. :type pretrained_embeddings: str (filepath) :param embeddings_on_cpu: by default embeddings matrices are stored on GPU memory if a GPU is used, as it allows for faster access, but in some cases the embedding matrix may be really big and this parameter forces the placement of the embedding matrix in regular memroy and the CPU is used to resolve them, slightly slowing down the process as a result of data transfer between CPU and GPU memory. :param conv_layers: it is a list of dictionaries containing the parameters of all the convolutional layers. The length of the list determines the number of parallel convolutional layers and the content of each dictionary determines the parameters for a specific layer. The available parameters for each layer are: `filter_size`, `num_filters`, `pool`, `norm`, `activation` and `regularize`. If any of those values is missing from the dictionary, the default one specified as a parameter of the encoder will be used instead. If both `conv_layers` and `num_conv_layers` are `None`, a default list will be assigned to `conv_layers` with the value `[{filter_size: 2}, {filter_size: 3}, {filter_size: 4}, {filter_size: 5}]`. :type conv_layers: List :param num_conv_layers: if `conv_layers` is `None`, this is the number of stacked convolutional layers. :type num_conv_layers: Integer :param filter_size: if a `filter_size` is not already specified in `conv_layers` this is the default `filter_size` that will be used for each layer. It indicates how wide is the 1d convolutional filter. :type filter_size: Integer :param num_filters: if a `num_filters` is not already specified in `conv_layers` this is the default `num_filters` that will be used for each layer. It indicates the number of filters, and by consequence the output channels of the 1d convolution. :type num_filters: Integer :param pool_size: if a `pool_size` is not already specified in `conv_layers` this is the default `pool_size` that will be used for each layer. It indicates the size of the max pooling that will be performed along the `s` sequence dimension after the convolution operation. :type pool_size: Integer :param num_rec_layers: the number of stacked recurrent layers. :type num_rec_layers: Integer :param cell_type: the type of recurrent cell to use. Available values are: `rnn`, `lstm`, `lstm_block`, `lstm`, `ln`, `lstm_cudnn`, `gru`, `gru_block`, `gru_cudnn`. For reference about the differences between the cells please refer to PyTorch's documentation. We suggest to use the `block` variants on CPU and the `cudnn` variants on GPU because of their increased speed. :type cell_type: str :param state_size: the size of the state of the rnn. :type state_size: Integer :param bidirectional: if `True` two recurrent networks will perform encoding in the forward and backward direction and their outputs will be concatenated. :type bidirectional: Boolean :param dropout: determines if there should be a dropout layer before returning the encoder output. :type dropout: Boolean :param initializer: the initializer to use. If `None` it uses `xavier_uniform`. Options are: `constant`, `identity`, `zeros`, `ones`, `orthogonal`, `normal`, `uniform`, `truncated_normal`, `variance_scaling`, `xavier_normal`, `xavier_uniform`, `xavier_normal`, `he_normal`, `he_uniform`, `lecun_normal`, `lecun_uniform`. Alternatively it is possible to specify a dictionary with a key `type` that identifies the type of initializer and other keys for its parameters, e.g. `{type: normal, mean: 0, stddev: 0}`. To know the parameters of each initializer, please refer to PyTorch's documentation. :type initializer: str :param reduce_output: defines how to reduce the output tensor of the convolutional layers along the `s` sequence length dimension if the rank of the tensor is greater than 2. Available values are: `sum`, `mean` or `avg`, `max`, `concat` (concatenates along the first dimension), `last` (returns the last vector of the first dimension) and `None` or `null` (which does not reduce and returns the full tensor). :type reduce_output: str """ super().__init__() self.config = encoder_config logger.debug(f" {self.name}") self.max_sequence_length = max_sequence_length self.should_embed = should_embed self.should_project = False self.embed_sequence = None if self.should_embed: logger.debug(" EmbedSequence") self.embed_sequence = TokenAndPositionEmbedding( max_sequence_length=max_sequence_length, vocab=vocab, embedding_size=embedding_size, representation=representation, embeddings_trainable=embeddings_trainable, pretrained_embeddings=pretrained_embeddings, embeddings_on_cpu=embeddings_on_cpu, dropout=dropout, embedding_initializer=weights_initializer, ) # If vocab size is smaller than embedding size, embedding layer will use len(vocab) as embedding_size. used_embedding_size = self.embed_sequence.output_shape[-1] if used_embedding_size != hidden_size: logger.debug(" project_to_embed_size") self.project_to_hidden_size = nn.Linear(self.embed_sequence.output_shape[-1], hidden_size) self.should_project = True else: logger.debug(" project_to_embed_size") self.project_to_hidden_size = nn.Linear(embedding_size, hidden_size) self.should_project = True logger.debug(" TransformerStack") self.transformer_stack = TransformerStack( input_size=hidden_size, max_sequence_length=max_sequence_length, hidden_size=hidden_size, num_heads=num_heads, output_size=transformer_output_size, num_layers=num_layers, dropout=dropout, ) self.reduce_output = reduce_output self.reduce_sequence = SequenceReducer( reduce_mode=reduce_output, max_sequence_length=self.transformer_stack.output_shape[-2], encoding_size=self.transformer_stack.output_shape[-1], # hidden_size ) if self.reduce_output is None: self.supports_masking = True else: logger.debug(" FCStack") self.fc_stack = FCStack( self.reduce_sequence.output_shape[-1], layers=fc_layers, num_layers=num_fc_layers, default_output_size=output_size, default_use_bias=use_bias, default_weights_initializer=weights_initializer, default_bias_initializer=bias_initializer, default_norm=norm, default_norm_params=norm_params, default_activation=fc_activation, default_dropout=fc_dropout, ) @staticmethod def get_schema_cls() -> type[SequenceEncoderConfig]: return StackedTransformerConfig @property def input_shape(self) -> torch.Size: return torch.Size([self.max_sequence_length]) @property def output_shape(self) -> torch.Size: if self.reduce_output is not None: return self.fc_stack.output_shape return self.transformer_stack.output_shape def forward(self, inputs: torch.Tensor, mask: torch.Tensor | None = None) -> EncoderOutputDict: """ :param inputs: The input sequence fed into the encoder. Shape: [batch x sequence length], type torch.int32 :param mask: Input mask (unused, not yet implemented) """ # ================ Embeddings ================ if self.should_embed: embedded_sequence = self.embed_sequence(inputs, mask=mask) else: embedded_sequence = inputs while len(embedded_sequence.shape) < 3: embedded_sequence = embedded_sequence.unsqueeze(-1) # shape=(?, sequence_length, embedding_size) if self.should_project: hidden = self.project_to_hidden_size(embedded_sequence) else: hidden = embedded_sequence # shape=(?, sequence_length, hidden) # ================ Transformer Layers ================ hidden = self.transformer_stack(hidden, mask=mask) # ================ Sequence Reduction ================ if self.reduce_output is not None: hidden = self.reduce_sequence(hidden) # ================ FC Layers ================ hidden = self.fc_stack(hidden, mask=mask) return {ENCODER_OUTPUT: hidden} ================================================ FILE: ludwig/encoders/set_encoders.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import logging from typing import Any import torch from ludwig.api_annotations import DeveloperAPI from ludwig.constants import ENCODER_OUTPUT, SET from ludwig.encoders.base import Encoder from ludwig.encoders.registry import register_encoder from ludwig.encoders.types import EncoderOutputDict from ludwig.modules.embedding_modules import EmbedSet from ludwig.modules.fully_connected_modules import FCStack from ludwig.schema.encoders.base import BaseEncoderConfig from ludwig.schema.encoders.set_encoders import SetSparseEncoderConfig logger = logging.getLogger(__name__) @DeveloperAPI @register_encoder("embed", SET) class SetSparseEncoder(Encoder): def __init__( self, vocab: list[str], representation: str = "dense", embedding_size: int = 50, embeddings_trainable: bool = True, pretrained_embeddings: str | None = None, embeddings_on_cpu: bool = False, fc_layers=None, num_fc_layers: int = 0, output_size: int = 10, use_bias: bool = True, weights_initializer: str = "xavier_uniform", bias_initializer: str = "zeros", norm: str | None = None, norm_params: dict[str, Any] | None = None, activation: str = "relu", dropout: float = 0.0, encoder_config=None, **kwargs, ): super().__init__() self.config = encoder_config logger.debug(f" {self.name}") self.vocab_size = len(vocab) logger.debug(" Embed") self.embed = EmbedSet( vocab, embedding_size, representation=representation, embeddings_trainable=embeddings_trainable, pretrained_embeddings=pretrained_embeddings, embeddings_on_cpu=embeddings_on_cpu, dropout=dropout, embedding_initializer=weights_initializer, ) logger.debug(" FCStack") # TODO(shreya): Make sure this is updated when FCStack is updated self.fc_stack = FCStack( first_layer_input_size=self.embed.output_shape[-1], layers=fc_layers, num_layers=num_fc_layers, default_output_size=output_size, default_use_bias=use_bias, default_weights_initializer=weights_initializer, default_bias_initializer=bias_initializer, default_norm=norm, default_norm_params=norm_params, default_activation=activation, default_dropout=dropout, ) def forward(self, inputs: torch.Tensor) -> EncoderOutputDict: """ Params: inputs: The inputs fed into the encoder. Shape: [batch x vocab_size], type tf.int32. Returns: Embeddings of shape [batch x vocab_size x embed size], type float32. """ hidden = self.embed(inputs) hidden = self.fc_stack(hidden) return {ENCODER_OUTPUT: hidden} @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return SetSparseEncoderConfig @property def input_shape(self) -> torch.Size: return torch.Size([self.vocab_size]) @property def output_shape(self) -> torch.Size: return self.fc_stack.output_shape ================================================ FILE: ludwig/encoders/text_encoders.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import inspect import logging from collections.abc import Callable from typing import Any, TYPE_CHECKING, TypeVar import numpy as np import torch from torch import nn from transformers import AutoConfig from ludwig.api_annotations import DeveloperAPI from ludwig.constants import ENCODER_OUTPUT, TEXT from ludwig.encoders.base import Encoder from ludwig.encoders.registry import register_encoder from ludwig.encoders.types import EncoderOutputDict from ludwig.modules.reduction_modules import SequenceReducer from ludwig.schema.encoders.base import BaseEncoderConfig from ludwig.schema.encoders.sequence_encoders import SequenceEncoderConfig from ludwig.schema.encoders.text_encoders import ( ALBERTConfig, AutoTransformerConfig, BERTConfig, CamemBERTConfig, CTRLConfig, DebertaV2Config, DistilBERTConfig, ELECTRAConfig, FlauBERTConfig, GPT2Config, GPTConfig, LLMEncoderConfig, LongformerConfig, MT5Config, RoBERTaConfig, T5Config, TfIdfEncoderConfig, TransformerXLConfig, XLMConfig, XLMRoBERTaConfig, XLNetConfig, ) from ludwig.schema.llms.peft import adapter_registry, BaseAdapterConfig from ludwig.utils.data_utils import clear_data_cache from ludwig.utils.hf_utils import load_pretrained_hf_model_with_hub_fallback from ludwig.utils.llm_utils import get_context_len, initialize_adapter, load_pretrained_from_config from ludwig.utils.tokenizers import HFTokenizer from ludwig.utils.torch_utils import FreezeModule if TYPE_CHECKING: from transformers import PretrainedConfig, PreTrainedModel from ludwig.schema.encoders.text_encoders import HFEncoderConfig logger = logging.getLogger(__name__) def _cls_pooled_error_message(encoder: str): # TODO(Arnav): Remove this once we have reduce_output options set for # each encoder type in the schema raise ValueError(f"reduce_output cannot be cls_pooled for {encoder}") class HFTextEncoder(Encoder): def _init_config(self, transformer, schema_keys: list[str], encoder_config: SequenceEncoderConfig): """Creates a config object for the encoder using the transformer model and the passed-in encoder config. The transformer's config is only known after it is instantiated, so we must update the encoder config with the values from the transformer config. Args: transformer: The transformer model. schema_keys: The keys in the encoder config schema. We only want to update the encoder config with the values from the transformer config that are in the schema. encoder_config: The existing encoder config containing defaults and user-specified values. If the values in this config differ from the transformer's config, the transformer's config values will override this config's values. Returns: A new encoder config object with the updated values from the transformer config. """ transformer_config = transformer.config.to_dict() final_hf_config_params = {k: v for k, v in transformer_config.items() if k in schema_keys} encoder_config_dict = encoder_config.to_dict() encoder_config_dict.update(final_hf_config_params) return self.get_schema_cls().from_dict(encoder_config_dict) def _init_transformer_from_scratch( self, hf_model_cls: type, hf_config_cls: type, hf_config_params: dict[str, Any], vocab_size: int ): """Initializes the transformer model from scratch. This is in contrast to loading a pre-trained model. Args: hf_model_cls: The HuggingFace model class. hf_config_cls: The HuggingFace config class. hf_config_params: The HuggingFace config parameters exposed through the Ludwig schema. vocab_size: The vocab size of the dataset. Because we are training from scratch, we can resize the token embeddings table freely. Returns: The transformer model. """ config = hf_config_cls(**hf_config_params) transformer = hf_model_cls(config) self._maybe_resize_token_embeddings(transformer, vocab_size) return transformer def _maybe_resize_token_embeddings(self, transformer, vocab_size: int) -> None: """Resizes the token embeddings if the vocab size is different from the transformer's vocab size. This should only happen if we are instantiating a model from scratch (i.e. not loading from a pretrained model or checkpoint). Pretrained models update the vocab size stored in the config. This means if we are loading a pretrained model from a checkpoint, the config vocab size should match the model's vocab size. It is important that pretrained models update the vocab size stored in the config because sometimes the pretrained models will have an embeddings table that is a different size than the vocab size. Examples: CamemBERT: https://github.com/huggingface/tokenizers/issues/900#issue-1122256698 T5: https://github.com/huggingface/transformers/issues/4875#issue-635471552 Args: transformer: The transformer model. vocab_size: The vocab size of the dataset. """ if vocab_size != transformer.config.vocab_size: transformer.resize_token_embeddings(vocab_size) def _wrap_transformer( self, transformer: nn.Module, adapter: BaseAdapterConfig | dict | None, trainable: bool ) -> nn.Module: if adapter is not None: from peft import get_peft_model if isinstance(adapter, dict): adapter_cls = adapter_registry[adapter["type"]] adapter = adapter_cls.model_validate(adapter) peft_config = adapter.to_config() transformer = get_peft_model(transformer, peft_config) logger.info("==================================================") logger.info("Trainable Parameter Summary For Fine-Tuning:") transformer.print_trainable_parameters() logger.info("==================================================") return FreezeModule(transformer, frozen=not trainable) def get_embedding_layer(self) -> nn.Module: return next(self.transformer.module.children()) HFModelT = TypeVar("HFModelT", bound="PreTrainedModel") HFConfigT = TypeVar("HFConfigT", bound="PretrainedConfig") ConfigT = TypeVar("ConfigT", bound="HFEncoderConfig") class HFTextEncoderImpl(HFTextEncoder): def __init__( self, model_cls: type[HFModelT], config_cls: type[HFConfigT], schema_cls: type[ConfigT], max_sequence_length: int, use_pretrained: bool, pretrained_model_name_or_path: str, saved_weights_in_checkpoint: bool, reduce_output: str, trainable: bool, adapter: BaseAdapterConfig | None, pretrained_kwargs: dict, encoder_config: ConfigT | None, **kwargs, ): super().__init__() # TODO(travis): get_hf_config_param_names should be implemented as abstract in HFEncoderConfig vocab_size = kwargs["vocab_size"] hf_config_params = {k: v for k, v in kwargs.items() if k in schema_cls.get_hf_config_param_names()} if use_pretrained and not saved_weights_in_checkpoint: pretrained_kwargs = pretrained_kwargs or {} transformer, _ = load_pretrained_hf_model_with_hub_fallback( model_cls, pretrained_model_name_or_path, **pretrained_kwargs ) else: transformer = self._init_transformer_from_scratch(model_cls, config_cls, hf_config_params, vocab_size) if encoder_config is not None: self.config = self._init_config(transformer, hf_config_params.keys(), encoder_config) else: self.config = None self.reduce_output = reduce_output if not self.reduce_output == "cls_pooled": self.reduce_sequence = SequenceReducer(reduce_mode=reduce_output) self.transformer = self._wrap_transformer(transformer, adapter, trainable) self.max_sequence_length = max_sequence_length def forward(self, inputs: torch.Tensor, mask: torch.Tensor | None = None) -> EncoderOutputDict: if mask is not None: mask = mask.to(torch.int32) transformer_outputs = self.transformer.module( input_ids=inputs, attention_mask=mask, token_type_ids=torch.zeros_like(inputs), ) if self.reduce_output == "cls_pooled": hidden = transformer_outputs["pooler_output"] else: hidden = transformer_outputs["last_hidden_state"][:, 1:-1, :] # bos + [sent] + sep hidden = self.reduce_sequence(hidden, self.reduce_output) return {ENCODER_OUTPUT: hidden} @property def input_shape(self) -> torch.Size: return torch.Size([self.max_sequence_length]) @property def output_shape(self) -> torch.Size: if self.reduce_output is None: return torch.Size([self.max_sequence_length - 2, self.transformer.module.config.hidden_size]) if self.reduce_output == "concat": return torch.Size( [ (self.max_sequence_length - 2) * self.transformer.module.config.hidden_size, ] ) elif self.reduce_output == "concat": # add the -2 to account of start and end tokens. return torch.Size([self.transformer.module.config.hidden_size * (self.max_sequence_length - 2)]) return torch.Size([self.transformer.module.config.hidden_size]) @property def input_dtype(self) -> torch.dtype: return torch.int32 @DeveloperAPI @register_encoder("albert", TEXT) class ALBERTEncoder(HFTextEncoder): DEFAULT_MODEL_NAME = "albert-base-v2" def __init__( self, max_sequence_length, use_pretrained: bool = True, pretrained_model_name_or_path: str = DEFAULT_MODEL_NAME, saved_weights_in_checkpoint: bool = False, trainable: bool = False, adapter: BaseAdapterConfig | None = None, reduce_output: str = "cls_pooled", vocab_size: int = 30000, embedding_size: int = 128, hidden_size: int = 4096, num_hidden_layers: int = 12, num_hidden_groups: int = 1, num_attention_heads: int = 64, intermediate_size: int = 16384, inner_group_num: int = 1, hidden_act: str = "gelu_new", hidden_dropout_prob: float = 0, attention_probs_dropout_prob: float = 0, max_position_embeddings: int = 512, type_vocab_size: int = 2, initializer_range: float = 0.02, layer_norm_eps: float = 1e-12, classifier_dropout_prob: float = 0.1, position_embedding_type: str = "absolute", pad_token_id: int = 0, bos_token_id: int = 2, eos_token_id: int = 3, pretrained_kwargs: dict = None, encoder_config=None, **kwargs, ): super().__init__() from transformers import AlbertConfig, AlbertModel hf_config_params = dict( vocab_size=vocab_size, embedding_size=embedding_size, hidden_size=hidden_size, num_hidden_layers=num_hidden_layers, num_hidden_groups=num_hidden_groups, num_attention_heads=num_attention_heads, intermediate_size=intermediate_size, inner_group_num=inner_group_num, hidden_act=hidden_act, hidden_dropout_prob=hidden_dropout_prob, attention_probs_dropout_prob=attention_probs_dropout_prob, max_position_embeddings=max_position_embeddings, type_vocab_size=type_vocab_size, initializer_range=initializer_range, layer_norm_eps=layer_norm_eps, classifier_dropout_prob=classifier_dropout_prob, position_embedding_type=position_embedding_type, pad_token_id=pad_token_id, bos_token_id=bos_token_id, eos_token_id=eos_token_id, ) if use_pretrained and not saved_weights_in_checkpoint: pretrained_kwargs = pretrained_kwargs or {} transformer, _ = load_pretrained_hf_model_with_hub_fallback( AlbertModel, pretrained_model_name_or_path, **pretrained_kwargs ) else: transformer = self._init_transformer_from_scratch(AlbertModel, AlbertConfig, hf_config_params, vocab_size) if encoder_config is not None: self.config = self._init_config(transformer, hf_config_params.keys(), encoder_config) else: self.config = None self.reduce_output = reduce_output if not self.reduce_output == "cls_pooled": self.reduce_sequence = SequenceReducer(reduce_mode=reduce_output) self.transformer = self._wrap_transformer(transformer, adapter, trainable) self.max_sequence_length = max_sequence_length def forward(self, inputs: torch.Tensor, mask: torch.Tensor | None = None) -> EncoderOutputDict: if mask is not None: mask = mask.to(torch.int32) transformer_outputs = self.transformer.module( input_ids=inputs, attention_mask=mask, token_type_ids=torch.zeros_like(inputs), ) if self.reduce_output == "cls_pooled": hidden = transformer_outputs[1] else: hidden = transformer_outputs[0][:, 1:-1, :] hidden = self.reduce_sequence(hidden, self.reduce_output) return {ENCODER_OUTPUT: hidden} @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return ALBERTConfig @property def input_shape(self) -> torch.Size: return torch.Size([self.max_sequence_length]) @property def output_shape(self) -> torch.Size: if self.reduce_output is None: # Subtract 2 to remove CLS and PAD tokens added by BERT tokenizer. return torch.Size( [ self.max_sequence_length - 2, self.transformer.module.config.hidden_size, ] ) elif self.reduce_output == "concat": # add the -2 to account of start and end tokens. return torch.Size([self.transformer.module.config.hidden_size * (self.max_sequence_length - 2)]) return torch.Size([self.transformer.module.config.hidden_size]) @property def input_dtype(self) -> torch.dtype: return torch.int32 @DeveloperAPI @register_encoder("mt5", TEXT) class MT5Encoder(HFTextEncoder): DEFAULT_MODEL_NAME = "google/mt5-base" def __init__( self, max_sequence_length: int, use_pretrained: bool = True, pretrained_model_name_or_path: str = DEFAULT_MODEL_NAME, saved_weights_in_checkpoint: bool = False, trainable: bool = False, adapter: BaseAdapterConfig | None = None, reduce_output: str = "sum", vocab_size: int = 250112, d_model: int = 512, d_kv: int = 64, d_ff: int = 1024, num_layers: int = 8, num_decoder_layers: int = None, num_heads: int = 6, relative_attention_num_buckets: int = 32, dropout_rate: float = 0.1, layer_norm_epsilon: float = 1e-06, initializer_factor: float = 1.0, feed_forward_proj: str = "gated-gelu", is_encoder_decoder: bool = True, use_cache: bool = True, tokenizer_class: str = "T5Tokenizer", tie_word_embeddings: bool = False, pad_token_id: int = 0, eos_token_id: int = 1, decoder_start_token_id: int = 0, pretrained_kwargs: dict = None, encoder_config=None, **kwargs, ): super().__init__() from transformers import MT5Config, MT5EncoderModel hf_config_params = dict( vocab_size=vocab_size, d_model=d_model, d_kv=d_kv, d_ff=d_ff, num_layers=num_layers, num_decoder_layers=num_decoder_layers, num_heads=num_heads, relative_attention_num_buckets=relative_attention_num_buckets, dropout_rate=dropout_rate, layer_norm_epsilon=layer_norm_epsilon, initializer_factor=initializer_factor, feed_forward_proj=feed_forward_proj, is_encoder_decoder=is_encoder_decoder, use_cache=use_cache, tokenizer_class=tokenizer_class, tie_word_embeddings=tie_word_embeddings, pad_token_id=pad_token_id, eos_token_id=eos_token_id, decoder_start_token_id=decoder_start_token_id, ) if use_pretrained and not saved_weights_in_checkpoint: pretrained_kwargs = pretrained_kwargs or {} transformer, _ = load_pretrained_hf_model_with_hub_fallback( MT5EncoderModel, pretrained_model_name_or_path, **pretrained_kwargs ) else: transformer = self._init_transformer_from_scratch(MT5EncoderModel, MT5Config, hf_config_params, vocab_size) if encoder_config is not None: self.config = self._init_config(transformer, hf_config_params.keys(), encoder_config) else: self.config = None self.reduce_output = reduce_output if reduce_output == "cls_pooled": _cls_pooled_error_message(self.__class__.__name__) self.reduce_sequence = SequenceReducer(reduce_mode=reduce_output) self.transformer = self._wrap_transformer(transformer, adapter, trainable) self.max_sequence_length = max_sequence_length def forward(self, inputs: torch.Tensor, mask: torch.Tensor | None = None) -> EncoderOutputDict: if mask is not None: mask = mask.to(torch.int32) transformer_outputs = self.transformer.module( input_ids=inputs, attention_mask=mask, ) hidden = transformer_outputs[0][:, 1:-1, :] hidden = self.reduce_sequence(hidden, self.reduce_output) return {ENCODER_OUTPUT: hidden} @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return MT5Config @property def input_shape(self) -> torch.Size: return torch.Size([self.max_sequence_length]) @property def output_shape(self) -> torch.Size: if self.reduce_output is None: # Subtract 2 to remove CLS and PAD tokens added by MT5 tokenizer. return torch.Size( [ self.max_sequence_length - 2, self.transformer.module.config.hidden_size, ] ) elif self.reduce_output == "concat": # add the -2 to account of start and end tokens. return torch.Size([self.transformer.module.config.hidden_size * (self.max_sequence_length - 2)]) return torch.Size([self.transformer.module.config.hidden_size]) @property def input_dtype(self) -> torch.dtype: return torch.int32 @DeveloperAPI @register_encoder("xlmroberta", TEXT) class XLMRoBERTaEncoder(HFTextEncoder): DEFAULT_MODEL_NAME = "xlm-roberta-base" def __init__( self, max_sequence_length: int, use_pretrained: bool = True, pretrained_model_name_or_path: str = DEFAULT_MODEL_NAME, saved_weights_in_checkpoint: bool = False, reduce_output: str = "cls_pooled", trainable: bool = False, adapter: BaseAdapterConfig | None = None, vocab_size: int = None, pad_token_id: int = 1, bos_token_id: int = 0, eos_token_id: int = 2, max_position_embeddings: int = 514, type_vocab_size: int = 1, add_pooling_layer: bool = True, pretrained_kwargs: dict = None, encoder_config=None, **kwargs, ): super().__init__() from transformers import XLMRobertaConfig, XLMRobertaModel hf_config_params = dict( pad_token_id=pad_token_id, bos_token_id=bos_token_id, eos_token_id=eos_token_id, max_position_embeddings=max_position_embeddings, type_vocab_size=type_vocab_size, ) if use_pretrained and not saved_weights_in_checkpoint: pretrained_kwargs = pretrained_kwargs or {} transformer, _ = load_pretrained_hf_model_with_hub_fallback( XLMRobertaModel, pretrained_model_name_or_path, **pretrained_kwargs ) else: transformer = self._init_transformer_from_scratch( XLMRobertaModel, XLMRobertaConfig, hf_config_params, vocab_size ) if encoder_config is not None: self.config = self._init_config(transformer, hf_config_params.keys(), encoder_config) else: self.config = None self.reduce_output = reduce_output if not self.reduce_output == "cls_pooled": self.reduce_sequence = SequenceReducer(reduce_mode=reduce_output) self.transformer = self._wrap_transformer(transformer, adapter, trainable) self.max_sequence_length = max_sequence_length def forward(self, inputs: torch.Tensor, mask: torch.Tensor | None = None) -> EncoderOutputDict: if mask is not None: mask = mask.to(torch.int32) transformer_outputs = self.transformer.module( input_ids=inputs, attention_mask=mask, token_type_ids=torch.zeros_like(inputs), ) if self.reduce_output == "cls_pooled": hidden = transformer_outputs[1] else: hidden = transformer_outputs[0][:, 1:-1, :] hidden = self.reduce_sequence(hidden, self.reduce_output) return {ENCODER_OUTPUT: hidden} @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return XLMRoBERTaConfig @property def input_shape(self) -> torch.Size: return torch.Size([self.max_sequence_length]) @property def output_shape(self) -> torch.Size: if self.reduce_output is None: # Subtract 2 to remove CLS and PAD tokens added by XLMRoberta tokenizer. return torch.Size( [ self.max_sequence_length - 2, self.transformer.module.config.hidden_size, ] ) elif self.reduce_output == "concat": # add the -2 to account of start and end tokens. return torch.Size([self.transformer.module.config.hidden_size * (self.max_sequence_length - 2)]) return torch.Size([self.transformer.module.config.hidden_size]) @property def input_dtype(self) -> torch.dtype: return torch.int32 @DeveloperAPI @register_encoder("bert", TEXT) class BERTEncoder(HFTextEncoder): DEFAULT_MODEL_NAME = "bert-base-uncased" def __init__( self, max_sequence_length: int, use_pretrained: bool = True, pretrained_model_name_or_path: str = DEFAULT_MODEL_NAME, saved_weights_in_checkpoint: bool = False, trainable: bool = False, adapter: BaseAdapterConfig | None = None, reduce_output: str = "cls_pooled", vocab_size: int = 30522, hidden_size: int = 768, num_hidden_layers: int = 12, num_attention_heads: int = 12, intermediate_size: int = 3072, hidden_act: str | Callable = "gelu", hidden_dropout_prob: float = 0.1, attention_probs_dropout_prob: float = 0.1, max_position_embeddings: int = 512, type_vocab_size: int = 2, initializer_range: float = 0.02, layer_norm_eps: float = 1e-12, pad_token_id: int = 0, gradient_checkpointing: bool = False, position_embedding_type: str = "absolute", classifier_dropout: float = None, pretrained_kwargs: dict = None, encoder_config=None, **kwargs, ): super().__init__() from transformers import BertConfig, BertModel hf_config_params = dict( vocab_size=vocab_size, hidden_size=hidden_size, num_hidden_layers=num_hidden_layers, num_attention_heads=num_attention_heads, intermediate_size=intermediate_size, hidden_act=hidden_act, hidden_dropout_prob=hidden_dropout_prob, attention_probs_dropout_prob=attention_probs_dropout_prob, max_position_embeddings=max_position_embeddings, type_vocab_size=type_vocab_size, initializer_range=initializer_range, layer_norm_eps=layer_norm_eps, pad_token_id=pad_token_id, gradient_checkpointing=gradient_checkpointing, position_embedding_type=position_embedding_type, classifier_dropout=classifier_dropout, ) if use_pretrained and not saved_weights_in_checkpoint: pretrained_kwargs = pretrained_kwargs or {} transformer, _ = load_pretrained_hf_model_with_hub_fallback( BertModel, pretrained_model_name_or_path, **pretrained_kwargs ) else: transformer = self._init_transformer_from_scratch(BertModel, BertConfig, hf_config_params, vocab_size) if encoder_config is not None: self.config = self._init_config(transformer, hf_config_params.keys(), encoder_config) else: self.config = None self.reduce_output = reduce_output if not self.reduce_output == "cls_pooled": self.reduce_sequence = SequenceReducer(reduce_mode=reduce_output) self.transformer = self._wrap_transformer(transformer, adapter, trainable) self.max_sequence_length = max_sequence_length def forward(self, inputs: torch.Tensor, mask: torch.Tensor | None = None) -> EncoderOutputDict: if mask is not None: mask = mask.to(torch.int32) transformer_outputs = self.transformer.module( input_ids=inputs, attention_mask=mask, token_type_ids=torch.zeros_like(inputs), ) if self.reduce_output == "cls_pooled": hidden = transformer_outputs[1] else: hidden = transformer_outputs[0][:, 1:-1, :] hidden = self.reduce_sequence(hidden, self.reduce_output) return {ENCODER_OUTPUT: hidden} @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return BERTConfig @property def input_shape(self) -> torch.Size: return torch.Size([self.max_sequence_length]) # TODO(shreya): Confirm that this is it @property def output_shape(self) -> torch.Size: if self.reduce_output is None: # Subtract 2 to remove CLS and PAD tokens added by BERT tokenizer. return torch.Size( [ self.max_sequence_length - 2, self.transformer.module.config.hidden_size, ] ) elif self.reduce_output == "concat": # add the -2 to account of start and end tokens. return torch.Size([self.transformer.module.config.hidden_size * (self.max_sequence_length - 2)]) return torch.Size([self.transformer.module.config.hidden_size]) @property def input_dtype(self) -> torch.dtype: return torch.int32 @DeveloperAPI @register_encoder("xlm", TEXT) class XLMEncoder(HFTextEncoder): DEFAULT_MODEL_NAME = "xlm-mlm-en-2048" def __init__( self, max_sequence_length: int, use_pretrained: bool = True, pretrained_model_name_or_path: str = DEFAULT_MODEL_NAME, saved_weights_in_checkpoint: bool = False, trainable: bool = False, adapter: BaseAdapterConfig | None = None, reduce_output: str = "sum", vocab_size: int = 30145, emb_dim: int = 2048, n_layers: int = 12, n_heads: int = 16, dropout: float = 0.1, attention_dropout: float = 0.1, gelu_activation: bool = True, sinusoidal_embeddings: bool = False, causal: bool = False, asm: bool = False, n_langs: int = 1, use_lang_emb: bool = True, max_position_embeddings: int = 512, embed_init_std: float = 2048**-0.5, layer_norm_eps: float = 1e-12, init_std: float = 0.02, bos_index: int = 0, eos_index: int = 1, pad_index: int = 2, unk_index: int = 3, mask_index: int = 5, is_encoder: bool = True, start_n_top: int = 5, end_n_top: int = 5, mask_token_id: int = 0, lang_id: int = 0, pad_token_id: int = 2, bos_token_id: int = 0, pretrained_kwargs: dict = None, encoder_config=None, **kwargs, ): super().__init__() from transformers import XLMConfig, XLMModel hf_config_params = dict( vocab_size=vocab_size, emb_dim=emb_dim, n_layers=n_layers, n_heads=n_heads, dropout=dropout, attention_dropout=attention_dropout, gelu_activation=gelu_activation, sinusoidal_embeddings=sinusoidal_embeddings, causal=causal, asm=asm, n_langs=n_langs, use_lang_emb=use_lang_emb, max_position_embeddings=max_position_embeddings, embed_init_std=embed_init_std, layer_norm_eps=layer_norm_eps, init_std=init_std, bos_index=bos_index, eos_index=eos_index, pad_index=pad_index, unk_index=unk_index, mask_index=mask_index, is_encoder=is_encoder, start_n_top=start_n_top, end_n_top=end_n_top, mask_token_id=mask_token_id, lang_id=lang_id, pad_token_id=pad_token_id, bos_token_id=bos_token_id, ) if use_pretrained and not saved_weights_in_checkpoint: pretrained_kwargs = pretrained_kwargs or {} transformer, _ = load_pretrained_hf_model_with_hub_fallback( XLMModel, pretrained_model_name_or_path, **pretrained_kwargs ) else: transformer = self._init_transformer_from_scratch(XLMModel, XLMConfig, hf_config_params, vocab_size) self.config = self._init_config(transformer, hf_config_params, encoder_config) self.transformer = self._wrap_transformer(transformer, adapter, trainable) self.reduce_output = reduce_output if self.reduce_output == "cls_pooled": _cls_pooled_error_message(self.__class__.__name__) self.reduce_sequence = SequenceReducer(reduce_mode=reduce_output) self.max_sequence_length = max_sequence_length def forward(self, inputs: torch.Tensor, mask: torch.Tensor | None = None) -> EncoderOutputDict: if mask is not None: mask = mask.to(torch.int32) transformer_outputs = self.transformer.module( input_ids=inputs, attention_mask=mask, token_type_ids=torch.zeros_like(inputs), ) hidden = transformer_outputs[0] hidden = self.reduce_sequence(hidden, self.reduce_output) return {ENCODER_OUTPUT: hidden} @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return XLMConfig @property def input_shape(self) -> torch.Size: return torch.Size([self.max_sequence_length]) # TODO(shreya): Confirm that this is it @property def output_shape(self) -> torch.Size: if self.reduce_output is None: # Subtract 2 to remove CLS and PAD tokens added by BERT tokenizer. return torch.Size( [ self.max_sequence_length - 2, self.transformer.module.config.hidden_size, ] ) elif self.reduce_output == "concat": # add the -2 to account of start and end tokens. return torch.Size([self.transformer.module.config.hidden_size * (self.max_sequence_length - 2)]) return torch.Size([self.transformer.module.config.hidden_size]) @property def input_dtype(self) -> torch.dtype: return torch.int32 @DeveloperAPI @register_encoder("gpt", TEXT) class GPTEncoder(HFTextEncoder): DEFAULT_MODEL_NAME = "openai-gpt" def __init__( self, max_sequence_length: int, reduce_output: str = "sum", use_pretrained: bool = True, pretrained_model_name_or_path: str = DEFAULT_MODEL_NAME, saved_weights_in_checkpoint: bool = False, trainable: bool = False, adapter: BaseAdapterConfig | None = None, vocab_size: int = 30522, n_positions: int = 40478, n_ctx: int = 512, n_embd: int = 768, n_layer: int = 12, n_head: int = 12, afn: str = "gelu", resid_pdrop: float = 0.1, embd_pdrop: float = 0.1, attn_pdrop: float = 0.1, layer_norm_epsilon: float = 1e-5, initializer_range: float = 0.02, pretrained_kwargs: dict = None, encoder_config=None, **kwargs, ): super().__init__() from transformers import OpenAIGPTConfig, OpenAIGPTModel hf_config_params = dict( vocab_size=vocab_size, n_positions=n_positions, n_ctx=n_ctx, n_embd=n_embd, n_layer=n_layer, n_head=n_head, afn=afn, resid_pdrop=resid_pdrop, embd_pdrop=embd_pdrop, attn_pdrop=attn_pdrop, layer_norm_epsilon=layer_norm_epsilon, initializer_range=initializer_range, ) if use_pretrained and not saved_weights_in_checkpoint: pretrained_kwargs = pretrained_kwargs or {} transformer, _ = load_pretrained_hf_model_with_hub_fallback( OpenAIGPTModel, pretrained_model_name_or_path, **pretrained_kwargs ) else: transformer = self._init_transformer_from_scratch( OpenAIGPTModel, OpenAIGPTConfig, hf_config_params, vocab_size ) if encoder_config is not None: self.config = self._init_config(transformer, hf_config_params.keys(), encoder_config) else: self.config = None self.reduce_output = reduce_output if self.reduce_output == "cls_pooled": _cls_pooled_error_message(self.__class__.__name__) self.reduce_sequence = SequenceReducer(reduce_mode=reduce_output) self.transformer = self._wrap_transformer(transformer, adapter, trainable) self.max_sequence_length = max_sequence_length def forward(self, inputs: torch.Tensor, mask: torch.Tensor | None = None) -> EncoderOutputDict: if mask is not None: mask = mask.to(torch.int32) transformer_outputs = self.transformer.module( input_ids=inputs, attention_mask=mask, token_type_ids=torch.zeros_like(inputs), ) hidden = transformer_outputs[0] hidden = self.reduce_sequence(hidden, self.reduce_output) return {ENCODER_OUTPUT: hidden} @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return GPTConfig @property def input_shape(self) -> torch.Size: return torch.Size([self.max_sequence_length]) @property def output_shape(self) -> torch.Size: if self.reduce_output is None: return torch.Size([self.max_sequence_length, self.transformer.module.config.hidden_size]) elif self.reduce_output == "concat": return torch.Size([self.transformer.module.config.hidden_size * self.max_sequence_length]) return torch.Size([self.transformer.module.config.hidden_size]) @property def input_dtype(self) -> torch.dtype: return torch.int32 @DeveloperAPI @register_encoder("gpt2", TEXT) class GPT2Encoder(HFTextEncoder): DEFAULT_MODEL_NAME = "gpt2" def __init__( self, max_sequence_length: int, use_pretrained: bool = True, pretrained_model_name_or_path: str = DEFAULT_MODEL_NAME, reduce_output: str = "sum", trainable: bool = False, adapter: BaseAdapterConfig | None = None, vocab_size: int = 50257, n_positions: int = 1024, n_ctx: int = 1024, n_embd: int = 768, n_layer: int = 12, n_head: int = 12, n_inner: int | None = None, activation_function: str = "gelu", resid_pdrop: float = 0.1, embd_pdrop: float = 0.1, attn_pdrop: float = 0.1, layer_norm_epsilon: float = 1e-5, initializer_range: float = 0.02, scale_attn_weights: bool = True, pretrained_kwargs: dict = None, encoder_config=None, **kwargs, ): super().__init__() from transformers import GPT2Config, GPT2Model hf_config_params = dict( vocab_size=vocab_size, n_positions=n_positions, n_ctx=n_ctx, n_embd=n_embd, n_layer=n_layer, n_head=n_head, n_inner=n_inner, activation_function=activation_function, resid_pdrop=resid_pdrop, embd_pdrop=embd_pdrop, attn_pdrop=attn_pdrop, layer_norm_epsilon=layer_norm_epsilon, initializer_range=initializer_range, scale_attn_weights=scale_attn_weights, ) if use_pretrained: pretrained_kwargs = pretrained_kwargs or {} transformer, _ = load_pretrained_hf_model_with_hub_fallback( GPT2Model, pretrained_model_name_or_path, **pretrained_kwargs ) else: transformer = self._init_transformer_from_scratch(GPT2Model, GPT2Config, hf_config_params, vocab_size) if encoder_config is not None: self.config = self._init_config(transformer, hf_config_params.keys(), encoder_config) else: self.config = None self.transformer = self._wrap_transformer(transformer, adapter, trainable) self.max_sequence_length = max_sequence_length self.reduce_output = reduce_output if self.reduce_output == "cls_pooled": _cls_pooled_error_message(self.__class__.__name__) self.reduce_sequence = SequenceReducer(reduce_mode=reduce_output) def forward(self, inputs: torch.Tensor, mask: torch.Tensor | None = None) -> EncoderOutputDict: if mask is not None: mask = mask.to(torch.int32) transformer_outputs = self.transformer.module( input_ids=inputs, attention_mask=mask, token_type_ids=torch.zeros_like(inputs), ) hidden = transformer_outputs[0] hidden = self.reduce_sequence(hidden, self.reduce_output) return {ENCODER_OUTPUT: hidden} @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return GPT2Config @property def input_shape(self) -> torch.Size: return torch.Size([self.max_sequence_length]) @property def output_shape(self) -> torch.Size: if self.reduce_output is None: return torch.Size([self.max_sequence_length, self.transformer.module.config.hidden_size]) elif self.reduce_output == "concat": return torch.Size([self.transformer.module.config.hidden_size * (self.max_sequence_length)]) return torch.Size([self.transformer.module.config.hidden_size]) @property def input_dtype(self) -> torch.dtype: return torch.int32 @DeveloperAPI @register_encoder("deberta", TEXT) class DeBERTaEncoder(HFTextEncoderImpl): def __init__(self, *args, **kwargs): from transformers import DebertaV2Config as _DebertaV2Config from transformers import DebertaV2Model super().__init__(DebertaV2Model, _DebertaV2Config, DebertaV2Config, *args, **kwargs) @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return DebertaV2Config @DeveloperAPI @register_encoder("roberta", TEXT) class RoBERTaEncoder(HFTextEncoder): DEFAULT_MODEL_NAME = "roberta-base" def __init__( self, max_sequence_length, use_pretrained: bool = True, pretrained_model_name_or_path: str = DEFAULT_MODEL_NAME, saved_weights_in_checkpoint: bool = False, reduce_output: str = "cls_pooled", trainable: bool = False, adapter: BaseAdapterConfig | None = None, vocab_size: int = None, pad_token_id: int = 1, bos_token_id: int = 0, eos_token_id: int = 2, max_position_embeddings: int = 514, type_vocab_size: int = 1, pretrained_kwargs: dict = None, encoder_config=None, **kwargs, ): super().__init__() from transformers import RobertaConfig, RobertaModel hf_config_params = dict( pad_token_id=pad_token_id, bos_token_id=bos_token_id, eos_token_id=eos_token_id, max_position_embeddings=max_position_embeddings, type_vocab_size=type_vocab_size, ) if use_pretrained and not saved_weights_in_checkpoint: pretrained_kwargs = pretrained_kwargs or {} transformer, _ = load_pretrained_hf_model_with_hub_fallback( RobertaModel, pretrained_model_name_or_path, **pretrained_kwargs ) else: transformer = self._init_transformer_from_scratch(RobertaModel, RobertaConfig, hf_config_params, vocab_size) if encoder_config is not None: self.config = self._init_config(transformer, hf_config_params.keys(), encoder_config) else: self.config = None self.transformer = self._wrap_transformer(transformer, adapter, trainable) self.max_sequence_length = max_sequence_length self.reduce_output = reduce_output if not self.reduce_output == "cls_pooled": self.reduce_sequence = SequenceReducer(reduce_mode=reduce_output) def forward(self, inputs: torch.Tensor, mask: torch.Tensor | None = None) -> EncoderOutputDict: if mask is not None: mask = mask.to(torch.int32) transformer_outputs = self.transformer.module( input_ids=inputs, attention_mask=mask, token_type_ids=torch.zeros_like(inputs), ) if self.reduce_output == "cls_pooled": hidden = transformer_outputs[1] else: hidden = transformer_outputs[0][:, 1:-1, :] # bos + [sent] + sep hidden = self.reduce_sequence(hidden, self.reduce_output) return {ENCODER_OUTPUT: hidden} @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return RoBERTaConfig @property def input_shape(self) -> torch.Size: return torch.Size([self.max_sequence_length]) @property def output_shape(self) -> torch.Size: if self.reduce_output is None: return torch.Size([self.max_sequence_length - 2, self.transformer.module.config.hidden_size]) elif self.reduce_output == "concat": # add the -2 to account of start and end tokens. return torch.Size([self.transformer.module.config.hidden_size * (self.max_sequence_length - 2)]) return torch.Size([self.transformer.module.config.hidden_size]) @property def input_dtype(self) -> torch.dtype: return torch.int32 @DeveloperAPI @register_encoder("transformer_xl", TEXT) class TransformerXLEncoder(HFTextEncoder): DEFAULT_MODEL_NAME = "transfo-xl-wt103" def __init__( self, max_sequence_length: int, use_pretrained: bool = True, pretrained_model_name_or_path: str = DEFAULT_MODEL_NAME, saved_weights_in_checkpoint: bool = False, reduce_output: str = "sum", trainable: bool = False, adapter: BaseAdapterConfig | None = None, vocab_size: int = 267735, cutoffs: list[int] = [20000, 40000, 200000], d_model: int = 1024, d_embed: int = 1024, n_head: int = 16, d_head: int = 64, d_inner: int = 4096, div_val: int = 4, pre_lnorm: bool = False, n_layer: int = 18, mem_len: int = 1600, clamp_len: int = 1000, same_length: bool = True, proj_share_all_but_first: bool = True, attn_type: int = 0, sample_softmax: int = -1, adaptive: bool = True, dropout: float = 0.1, dropatt: float = 0.0, untie_r: bool = True, init: str = "normal", init_range: float = 0.01, proj_init_std: float = 0.01, init_std: float = 0.02, layer_norm_epsilon: float = 1e-5, eos_token_id: int = 0, pretrained_kwargs: dict = None, encoder_config=None, **kwargs, ): super().__init__() from transformers import TransfoXLConfig, TransfoXLModel hf_config_params = dict( vocab_size=vocab_size, cutoffs=cutoffs, d_model=d_model, d_embed=d_embed, n_head=n_head, d_head=d_head, d_inner=d_inner, div_val=div_val, pre_lnorm=pre_lnorm, n_layer=n_layer, mem_len=mem_len, clamp_len=clamp_len, same_length=same_length, proj_share_all_but_first=proj_share_all_but_first, attn_type=attn_type, sample_softmax=sample_softmax, adaptive=adaptive, dropout=dropout, dropatt=dropatt, untie_r=untie_r, init=init, init_range=init_range, proj_init_std=proj_init_std, init_std=init_std, layer_norm_epsilon=layer_norm_epsilon, eos_token_id=eos_token_id, ) if use_pretrained and not saved_weights_in_checkpoint: pretrained_kwargs = pretrained_kwargs or {} transformer, _ = load_pretrained_hf_model_with_hub_fallback( TransfoXLModel, pretrained_model_name_or_path, **pretrained_kwargs ) else: config = TransfoXLConfig(**hf_config_params) transformer = TransfoXLModel(config) if encoder_config is not None: self.config = self._init_config(transformer, hf_config_params.keys(), encoder_config) else: self.config = None self.reduce_output = reduce_output if self.reduce_output == "cls_pooled": _cls_pooled_error_message(self.__class__.__name__) self.reduce_sequence = SequenceReducer(reduce_mode=reduce_output) self.transformer = self._wrap_transformer(transformer, adapter, trainable) self.max_sequence_length = max_sequence_length def forward(self, inputs: torch.Tensor, mask: torch.Tensor | None = None) -> EncoderOutputDict: transformer_outputs = self.transformer.module(inputs) hidden = transformer_outputs[0] hidden = self.reduce_sequence(hidden, self.reduce_output) return {ENCODER_OUTPUT: hidden} @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return TransformerXLConfig @property def input_shape(self) -> torch.Size: return torch.Size([self.max_sequence_length]) @property def output_shape(self) -> torch.Size: if self.reduce_output is None: return torch.Size([self.max_sequence_length, self.transformer.module.config.d_model]) elif self.reduce_output == "concat": # add the -2 to account of start and end tokens. return torch.Size([self.transformer.module.config.d_model * self.max_sequence_length]) return torch.Size([self.transformer.module.config.d_model]) @property def input_dtype(self) -> torch.dtype: return torch.int32 @DeveloperAPI @register_encoder("xlnet", TEXT) class XLNetEncoder(HFTextEncoder): DEFAULT_MODEL_NAME = "xlnet-base-cased" def __init__( self, max_sequence_length: int, use_pretrained: bool = True, pretrained_model_name_or_path: str = DEFAULT_MODEL_NAME, saved_weights_in_checkpoint: bool = False, reduce_output: str = "sum", trainable: bool = False, adapter: BaseAdapterConfig | None = None, vocab_size: int = 32000, d_model: int = 1024, n_layer: int = 24, n_head: int = 16, d_inner: int = 4096, ff_activation: str = "gelu", untie_r: bool = True, attn_type: str = "bi", initializer_range: float = 0.02, layer_norm_eps: float = 1e-12, dropout: float = 0.1, mem_len: int | None = 512, reuse_len: int | None = None, use_mems_eval: bool = True, use_mems_train: bool = False, bi_data: bool = False, clamp_len: int = -1, same_length: bool = False, summary_type: str = "last", summary_use_proj: bool = True, summary_activation: str = "tanh", summary_last_dropout: float = 0.1, start_n_top: int = 5, end_n_top: int = 5, pad_token_id: int = 5, bos_token_id: int = 1, eos_token_id: int = 2, pretrained_kwargs: dict = None, encoder_config=None, **kwargs, ): super().__init__() from transformers import XLNetConfig, XLNetModel hf_config_params = dict( vocab_size=vocab_size, d_model=d_model, n_layer=n_layer, n_head=n_head, d_inner=d_inner, ff_activation=ff_activation, untie_r=untie_r, attn_type=attn_type, initializer_range=initializer_range, layer_norm_eps=layer_norm_eps, dropout=dropout, mem_len=mem_len, reuse_len=reuse_len, use_mems_eval=use_mems_eval, use_mems_train=use_mems_train, bi_data=bi_data, clamp_len=clamp_len, same_length=same_length, summary_type=summary_type, summary_use_proj=summary_use_proj, summary_activation=summary_activation, summary_last_dropout=summary_last_dropout, start_n_top=start_n_top, end_n_top=end_n_top, pad_token_id=pad_token_id, bos_token_id=bos_token_id, eos_token_id=eos_token_id, ) if use_pretrained and not saved_weights_in_checkpoint: pretrained_kwargs = pretrained_kwargs or {} transformer, _ = load_pretrained_hf_model_with_hub_fallback( XLNetModel, pretrained_model_name_or_path, **pretrained_kwargs ) else: transformer = self._init_transformer_from_scratch(XLNetModel, XLNetConfig, hf_config_params, vocab_size) if encoder_config is not None: self.config = self._init_config(transformer, hf_config_params.keys(), encoder_config) else: self.config = None self.max_sequence_length = max_sequence_length self.reduce_output = reduce_output if self.reduce_output == "cls_pooled": _cls_pooled_error_message(self.__class__.__name__) self.reduce_sequence = SequenceReducer(reduce_mode=reduce_output) self.transformer = self._wrap_transformer(transformer, adapter, trainable) def forward(self, inputs: torch.Tensor, mask: torch.Tensor | None = None) -> EncoderOutputDict: if mask is not None: mask = mask.to(torch.int32) transformer_outputs = self.transformer.module( input_ids=inputs, attention_mask=mask, token_type_ids=torch.zeros_like(inputs), ) hidden = transformer_outputs[0] hidden = self.reduce_sequence(hidden, self.reduce_output) return {ENCODER_OUTPUT: hidden} @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return XLNetConfig @property def input_shape(self) -> torch.Size: return torch.Size([self.max_sequence_length]) @property def output_shape(self) -> torch.Size: if self.reduce_output is None: return torch.Size([self.max_sequence_length, self.transformer.module.config.d_model]) elif self.reduce_output == "concat": return torch.Size([self.transformer.module.config.d_model * self.max_sequence_length]) return torch.Size([self.transformer.module.config.d_model]) @property def input_dtype(self) -> torch.dtype: return torch.int32 @DeveloperAPI @register_encoder("distilbert", TEXT) class DistilBERTEncoder(HFTextEncoder): DEFAULT_MODEL_NAME = "distilbert-base-uncased" def __init__( self, max_sequence_length: int, pretrained_model_name_or_path: str = DEFAULT_MODEL_NAME, saved_weights_in_checkpoint: bool = False, reduce_output: str = "sum", trainable: bool = False, adapter: BaseAdapterConfig | None = None, use_pretrained: bool = True, vocab_size: int = 30522, max_position_embeddings: int = 512, sinusoidal_pos_embds: bool = False, n_layers: int = 6, n_heads: int = 12, dim: int = 768, hidden_dim: int = 3072, dropout: float = 0.1, attention_dropout: float = 0.1, activation: str | Callable = "gelu", initializer_range: float = 0.02, qa_dropout: float = 0.1, seq_classif_dropout: float = 0.2, pretrained_kwargs: dict = None, encoder_config=None, **kwargs, ): super().__init__() from transformers import DistilBertConfig, DistilBertModel hf_config_params = dict( vocab_size=vocab_size, max_position_embeddings=max_position_embeddings, sinusoidal_pos_embds=sinusoidal_pos_embds, n_layers=n_layers, n_heads=n_heads, dim=dim, hidden_dim=hidden_dim, dropout=dropout, attention_dropout=attention_dropout, activation=activation, initializer_range=initializer_range, qa_dropout=qa_dropout, seq_classif_dropout=seq_classif_dropout, ) if use_pretrained and not saved_weights_in_checkpoint: pretrained_kwargs = pretrained_kwargs or {} transformer, _ = load_pretrained_hf_model_with_hub_fallback( DistilBertModel, pretrained_model_name_or_path, **pretrained_kwargs ) else: transformer = self._init_transformer_from_scratch( DistilBertModel, DistilBertConfig, hf_config_params, vocab_size ) if encoder_config is not None: self.config = self._init_config(transformer, hf_config_params.keys(), encoder_config) else: self.config = None self.transformer = self._wrap_transformer(transformer, adapter, trainable) self.reduce_output = reduce_output if self.reduce_output == "cls_pooled": _cls_pooled_error_message(self.__class__.__name__) self.max_sequence_length = max_sequence_length self.reduce_sequence = SequenceReducer(reduce_mode=reduce_output) self.last_inputs = None self.last_hidden = None def forward(self, inputs: torch.Tensor, mask: torch.Tensor | None = None) -> EncoderOutputDict: if mask is not None: mask = mask.to(torch.int32) transformer_outputs = self.transformer.module( input_ids=inputs, attention_mask=mask, ) hidden = transformer_outputs[0][:, 1:-1, :] self.last_inputs = inputs self.last_hidden = hidden hidden = self.reduce_sequence(hidden, self.reduce_output) return {ENCODER_OUTPUT: hidden} @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return DistilBERTConfig @property def input_shape(self) -> torch.Size: return torch.Size([self.max_sequence_length]) @property def output_shape(self) -> torch.Size: if self.reduce_output is None: # Subtract 2 to remove CLS and PAD tokens added by BERT tokenizer. return torch.Size([self.max_sequence_length - 2, self.transformer.module.config.dim]) elif self.reduce_output == "concat": # add the -2 to account of start and end tokens. return torch.Size([self.transformer.module.config.dim * (self.max_sequence_length - 2)]) return torch.Size([self.transformer.module.config.dim]) @property def input_dtype(self) -> torch.dtype: return torch.int32 @DeveloperAPI @register_encoder("ctrl", TEXT) class CTRLEncoder(HFTextEncoder): DEFAULT_MODEL_NAME = "ctrl" def __init__( self, max_sequence_length: int, use_pretrained: bool = True, pretrained_model_name_or_path: str = DEFAULT_MODEL_NAME, saved_weights_in_checkpoint: bool = False, reduce_output: str = "sum", trainable: bool = False, adapter: BaseAdapterConfig | None = None, vocab_size: int = 246534, n_positions: int = 256, n_ctx: int = 256, n_embd: int = 1280, dff: int = 8192, n_layer: int = 48, n_head: int = 16, resid_pdrop: float = 0.1, embd_pdrop: float = 0.1, attn_pdrop: float = 0.1, layer_norm_epsilon: float = 1e-6, initializer_range: float = 0.02, pretrained_kwargs: dict = None, encoder_config=None, **kwargs, ): super().__init__() from transformers import CTRLConfig, CTRLModel hf_config_params = dict( vocab_size=vocab_size, n_positions=n_positions, n_ctx=n_ctx, n_embd=n_embd, dff=dff, n_layer=n_layer, n_head=n_head, resid_pdrop=resid_pdrop, embd_pdrop=embd_pdrop, attn_pdrop=attn_pdrop, layer_norm_epsilon=layer_norm_epsilon, initializer_range=initializer_range, ) if use_pretrained and not saved_weights_in_checkpoint: pretrained_kwargs = pretrained_kwargs or {} transformer, _ = load_pretrained_hf_model_with_hub_fallback( CTRLModel, pretrained_model_name_or_path, **pretrained_kwargs ) self.vocab_size = transformer.config.vocab_size else: transformer = self._init_transformer_from_scratch(CTRLModel, CTRLConfig, hf_config_params, vocab_size) self.vocab_size = vocab_size if encoder_config is not None: self.config = self._init_config(transformer, hf_config_params.keys(), encoder_config) else: self.config = None self.max_sequence_length = max_sequence_length self.transformer = self._wrap_transformer(transformer, adapter, trainable) self.reduce_output = reduce_output if self.reduce_output == "cls_pooled": _cls_pooled_error_message(self.__class__.__name__) self.reduce_sequence = SequenceReducer(reduce_mode=reduce_output) def forward(self, inputs: torch.Tensor, mask: torch.Tensor | None = None) -> EncoderOutputDict: if mask is not None: mask = mask.to(torch.int32) transformer_outputs = self.transformer.module( input_ids=inputs, attention_mask=mask, token_type_ids=torch.zeros_like(inputs), ) hidden = transformer_outputs[0] hidden = self.reduce_sequence(hidden, self.reduce_output) return {ENCODER_OUTPUT: hidden} @staticmethod def get_schema_cls(): return CTRLConfig @property def input_shape(self) -> torch.Size: return torch.Size([self.max_sequence_length]) @property def output_shape(self) -> torch.Size: if self.reduce_output is None: return torch.Size([self.max_sequence_length, self.transformer.module.config.n_embd]) elif self.reduce_output == "concat": # add the -2 to account of start and end tokens. return torch.Size([self.transformer.module.config.n_embd * (self.max_sequence_length - 2)]) return torch.Size([self.transformer.module.config.n_embd]) @property def input_dtype(self) -> torch.dtype: return torch.int32 @DeveloperAPI @register_encoder("camembert", TEXT) class CamemBERTEncoder(HFTextEncoder): DEFAULT_MODEL_NAME = "camembert-base" def __init__( self, max_sequence_length: int, use_pretrained: bool = True, pretrained_model_name_or_path: str = DEFAULT_MODEL_NAME, saved_weights_in_checkpoint: bool = False, reduce_output: str = "cls-pooled", trainable: bool = False, adapter: BaseAdapterConfig | None = None, vocab_size: int = 30522, hidden_size: int = 768, num_hidden_layers: int = 12, num_attention_heads: int = 12, intermediate_size: int = 3072, hidden_act: str | Callable = "gelu", hidden_dropout_prob: float = 0.1, attention_probs_dropout_prob: float = 0.1, max_position_embeddings: int = 512, type_vocab_size: int = 2, initializer_range: float = 0.02, layer_norm_eps: float = 1e-12, pad_token_id: int = 0, gradient_checkpointing: bool = False, position_embedding_type: str = "absolute", classifier_dropout: float = None, pretrained_kwargs: dict = None, encoder_config=None, **kwargs, ): super().__init__() from transformers import CamembertConfig, CamembertModel hf_config_params = dict( vocab_size=vocab_size, hidden_size=hidden_size, num_hidden_layers=num_hidden_layers, num_attention_heads=num_attention_heads, intermediate_size=intermediate_size, hidden_act=hidden_act, hidden_dropout_prob=hidden_dropout_prob, attention_probs_dropout_prob=attention_probs_dropout_prob, max_position_embeddings=max_position_embeddings, type_vocab_size=type_vocab_size, initializer_range=initializer_range, layer_norm_eps=layer_norm_eps, pad_token_id=pad_token_id, gradient_checkpointing=gradient_checkpointing, position_embedding_type=position_embedding_type, classifier_dropout=classifier_dropout, ) if use_pretrained and not saved_weights_in_checkpoint: pretrained_kwargs = pretrained_kwargs or {} transformer, _ = load_pretrained_hf_model_with_hub_fallback( CamembertModel, pretrained_model_name_or_path, **pretrained_kwargs ) else: transformer = self._init_transformer_from_scratch( CamembertModel, CamembertConfig, hf_config_params, vocab_size ) if encoder_config is not None: self.config = self._init_config(transformer, hf_config_params.keys(), encoder_config) else: self.config = None self.transformer = self._wrap_transformer(transformer, adapter, trainable) self.reduce_output = reduce_output if not self.reduce_output == "cls_pooled": self.reduce_sequence = SequenceReducer(reduce_mode=reduce_output) self.max_sequence_length = max_sequence_length def forward(self, inputs: torch.Tensor, mask: torch.Tensor | None = None) -> EncoderOutputDict: if mask is not None: mask = mask.to(torch.int32) transformer_outputs = self.transformer.module( input_ids=inputs, attention_mask=mask, token_type_ids=torch.zeros_like(inputs), ) if self.reduce_output == "cls_pooled": hidden = transformer_outputs[1] else: hidden = transformer_outputs[0][:, 1:-1, :] hidden = self.reduce_sequence(hidden, self.reduce_output) return {ENCODER_OUTPUT: hidden} @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return CamemBERTConfig @property def input_shape(self) -> torch.Size: return torch.Size([self.max_sequence_length]) @property def output_shape(self) -> torch.Size: if self.reduce_output is None: # Subtract 2 to remove CLS and PAD tokens added by BERT tokenizer. return torch.Size( [ self.max_sequence_length - 2, self.transformer.module.config.hidden_size, ] ) elif self.reduce_output == "concat": # add the -2 to account of start and end tokens. return torch.Size([self.transformer.module.config.hidden_size * (self.max_sequence_length - 2)]) return torch.Size([self.transformer.module.config.hidden_size]) @property def input_dtype(self) -> torch.dtype: return torch.int32 @DeveloperAPI @register_encoder("t5", TEXT) class T5Encoder(HFTextEncoder): DEFAULT_MODEL_NAME = "t5-small" def __init__( self, max_sequence_length: int, use_pretrained: bool = True, pretrained_model_name_or_path: str = DEFAULT_MODEL_NAME, saved_weights_in_checkpoint: bool = False, reduce_output: str = "sum", trainable: bool = False, adapter: BaseAdapterConfig | None = None, vocab_size: int = 32128, d_model: int = 512, d_kv: int = 64, d_ff: int = 2048, num_layers: int = 6, num_decoder_layers: int | None = None, num_heads: int = 8, relative_attention_num_buckets: int = 32, dropout_rate: float = 0.1, layer_norm_eps: float = 1e-6, initializer_factor: float = 1, feed_forward_proj: str = "relu", pretrained_kwargs: dict = None, encoder_config=None, **kwargs, ): super().__init__() from transformers import T5Config, T5Model hf_config_params = dict( vocab_size=vocab_size, d_model=d_model, d_kv=d_kv, d_ff=d_ff, num_layers=num_layers, num_decoder_layers=num_decoder_layers, num_heads=num_heads, relative_attention_num_buckets=relative_attention_num_buckets, dropout_rate=dropout_rate, layer_norm_eps=layer_norm_eps, initializer_factor=initializer_factor, feed_forward_proj=feed_forward_proj, ) if use_pretrained and not saved_weights_in_checkpoint: pretrained_kwargs = pretrained_kwargs or {} transformer, _ = load_pretrained_hf_model_with_hub_fallback( T5Model, pretrained_model_name_or_path, **pretrained_kwargs ) else: transformer = self._init_transformer_from_scratch(T5Model, T5Config, hf_config_params, vocab_size) if encoder_config is not None: self.config = self._init_config(transformer, hf_config_params.keys(), encoder_config) else: self.config = None self.max_sequence_length = max_sequence_length self.reduce_output = reduce_output if self.reduce_output == "cls_pooled": _cls_pooled_error_message(self.__class__.__name__) self.reduce_sequence = SequenceReducer(reduce_mode=reduce_output) self.transformer = self._wrap_transformer(transformer, adapter, trainable) def forward(self, inputs: torch.Tensor, mask: torch.Tensor | None = None) -> EncoderOutputDict: if mask is not None: mask = mask.to(torch.int32) transformer_outputs = self.transformer.module( inputs, decoder_input_ids=inputs, attention_mask=mask, ) hidden = transformer_outputs[0][:, 0:-1, :] # [eos token] hidden = self.reduce_sequence(hidden, self.reduce_output) return {ENCODER_OUTPUT: hidden} @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return T5Config @property def input_shape(self) -> torch.Size: return torch.Size([self.max_sequence_length]) @property def output_shape(self) -> torch.Size: if self.reduce_output is None: # Subtract 1 to remove EOS token added by T5 tokenizer. return torch.Size( [ self.max_sequence_length - 1, self.transformer.module.config.hidden_size, ] ) elif self.reduce_output == "concat": # add the -1 to account of start and end tokens. return torch.Size([self.transformer.module.config.hidden_size * (self.max_sequence_length - 1)]) return torch.Size([self.transformer.module.config.d_model]) @property def input_dtype(self) -> torch.dtype: return torch.int32 @DeveloperAPI @register_encoder("flaubert", TEXT) class FlauBERTEncoder(HFTextEncoder): DEFAULT_MODEL_NAME = "flaubert/flaubert_small_cased" def __init__( self, max_sequence_length: int, use_pretrained: bool, pretrained_model_name_or_path: str = DEFAULT_MODEL_NAME, saved_weights_in_checkpoint: bool = False, reduce_output: str = "sum", trainable: bool = False, adapter: BaseAdapterConfig | None = None, vocab_size: int = 30145, pre_norm: bool = False, layerdrop: float = 0.0, emb_dim: int = 2048, n_layers: int = 12, n_heads: int = 16, dropout: float = 0.1, attention_dropout: float = 0.1, gelu_activation: bool = True, sinusoidal_embeddings: bool = False, causal: bool = False, asm: bool = False, n_langs: int = 1, use_lang_emb: bool = True, max_position_embeddings: int = 512, embed_init_std: float = 2048**-0.5, init_std: int = 0.02, layer_norm_eps: float = 1e-12, bos_index: int = 0, eos_index: int = 1, pad_index: int = 2, unk_index: int = 3, mask_index: int = 5, is_encoder: bool = True, mask_token_id: int = 0, lang_id: int = 1, pretrained_kwargs: dict = None, encoder_config=None, **kwargs, ): super().__init__() from transformers import FlaubertConfig, FlaubertModel hf_config_params = dict( vocab_size=vocab_size, pre_norm=pre_norm, layerdrop=layerdrop, emb_dim=emb_dim, n_layers=n_layers, n_heads=n_heads, dropout=dropout, attention_dropout=dropout, gelu_activation=gelu_activation, sinusoidal_embeddings=sinusoidal_embeddings, causal=causal, asm=asm, n_langs=n_langs, use_lang_emb=use_lang_emb, max_position_embeddings=max_position_embeddings, embed_init_std=embed_init_std, init_std=init_std, layer_norm_eps=layer_norm_eps, bos_index=bos_index, eos_index=eos_index, pad_index=pad_index, unk_index=unk_index, mask_index=mask_index, is_encoder=is_encoder, mask_token_id=mask_token_id, lang_id=lang_id, ) if use_pretrained and not saved_weights_in_checkpoint: pretrained_kwargs = pretrained_kwargs or {} transformer, _ = load_pretrained_hf_model_with_hub_fallback( FlaubertModel, pretrained_model_name_or_path, **pretrained_kwargs ) else: transformer = self._init_transformer_from_scratch( FlaubertModel, FlaubertConfig, hf_config_params, vocab_size ) if encoder_config is not None: self.config = self._init_config(transformer, hf_config_params.keys(), encoder_config) else: self.config = None self.max_sequence_length = max_sequence_length self.reduce_output = reduce_output if self.reduce_output == "cls_pooled": _cls_pooled_error_message(self.__class__.__name__) self.reduce_sequence = SequenceReducer(reduce_mode=reduce_output) self.transformer = self._wrap_transformer(transformer, adapter, trainable) def forward(self, inputs: torch.Tensor, mask: torch.Tensor | None = None) -> EncoderOutputDict: if mask is not None: mask = mask.to(torch.int32) transformer_outputs = self.transformer.module( input_ids=inputs, attention_mask=mask, token_type_ids=torch.zeros_like(inputs), ) hidden = transformer_outputs[0][:, 1:-1, :] hidden = self.reduce_sequence(hidden, self.reduce_output) return {ENCODER_OUTPUT: hidden} @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return FlauBERTConfig @property def input_shape(self) -> torch.Size: return torch.Size([self.max_sequence_length]) @property def output_shape(self) -> torch.Size: if self.reduce_output is None: # Subtract 2 to remove CLS and PAD tokens added by tokenizer. return torch.Size( [ self.max_sequence_length - 2, self.transformer.module.config.hidden_size, ] ) elif self.reduce_output == "concat": # add the -2 to account of start and end tokens. return torch.Size([self.transformer.module.config.hidden_size * (self.max_sequence_length - 2)]) return torch.Size([self.transformer.module.config.emb_dim]) @property def input_dtype(self) -> torch.dtype: return torch.int32 @DeveloperAPI @register_encoder("electra", TEXT) class ELECTRAEncoder(HFTextEncoder): DEFAULT_MODEL_NAME = "google/electra-small-discriminator" def __init__( self, max_sequence_length: int, use_pretrained: bool = True, pretrained_model_name_or_path: str = DEFAULT_MODEL_NAME, saved_weights_in_checkpoint: bool = False, reduce_output: str = "sum", trainable: bool = False, adapter: BaseAdapterConfig | None = None, vocab_size: int = 30522, embedding_size: int = 128, hidden_size: int = 256, num_hidden_layers: int = 12, num_attention_heads: int = 4, intermediate_size: int = 1024, hidden_act: str | Callable = "gelu", hidden_dropout_prob: float = 0.1, attention_probs_dropout_prob: float = 0.1, max_position_embeddings: int = 512, type_vocab_size: int = 2, initializer_range: float = 0.02, layer_norm_eps: float = 1e-12, position_embedding_type: str = "absolute", classifier_dropout: float | None = None, pretrained_kwargs: dict = None, encoder_config=None, **kwargs, ): super().__init__() from transformers import ElectraConfig, ElectraModel hf_config_params = dict( vocab_size=vocab_size, embedding_size=embedding_size, hidden_size=hidden_size, num_hidden_layers=num_hidden_layers, num_attention_heads=num_attention_heads, intermediate_size=intermediate_size, hidden_act=hidden_act, hidden_dropout_prob=hidden_dropout_prob, attention_probs_dropout_prob=attention_probs_dropout_prob, max_position_embeddings=max_position_embeddings, type_vocab_size=type_vocab_size, initializer_range=initializer_range, layer_norm_eps=layer_norm_eps, position_embedding_type=position_embedding_type, classifier_dropout=classifier_dropout, ) if use_pretrained and not saved_weights_in_checkpoint: pretrained_kwargs = pretrained_kwargs or {} transformer, _ = load_pretrained_hf_model_with_hub_fallback( ElectraModel, pretrained_model_name_or_path, **pretrained_kwargs ) else: transformer = self._init_transformer_from_scratch(ElectraModel, ElectraConfig, hf_config_params, vocab_size) if encoder_config is not None: self.config = self._init_config(transformer, hf_config_params.keys(), encoder_config) else: self.config = None self.max_sequence_length = max_sequence_length self.reduce_output = reduce_output if self.reduce_output == "cls_pooled": _cls_pooled_error_message(self.__class__.__name__) self.reduce_sequence = SequenceReducer(reduce_mode=reduce_output) self.transformer = self._wrap_transformer(transformer, adapter, trainable) def forward(self, inputs: torch.Tensor, mask: torch.Tensor | None = None) -> EncoderOutputDict: if mask is not None: mask = mask.to(torch.int32) transformer_outputs = self.transformer.module( input_ids=inputs, attention_mask=mask, token_type_ids=torch.zeros_like(inputs), ) hidden = transformer_outputs[0][:, 1:-1, :] hidden = self.reduce_sequence(hidden, self.reduce_output) return {ENCODER_OUTPUT: hidden} @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return ELECTRAConfig @property def input_shape(self) -> torch.Size: return torch.Size([self.max_sequence_length]) @property def output_shape(self) -> torch.Size: if self.reduce_output is None: # Subtract 2 to remove CLS and PAD tokens added by tokenizer. return torch.Size( [ self.max_sequence_length - 2, self.transformer.module.config.hidden_size, ] ) elif self.reduce_output == "concat": # add the -2 to account of start and end tokens. return torch.Size([self.transformer.module.config.hidden_size * (self.max_sequence_length - 2)]) return torch.Size([self.transformer.module.config.hidden_size]) @property def input_dtype(self) -> torch.dtype: return torch.int32 @DeveloperAPI @register_encoder("longformer", TEXT) class LongformerEncoder(HFTextEncoder): DEFAULT_MODEL_NAME = "allenai/longformer-base-4096" def __init__( self, max_sequence_length: int, use_pretrained: bool = True, attention_window: list[int] | int = 512, sep_token_id: int = 2, pretrained_model_name_or_path: str = DEFAULT_MODEL_NAME, saved_weights_in_checkpoint: bool = False, reduce_output: str | None = "cls_pooled", trainable: bool = False, adapter: BaseAdapterConfig | None = None, vocab_size: int = 50265, num_tokens: int | None = None, pretrained_kwargs: dict = None, encoder_config=None, **kwargs, ): super().__init__() from transformers import LongformerConfig, LongformerModel hf_config_params = dict( attention_window=attention_window, sep_token_id=sep_token_id, vocab_size=vocab_size, **kwargs, ) if use_pretrained and not saved_weights_in_checkpoint: pretrained_kwargs = pretrained_kwargs or {} transformer, _ = load_pretrained_hf_model_with_hub_fallback( LongformerModel, pretrained_model_name_or_path, **pretrained_kwargs ) else: transformer = self._init_transformer_from_scratch( LongformerModel, LongformerConfig, hf_config_params, vocab_size ) if encoder_config is not None: self.config = self._init_config(transformer, hf_config_params.keys(), encoder_config) else: self.config = None self.reduce_output = reduce_output if not self.reduce_output == "cls_pooled": self.reduce_sequence = SequenceReducer(reduce_mode=reduce_output) self.transformer = self._wrap_transformer(transformer, adapter, trainable) self.max_sequence_length = max_sequence_length def forward(self, inputs: torch.Tensor, mask: torch.Tensor | None = None) -> EncoderOutputDict: if mask is not None: mask = mask.to(torch.int32) transformer_outputs = self.transformer.module( input_ids=inputs, attention_mask=mask, token_type_ids=torch.zeros_like(inputs), ) if self.reduce_output == "cls_pooled": hidden = transformer_outputs[1] else: hidden = transformer_outputs[0][:, 1:-1, :] # bos + [sent] + sep hidden = self.reduce_sequence(hidden, self.reduce_output) return {ENCODER_OUTPUT: hidden} @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return LongformerConfig @property def input_shape(self) -> torch.Size: return torch.Size([self.max_sequence_length]) @property def output_shape(self) -> torch.Size: if self.reduce_output is None: # Subtract 2 to remove CLS and PAD tokens added by Longformer (== Roberta) tokenizer. return torch.Size( [ self.max_sequence_length - 2, self.transformer.module.config.hidden_size, ] ) elif self.reduce_output == "concat": # add the -2 to account of start and end tokens. return torch.Size([self.transformer.module.config.hidden_size * (self.max_sequence_length - 2)]) return torch.Size([self.transformer.module.config.hidden_size]) @property def input_dtype(self) -> torch.dtype: return torch.int32 @DeveloperAPI @register_encoder("auto_transformer", TEXT) class AutoTransformerEncoder(HFTextEncoder): DEFAULT_MODEL_NAME = None def __init__( self, pretrained_model_name_or_path: str, max_sequence_length: int, reduce_output: str = "sum", trainable: bool = False, adapter: BaseAdapterConfig | None = None, vocab_size: int | None = None, pretrained_kwargs: dict = None, encoder_config=None, **kwargs, ): super().__init__() from transformers import AutoModel pretrained_kwargs = pretrained_kwargs or {} transformer, _ = load_pretrained_hf_model_with_hub_fallback( AutoModel, pretrained_model_name_or_path, **pretrained_kwargs ) self._maybe_resize_token_embeddings(transformer, vocab_size) self.config = self._init_config(transformer, [], encoder_config) # Precompute the set of params that are included in the forward signature of the AutoModel implementation so # we can filter out unused params during the `forward` call. self.forward_kwargs = set(inspect.signature(transformer.forward).parameters.keys()) self.transformer = self._wrap_transformer(transformer, adapter, trainable) self.reduce_output = reduce_output if self.reduce_output != "cls_pooled": self.reduce_sequence = SequenceReducer( reduce_mode=reduce_output, encoding_size=self.transformer.module.config.hidden_size ) self.max_sequence_length = max_sequence_length def _maybe_resize_token_embeddings(self, transformer, vocab_size: int | None = None): """Overridden because AutoModel should use its own vocab size unless vocab size is explicitly specified.""" if vocab_size is not None: transformer.resize_token_embeddings(vocab_size) self.vocab_size = vocab_size else: self.vocab_size = transformer.config.vocab_size def forward(self, inputs: torch.Tensor, mask: torch.Tensor | None = None) -> EncoderOutputDict: if mask is not None: mask = mask.to(torch.int32) # The forward signature of AutoModel is not consistent across implementations, so we need to make sure we're # only passing in params included in the forward signature. kwargs = dict( input_ids=inputs, attention_mask=mask, token_type_ids=torch.zeros_like(inputs), ) kwargs = {k: v for k, v in kwargs.items() if k in self.forward_kwargs} transformer_outputs = self.transformer.module(**kwargs) if self.reduce_output == "cls_pooled": # this works only if the user know that the specific model # they want to use has the same outputs of # the BERT base class call() function hidden = transformer_outputs["pooler_output"] else: hidden = transformer_outputs["last_hidden_state"] hidden = self.reduce_sequence(hidden, self.reduce_output) return {ENCODER_OUTPUT: hidden} @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return AutoTransformerConfig @property def input_shape(self) -> torch.Size: return torch.Size([self.max_sequence_length]) @property def output_shape(self) -> torch.Size: if self.reduce_output is None: # TODO(justin): This may need to be conditioned on which AutoModel gets chosen. return torch.Size([self.max_sequence_length, self.transformer.module.config.hidden_size]) if self.reduce_output == "concat": return torch.Size( [ self.max_sequence_length * self.transformer.module.config.hidden_size, ] ) elif self.reduce_output == "concat": # add the -2 to account of start and end tokens. return torch.Size([self.transformer.module.config.hidden_size * (self.max_sequence_length - 2)]) return torch.Size([self.transformer.module.config.hidden_size]) @property def input_dtype(self) -> torch.dtype: return torch.int32 @DeveloperAPI @register_encoder("tf_idf", [TEXT]) class TfIdfEncoder(Encoder): def __init__( self, max_sequence_length: int, encoder_config=None, str2idf=None, vocab=None, vocab_size: int = None, **kwargs, ): super().__init__() self.config = encoder_config self.max_sequence_length = max_sequence_length self.vocab_size = vocab_size logger.debug(f" {self.name}") # Convert mapping of token -> frequency to a dense array idf = np.zeros(vocab_size) for i, s in enumerate(vocab): idf[i] = str2idf[s] self.register_buffer("idf", torch.from_numpy(idf).float().unsqueeze(0)) def forward(self, t: torch.Tensor, mask: torch.Tensor | None = None) -> EncoderOutputDict: # Compute the term frequency within each row tf = torch.stack([t_i.bincount(minlength=self.vocab_size) for t_i in torch.unbind(t.long())]) # Normalize the term frequency by the number of tokens in each row tf = tf / tf.sum(dim=1).unsqueeze(-1) # Multiply the term frequency by the inverse document frequency tfidf = tf * self.idf return {ENCODER_OUTPUT: tfidf} @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return TfIdfEncoderConfig @property def input_shape(self) -> torch.Size: return torch.Size([self.max_sequence_length]) @property def output_shape(self) -> torch.Size: return torch.Size([self.vocab_size]) def get_embedding_layer(self) -> nn.Module: return self @DeveloperAPI @register_encoder("llm", [TEXT]) class LLMEncoder(Encoder): # Per-adapter type prefixes for parameter names in the state dict, taken from # https://github.com/huggingface/peft/blob/0f1e9091cc975eb5458cc163bf1843a34fb42b76/src/peft/utils/save_and_load.py#L173C9-L180 ADAPTER_PARAM_NAME_PREFIX = { "adalora": "lora_", "ia3": "ia3_", "lora": "lora_", } def __init__(self, encoder_config: LLMEncoderConfig = None, **kwargs): super().__init__() self.register_load_state_dict_post_hook(self.remove_missing_non_adapter_keys) self.config = encoder_config self.adapter_is_initialized = False self.model_name = self.config.base_model self.model_config = AutoConfig.from_pretrained(self.config.base_model) self.model = load_pretrained_from_config(self.config, model_config=self.model_config) self.curr_device = next(self.model.parameters()).device logger.info("Done.") self.context_len = get_context_len(self.model_config) # TODO(Arnav): This needs be more flexible to account for RoPE Scaling # When merging input IDs and target IDs for LLM fine-tuning, we want to make sure that the merged tensor is # not longer than the global maximum sequence length. This is provided in the preprocessing config. We never # want to exceed the maximum possible context length so we also check for that. if self.config.max_sequence_length: max_sequence_length = self.config.max_sequence_length self.max_sequence_length = ( max_sequence_length if max_sequence_length <= self.context_len else self.context_len ) else: self.max_sequence_length = self.context_len # Initialize tokenizer self.tokenizer = HFTokenizer(self.config.base_model).tokenizer self.attention_masks = None clear_data_cache() # Because we use the last hidden state as encoder output rather than the logits, the final module of the model # has input pass through but no gradient update in the backward pass. This can lead to a DDP error. Freezing # the module prevents this from happening. This is done at initialization to prevent "unused parameters" errors # from happening when the encoder is used before `prepare_for_training` is called, for example during batch # size tuning. out_module = list(self.model.modules())[-1] out_module.requires_grad_(requires_grad=False) @staticmethod def get_schema_cls() -> type[BaseEncoderConfig]: return LLMEncoderConfig @property def input_shape(self) -> torch.Size: return torch.Size([self.max_sequence_length]) @property def output_shape(self) -> torch.Size: return torch.Size([self.max_sequence_length, self.model_config.hidden_size]) def get_embedding_layer(self) -> nn.Module: return self def initialize_adapter(self): """If an adapter config is provided, we want to wrap the model with a PEFT model for fine-tuning.""" if self.config.adapter: self.model = initialize_adapter(self.model, self.config) logger.info("==================================================") logger.info("Trainable Parameter Summary For LLM Encoder Fine-Tuning") logger.info(f"Fine-tuning with adapter: {self.config.adapter.type}") self.model.print_trainable_parameters() logger.info("==================================================") self.adapter_is_initialized = True def prepare_for_training(self): # TODO: this implementation will not work if resuming from a previous checkpoint. Need to fix this. if self.config.quantization: self.prepare_for_quantized_training() self.initialize_adapter() def prepare_for_quantized_training(self): from peft import prepare_model_for_kbit_training self.model = prepare_model_for_kbit_training(self.model, use_gradient_checkpointing=False) def forward(self, inputs: torch.Tensor, mask: torch.Tensor | None = None): # Get the hidden state of the last layer and return it as the text encoding model_outputs = self.model(input_ids=inputs, output_hidden_states=True).hidden_states[-1] return {ENCODER_OUTPUT: model_outputs.type(torch.float32)} def _save_to_state_dict(self, destination: dict, prefix: str, keep_vars: bool): # This is called by `torch.nn.Module.state_dict()` under the hood. `state_dict()` does additional work to # prep the dictionary, get submodule state, and run hooks. Overriding this method only impacts the # contents of the state_dict. # The three args to this method are supplied by Module.state_dict # https://github.com/pytorch/pytorch/blob/8739d1e3f9b08f4282fe79fc8dacd781d16913ff/torch/nn/modules/module.py#L1824 if self.config.adapter and self.adapter_is_initialized: # get_peft_model_state_dict geneates a state dict that only contains the adapter weights from peft.utils.save_and_load import get_peft_model_state_dict sd = get_peft_model_state_dict(self.model) destination.update(sd) else: super()._save_to_state_dict(destination, prefix=prefix, keep_vars=keep_vars) def state_dict(self, *args, destination=None, prefix="", keep_vars=False): destination = super().state_dict(destination, prefix=prefix, keep_vars=keep_vars) if self.config.adapter and self.adapter_is_initialized: adapter_type_prefix = self.ADAPTER_PARAM_NAME_PREFIX[self.config.adapter.type] exclude_model_keys = [k for k in destination.keys() if adapter_type_prefix not in k] for k in exclude_model_keys: del destination[k] return destination def _load_from_state_dict( self, state_dict, prefix, local_metadata, strict, missing_keys, unexpected_keys, error_msgs ): # Call this first to make sure torch can do its usual load. In the adapter case, this should essentially be a # no-op, but the adapter weights will be collected in `unexpected_keys` because PEFT changes the parameter # names under the hood. super()._load_from_state_dict( state_dict, prefix, local_metadata, strict, missing_keys, unexpected_keys, error_msgs ) if self.config.adapter and self.adapter_is_initialized: # When using an adapter, only the adapter weights are saved, and so we only want to load those weights. # Under the hood, PEFT alters the names of the parameters, which leads to an "unexpected keys" error when # using strict mode. This block uses PEFT's version of `load_state_dict` to handle loading in weights. from peft.utils.save_and_load import set_peft_model_state_dict adapter_type_prefix = self.ADAPTER_PARAM_NAME_PREFIX[self.config.adapter.type] peft_model_state_dict = {k: v for k, v in state_dict.items() if adapter_type_prefix in k} set_peft_model_state_dict(self.model, peft_model_state_dict) def remove_missing_non_adapter_keys(self, module, incompatible_keys): """Update the missing and unexpected keys lists to reflect custom adapter state load logic. This method should never return anything unless the underlying torch hook logic is updated. Any changes to the lists in `incompatible_keys` must be made in-place. Args: module: The torch module with newly loaded state incompatible_keys: A tuple with the lists of missing and unexpected keys that were recorded while loading """ # If no adapter was used, `LLMEncoder.load_state_dict` should use the default `torch.Module.load_state_dict` # code path to load weights and no modification should be necessary. if self.config.adapter and self.adapter_is_initialized: adapter_type_prefix = self.ADAPTER_PARAM_NAME_PREFIX[self.config.adapter.type] missing_keys, unexpected_keys = incompatible_keys # The state dict uses fully qualified parameter names, but this function does not have access to the # fully qualified names or a prefix to recreate them. Iterate over the missing keys and greedily select the # first non-adapter key that shares a suffix with a model parameter name. sample_missing_key = "" sample_model_key = "" for k in missing_keys: # Exclude any adapter weight--those should not be missing. Let torch handle that downstream. if adapter_type_prefix not in k: sample_model_keys = [p for p, _ in self.named_parameters() if p in k] if sample_model_keys: sample_model_key = sample_model_keys[0] sample_missing_key = k break sd_prefix = sample_missing_key.replace(sample_model_key, "") # When loading the adapter weights in strict mode, torch will register the base model weights as missing # from the state dict and raise an exception. The base model weights are intended to be excluded, so the # missing_keys list is updated post-load to avoid the error. for k, _ in self.named_parameters(): full_name = f"{sd_prefix}{k}" if full_name in missing_keys and adapter_type_prefix not in full_name: missing_keys.remove(full_name) # peft changes the adapter parameter names under the hood to include the adapter name. When retreiving the # adapter state dict, however, the name is not included. This causes the adpater weights to be recorded as # unexpected parameters. `LLMEncoder._load_from_state_dict` loads the adapter parameters using a peft # utility that accounts for the updated names, so here we remove any adapter parameters from the unexpected # keys list to avoid errors. from peft.utils.save_and_load import get_peft_model_state_dict sd = get_peft_model_state_dict(self.model) for k in sd.keys(): if k in unexpected_keys: unexpected_keys.remove(k) ================================================ FILE: ludwig/encoders/types.py ================================================ from typing import TypedDict import torch class EncoderOutputDict(TypedDict, total=False): encoder_output: torch.Tensor encoder_output_state: torch.Tensor # only used by sequence and h3 encoders attentions: torch.Tensor # only used by the vit legacy encoder ================================================ FILE: ludwig/error.py ================================================ # Copyright (c) 2022 Predibase, 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. # ============================================================================== from ludwig.api_annotations import PublicAPI @PublicAPI class LudwigError(Exception): """Base class for all custom exceptions raised by the Ludwig framework.""" def __reduce__(self): """Docs: https://docs.python.org/3/library/pickle.html#object.__reduce__.""" raise NotImplementedError( "Implement __reduce__ for all subclasses of LudwigError as it's necessary for " "serialization by Ray. See https://github.com/ludwig-ai/ludwig/pull/2695." ) @PublicAPI class InputDataError(LudwigError, ValueError): """Exception raised for errors in the input data. Appropriate for data which is not convertible to the input feature type, columns with all missing values, categorical columns with only one category, etc... Attributes: column - The name of the input column which caused the error feature_type - The Ludwig feature type which caused the error (number, binary, category...). message - An error message describing the situation. """ def __init__(self, column_name: str, feature_type: str, message: str): self.column_name = column_name self.feature_type = feature_type self.message = message super().__init__(message) def __str__(self): return f'Column "{self.column_name}" as {self.feature_type} feature: {self.message}' def __reduce__(self): return type(self), (self.column_name, self.feature_type, self.message) @PublicAPI class ConfigValidationError(LudwigError, ValueError): """Exception raised for errors in the Ludwig configuration. Appropriate for bad configuration values, missing required configuration values, etc... Attributes: message - An error message describing the situation. """ def __init__(self, message: str): self.message = message super().__init__(message) def __reduce__(self): return type(self), (self.message,) ================================================ FILE: ludwig/evaluate.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import argparse import logging import sys import pandas as pd from ludwig.api import LudwigModel from ludwig.backend import ALL_BACKENDS, Backend, initialize_backend from ludwig.callbacks import Callback from ludwig.constants import FULL, TEST, TRAINING, VALIDATION from ludwig.contrib import add_contrib_callback_args from ludwig.globals import LUDWIG_VERSION from ludwig.utils.print_utils import get_logging_level_registry, print_ludwig logger = logging.getLogger(__name__) def evaluate_cli( model_path: str, dataset: str | dict | pd.DataFrame = None, data_format: str = None, split: str = FULL, batch_size: int = 128, skip_save_unprocessed_output: bool = False, skip_save_predictions: bool = False, skip_save_eval_stats: bool = False, skip_collect_predictions: bool = False, skip_collect_overall_stats: bool = False, output_directory: str = "results", gpus: str | int | list[int] = None, gpu_memory_limit: float | None = None, allow_parallel_threads: bool = True, callbacks: list[Callback] = None, backend: Backend | str = None, logging_level: int = logging.INFO, **kwargs, ) -> None: """Loads pre-trained model and evaluates its performance by comparing the predictions against ground truth. # Inputs :param model_path: (str) filepath to pre-trained model. :param dataset: (Union[str, dict, pandas.DataFrame], default: `None`) source containing the entire dataset to be used in the evaluation. :param data_format: (str, default: `None`) format to interpret data sources. Will be inferred automatically if not specified. Valid formats are `'auto'`, `'csv'`, `'excel'`, `'feather'`, `'fwf'`, `'hdf5'` (cache file produced during previous training), `'html'` (file containing a single HTML `
`), `'json'`, `'jsonl'`, `'parquet'`, `'pickle'` (pickled Pandas DataFrame), `'sas'`, `'spss'`, `'stata'`, `'tsv'`. :param split: (str, default: `full`) split on which to perform predictions. Valid values are `'training'`, `'validation'`, `'test'` and `'full'`. :param batch_size: (int, default `128`) size of batches for processing. :param skip_save_unprocessed_output: (bool, default: `False`) by default predictions and their probabilities are saved in both raw unprocessed numpy files containing tensors and as postprocessed CSV files (one for each output feature). If this parameter is True, only the CSV ones are saved and the numpy ones are skipped. :param skip_save_predictions: (bool, default: `False`) skips saving test predictions CSV files :param skip_save_eval_stats: (bool, default: `False`) skips saving test statistics JSON file :param skip_collect_predictions: (bool, default: `False`) skips collecting post-processed predictions during eval. :param skip_collect_overall_stats: (bool, default: `False`) skips collecting overall stats during eval. :param output_directory: (str, default: `'results'`) the directory that will contain the training statistics, TensorBoard logs, the saved model and the training progress files. :param gpus: (list, default: `None`) list of GPUs that are available for training. :param gpu_memory_limit: (float: default: `None`) maximum memory fraction [0, 1] allowed to allocate per GPU device. :param allow_parallel_threads: (bool, default: `True`) allow PyTorch to use multithreading parallelism to improve performance at the cost of determinism. :param callbacks: (list, default: `None`) a list of `ludwig.callbacks.Callback` objects that provide hooks into the Ludwig pipeline. :param backend: (Union[Backend, str]) `Backend` or string name of backend to use to execute preprocessing / training steps. :param logging_level: (int) Log level that will be sent to stderr. # Returns :return: (`None`) """ model = LudwigModel.load( model_path, logging_level=logging_level, backend=backend, gpus=gpus, gpu_memory_limit=gpu_memory_limit, allow_parallel_threads=allow_parallel_threads, callbacks=callbacks, ) model.evaluate( dataset=dataset, data_format=data_format, batch_size=batch_size, split=split, skip_save_unprocessed_output=skip_save_unprocessed_output, skip_save_predictions=skip_save_predictions, skip_save_eval_stats=skip_save_eval_stats, collect_predictions=not skip_collect_predictions, collect_overall_stats=not skip_collect_overall_stats, output_directory=output_directory, return_type="dict", ) def cli(sys_argv): parser = argparse.ArgumentParser( description="This script loads a pretrained model " "and evaluates its performance by comparing" "its predictions with ground truth.", prog="ludwig evaluate", usage="%(prog)s [options]", ) # --------------- # Data parameters # --------------- parser.add_argument("--dataset", help="input data file path", required=True) parser.add_argument( "--data_format", help="format of the input data", default="auto", choices=[ "auto", "csv", "excel", "feather", "fwf", "hdf5", "html" "tables", "json", "jsonl", "parquet", "pickle", "sas", "spss", "stata", "tsv", ], ) parser.add_argument( "-s", "--split", default=FULL, choices=[TRAINING, VALIDATION, TEST, FULL], help="the split to test the model on" ) # ---------------- # Model parameters # ---------------- parser.add_argument("-m", "--model_path", help="model to load", required=True) # ------------------------- # Output results parameters # ------------------------- parser.add_argument( "-od", "--output_directory", type=str, default="results", help="directory that contains the results" ) parser.add_argument( "-ssuo", "--skip_save_unprocessed_output", help="skips saving intermediate NPY output files", action="store_true", default=False, ) parser.add_argument( "-sses", "--skip_save_eval_stats", help="skips saving intermediate JSON eval statistics", action="store_true", default=False, ) parser.add_argument( "-scp", "--skip_collect_predictions", help="skips collecting predictions", action="store_true", default=False ) parser.add_argument( "-scos", "--skip_collect_overall_stats", help="skips collecting overall stats", action="store_true", default=False, ) # ------------------ # Generic parameters # ------------------ parser.add_argument("-bs", "--batch_size", type=int, default=128, help="size of batches") # ------------------ # Runtime parameters # ------------------ parser.add_argument("-g", "--gpus", type=int, default=0, help="list of gpu to use") parser.add_argument( "-gml", "--gpu_memory_limit", type=float, default=None, help="maximum memory fraction [0, 1] allowed to allocate per GPU device", ) parser.add_argument( "-dpt", "--disable_parallel_threads", action="store_false", dest="allow_parallel_threads", help="disable PyTorch from using multithreading for reproducibility", ) parser.add_argument( "-b", "--backend", help="specifies backend to use for parallel / distributed execution, " "defaults to local execution", choices=ALL_BACKENDS, ) parser.add_argument( "-l", "--logging_level", default="info", help="the level of logging to use", choices=["critical", "error", "warning", "info", "debug", "notset"], ) add_contrib_callback_args(parser) args = parser.parse_args(sys_argv) args.evaluate_performance = True args.callbacks = args.callbacks or [] for callback in args.callbacks: callback.on_cmdline("evaluate", *sys_argv) args.logging_level = get_logging_level_registry()[args.logging_level] logging.getLogger("ludwig").setLevel(args.logging_level) global logger logger = logging.getLogger("ludwig.test_performance") backend = initialize_backend(args.backend) if backend.is_coordinator(): print_ludwig("Evaluate", LUDWIG_VERSION) logger.info(f"Dataset path: {args.dataset}") logger.info(f"Model path: {args.model_path}") logger.info("") evaluate_cli(**vars(args)) if __name__ == "__main__": cli(sys.argv[1:]) ================================================ FILE: ludwig/experiment.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import argparse import logging import os import sys import pandas as pd from ludwig.api import kfold_cross_validate, LudwigModel from ludwig.backend import ALL_BACKENDS, Backend, initialize_backend from ludwig.callbacks import Callback from ludwig.constants import CONTINUE_PROMPT, FULL, HYPEROPT, HYPEROPT_WARNING, TEST, TRAINING, VALIDATION from ludwig.contrib import add_contrib_callback_args from ludwig.globals import LUDWIG_VERSION from ludwig.utils.data_utils import load_config_from_str, load_yaml, save_json from ludwig.utils.defaults import default_random_seed from ludwig.utils.print_utils import get_logging_level_registry, print_ludwig, query_yes_no logger = logging.getLogger(__name__) def experiment_cli( config: str | dict, dataset: str | dict | pd.DataFrame = None, training_set: str | dict | pd.DataFrame = None, validation_set: str | dict | pd.DataFrame = None, test_set: str | dict | pd.DataFrame = None, training_set_metadata: str | dict = None, data_format: str = None, experiment_name: str = "experiment", model_name: str = "run", model_load_path: str = None, model_resume_path: str = None, eval_split: str = TEST, skip_save_training_description: bool = False, skip_save_training_statistics: bool = False, skip_save_model: bool = False, skip_save_progress: bool = False, skip_save_log: bool = False, skip_save_processed_input: bool = False, skip_save_unprocessed_output: bool = False, skip_save_predictions: bool = False, skip_save_eval_stats: bool = False, skip_collect_predictions: bool = False, skip_collect_overall_stats: bool = False, output_directory: str = "results", gpus: str | int | list[int] = None, gpu_memory_limit: float | None = None, allow_parallel_threads: bool = True, callbacks: list[Callback] = None, backend: Backend | str = None, random_seed: int = default_random_seed, logging_level: int = logging.INFO, **kwargs, ): """Trains a model on a dataset's training and validation splits and uses it to predict on the test split. It saves the trained model and the statistics of training and testing. # Inputs :param config: (Union[str, dict]) in-memory representation of config or string path to a YAML config file. :param dataset: (Union[str, dict, pandas.DataFrame], default: `None`) source containing the entire dataset to be used in the experiment. If it has a split column, it will be used for splitting (0 for train, 1 for validation, 2 for test), otherwise the dataset will be randomly split. :param training_set: (Union[str, dict, pandas.DataFrame], default: `None`) source containing training data. :param validation_set: (Union[str, dict, pandas.DataFrame], default: `None`) source containing validation data. :param test_set: (Union[str, dict, pandas.DataFrame], default: `None`) source containing test data. :param training_set_metadata: (Union[str, dict], default: `None`) metadata JSON file or loaded metadata. Intermediate preprocessed structure containing the mappings of the input dataset created the first time an input file is used in the same directory with the same name and a '.meta.json' extension. :param data_format: (str, default: `None`) format to interpret data sources. Will be inferred automatically if not specified. Valid formats are `'auto'`, `'csv'`, `'excel'`, `'feather'`, `'fwf'`, `'hdf5'` (cache file produced during previous training), `'html'` (file containing a single HTML `
`), `'json'`, `'jsonl'`, `'parquet'`, `'pickle'` (pickled Pandas DataFrame), `'sas'`, `'spss'`, `'stata'`, `'tsv'`. :param experiment_name: (str, default: `'experiment'`) name for the experiment. :param model_name: (str, default: `'run'`) name of the model that is being used. :param model_load_path: (str, default: `None`) if this is specified the loaded model will be used as initialization (useful for transfer learning). :param model_resume_path: (str, default: `None`) resumes training of the model from the path specified. The config is restored. In addition to config, training statistics and loss for epoch and the state of the optimizer are restored such that training can be effectively continued from a previously interrupted training process. :param eval_split: (str, default: `test`) split on which to perform evaluation. Valid values are `training`, `validation` and `test`. :param skip_save_training_description: (bool, default: `False`) disables saving the description JSON file. :param skip_save_training_statistics: (bool, default: `False`) disables saving training statistics JSON file. :param skip_save_model: (bool, default: `False`) disables saving model weights and hyperparameters each time the model improves. By default Ludwig saves model weights after each epoch the validation metric improves, but if the model is really big that can be time consuming. If you do not want to keep the weights and just find out what performance a model can get with a set of hyperparameters, use this parameter to skip it, but the model will not be loadable later on and the returned model will have the weights obtained at the end of training, instead of the weights of the epoch with the best validation performance. :param skip_save_progress: (bool, default: `False`) disables saving progress each epoch. By default Ludwig saves weights and stats after each epoch for enabling resuming of training, but if the model is really big that can be time consuming and will uses twice as much space, use this parameter to skip it, but training cannot be resumed later on. :param skip_save_log: (bool, default: `False`) disables saving TensorBoard logs. By default Ludwig saves logs for the TensorBoard, but if it is not needed turning it off can slightly increase the overall speed. :param skip_save_processed_input: (bool, default: `False`) if input dataset is provided it is preprocessed and cached by saving an HDF5 and JSON files to avoid running the preprocessing again. If this parameter is `False`, the HDF5 and JSON file are not saved. :param skip_save_unprocessed_output: (bool, default: `False`) by default predictions and their probabilities are saved in both raw unprocessed numpy files containing tensors and as postprocessed CSV files (one for each output feature). If this parameter is True, only the CSV ones are saved and the numpy ones are skipped. :param skip_save_predictions: (bool, default: `False`) skips saving test predictions CSV files :param skip_save_eval_stats: (bool, default: `False`) skips saving test statistics JSON file :param skip_collect_predictions: (bool, default: `False`) skips collecting post-processed predictions during eval. :param skip_collect_overall_stats: (bool, default: `False`) skips collecting overall stats during eval. :param output_directory: (str, default: `'results'`) the directory that will contain the training statistics, TensorBoard logs, the saved model and the training progress files. :param gpus: (list, default: `None`) list of GPUs that are available for training. :param gpu_memory_limit: (float: default: `None`) maximum memory fraction [0, 1] allowed to allocate per GPU device. :param allow_parallel_threads: (bool, default: `True`) allow PyTorch to use multithreading parallelism to improve performance at the cost of determinism. :param callbacks: (list, default: `None`) a list of `ludwig.callbacks.Callback` objects that provide hooks into the Ludwig pipeline. :param backend: (Union[Backend, str]) `Backend` or string name of backend to use to execute preprocessing / training steps. :param random_seed: (int: default: 42) random seed used for weights initialization, splits and any other random function. :param logging_level: (int) Log level that will be sent to stderr. # Return :return: (Tuple[LudwigModel, dict, dict, tuple, str)): `(model, evaluation_statistics, training_statistics, preprocessed_data, output_directory)` `model` LudwigModel instance `evaluation_statistics` dictionary with evaluation performance statistics on the test_set, `training_statistics` is a nested dictionary of dataset -> feature_name -> metric_name -> List of metrics. Each metric corresponds to each training checkpoint. `preprocessed_data` tuple containing preprocessed `(training_set, validation_set, test_set)`, `output_directory` filepath string to where results are stored. """ if HYPEROPT in config: if not query_yes_no(HYPEROPT_WARNING + CONTINUE_PROMPT): exit(1) if isinstance(config, str): config = load_yaml(config) backend = initialize_backend(backend or config.get("backend")) if model_load_path: model = LudwigModel.load( model_load_path, logging_level=logging_level, backend=backend, gpus=gpus, gpu_memory_limit=gpu_memory_limit, allow_parallel_threads=allow_parallel_threads, callbacks=callbacks, ) else: model = LudwigModel( config=config, logging_level=logging_level, backend=backend, gpus=gpus, gpu_memory_limit=gpu_memory_limit, allow_parallel_threads=allow_parallel_threads, callbacks=callbacks, ) eval_stats, train_stats, preprocessed_data, output_directory = model.experiment( dataset=dataset, training_set=training_set, validation_set=validation_set, test_set=test_set, training_set_metadata=training_set_metadata, data_format=data_format, experiment_name=experiment_name, model_name=model_name, model_resume_path=model_resume_path, eval_split=eval_split, skip_save_training_description=skip_save_training_description, skip_save_training_statistics=skip_save_training_statistics, skip_save_model=skip_save_model, skip_save_progress=skip_save_progress, skip_save_log=skip_save_log, skip_save_processed_input=skip_save_processed_input, skip_save_unprocessed_output=skip_save_unprocessed_output, skip_save_predictions=skip_save_predictions, skip_save_eval_stats=skip_save_eval_stats, skip_collect_predictions=skip_collect_predictions, skip_collect_overall_stats=skip_collect_overall_stats, output_directory=output_directory, random_seed=random_seed, ) return model, eval_stats, train_stats, preprocessed_data, output_directory def kfold_cross_validate_cli( k_fold, config=None, dataset=None, data_format=None, output_directory="results", random_seed=default_random_seed, skip_save_k_fold_split_indices=False, **kwargs, ): """Wrapper function to performs k-fold cross validation. # Inputs :param k_fold: (int) number of folds to create for the cross-validation :param config: (Union[str, dict], default: None) a dictionary or file path containing model configuration. Refer to the [User Guide] (http://ludwig.ai/user_guide/#model-config) for details. :param dataset: (string, default: None) :param output_directory: (string, default: 'results') :param random_seed: (int) Random seed used k-fold splits. :param skip_save_k_fold_split_indices: (boolean, default: False) Disables saving k-fold split indices :return: None """ kfold_cv_stats, kfold_split_indices = kfold_cross_validate( k_fold, config=config, dataset=dataset, data_format=data_format, output_directory=output_directory, random_seed=random_seed, ) # save k-fold cv statistics save_json(os.path.join(output_directory, "kfold_training_statistics.json"), kfold_cv_stats) # save k-fold split indices if not skip_save_k_fold_split_indices: save_json(os.path.join(output_directory, "kfold_split_indices.json"), kfold_split_indices) def cli(sys_argv): parser = argparse.ArgumentParser( description="This script trains and evaluates a model", prog="ludwig experiment", usage="%(prog)s [options]" ) # ---------------------------- # Experiment naming parameters # ---------------------------- parser.add_argument("--output_directory", type=str, default="results", help="directory that contains the results") parser.add_argument("--experiment_name", type=str, default="experiment", help="experiment name") parser.add_argument("--model_name", type=str, default="run", help="name for the model") # --------------- # Data parameters # --------------- parser.add_argument( "--dataset", help="input data file path. " "If it has a split column, it will be used for splitting " "(0: train, 1: validation, 2: test), " "otherwise the dataset will be randomly split", ) parser.add_argument("--training_set", help="input train data file path") parser.add_argument("--validation_set", help="input validation data file path") parser.add_argument("--test_set", help="input test data file path") parser.add_argument( "--training_set_metadata", help="input metadata JSON file path. An intermediate preprocessed file " "containing the mappings of the input file created " "the first time a file is used, in the same directory " "with the same name and a .json extension", ) parser.add_argument( "--data_format", help="format of the input data", default="auto", choices=[ "auto", "csv", "excel", "feather", "fwf", "hdf5", "html" "tables", "json", "jsonl", "parquet", "pickle", "sas", "spss", "stata", "tsv", ], ) parser.add_argument( "-es", "--eval_split", default=TEST, choices=[TRAINING, VALIDATION, TEST, FULL], help="the split to evaluate the model on", ) parser.add_argument( "-sspi", "--skip_save_processed_input", help="skips saving intermediate HDF5 and JSON files", action="store_true", default=False, ) parser.add_argument( "-ssuo", "--skip_save_unprocessed_output", help="skips saving intermediate NPY output files", action="store_true", default=False, ) # ----------------- # K-fold parameters # ----------------- parser.add_argument( "-kf", "--k_fold", type=int, default=None, help="number of folds for a k-fold cross validation run " ) parser.add_argument( "-skfsi", "--skip_save_k_fold_split_indices", action="store_true", default=False, help="disables saving indices generated to split training data set " "for the k-fold cross validation run, but if it is not needed " "turning it off can slightly increase the overall speed", ) # ---------------- # Model parameters # ---------------- config = parser.add_mutually_exclusive_group(required=True) config.add_argument( "-c", "--config", type=load_yaml, help="Path to the YAML file containing the model configuration", ) config.add_argument( "-cs", "--config_str", dest="config", type=load_config_from_str, help="JSON or YAML serialized string of the model configuration", ) parser.add_argument("-mlp", "--model_load_path", help="path of a pretrained model to load as initialization") parser.add_argument("-mrp", "--model_resume_path", help="path of the model directory to resume training of") parser.add_argument( "-sstd", "--skip_save_training_description", action="store_true", default=False, help="disables saving the description JSON file", ) parser.add_argument( "-ssts", "--skip_save_training_statistics", action="store_true", default=False, help="disables saving training statistics JSON file", ) parser.add_argument( "-sstp", "--skip_save_predictions", help="skips saving test predictions CSV files", action="store_true", default=False, ) parser.add_argument( "-sstes", "--skip_save_eval_stats", help="skips saving eval statistics JSON file", action="store_true", default=False, ) parser.add_argument( "-ssm", "--skip_save_model", action="store_true", default=False, help="disables saving model weights and hyperparameters each time " "the model improves. " "By default Ludwig saves model weights after each epoch " "the validation metric improves, but if the model is really big " "that can be time consuming. If you do not want to keep " "the weights and just find out what performance a model can get " "with a set of hyperparameters, use this parameter to skip it," "but the model will not be loadable later on", ) parser.add_argument( "-ssp", "--skip_save_progress", action="store_true", default=False, help="disables saving progress each epoch. By default Ludwig saves " "weights and stats after each epoch for enabling resuming " "of training, but if the model is really big that can be " "time consuming and will uses twice as much space, use " "this parameter to skip it, but training cannot be resumed " "later on", ) parser.add_argument( "-ssl", "--skip_save_log", action="store_true", default=False, help="disables saving TensorBoard logs. By default Ludwig saves " "logs for the TensorBoard, but if it is not needed turning it off " "can slightly increase the overall speed", ) # ------------------ # Runtime parameters # ------------------ parser.add_argument( "-rs", "--random_seed", type=int, default=42, help="a random seed that is going to be used anywhere there is a call " "to a random number generator: data splitting, parameter " "initialization and training set shuffling", ) parser.add_argument("-g", "--gpus", nargs="+", type=int, default=None, help="list of GPUs to use") parser.add_argument( "-gml", "--gpu_memory_limit", type=float, default=None, help="maximum memory fraction [0, 1] allowed to allocate per GPU device", ) parser.add_argument( "-dpt", "--disable_parallel_threads", action="store_false", dest="allow_parallel_threads", help="disable PyTorch from using multithreading for reproducibility", ) parser.add_argument( "-b", "--backend", help="specifies backend to use for parallel / distributed execution, " "defaults to local execution", choices=ALL_BACKENDS, ) parser.add_argument( "-l", "--logging_level", default="info", help="the level of logging to use", choices=["critical", "error", "warning", "info", "debug", "notset"], ) add_contrib_callback_args(parser) args = parser.parse_args(sys_argv) args.callbacks = args.callbacks or [] for callback in args.callbacks: callback.on_cmdline("experiment", *sys_argv) args.logging_level = get_logging_level_registry()[args.logging_level] logging.getLogger("ludwig").setLevel(args.logging_level) global logger logger = logging.getLogger("ludwig.experiment") args.backend = initialize_backend(args.backend or args.config.get("backend")) if args.backend.is_coordinator(): print_ludwig("Experiment", LUDWIG_VERSION) if args.k_fold is None: experiment_cli(**vars(args)) else: kfold_cross_validate_cli(**vars(args)) if __name__ == "__main__": cli(sys.argv[1:]) ================================================ FILE: ludwig/explain/__init__.py ================================================ ================================================ FILE: ludwig/explain/captum.py ================================================ import copy import gc import logging from collections import defaultdict from dataclasses import dataclass import numpy as np import numpy.typing as npt import pandas as pd import torch from captum.attr import LayerIntegratedGradients, TokenReferenceBase from captum.attr._utils.input_layer_wrapper import InputIdentity from torch.autograd import Variable from tqdm import tqdm from ludwig.api import LudwigModel from ludwig.api_annotations import PublicAPI from ludwig.constants import ( BINARY, CATEGORY, DATE, IMAGE, INPUT_FEATURES, MINIMUM_BATCH_SIZE, NAME, NUMBER, PREPROCESSING, SEQUENCE, SET, TEXT, UNKNOWN_SYMBOL, ) from ludwig.data.preprocessing import preprocess_for_prediction from ludwig.explain.explainer import Explainer from ludwig.explain.explanation import ExplanationsResult from ludwig.explain.util import get_pred_col, replace_layer_with_copy from ludwig.features.feature_utils import LudwigFeatureDict from ludwig.models.ecd import ECD from ludwig.utils.torch_utils import DEVICE logger = logging.getLogger(__name__) # These types as provided as integer values and passed through an embedding layer that breaks integrated gradients. # As such, we need to take care to encode them before handing them to the explainer. EMBEDDED_TYPES = {SEQUENCE, TEXT, CATEGORY, SET, DATE} @dataclass class ExplanationRunConfig: """Mutable state containing runtime configuration for explanation process. This is useful for updating the batch size used during explanation so it can be propagated across calls to `get_total_attribution`. """ batch_size: int def retry_with_halved_batch_size(run_config: ExplanationRunConfig): """Function wrapper that retries an fn with a halved batch size. We want to maintain as large of a batch size as possible to maximize throughput. However, calculating explanations requires significantly more memory, and the original batch sized used during training may be too large and cause a CUDA OOM error, for example, if using GPUs. Will raise an error if a non-OOM error is raised, or if the batch size is reduced below 1 and the fn still fails. """ def retry_with_halved_batch_size_fn(fn): def retry_with_halved_batch_size_wrapper(*args, **kwargs): latest_error = None while run_config.batch_size >= MINIMUM_BATCH_SIZE: try: return fn(*args, **kwargs) except RuntimeError as e: latest_error = e # PyTorch only generates Runtime errors for CUDA OOM. gc.collect() if "CUDA out of memory" in str(e) or isinstance(e, torch.cuda.OutOfMemoryError): logger.exception(f"OOM at batch_size={run_config.batch_size}, halving and trying again") run_config.batch_size //= 2 else: # Not a CUDA error raise raise RuntimeError( f"Ran into latest error {latest_error} during explanation. " "If a CUDA out of memory error, then the batch size could not be reduced any further." ) return retry_with_halved_batch_size_wrapper return retry_with_halved_batch_size_fn class WrapperModule(torch.nn.Module): """Model used by the explainer to generate predictions. Unlike Ludwig's ECD class, this wrapper takes individual args as inputs to the forward function. We derive the order of these args from the order of the input_feature keys in ECD, which is guaranteed to be consistent (Python dictionaries are ordered consistently), so we can map back to the input feature dictionary as a second step within this wrapper. """ def __init__(self, model: ECD, target: str): super().__init__() self.model = model self.target = target self.input_maps = LudwigFeatureDict() self.input_maps.update( { arg_name: InputIdentity(arg_name) for arg_name in self.model.input_features.keys() if self.model.input_features.get(arg_name).type() not in EMBEDDED_TYPES } ) def forward(self, *args): # Add back the dictionary structure so it conforms to ECD format. input_features: LudwigFeatureDict = self.model.input_features inputs = { # Send the input through the identity layer so that we can use the output of the layer for attribution. # Except for text/category features where we use the embedding layer for attribution. feat_name: ( feat_input if input_features.get(feat_name).type() in EMBEDDED_TYPES else self.input_maps.get(feat_name)(feat_input) ) for feat_name, feat_input in zip(input_features.keys(), args) } outputs = self.model(inputs) # At this point we only have the raw logits, but to make explainability work we need the probabilities # and predictions as well, so derive them. predictions = {} for of_name in self.model.output_features: predictions[of_name] = self.model.output_features.get(of_name).predictions(outputs, of_name) pred_t = get_pred_col(predictions, self.target) # If the target feature is a non-scalar type (vector, set, etc.), sum it to get a scalar value. # https://github.com/pytorch/captum/issues/377 if len(pred_t.shape) > 1 and self.model.output_features.get(self.target).type() not in { CATEGORY, NUMBER, BINARY, }: pred_t = torch.sum(pred_t.reshape(pred_t.shape[0], -1), dim=1) return pred_t @PublicAPI(stability="experimental") class IntegratedGradientsExplainer(Explainer): def explain(self) -> ExplanationsResult: """Explain the model's predictions using Integrated Gradients. # Return :return: ExplanationsResult containing the explanations. `global_explanations`: (Explanation) Aggregate explanation for the entire input data. `row_explanations`: (List[Explanation]) A list of explanations, one for each row in the input data. Each explanation contains the integrated gradients for each label in the target feature's vocab with respect to each input feature. `expected_values`: (List[float]) of length [output feature cardinality] Average convergence delta for each label in the target feature's vocab. """ # TODO(travis): add back skip encoders at the end in finally. Shouldn't be an issue in most cases as we # typically perform explanations on a loaded model and don't use it to predict afterwards. self.model.model.unskip() self.model.model.to(DEVICE) input_features: LudwigFeatureDict = self.model.model.input_features run_config = ExplanationRunConfig(batch_size=self.model.config_obj.trainer.batch_size) get_input_tensors_with_retry = retry_with_halved_batch_size(run_config)(get_input_tensors) get_total_attribution_with_retry = retry_with_halved_batch_size(run_config)(get_total_attribution) # Convert input data into embedding tensors from the output of the model encoders. inputs_encoded = get_input_tensors_with_retry(self.model, self.inputs_df, run_config) sample_encoded = get_input_tensors_with_retry(self.model, self.sample_df, run_config) baseline = get_baseline(self.model, sample_encoded) # Compute attribution for each possible output feature label separately. expected_values = [] for target_idx in tqdm(range(self.vocab_size), desc="Explain"): total_attribution, feat_to_token_attributions, total_attribution_global = get_total_attribution_with_retry( self.model, self.target_feature_name, target_idx if self.is_category_target else None, inputs_encoded, baseline, len(self.inputs_df), run_config, ) # Aggregate token attributions feat_to_token_attributions_global = {} for feat_name, token_attributions in feat_to_token_attributions.items(): token_attributions_global = defaultdict(float) # sum attributions for each token for token, token_attribution in (ta for tas in token_attributions for ta in tas): token_attributions_global[token] += abs(token_attribution) # divide by number of samples to get average attribution per token token_attributions_global = { token: token_attribution / max(0, len(token_attributions)) for token, token_attribution in token_attributions_global.items() } # convert to list of tuples and sort by attribution token_attributions_global = sorted(token_attributions_global.items(), key=lambda x: x[1], reverse=True) # keep only top 100 tokens token_attributions_global = token_attributions_global[:100] feat_to_token_attributions_global[feat_name] = token_attributions_global self.global_explanation.add( input_features.keys(), total_attribution_global, feat_to_token_attributions_global ) for i, (feature_attributions, explanation) in enumerate(zip(total_attribution, self.row_explanations)): # Add the feature attributions to the explanation object for this row. explanation.add( input_features.keys(), feature_attributions, {k: v[i] for k, v in feat_to_token_attributions.items()}, ) # TODO(travis): for force plots, need something similar to SHAP E[X] expected_values.append(0.0) if self.is_binary_target: # For binary targets, we only need to compute attribution for the positive class (see below). break # For binary targets, add an extra attribution for the negative class (false). if self.is_binary_target: le_true = self.global_explanation.label_explanations[0] negated_attributions = le_true.to_array() * -1 negated_token_attributions = { fa.feature_name: [(t, -a) for t, a in fa.token_attributions] for fa in le_true.feature_attributions if fa.token_attributions is not None } # Prepend the negative class to the list of label explanations. self.global_explanation.add( input_features.keys(), negated_attributions, negated_token_attributions, prepend=True ) for explanation in self.row_explanations: le_true = explanation.label_explanations[0] negated_attributions = le_true.to_array() * -1 negated_token_attributions = { fa.feature_name: [(t, -a) for t, a in fa.token_attributions] for fa in le_true.feature_attributions if fa.token_attributions is not None } # Prepend the negative class to the list of label explanations. explanation.add(input_features.keys(), negated_attributions, negated_token_attributions, prepend=True) # TODO(travis): for force plots, need something similar to SHAP E[X] expected_values.append(0.0) return ExplanationsResult(self.global_explanation, self.row_explanations, expected_values) def get_input_tensors( model: LudwigModel, input_set: pd.DataFrame, run_config: ExplanationRunConfig ) -> list[torch.Tensor]: """Convert the input data into a list of variables, one for each input feature. # Inputs :param model: The LudwigModel to use for encoding. :param input_set: The input data to encode of shape [batch size, num input features]. # Return :return: A list of variables, one for each input feature. Shape of each variable is [batch size, embedding size]. """ # Ignore sample_ratio and sample_size from the model config, since we want to explain all the data. sample_ratio_bak = model.config_obj.preprocessing.sample_ratio sample_size_bak = model.config_obj.preprocessing.sample_size model.config_obj.preprocessing.sample_ratio = 1.0 model.config_obj.preprocessing.sample_size = None config = model.config_obj.to_dict() training_set_metadata = copy.deepcopy(model.training_set_metadata) for feature in config[INPUT_FEATURES]: preprocessing = training_set_metadata[feature[NAME]][PREPROCESSING] if preprocessing.get("cache_encoder_embeddings"): preprocessing["cache_encoder_embeddings"] = False # Convert raw input data into preprocessed tensor data dataset, _ = preprocess_for_prediction( config, dataset=input_set, training_set_metadata=training_set_metadata, data_format="auto", split="full", include_outputs=False, backend=model.backend, callbacks=model.callbacks, ) # Restore sample_ratio and sample_size model.config_obj.preprocessing.sample_ratio = sample_ratio_bak model.config_obj.preprocessing.sample_size = sample_size_bak # Make sure the number of rows in the preprocessed dataset matches the number of rows in the input data assert ( dataset.to_df().shape[0] == input_set.shape[0] ), f"Expected {input_set.shape[0]} rows in preprocessed dataset, but got {dataset.to_df().shape[0]}" # Convert dataset into a dict of tensors, and split each tensor into batches to control GPU memory usage inputs = { name: torch.from_numpy(dataset.dataset[feature.proc_column]).split(run_config.batch_size) for name, feature in model.model.input_features.items() } # Dict of lists to list of dicts input_batches = [dict(zip(inputs, t)) for t in zip(*inputs.values())] # List of dicts to dict of lists preproc_inputs = {k: torch.cat([d[k] for d in input_batches]) for k in input_batches[0]} data_to_predict = [v for _, v in preproc_inputs.items()] tensors = [] for t in data_to_predict: # TODO(travis): Consider changing to `if not torch.is_floating_point(t.dtype)` to simplify, then handle bool # case in this block. if t.dtype == torch.int8 or t.dtype == torch.int16 or t.dtype == torch.int32 or t.dtype == torch.int64: # Don't wrap input into a variable if it's an integer type, since it will be used as an index into the # embedding table. We explain the output of the embedding table, not the input to the embedding table using # LayerIntegratedGradients. tensors.append(t) else: # Wrap input into a variable so torch will track the gradient and LayerIntegratedGradients can explain it. if t.dtype == torch.bool: t = t.to(torch.float32) tensors.append(Variable(t, requires_grad=True)) return tensors def get_baseline(model: LudwigModel, sample_encoded: list[Variable]) -> list[torch.Tensor]: # TODO(travis): pre-compute this during training from the full training dataset. input_features: LudwigFeatureDict = model.model.input_features baselines = [] for sample_input, (name, feature) in zip(sample_encoded, input_features.items()): metadata = model.training_set_metadata[name] if feature.type() == TEXT: PAD_IND = metadata.get("pad_idx", metadata.get("word_pad_idx")) token_reference = TokenReferenceBase(reference_token_idx=PAD_IND) baseline = token_reference.generate_reference(sequence_length=sample_input.shape[1], device=DEVICE) elif feature.type() == CATEGORY: most_popular_token = max(metadata["str2freq"], key=metadata["str2freq"].get) most_popular_tok_idx = metadata["str2idx"].get(most_popular_token) # If an unknown is defined, use that as the baseline index, else use the most popular token baseline_tok_idx = metadata["str2idx"].get(UNKNOWN_SYMBOL, most_popular_tok_idx) baseline = torch.tensor(baseline_tok_idx, device=DEVICE) elif feature.type() == IMAGE: baseline = torch.zeros_like(sample_input[0], device=DEVICE) else: # For a robust baseline, we take the mean of all samples from the training data. baseline = torch.mean(sample_input.float(), dim=0) baselines.append(baseline.unsqueeze(0)) return baselines def get_total_attribution( model: LudwigModel, target_feature_name: str, target_idx: int | None, feature_inputs: list[Variable], baseline: list[torch.Tensor], nsamples: int, run_config: ExplanationRunConfig, ) -> tuple[npt.NDArray[np.float64], dict[str, list[list[tuple[str, float]]]]]: """Compute the total attribution for each input feature for each row in the input data. Args: model: The Ludwig model to explain. target_feature_name: The name of the target feature to explain. target_idx: The index of the target feature label to explain if the target feature is a category. feature_inputs: The preprocessed input data as a list of tensors of length [num_features]. baseline: The baseline input data as a list of tensors of length [num_features]. nsamples: The total number of samples in the input data. Returns: The token-attribution pair for each token in the input feature for each row in the input data. The members of the output tuple are structured as follows: `total_attribution_rows`: (npt.NDArray[np.float64]) of shape [num_rows, num_features] The total attribution for each input feature for each row in the input data. `feat_to_token_attributions`: (Dict[str, List[List[Tuple[str, float]]]]) with values of shape [num_rows, seq_len, 2] `total_attribution_global`: (npt.NDArray[np.float64]) of shape [num_features] The attribution for each input feature aggregated across all input data. """ input_features: LudwigFeatureDict = model.model.input_features # Configure the explainer, which includes wrapping the model so its interface conforms to # the format expected by Captum. model.model.zero_grad() explanation_model = WrapperModule(model.model, target_feature_name) layers = [] for feat_name, feat in input_features.items(): if feat.type() in EMBEDDED_TYPES: # Get embedding layer from encoder, which is the first child of the encoder. target_layer = feat.encoder_obj.get_embedding_layer() # If the current layer matches any layer in the list, make a deep copy of the layer. if len(layers) > 0 and any(target_layer == layer for layer in layers): # Replace the layer with a deep copy of the layer to ensure that the attributions unique for each input # feature that uses a shared layer. # Recommended here: https://github.com/pytorch/captum/issues/794#issuecomment-1093021638 replace_layer_with_copy(feat, target_layer) target_layer = feat.encoder_obj.get_embedding_layer() # get the new copy else: # Get the wrapped input layer. target_layer = explanation_model.input_maps.get(feat_name) layers.append(target_layer) explainer = LayerIntegratedGradients(explanation_model, layers) feature_inputs_splits = [ipt.split(run_config.batch_size) for ipt in feature_inputs] baseline = [t.to(DEVICE) for t in baseline] total_attribution_rows = None total_attribution_global = None feat_to_token_attributions = defaultdict(list) for input_batch in zip(*feature_inputs_splits): input_batch = [ipt.to(DEVICE) for ipt in input_batch] attribution = explainer.attribute( tuple(input_batch), baselines=tuple(baseline), target=target_idx, # https://captum.ai/docs/faq#i-am-facing-out-of-memory-oom-errors-when-using-captum-how-do-i-resolve-this internal_batch_size=run_config.batch_size, ) attributions_reduced = [] for a in attribution: a_reduced = a.detach().cpu() if a_reduced.ndim == 2 or a_reduced.ndim == 3: # Reduces category-level attributions of shape [batch_size, embedding_dim] by summing over the # embedding dimension to get attributions of shape [batch_size]. # Reduces token-level attributions of shape [batch_size, sequence_length, embedding_dim] by summing # over the embedding dimension to get attributions of shape [batch_size, sequence_length]. We keep # the sequence dimension so we can map the attributions to the tokens. a_reduced = a_reduced.sum(dim=-1) elif a_reduced.ndim == 4: # Reduce pixel-level attributions of shape [batch_size, num_channels, height, width] by summing # over the channel and spatial dimensions to get attributions of shape [batch_size]. a_reduced = a_reduced.sum(dim=(1, 2, 3)) attributions_reduced.append(a_reduced) for inputs, attrs, (name, feat) in zip(input_batch, attributions_reduced, input_features.items()): if feat.type() == TEXT: tok_attrs = get_token_attributions(model, name, inputs.detach().cpu(), attrs) feat_to_token_attributions[name].append(tok_attrs) # Reduce attribution to [num_input_features, batch_size] by summing over the sequence dimension (if present). attribution = [a.sum(dim=-1) if a.ndim == 2 else a for a in attributions_reduced] attribution = np.stack(attribution) # Transpose to [batch_size, num_input_features] attribution = attribution.T if total_attribution_rows is not None: total_attribution_rows = np.concatenate([total_attribution_rows, attribution], axis=0) else: total_attribution_rows = attribution if total_attribution_global is not None: total_attribution_global += attribution.sum(axis=0) else: total_attribution_global = attribution.sum(axis=0) total_attribution_global /= nsamples feat_to_token_attributions = {k: [e for lst in v for e in lst] for k, v in feat_to_token_attributions.items()} return total_attribution_rows, feat_to_token_attributions, total_attribution_global def get_token_attributions( model: LudwigModel, feature_name: str, input_ids: torch.Tensor, token_attributions: torch.Tensor, ) -> list[list[tuple[str, float]]]: """Convert token-level attributions to an array of token-attribution pairs of shape. [batch_size, sequence_length, 2]. Args: model: The LudwigModel used to generate the attributions. feature_name: The name of the feature for which the attributions were generated. input_ids: The input ids of shape [batch_size, sequence_length]. token_attributions: The token-level attributions of shape [batch_size, sequence_length]. Returns: An array of token-attribution pairs of shape [batch_size, sequence_length, 2]. """ assert ( input_ids.dtype == torch.int8 or input_ids.dtype == torch.int16 or input_ids.dtype == torch.int32 or input_ids.dtype == torch.int64 ) # Normalize token-level attributions to visualize the relative importance of each token. norm = torch.linalg.norm(token_attributions, dim=1) # Safe divide by zero by setting the norm to 1 if the norm is 0. norm = torch.where(norm == 0, torch.ones_like(norm), norm) token_attributions = token_attributions / norm.unsqueeze(-1) # map input ids to input tokens via the vocabulary feature = model.training_set_metadata[feature_name] vocab = feature.get("idx2str", feature.get("word_idx2str")) idx2str = np.vectorize(lambda idx: vocab[idx]) input_tokens = idx2str(input_ids) # add attribution to the input tokens tok_attrs = [ list(zip(t, a)) for t, a in zip(input_tokens, token_attributions.tolist()) ] # [batch_size, sequence_length, 2] return tok_attrs ================================================ FILE: ludwig/explain/captum_ray.py ================================================ from collections import defaultdict from typing import Any import numpy as np import pandas as pd import ray from torch.autograd import Variable from tqdm import tqdm from ludwig.api import LudwigModel from ludwig.api_annotations import PublicAPI from ludwig.explain.captum import ( ExplanationRunConfig, get_baseline, get_input_tensors, get_total_attribution, IntegratedGradientsExplainer, retry_with_halved_batch_size, ) from ludwig.explain.explanation import ExplanationsResult from ludwig.features.feature_utils import LudwigFeatureDict from ludwig.utils.torch_utils import get_torch_device @PublicAPI(stability="experimental") class RayIntegratedGradientsExplainer(IntegratedGradientsExplainer): def __init__(self, *args, resources_per_task: dict[str, Any] = None, num_workers: int = 1, **kwargs): super().__init__(*args, **kwargs) self.resources_per_task = resources_per_task or {} self.num_workers = num_workers def explain(self) -> ExplanationsResult: """Explain the model's predictions using Integrated Gradients. # Return :return: ExplanationsResult containing the explanations. `global_explanations`: (Explanation) Aggregate explanation for the entire input data. `row_explanations`: (List[Explanation]) A list of explanations, one for each row in the input data. Each explanation contains the integrated gradients for each label in the target feature's vocab with respect to each input feature. `expected_values`: (List[float]) of length [output feature cardinality] Average convergence delta for each label in the target feature's vocab. """ self.model.model.cpu() input_features: LudwigFeatureDict = self.model.model.input_features model_ref = ray.put(self.model) run_config = ExplanationRunConfig(batch_size=self.model.config_obj.trainer.batch_size) # Convert input data into embedding tensors from the output of the model encoders. inputs_encoded_ref = get_input_tensors_task.options(**self.resources_per_task).remote( model_ref, ray.put(self.inputs_df), run_config ) sample_encoded_ref = get_input_tensors_task.options(**self.resources_per_task).remote( model_ref, ray.put(self.sample_df), run_config ) inputs_encoded, run_config = ray.get(inputs_encoded_ref) sample_encoded, run_config = ray.get(sample_encoded_ref) baseline = get_baseline(self.model, sample_encoded) inputs_encoded_ref = ray.put(inputs_encoded) baseline_ref = ray.put(baseline) if self.is_category_target: # Evenly divide the list of labels among the desired number of workers (Ray tasks). # For example, 4 GPUs -> 4 workers. We do this instead of creating nlabels tasks because # there is significant overhead to spawning a Ray task. target_splits = split_list(list(range(self.vocab_size)), self.num_workers) else: # No target index to compare against exists for number features. # For binary targets, we only need to compute attribution for the positive class (see below). # May need to revisit in the future for additional feature types. target_splits = [[None]] # Compute attribution for each possible output feature label separately. attrs_refs = [] for target_indices in target_splits: attrs_ref = get_total_attribution_task.options(**self.resources_per_task).remote( model_ref, self.target_feature_name, target_indices, inputs_encoded_ref, baseline_ref, len(self.inputs_df), run_config, ) attrs_refs.append(attrs_ref) # Await the completion of our Ray tasks, then merge the results. expected_values = [] for attrs_ref in tqdm(attrs_refs, desc="Explain"): attrs = ray.get(attrs_ref) for total_attribution, feat_to_token_attributions, total_attribution_global in attrs: # Aggregate token attributions feat_to_token_attributions_global = {} for feat_name, token_attributions in feat_to_token_attributions.items(): token_attributions_global = defaultdict(float) # sum attributions for each token for token, token_attribution in (ta for tas in token_attributions for ta in tas): token_attributions_global[token] += token_attribution # divide by number of samples to get average attribution per token token_attributions_global = { token: token_attribution / max(0, len(token_attributions)) for token, token_attribution in token_attributions_global.items() } # convert to list of tuples and sort by attribution token_attributions_global = sorted( token_attributions_global.items(), key=lambda x: x[1], reverse=True ) # keep only top 100 tokens token_attributions_global = token_attributions_global[:100] feat_to_token_attributions_global[feat_name] = token_attributions_global self.global_explanation.add( input_features.keys(), total_attribution_global, feat_to_token_attributions_global ) for i, (feature_attributions, explanation) in enumerate(zip(total_attribution, self.row_explanations)): # Add the feature attributions to the explanation object for this row. explanation.add( input_features.keys(), feature_attributions, {k: v[i] for k, v in feat_to_token_attributions.items()}, ) # TODO(travis): for force plots, need something similar to SHAP E[X] expected_values.append(0.0) # For binary targets, add an extra attribution for the negative class (false). if self.is_binary_target: le_true = self.global_explanation.label_explanations[0] negated_attributions = le_true.to_array() * -1 negated_token_attributions = { fa.feature_name: [(t, -a) for t, a in fa.token_attributions] for fa in le_true.feature_attributions if fa.token_attributions is not None } # Prepend the negative class to the list of label explanations. self.global_explanation.add( input_features.keys(), negated_attributions, negated_token_attributions, prepend=True ) for explanation in self.row_explanations: le_true = explanation.label_explanations[0] negated_attributions = le_true.to_array() * -1 negated_token_attributions = { fa.feature_name: [(t, -a) for t, a in fa.token_attributions] for fa in le_true.feature_attributions if fa.token_attributions is not None } # Prepend the negative class to the list of label explanations. explanation.add(input_features.keys(), negated_attributions, negated_token_attributions, prepend=True) # TODO(travis): for force plots, need something similar to SHAP E[X] expected_values.append(0.0) return ExplanationsResult(self.global_explanation, self.row_explanations, expected_values) @ray.remote(max_calls=1) def get_input_tensors_task( model: LudwigModel, df: pd.DataFrame, run_config: ExplanationRunConfig ) -> tuple[list[Variable], ExplanationRunConfig]: model.model.unskip() model.model.to(get_torch_device()) try: get_total_attribution_with_retry = retry_with_halved_batch_size(run_config)(get_input_tensors) return get_total_attribution_with_retry(model, df, run_config), run_config finally: model.model.cpu() @ray.remote(max_calls=1) def get_total_attribution_task( model: LudwigModel, target_feature_name: str, target_indices: list[int | None], inputs_encoded: list[Variable], baseline: list[Variable], nsamples: int, run_config: ExplanationRunConfig, ) -> list[np.array]: model.model.unskip() model.model.to(get_torch_device()) try: get_total_attribution_with_retry = retry_with_halved_batch_size(run_config)(get_total_attribution) return [ get_total_attribution_with_retry( model=model, target_feature_name=target_feature_name, target_idx=target_idx, feature_inputs=inputs_encoded, baseline=baseline, nsamples=nsamples, run_config=run_config, ) for target_idx in tqdm(target_indices, desc="Explain") ] finally: model.model.cpu() def split_list(v, n): """Splits a list into n roughly equal sub-lists. Source: https://stackoverflow.com/a/2135920 """ k, m = divmod(len(v), n) return (v[i * k + min(i, m) : (i + 1) * k + min(i + 1, m)] for i in range(n)) ================================================ FILE: ludwig/explain/explainer.py ================================================ from abc import ABCMeta, abstractmethod import pandas as pd from ludwig.api import LudwigModel from ludwig.api_annotations import DeveloperAPI from ludwig.constants import BINARY, CATEGORY, TYPE from ludwig.explain.explanation import Explanation, ExplanationsResult from ludwig.explain.util import prepare_data @DeveloperAPI class Explainer(metaclass=ABCMeta): def __init__( self, model: LudwigModel, inputs_df: pd.DataFrame, sample_df: pd.DataFrame, target: str, ): """Constructor for the explainer. # Inputs :param model: (LudwigModel) The LudwigModel to explain. :param inputs_df: (pd.DataFrame) The input data to explain. :param sample_df: (pd.DataFrame) A sample of the ground truth data. :param target: (str) The name of the target to explain. """ self.model = model self.inputs_df = inputs_df self.sample_df = sample_df self.target = target self.inputs_df, self.sample_df, self.feature_cols, self.target_feature_name = prepare_data( model, inputs_df, sample_df, target ) self.global_explanation = Explanation(self.target_feature_name) self.row_explanations = [Explanation(self.target_feature_name) for _ in self.inputs_df.index] # Lookup from column name to output feature config = self.model.config self.output_feature_map = {feature["column"]: feature for feature in config["output_features"]} @property def is_binary_target(self) -> bool: """Whether the target is binary.""" return self.output_feature_map[self.target_feature_name][TYPE] == BINARY @property def is_category_target(self) -> bool: """Whether the target is categorical.""" return self.output_feature_map[self.target_feature_name][TYPE] == CATEGORY @property def vocab_size(self) -> int: """The vocab size of the target feature. For regression (number) this is 1, for binary it is 2, and for category it is the vocab size. """ if self.is_category_target: return self.model.training_set_metadata[self.target_feature_name]["vocab_size"] elif self.is_binary_target: return 2 return 1 @abstractmethod def explain(self) -> ExplanationsResult: """Explain the model's predictions. # Return :return: ExplanationsResult containing the explanations. """ ================================================ FILE: ludwig/explain/explanation.py ================================================ from dataclasses import dataclass, field import numpy as np import numpy.typing as npt from ludwig.api_annotations import DeveloperAPI, PublicAPI @DeveloperAPI @dataclass class FeatureAttribution: """Stores the attribution for a single input feature.""" # The name of the input feature. feature_name: str # The scalar attribution for the input feature. attribution: float # (Optional) The attribution for each token in the input feature as an array of shape (seq_len, 2). token_attributions: list[tuple[str, float]] = None @DeveloperAPI @dataclass class LabelExplanation: """Stores the feature attributions for a single label in the target feature's vocab.""" # The attribution for each input feature. feature_attributions: list[FeatureAttribution] = field(default_factory=list) def add(self, feature_name: str, attribution: float, token_attributions: list[tuple[str, float]] = None): """Add the attribution for a single input feature.""" self.feature_attributions.append(FeatureAttribution(feature_name, attribution, token_attributions)) def to_array(self) -> npt.NDArray[np.float64]: """Convert the explanation to a 1D array of shape (num_features,).""" return np.array([fa.attribution for fa in self.feature_attributions]) @DeveloperAPI @dataclass class Explanation: """Stores the explanations for a single row of input data. Contains the feature attributions for each label in the target feature's vocab. """ target: str # The explanations for each label in the vocab of the target feature. label_explanations: list[LabelExplanation] = field(default_factory=list) def add( self, feat_names: list[str], feat_attributions: npt.NDArray[np.float64], feat_to_token_attributions: dict[str, list[tuple[str, float]]] = None, prepend: bool = False, ): """Add the feature attributions for a single label.""" assert len(feat_names) == len( feat_attributions ), f"Expected {len(feat_names)} feature attributions, got {len(feat_attributions)}" if len(self.label_explanations) > 0: # Check that the feature attributions are the same shape as existing explanations. assert self.label_explanations[0].to_array().shape == feat_attributions.shape, ( f"Expected feature attributions of shape {self.label_explanations[0].to_array().shape}, " f"got {feat_attributions.shape}" ) le = LabelExplanation() for i, feat_name in enumerate(feat_names): le.add( feat_name, feat_attributions[i], feat_to_token_attributions.get(feat_name) if feat_to_token_attributions else None, ) self.label_explanations.insert(0, le) if prepend else self.label_explanations.append(le) def to_array(self) -> npt.NDArray[np.float64]: """Convert the explanation to a 2D array of shape (num_labels, num_features).""" return np.array([le.to_array() for le in self.label_explanations]) @PublicAPI(stability="experimental") @dataclass class ExplanationsResult: # Aggregate explanation for the entire input data. global_explanation: Explanation # GlobalExplanation # A list of explanations, one for each row in the input data. # Each explanation contains the feature attributions for each label in the target feature's vocab. row_explanations: list[Explanation] # Expected value for each label in the target feature's vocab. expected_values: list[float] ================================================ FILE: ludwig/explain/util.py ================================================ from copy import deepcopy import pandas as pd import torch from ludwig.api import LudwigModel from ludwig.constants import COLUMN, INPUT_FEATURES, PREPROCESSING, SPLIT from ludwig.data.split import get_splitter from ludwig.features.base_feature import BaseFeature def filter_cols(df, cols): cols = {c.lower() for c in cols} retain_cols = [c for c in df.columns if c.lower() in cols] return df[retain_cols] def prepare_data(model: LudwigModel, inputs_df: pd.DataFrame, sample_df: pd.DataFrame, target: str): config = model.config feature_cols = [feature[COLUMN] for feature in config[INPUT_FEATURES]] if SPLIT in config.get(PREPROCESSING, {}): # Keep columns required for Ludwig preprocessing splitter = get_splitter(**config[PREPROCESSING][SPLIT]) feature_cols += splitter.required_columns target_feature_name = get_feature_name(model, target) inputs_df = filter_cols(inputs_df, feature_cols) if sample_df is not None: sample_df = filter_cols(sample_df, feature_cols) return inputs_df, sample_df, feature_cols, target_feature_name def get_pred_col(preds, target): t = target.lower() for c in preds.keys(): if c.lower() == t: if "probabilities" in preds[c]: return preds[c]["probabilities"] else: return preds[c]["predictions"] raise ValueError(f"Unable to find target column {t} in {preds.keys()}") def get_feature_name(model: LudwigModel, target: str) -> str: t = target.lower() for c in model.training_set_metadata.keys(): if c.lower() == t: return c raise ValueError(f"Unable to find target column {t} in {model.training_set_metadata.keys()}") def get_absolute_module_key_from_submodule(module: torch.nn.Module, submodule: torch.nn.Module): """Get the absolute module key for each param in the target layer. Assumes that the keys in the submodule are relative to the module. We find the params from the submodule in the module by comparing the data pointers, since the data returned by named_parameters is by reference. More information on checking if tensors point to the same place in storage can be found here: https://discuss.pytorch.org/t/any-way-to-check-if-two-tensors-have-the-same-base/44310/2 """ absolute_keys = [] for module_key, module_param in module.named_parameters(): for _, submodule_param in submodule.named_parameters(): if submodule_param.data_ptr() == module_param.data_ptr(): absolute_keys.append(module_key) break return absolute_keys def replace_layer_with_copy(feat: BaseFeature, target_layer: torch.nn.Module): """Replaces a layer in a feature with a copy of the layer in-place. This is useful in a tied weights scenario, where a single encoder may be used by multiple features. If we leave as-is, Captum complains about the resulting computation graph. The solution is to create an identical (deep) copy of the layer fed into Captum: https://github.com/pytorch/captum/issues/794#issuecomment-1093021638 This is safe to do during the explain step because we are essentially running inference, and no model artifacts are being saved during the explain step. TODO(geoffrey): if a user ever wants to train immediately after explain (i.e. w/o loading weights from the disk), we might want to implement this as a context so that we can restore the original encoder object at the end. Will defer this implementation for now because that scenario seems unlikely. At a high-level the approach is the following: 1. Create a deep-copy of the entire encoder object and set it as the feature's encoder object 2. Replace the tensors in the copied encoder object with the tensors from the original encoder object, except for the tensors in the target layer. We want to explain these tensors, so we want to keep them as deep copies. This approach ensures that at most 2 copies of the encoder object are in memory at any given time. """ with torch.no_grad(): # Get the original encoder object and a mapping from param names to the params themselves. orig_encoder_obj = feat.encoder_obj orig_encoder_obj_state_dict = orig_encoder_obj.state_dict() # Deep copy the original encoder object and set the copy as this feature's encoder object. copy_encoder_obj = deepcopy(orig_encoder_obj) feat.encoder_obj = copy_encoder_obj # We have to get the absolute module key in order to do string matching because the target_layer keys are # relative to itself. If we were to leave it as-is and attempt to suffix match, we may get duplicates for # common layers i.e. "LayerNorm.weight" and "LayerNorm.bias". Getting the absolute module key ensures we # use values like "transformer.module.embedding.LayerNorm.weight" instead. keys_to_keep_copy = get_absolute_module_key_from_submodule(orig_encoder_obj, target_layer) # Get the tensors to keep from the copied encoder object. These are the tensors in the target layer. for key, param in copy_encoder_obj.named_parameters(): if key not in keys_to_keep_copy: param.data = orig_encoder_obj_state_dict[key].data ================================================ FILE: ludwig/export.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import argparse import logging import os import sys from ludwig.api import LudwigModel from ludwig.contrib import add_contrib_callback_args from ludwig.globals import LUDWIG_VERSION from ludwig.utils.print_utils import get_logging_level_registry, print_ludwig from ludwig.utils.triton_utils import export_triton as utils_export_triton logger = logging.getLogger(__name__) def export_torchscript( model_path: str, model_only: bool = False, output_path: str | None = None, device: str | None = None, **kwargs ) -> None: """Exports a model to torchscript. # Inputs :param model_path: (str) filepath to pre-trained model. :param model_only: (bool, default: `False`) If true, scripts and exports the model only. :param output_path: directory to store torchscript. If `None`, defaults to model_path # Return :returns: (`None`) """ logger.info(f"Model path: {model_path}") logger.info(f"Saving model only: {model_only}") if output_path is None: logger.info("output_path is None, defaulting to model_path") output_path = model_path logger.info(f"Output path: {output_path}") logger.info("\n") model = LudwigModel.load(model_path) os.makedirs(output_path, exist_ok=True) model.save_torchscript(output_path, model_only=model_only, device=device) logger.info(f"Saved to: {output_path}") def export_triton(model_path, output_path="model_repository", model_name="ludwig_model", model_version=1, **kwargs): """Exports a model in torchscript format with config for Triton serving. # Inputs :param model_path: (str) filepath to pre-trained model. :param output_path: (str, default: `'model_repository'`) directory to store the triton models. :param model_name: (str, default: `'ludwig_model'`) save triton under this name. :param model_name: (int, default: `1`) save model under this verison. # Return :returns: (`None`) """ logger.info(f"Model path: {model_path}") logger.info(f"Output path: {output_path}") logger.info(f"Model name: {model_name}") logger.info(f"Model version: {model_version}") logger.info("\n") model = LudwigModel.load(model_path) os.makedirs(output_path, exist_ok=True) utils_export_triton(model=model, output_path=output_path, model_name=model_name, model_version=model_version) logger.info(f"Saved to: {output_path}") def export_mlflow(model_path, output_path="mlflow", registered_model_name=None, **kwargs): """Exports a model to MLflow. # Inputs :param model_path: (str) filepath to pre-trained model. :param output_path: (str, default: `'mlflow'`) directory to store the mlflow model. :param registered_model_name: (str, default: `None`) save mlflow under this name in the model registry. Saved locally if `None`. # Return :returns: (`None`) """ logger.info(f"Model path: {model_path}") logger.info(f"Output path: {output_path}") logger.info("\n") from ludwig.contribs.mlflow.model import export_model export_model(model_path, output_path, registered_model_name) logger.info(f"Saved to: {output_path}") def cli_export_torchscript(sys_argv): parser = argparse.ArgumentParser( description="This script loads a pretrained model " "and saves it as torchscript.", prog="ludwig export_torchscript", usage="%(prog)s [options]", ) # ---------------- # Model parameters # ---------------- parser.add_argument("-m", "--model_path", help="model to load", required=True) parser.add_argument( "-mo", "--model_only", help="Script and export the model only.", action="store_true", ) parser.add_argument( "-d", "--device", type=str, help=( 'Device to use for torchscript tracing (e.g. "cuda" or "cpu"). Ideally, this is the same as the device ' "used when the model is loaded." ), default=None, ) # ----------------- # Output parameters # ----------------- parser.add_argument( "-op", "--output_path", type=str, help="path where to save the export model. If not specified, defaults to model_path.", default=None, ) # ------------------ # Runtime parameters # ------------------ parser.add_argument( "-l", "--logging_level", default="info", help="the level of logging to use", choices=["critical", "error", "warning", "info", "debug", "notset"], ) add_contrib_callback_args(parser) args = parser.parse_args(sys_argv) args.callbacks = args.callbacks or [] for callback in args.callbacks: callback.on_cmdline("export_torchscript", *sys_argv) args.logging_level = get_logging_level_registry()[args.logging_level] logging.getLogger("ludwig").setLevel(args.logging_level) global logger logger = logging.getLogger("ludwig.export") print_ludwig("Export Torchscript", LUDWIG_VERSION) export_torchscript(**vars(args)) def cli_export_triton(sys_argv): parser = argparse.ArgumentParser( description="This script loads a pretrained model " "and saves it as torchscript for Triton.", prog="ludwig export_triton", usage="%(prog)s [options]", ) # ---------------- # Model parameters # ---------------- parser.add_argument("-m", "--model_path", help="model to load", required=True) parser.add_argument("-mn", "--model_name", help="model name", default="ludwig_model") parser.add_argument("-mv", "--model_version", type=int, help="model version", default=1) # ----------------- # Output parameters # ----------------- parser.add_argument("-op", "--output_path", type=str, help="path where to save the export model", required=True) # ------------------ # Runtime parameters # ------------------ parser.add_argument( "-l", "--logging_level", default="info", help="the level of logging to use", choices=["critical", "error", "warning", "info", "debug", "notset"], ) add_contrib_callback_args(parser) args = parser.parse_args(sys_argv) args.callbacks = args.callbacks or [] for callback in args.callbacks: callback.on_cmdline("export_triton", *sys_argv) args.logging_level = get_logging_level_registry()[args.logging_level] logging.getLogger("ludwig").setLevel(args.logging_level) global logger logger = logging.getLogger("ludwig.export") print_ludwig("Export Triton", LUDWIG_VERSION) export_triton(**vars(args)) def cli_export_mlflow(sys_argv): parser = argparse.ArgumentParser( description="This script loads a pretrained model " "and saves it as an MLFlow model.", prog="ludwig export_mlflow", usage="%(prog)s [options]", ) # ---------------- # Model parameters # ---------------- parser.add_argument("-m", "--model_path", help="model to load", required=True) parser.add_argument( "-mn", "--registered_model_name", help="model name to upload to in MLflow model registry", ) # ----------------- # Output parameters # ----------------- parser.add_argument( "-op", "--output_path", type=str, help="path where to save the exported model", default="mlflow" ) # ------------------ # Runtime parameters # ------------------ parser.add_argument( "-l", "--logging_level", default="info", help="the level of logging to use", choices=["critical", "error", "warning", "info", "debug", "notset"], ) add_contrib_callback_args(parser) args = parser.parse_args(sys_argv) args.callbacks = args.callbacks or [] for callback in args.callbacks: callback.on_cmdline("export_mlflow", *sys_argv) args.logging_level = get_logging_level_registry()[args.logging_level] logging.getLogger("ludwig").setLevel(args.logging_level) global logger logger = logging.getLogger("ludwig.export") print_ludwig("Export MLFlow", LUDWIG_VERSION) export_mlflow(**vars(args)) if __name__ == "__main__": if len(sys.argv) > 1: if sys.argv[1] == "savedmodel": cli_export_torchscript(sys.argv[2:]) elif sys.argv[1] == "mlflow": cli_export_mlflow(sys.argv[2:]) elif sys.argv[1] == "triton": cli_export_triton(sys.argv[2:]) else: print("Unrecognized command") else: print("Unrecognized command") ================================================ FILE: ludwig/features/__init__.py ================================================ ================================================ FILE: ludwig/features/audio_feature.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import logging import os import numpy as np import torch import torchaudio from packaging import version from ludwig.constants import AUDIO, AUDIO_FEATURE_KEYS, COLUMN, NAME, PREPROCESSING, PROC_COLUMN, SRC, TYPE from ludwig.features.base_feature import BaseFeatureMixin from ludwig.features.sequence_feature import SequenceInputFeature from ludwig.schema.features.audio_feature import AudioInputFeatureConfig from ludwig.types import FeatureMetadataDict, ModelConfigDict, PreprocessingConfigDict, TrainingSetMetadataDict from ludwig.utils.audio_utils import ( calculate_mean, calculate_var, get_default_audio, get_fbank, get_group_delay, get_length_in_samp, get_max_length_stft_based, get_non_symmetric_length, get_phase_stft_magnitude, get_stft_magnitude, is_torch_audio_tuple, read_audio_from_bytes_obj, read_audio_from_path, ) from ludwig.utils.data_utils import get_abs_path from ludwig.utils.fs_utils import has_remote_protocol from ludwig.utils.misc_utils import set_default_value from ludwig.utils.types import TorchscriptPreprocessingInput logger = logging.getLogger(__name__) _TORCH_200 = version.parse(torch.__version__) >= version.parse("2.0.0") class _AudioPreprocessing(torch.nn.Module): audio_feature_dict: dict[str, float | int | str] def __init__(self, metadata: TrainingSetMetadataDict): super().__init__() self.audio_feature_dict = { key: value for key, value in metadata["preprocessing"].items() if key in AUDIO_FEATURE_KEYS and value is not None } self.feature_dim = metadata["feature_dim"] self.max_length = metadata["max_length"] self.padding_value = metadata["preprocessing"]["padding_value"] self.normalization_type = metadata["preprocessing"]["norm"] def forward(self, v: TorchscriptPreprocessingInput) -> torch.Tensor: if not torch.jit.isinstance(v, list[tuple[torch.Tensor, int]]): raise ValueError(f"Unsupported input: {v}") processed_audio_matrix = [] for audio, sampling_rate_in_hz in v: processed_audio = AudioFeatureMixin._transform_to_feature( audio, sampling_rate_in_hz, self.audio_feature_dict, self.feature_dim, self.max_length, self.padding_value, self.normalization_type, ) processed_audio_matrix.append(processed_audio) return torch.stack(processed_audio_matrix) class AudioFeatureMixin(BaseFeatureMixin): @staticmethod def type(): return AUDIO @staticmethod def cast_column(column, backend): return column @staticmethod def get_feature_meta( config: ModelConfigDict, column, preprocessing_parameters: PreprocessingConfigDict, backend, is_input_feature: bool, ) -> FeatureMetadataDict: first_audio_file_path = column.head(1).iloc[0] _, sampling_rate_in_hz = torchaudio.load(first_audio_file_path) feature_dim = AudioFeatureMixin._get_feature_dim(preprocessing_parameters, sampling_rate_in_hz) audio_file_length_limit_in_s = preprocessing_parameters["audio_file_length_limit_in_s"] max_length = AudioFeatureMixin._get_max_length_feature( preprocessing_parameters, sampling_rate_in_hz, audio_file_length_limit_in_s ) return { "feature_dim": feature_dim, "sampling_rate_in_hz": sampling_rate_in_hz, "max_length": max_length, "reshape": (max_length, feature_dim), } @staticmethod def _get_feature_dim(preprocessing_parameters: PreprocessingConfigDict, sampling_rate_in_hz): feature_type = preprocessing_parameters[TYPE] if feature_type == "raw": feature_dim = 1 elif feature_type == "stft_phase": feature_dim_symmetric = get_length_in_samp( preprocessing_parameters["window_length_in_s"], sampling_rate_in_hz ) feature_dim = 2 * get_non_symmetric_length(feature_dim_symmetric) elif feature_type in ["stft", "group_delay"]: feature_dim_symmetric = get_length_in_samp( preprocessing_parameters["window_length_in_s"], sampling_rate_in_hz ) feature_dim = get_non_symmetric_length(feature_dim_symmetric) elif feature_type == "fbank": feature_dim = preprocessing_parameters["num_filter_bands"] else: raise ValueError(f"{feature_type} is not recognized.") return feature_dim @staticmethod def _process_in_memory( column, audio_feature_dict, feature_dim, max_length, padding_value, normalization_type, audio_file_length_limit_in_s, backend, ): df_engine = backend.df_engine if _TORCH_200: # Read audio from path if the version of torch is >= 2.0.0. raw_audio = backend.read_binary_files(column, map_fn=read_audio_from_path) else: raw_audio = backend.read_binary_files(column, map_fn=read_audio_from_bytes_obj) try: default_audio = get_default_audio([audio for audio in raw_audio if is_torch_audio_tuple(audio)]) except RuntimeError as e: raise RuntimeError(f"Unable to process audio files provided: {e}") from e raw_audio = df_engine.map_objects(raw_audio, lambda row: row if is_torch_audio_tuple(row) else default_audio) processed_audio = df_engine.map_objects( raw_audio, lambda row: AudioFeatureMixin._transform_to_feature( audio=row[0], sampling_rate_in_hz=row[1], audio_feature_dict=audio_feature_dict, feature_dim=feature_dim, max_length=max_length, padding_value=padding_value, normalization_type=normalization_type, ).numpy(), # non-torchscript preprocessing requires np.ndarray ) audio_stats = df_engine.map_objects( raw_audio, lambda row: AudioFeatureMixin._get_stats( audio=row[0], sampling_rate_in_hz=row[1], max_length_in_s=audio_file_length_limit_in_s, ), ) def reduce(series): merged_stats = None for audio_stats in series: if merged_stats is None: merged_stats = audio_stats.copy() else: AudioFeatureMixin._merge_stats(merged_stats, audio_stats) return merged_stats merged_stats = df_engine.reduce_objects(audio_stats, reduce) merged_stats["mean"] = calculate_mean(merged_stats["sum"], merged_stats["count"]) merged_stats["var"] = calculate_var(merged_stats["sum"], merged_stats["sum2"], merged_stats["count"]) merged_stats["std"] = np.sqrt(merged_stats["var"] / float(merged_stats["count"])) print_statistics = ( "{} audio files loaded.\n" "Statistics of audio file lengths:\n" "- mean: {:.4f}\n" "- std: {:.4f}\n" "- max: {:.4f}\n" "- min: {:.4f}\n" "- cropped audio_files: {}\n" "Max length was given as {}s" ).format( merged_stats["count"], merged_stats["mean"], merged_stats["std"], merged_stats["max"], merged_stats["min"], merged_stats["cropped"], audio_file_length_limit_in_s, ) logger.debug(print_statistics) return processed_audio @staticmethod def _transform_to_feature( audio: torch.Tensor, sampling_rate_in_hz: int, audio_feature_dict: dict[str, float | int | str], feature_dim: int, max_length: int, padding_value: float, normalization_type: str | None = None, type_key: str = TYPE, ): feature_type: str = str(audio_feature_dict[type_key]) if feature_type == "raw": audio_feature = torch.unsqueeze(audio[0], dim=-1) elif feature_type in ["stft", "stft_phase", "group_delay", "fbank"]: audio_feature = AudioFeatureMixin._get_2D_feature( audio, feature_type, audio_feature_dict, sampling_rate_in_hz ) audio_feature = torch.transpose(audio_feature, 0, 1) else: raise ValueError(f"{feature_type} is not recognized.") # Outer conditional is type refinement from Union[str, None] to str if normalization_type is not None: if normalization_type == "per_file": mean = torch.mean(audio_feature, dim=0) std = torch.std(audio_feature, dim=0) audio_feature = torch.divide((audio_feature - mean), std + 1.0e-10) elif normalization_type == "global": raise ValueError("not implemented yet") feature_length = audio_feature.shape[0] broadcast_feature_length = min(feature_length, max_length) audio_feature_padded = torch.full( (max_length, feature_dim), padding_value, dtype=torch.float32, device=audio_feature.device ) audio_feature_padded[:broadcast_feature_length, :] = audio_feature[:max_length, :] return audio_feature_padded @staticmethod def _get_stats(audio, sampling_rate_in_hz, max_length_in_s): audio_length_in_s = audio.shape[-1] / float(sampling_rate_in_hz) return { "count": 1, "sum": audio_length_in_s, "sum2": audio_length_in_s * audio_length_in_s, "min": audio_length_in_s, "max": audio_length_in_s, "cropped": 1 if audio_length_in_s > max_length_in_s else 0, } @staticmethod def _merge_stats(merged_stats, audio_stats): merged_stats["count"] += audio_stats["count"] merged_stats["sum"] += audio_stats["sum"] merged_stats["sum2"] += audio_stats["sum2"] merged_stats["min"] = min(merged_stats["min"], audio_stats["min"]) merged_stats["max"] = max(merged_stats["max"], audio_stats["max"]) merged_stats["cropped"] += audio_stats["cropped"] @staticmethod def _get_2D_feature( audio: torch.Tensor, feature_type: str, audio_feature_dict: dict[str, float | int | str], sampling_rate_in_hz: int, ) -> torch.Tensor: window_length_in_s = audio_feature_dict["window_length_in_s"] window_shift_in_s = audio_feature_dict["window_shift_in_s"] assert torch.jit.isinstance(window_length_in_s, float) assert torch.jit.isinstance(window_shift_in_s, float) window_length_in_samp = get_length_in_samp(window_length_in_s, sampling_rate_in_hz) if "num_fft_points" in audio_feature_dict: num_fft_points = audio_feature_dict["num_fft_points"] assert torch.jit.isinstance(num_fft_points, int) if num_fft_points < window_length_in_samp: raise ValueError( "num_fft_points: {} < window length in " "samples: {} (corresponds to window length" " in s: {}".format(num_fft_points, window_length_in_s, window_length_in_samp) ) else: num_fft_points = window_length_in_samp if "window_type" in audio_feature_dict: window_type = audio_feature_dict["window_type"] assert torch.jit.isinstance(window_type, str) else: window_type = "hamming" if feature_type == "stft_phase": return get_phase_stft_magnitude( audio, sampling_rate_in_hz, window_length_in_s, window_shift_in_s, num_fft_points, window_type ) elif feature_type == "stft": return get_stft_magnitude( audio, sampling_rate_in_hz, window_length_in_s, window_shift_in_s, num_fft_points, window_type ) elif feature_type == "group_delay": return get_group_delay( audio, sampling_rate_in_hz, window_length_in_s, window_shift_in_s, num_fft_points, window_type ) elif feature_type == "fbank": num_filter_bands = audio_feature_dict["num_filter_bands"] assert torch.jit.isinstance(num_filter_bands, int) return get_fbank( audio, sampling_rate_in_hz, window_length_in_s, window_shift_in_s, num_fft_points, window_type, num_filter_bands, ) else: raise ValueError(f'feature_type "{feature_type}" is not recognized.') @staticmethod def add_feature_data( feature_config, input_df, proc_df, metadata, preprocessing_parameters: PreprocessingConfigDict, backend, skip_save_processed_input, ): set_default_value(feature_config["preprocessing"], "in_memory", preprocessing_parameters["in_memory"]) name = feature_config[NAME] column = input_df[feature_config[COLUMN]] num_audio_files = len(column) if num_audio_files == 0: raise ValueError("There are no audio files in the dataset provided.") first_audio_entry = next(iter(column)) logger.debug(f"Detected audio feature type is {type(first_audio_entry)}") if not isinstance(first_audio_entry, str) and not isinstance(first_audio_entry, torch.Tensor): raise ValueError( "Invalid audio feature data type. Detected type is {}, " "expected either string for local/remote file path or Torch Tensor.".format(type(first_audio_entry)) ) src_path = None if SRC in metadata: if isinstance(first_audio_entry, str) and not has_remote_protocol(first_audio_entry): src_path = os.path.dirname(os.path.abspath(metadata.get(SRC))) abs_path_column = backend.df_engine.map_objects( # This gets the CSV file path column, lambda row: get_abs_path(src_path, row) if isinstance(row, str) else row ) num_audio_utterances = len(input_df[feature_config[COLUMN]]) padding_value = preprocessing_parameters["padding_value"] normalization_type = preprocessing_parameters["norm"] feature_dim = metadata[name]["feature_dim"] max_length = metadata[name]["max_length"] audio_feature_dict = { key: value for key, value in preprocessing_parameters.items() if key in AUDIO_FEATURE_KEYS and value is not None } audio_file_length_limit_in_s = preprocessing_parameters["audio_file_length_limit_in_s"] if num_audio_utterances == 0: raise ValueError("There are no audio files in the dataset provided.") if feature_config[PREPROCESSING]["in_memory"]: audio_features = AudioFeatureMixin._process_in_memory( abs_path_column, audio_feature_dict, feature_dim, max_length, padding_value, normalization_type, audio_file_length_limit_in_s, backend, ) proc_df[feature_config[PROC_COLUMN]] = audio_features return proc_df @staticmethod def _get_max_length_feature( preprocessing_parameters: PreprocessingConfigDict, sampling_rate_in_hz, audio_length_limit_in_s ): feature_type = preprocessing_parameters[TYPE] audio_length_limit_in_samp = audio_length_limit_in_s * sampling_rate_in_hz if not audio_length_limit_in_samp.is_integer(): raise ValueError( "Audio_file_length_limit has to be chosen " "so that {} (in s) * {} (sampling rate in Hz) " "is an integer.".format(audio_length_limit_in_s, sampling_rate_in_hz) ) audio_length_limit_in_samp = int(audio_length_limit_in_samp) if feature_type == "raw": return audio_length_limit_in_samp elif feature_type in ["stft", "stft_phase", "group_delay", "fbank"]: window_length_in_s = preprocessing_parameters["window_length_in_s"] window_shift_in_s = preprocessing_parameters["window_shift_in_s"] return get_max_length_stft_based( audio_length_limit_in_samp, window_length_in_s, window_shift_in_s, sampling_rate_in_hz ) else: raise ValueError(f"{feature_type} is not recognized.") class AudioInputFeature(AudioFeatureMixin, SequenceInputFeature): def __init__(self, input_feature_config: AudioInputFeatureConfig, encoder_obj=None, **kwargs): super().__init__(input_feature_config, encoder_obj=encoder_obj, **kwargs) if not getattr(self.encoder_obj.config, "embedding_size", None): raise ValueError("embedding_size has to be defined - " 'check "update_config_with_metadata()"') if not getattr(self.encoder_obj.config, "max_sequence_length", None): raise ValueError("max_sequence_length has to be defined - " 'check "update_config_with_metadata()"') def forward(self, inputs, mask=None): assert isinstance(inputs, torch.Tensor) assert inputs.dtype == torch.float32 assert len(inputs.shape) == 3, f"expected 3D shape, found: {inputs.shape}" encoder_output = self.encoder_obj(inputs, mask=mask) return encoder_output @property def input_shape(self) -> torch.Size: return torch.Size([self.encoder_obj.config.max_sequence_length, self.encoder_obj.config.embedding_size]) @property def input_dtype(self): return torch.float32 @staticmethod def update_config_with_metadata(feature_config, feature_metadata, *args, **kwargs): feature_config.encoder.max_sequence_length = feature_metadata["max_length"] feature_config.encoder.embedding_size = feature_metadata["feature_dim"] feature_config.encoder.should_embed = False @staticmethod def create_preproc_module(metadata: TrainingSetMetadataDict) -> torch.nn.Module: return _AudioPreprocessing(metadata) @staticmethod def get_schema_cls(): return AudioInputFeatureConfig ================================================ FILE: ludwig/features/bag_feature.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import logging from collections import Counter import numpy as np import torch from ludwig.constants import BAG, COLUMN, NAME, PROC_COLUMN from ludwig.features.base_feature import BaseFeatureMixin, InputFeature from ludwig.features.feature_utils import set_str_to_idx from ludwig.features.set_feature import _SetPreprocessing from ludwig.schema.features.bag_feature import BagInputFeatureConfig from ludwig.types import FeatureMetadataDict, ModelConfigDict, PreprocessingConfigDict, TrainingSetMetadataDict from ludwig.utils.strings_utils import create_vocabulary logger = logging.getLogger(__name__) class BagFeatureMixin(BaseFeatureMixin): @staticmethod def type(): return BAG @staticmethod def cast_column(column, backend): return column.astype(str) @staticmethod def get_feature_meta( config: ModelConfigDict, column, preprocessing_parameters: PreprocessingConfigDict, backend, is_input_feature: bool, ) -> FeatureMetadataDict: vocabulary = create_vocabulary( column, preprocessing_parameters["tokenizer"], num_most_frequent=preprocessing_parameters["most_common"], lowercase=preprocessing_parameters["lowercase"], processor=backend.df_engine, ) return { "idx2str": vocabulary.vocab, "str2idx": vocabulary.str2idx, "str2freq": vocabulary.str2freq, "vocab_size": len(vocabulary.str2idx), "max_set_size": vocabulary.max_sequence_length, } @staticmethod def feature_data(column, metadata, preprocessing_parameters: PreprocessingConfigDict, backend): def to_vector(set_str): bag_vector = np.zeros((len(metadata["str2idx"]),), dtype=np.float32) col_counter = Counter(set_str_to_idx(set_str, metadata["str2idx"], preprocessing_parameters["tokenizer"])) bag_vector[list(col_counter.keys())] = list(col_counter.values()) return bag_vector return backend.df_engine.map_objects(column, to_vector) @staticmethod def add_feature_data( feature_config, input_df, proc_df, metadata, preprocessing_parameters: PreprocessingConfigDict, backend, skip_save_processed_input, ): proc_df[feature_config[PROC_COLUMN]] = BagFeatureMixin.feature_data( input_df[feature_config[COLUMN]], metadata[feature_config[NAME]], preprocessing_parameters, backend, ) return proc_df class BagInputFeature(BagFeatureMixin, InputFeature): def __init__(self, input_feature_config: BagInputFeatureConfig, encoder_obj=None, **kwargs): super().__init__(input_feature_config, **kwargs) if encoder_obj: self.encoder_obj = encoder_obj else: self.encoder_obj = self.initialize_encoder(input_feature_config.encoder) def forward(self, inputs): assert isinstance(inputs, torch.Tensor) # assert inputs.dtype == tf.bool # this fails encoder_output = self.encoder_obj(inputs) return encoder_output @property def input_shape(self) -> torch.Size: return torch.Size([len(self.encoder_obj.config.vocab)]) @property def output_shape(self) -> torch.Size: return self.encoder_obj.output_shape @staticmethod def update_config_with_metadata(feature_config, feature_metadata, *args, **kwargs): feature_config.encoder.vocab = feature_metadata["idx2str"] @staticmethod def get_schema_cls(): return BagInputFeatureConfig @staticmethod def create_preproc_module(metadata: TrainingSetMetadataDict) -> torch.nn.Module: return _SetPreprocessing(metadata, is_bag=True) ================================================ FILE: ludwig/features/base_feature.py ================================================ # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import logging from abc import ABC, abstractmethod, abstractstaticmethod from dataclasses import dataclass from typing import Any import torch from torch import Tensor from ludwig.constants import ( ENCODER_OUTPUT, ENCODER_OUTPUT_STATE, HIDDEN, LENGTHS, LOGITS, LOSS, PREDICTIONS, PROBABILITIES, ) from ludwig.decoders.registry import get_decoder_cls from ludwig.encoders.registry import get_encoder_cls from ludwig.features.feature_utils import get_input_size_with_dependencies from ludwig.modules.fully_connected_modules import FCStack from ludwig.modules.loss_modules import create_loss from ludwig.modules.metric_modules import LossMetric, LudwigMetric, MeanMetric from ludwig.modules.metric_registry import get_metric_classes, get_metric_cls, get_metric_tensor_input from ludwig.modules.reduction_modules import SequenceReducer from ludwig.schema.features.base import BaseFeatureConfig, BaseOutputFeatureConfig from ludwig.types import ( FeatureConfigDict, FeatureMetadataDict, ModelConfigDict, PreprocessingConfigDict, TrainingSetMetadataDict, ) from ludwig.utils import output_feature_utils from ludwig.utils.calibration import CalibrationModule from ludwig.utils.torch_utils import LudwigModule from ludwig.utils.types import DataFrame, TorchscriptPreprocessingInput logger = logging.getLogger(__name__) class BaseFeatureMixin(ABC): """Parent class for feature mixins. Feature mixins support preprocessing functionality shared across input and output features. """ @abstractstaticmethod def type() -> str: """Returns the type of feature this mixin supports.""" raise NotImplementedError @abstractstaticmethod def cast_column(column: DataFrame, backend) -> DataFrame: """Returns a copy of the dataset column for the given feature, potentially after a type cast. Args: column: Pandas column of values. backend: (Union[Backend, str]) Backend to use for feature data processing. """ raise NotImplementedError @abstractstaticmethod def get_feature_meta( config: ModelConfigDict, column: DataFrame, preprocessing_parameters: PreprocessingConfigDict, backend, is_input_feature: bool, ) -> FeatureMetadataDict: """Returns a dictionary of feature metadata. Args: config: Ludwig model config dict. column: Pandas column of values. preprocessing_parameters: Preprocessing configuration for this feature. backend: (Union[Backend, str]) Backend to use for feature data processing. """ raise NotImplementedError @abstractstaticmethod def add_feature_data( feature_config: FeatureConfigDict, input_df: DataFrame, proc_df: dict[str, DataFrame], metadata: TrainingSetMetadataDict, preprocessing_parameters: PreprocessingConfigDict, backend, # Union[Backend, str] skip_save_processed_input: bool, ) -> None: """Runs preprocessing on the input_df and stores results in the proc_df and metadata dictionaries. Args: feature_config: Feature configuration. input_df: Pandas column of values. proc_df: Dict of processed columns of data. Feature data is added to this. metadata: Metadata returned by get_feature_meta(). Additional information may be added to this. preprocessing_parameters: Preprocessing configuration for this feature. backend: (Union[Backend, str]) Backend to use for feature data processing. skip_save_processed_input: Whether to skip saving the processed input. """ raise NotImplementedError @dataclass class ModuleWrapper: """Used to prevent the PredictModule from showing up an attribute on the feature module. This is necessary to avoid inflight errors from DeepSpeed. These errors occur when DeepSpeed believes that a param is still in the process of being processed asynchronously (allgathered, etc.). """ module: torch.nn.Module class PredictModule(torch.nn.Module): """Base class for all modules that convert model outputs to predictions. Explicit member variables needed here for scripting, as Torchscript will not be able to recognize global variables during scripting. """ def __init__(self): super().__init__() self.predictions_key = PREDICTIONS self.probabilities_key = PROBABILITIES self.logits_key = LOGITS class BaseFeature: """Base class for all features. Note that this class is not-cooperative (does not forward kwargs), so when constructing feature class hierarchies, there should be only one parent class that derives from base feature. Other functionality should be put into mixin classes to avoid the diamond pattern. """ def __init__(self, feature: BaseFeatureConfig): super().__init__() if not feature.name: raise ValueError("Missing feature name") self.feature_name = feature.name if not feature.column: feature.column = self.feature_name self.column = feature.column self.proc_column = feature.proc_column class InputFeature(BaseFeature, LudwigModule, ABC): """Parent class for all input features.""" def create_sample_input(self, batch_size: int = 2): # Used by get_model_inputs(), which is used for tracing-based torchscript generation. return torch.rand([batch_size, *self.input_shape]).to(self.input_dtype) def unskip(self) -> "InputFeature": """Convert feature using passthrough wrapper back to full encoder.""" return self @staticmethod @abstractmethod def update_config_with_metadata(feature_config, feature_metadata, *args, **kwargs): pass def update_config_after_module_init(self, feature_config): """Updates the config after the torch.nn.Module objects have been initialized.""" def initialize_encoder(self, encoder_config): encoder_cls = get_encoder_cls(self.type(), encoder_config.type) encoder_schema = encoder_cls.get_schema_cls().Schema() encoder_params_dict = encoder_schema.dump(encoder_config) return encoder_cls(encoder_config=encoder_config, **encoder_params_dict) @classmethod def get_preproc_input_dtype(cls, metadata: TrainingSetMetadataDict) -> str: return "string" @staticmethod def create_preproc_module(metadata: TrainingSetMetadataDict) -> torch.nn.Module: raise NotImplementedError("Torchscript tracing not supported for feature") class OutputFeature(BaseFeature, LudwigModule, ABC): """Parent class for all output features.""" def __init__( self, feature: BaseOutputFeatureConfig, other_output_features: dict[str, "OutputFeature"], *args, **kwargs, ): """Defines defaults, overwrites them based on the feature dictionary, and sets up dependencies. Any output feature can depend on one or more other output features. The `other_output_features` input dictionary should contain entries for any dependent output features, which is accomplished by constructing output features in topographically sorted order. Attributes of any dependent output features are used to properly initialize this feature's sizes. """ super().__init__(feature) # List of names of metrics that this OutputFeature computes. self.metric_names = [] self.loss = feature.loss self.reduce_input = feature.reduce_input self.reduce_dependencies = feature.reduce_dependencies # List of feature names that this output feature is dependent on. self.dependencies = feature.dependencies logger.debug(" output feature fully connected layers") logger.debug(" FCStack") self.input_size = get_input_size_with_dependencies(feature.input_size, self.dependencies, other_output_features) feature.input_size = self.input_size self.fc_stack = FCStack( first_layer_input_size=self.input_size, layers=feature.decoder.fc_layers, num_layers=feature.decoder.num_fc_layers, default_output_size=feature.decoder.fc_output_size, default_use_bias=feature.decoder.fc_use_bias, default_weights_initializer=feature.decoder.fc_weights_initializer, default_bias_initializer=feature.decoder.fc_bias_initializer, default_norm=feature.decoder.fc_norm, default_norm_params=feature.decoder.fc_norm_params, default_activation=feature.decoder.fc_activation, default_dropout=feature.decoder.fc_dropout, ) self._calibration_module = self.create_calibration_module(feature) self._prediction_module = ModuleWrapper(self.create_predict_module()) # set up two sequence reducers, one for inputs and other for dependencies self.reduce_sequence_input = SequenceReducer(reduce_mode=self.reduce_input) if self.dependencies: self.dependency_reducers = torch.nn.ModuleDict() # todo: re-evaluate need for separate handling of `attention` reducer # currently this code does not support `attention` for dependency in self.dependencies: self.dependency_reducers[dependency] = SequenceReducer(reduce_mode=self.reduce_dependencies) def create_sample_output(self, batch_size: int = 2): output_shape = self.output_shape shape = [batch_size, *self.output_shape] if output_shape != torch.Size([1]) else [batch_size] return torch.rand(shape).to(self.get_output_dtype()) @abstractmethod def get_prediction_set(self): """Returns the set of tensor keys returned by this feature's PredictModule. TODO(Justin): Move this to the PredictModule. """ raise NotImplementedError("OutputFeature is missing implementation for get_prediction_set.") @classmethod @abstractmethod def get_output_dtype(cls): """Returns the Tensor data type feature outputs.""" def initialize_decoder(self, decoder_config): # Input to the decoder is the output feature's FC hidden layer. decoder_config.input_size = self.fc_stack.output_shape[-1] decoder_cls = get_decoder_cls(self.type(), decoder_config.type) decoder_schema = decoder_cls.get_schema_cls().Schema() decoder_params_dict = decoder_schema.dump(decoder_config) return decoder_cls(decoder_config=decoder_config, **decoder_params_dict) def train_loss(self, targets: Tensor, predictions: dict[str, Tensor], feature_name): loss_class = type(self.train_loss_function) prediction_key = output_feature_utils.get_feature_concat_name(feature_name, loss_class.get_loss_inputs()) return self.train_loss_function(predictions[prediction_key], targets) def eval_loss(self, targets: Tensor, predictions: dict[str, Tensor]): loss_class = type(self.train_loss_function) prediction_key = loss_class.get_loss_inputs() if isinstance(self.eval_loss_metric, MeanMetric): # MeanMetric's forward() implicitly updates the running average. # For MeanMetrics, we use get_current_value() to compute the loss without changing the state. All metrics # are updated at the BaseModel level as part of update_metrics(). return self.eval_loss_metric.get_current_value(predictions[prediction_key].detach(), targets) return self.eval_loss_metric(predictions[prediction_key].detach(), targets) def _setup_loss(self): self.train_loss_function = create_loss(self.loss) self._eval_loss_metric = ModuleWrapper(get_metric_cls(self.type(), self.loss.type)(config=self.loss)) def _setup_metrics(self): kwargs = {} for name, cls in get_metric_classes(self.type()).items(): if cls.can_report(self) and isinstance(cls, LossMetric): kwargs[name] = cls(config=self.loss, **self.metric_kwargs()) elif cls.can_report(self): kwargs[name] = cls(**self.metric_kwargs()) self._metric_functions = { LOSS: self.eval_loss_metric, **kwargs, } self.metric_names = sorted(list(self._metric_functions.keys())) def create_calibration_module(self, feature: BaseOutputFeatureConfig) -> CalibrationModule: """Creates and returns a CalibrationModule that converts logits to a probability distribution.""" return None @property def eval_loss_metric(self) -> LudwigMetric: return self._eval_loss_metric.module @property def calibration_module(self) -> torch.nn.Module: """Returns the CalibrationModule used to convert logits to a probability distribution.""" return self._calibration_module @abstractmethod def create_predict_module(self) -> PredictModule: """Creates and returns a `nn.Module` that converts raw model outputs (logits) to predictions. This module is needed when generating the Torchscript model using scripting. """ raise NotImplementedError() @property def prediction_module(self) -> PredictModule: """Returns the PredictModule used to convert model outputs to predictions.""" return self._prediction_module.module def predictions(self, all_decoder_outputs: dict[str, torch.Tensor], feature_name: str) -> dict[str, torch.Tensor]: """Computes actual predictions from the outputs of feature decoders. TODO(Justin): Consider refactoring this to accept feature-specific decoder outputs. Args: all_decoder_outputs: A dictionary of {feature name}::{tensor_name} -> output tensor. Returns: Dictionary of tensors with predictions as well as any additional tensors that may be necessary for computing evaluation metrics. """ return self.prediction_module(all_decoder_outputs, feature_name) @abstractmethod def logits(self, combiner_outputs: dict[str, torch.Tensor], target=None, **kwargs) -> dict[str, torch.Tensor]: """Unpacks and feeds combiner_outputs to the decoder. Invoked as part of the output feature's forward pass. If target is not None, then we are in training. Args: combiner_outputs: Dictionary of tensors from the combiner's forward pass. Returns: Dictionary of decoder's output tensors (non-normalized), as well as any additional tensors that may be necessary for computing predictions or evaluation metrics. """ raise NotImplementedError("OutputFeature is missing logits() implementation.") def metric_kwargs(self) -> dict[str, Any]: """Returns arguments that are used to instantiate an instance of each metric class.""" return {} def update_metrics(self, targets: Tensor, predictions: dict[str, Tensor]) -> None: """Updates metrics with the given targets and predictions. Args: targets: Tensor with target values for this output feature. predictions: Dict of tensors returned by predictions(). """ for metric_name, metric_fn in self._metric_functions.items(): prediction_key = get_metric_tensor_input(metric_name) metric_fn = metric_fn.to(predictions[prediction_key].device) metric_fn.update(predictions[prediction_key].detach(), targets) def get_metrics(self): metric_vals = {} for metric_name, metric_fn in self._metric_functions.items(): try: computed_metric = metric_fn.compute() except Exception as e: logger.exception(f"Caught exception computing metric: {metric_name} with error: {e}.") continue # Metrics from torchmetrics can be a straightforward tensor. if isinstance(computed_metric, Tensor): metric_vals[metric_name] = computed_metric.detach().cpu().numpy().item() else: # Metrics from torchmetrics can be a dict of tensors. # For example, ROUGE is returned as a dictionary of tensors. # Unpack. for sub_metric_name, metric in computed_metric.items(): metric_vals[sub_metric_name] = metric.detach().cpu().numpy().item() return metric_vals def reset_metrics(self): for _, metric_fn in self._metric_functions.items(): if metric_fn is not None: metric_fn.reset() def forward( self, combiner_outputs: dict[str, torch.Tensor], other_output_feature_outputs: dict[str, torch.Tensor], mask: torch.Tensor | None = None, target: torch.Tensor | None = None, ) -> dict[str, torch.Tensor]: """Forward pass that takes in output from the combiner, and passes it through to the decoder. Args: combiner_outputs: Dict of outputs from the combiner. other_output_feature_outputs: Dict of tensors from other output features. Used for resolving dependencies. mask: (Unused). Tensor for masking. target: Tensor with targets. During training, targets != None. During prediction, targets = None. Returns: Dict of output tensors, with at least 'last_hidden' and 'logits' as keys, as well as any additional tensor results from the decoder. """ # extract the combined hidden layer combiner_hidden = combiner_outputs["combiner_output"] hidden = self.prepare_decoder_inputs(combiner_hidden, other_output_feature_outputs, mask=mask) # ================ Predictions ================ logits_input = {HIDDEN: hidden} # pass supplemental data from encoders to decoder if ENCODER_OUTPUT_STATE in combiner_outputs: logits_input[ENCODER_OUTPUT_STATE] = combiner_outputs[ENCODER_OUTPUT_STATE] if LENGTHS in combiner_outputs: logits_input[LENGTHS] = combiner_outputs[LENGTHS] logits = self.logits(logits_input, target=target) # For binary and number features, self.logits() is a tensor. # There are two special cases where self.logits() is a dict: # categorical # keys: logits, projection_input # sequence # keys: logits # TODO(Justin): Clean this up. if isinstance(logits, Tensor): logits = {"logits": logits} # For multi-class features, we must choose a consistent tuple subset. return { # last_hidden used for dependencies processing "last_hidden": hidden, **logits, } @abstractmethod def postprocess_predictions( self, result: dict[str, Tensor], metadata: TrainingSetMetadataDict, ): raise NotImplementedError @classmethod def get_postproc_output_dtype(cls, metadata: TrainingSetMetadataDict) -> str: return "string" @staticmethod def create_postproc_module(metadata: TrainingSetMetadataDict) -> torch.nn.Module: raise NotImplementedError("Torchscript tracing not supported for feature") @staticmethod @abstractmethod def update_config_with_metadata(feature_config, feature_metadata, *args, **kwargs): pass @staticmethod @abstractmethod def calculate_overall_stats(predictions, targets, train_set_metadata): pass def output_specific_fully_connected(self, inputs, mask=None): feature_hidden = inputs original_feature_hidden = inputs # flatten inputs if len(original_feature_hidden.shape) > 2: feature_hidden = torch.reshape(feature_hidden, (-1, list(feature_hidden.shape)[-1])) # pass it through fc_stack feature_hidden = self.fc_stack(feature_hidden, mask=mask) feature_hidden_size = feature_hidden.shape[-1] # reshape back to original first and second dimension if len(original_feature_hidden.shape) > 2: sequence_length = original_feature_hidden.shape[1] feature_hidden = torch.reshape(feature_hidden, (-1, sequence_length, feature_hidden_size)) return feature_hidden def prepare_decoder_inputs( self, combiner_hidden: Tensor, other_output_features: dict[str, Tensor], mask=None ) -> Tensor: """Takes the combiner output and the outputs of other outputs features computed so far and performs: - reduction of combiner outputs (if needed) - concatenating the outputs of dependent features (if needed) - output_specific fully connected layers (if needed) Args: combiner_hidden: hidden state of the combiner other_output_features: output tensors from other output features """ # ================ Reduce Inputs ================ feature_hidden = combiner_hidden if self.reduce_input is not None and len(combiner_hidden.shape) > 2: feature_hidden = self.reduce_sequence_input(combiner_hidden) # ================ Concat Dependencies ================ if self.dependencies: feature_hidden = output_feature_utils.concat_dependencies( self.column, self.dependencies, self.dependency_reducers, feature_hidden, other_output_features ) # ================ Output-wise Fully Connected ================ feature_hidden = self.output_specific_fully_connected(feature_hidden, mask=mask) return feature_hidden class PassthroughPreprocModule(torch.nn.Module): """Combines preprocessing and encoding into a single module for TorchScript inference. For encoder outputs that were cached during preprocessing, the encoder is simply the identity function in the ECD module. As such, we need this module to apply the encoding that would normally be done during preprocessing for realtime inference. """ def __init__(self, preproc: torch.nn.Module, encoder: torch.nn.Module): self.preproc = preproc self.encoder = encoder def forward(self, v: TorchscriptPreprocessingInput) -> torch.Tensor: preproc_v = self.preproc(v) return self.encoder(preproc_v) def create_passthrough_input_feature(feature: InputFeature, config: BaseFeatureConfig) -> InputFeature: """Creates a shim input feature that acts as a transparent identifiy function on the input data. Used when the feature's encoder embeddings were cached in preprocessing. This way, we don't need to make any changes to the underlying interface in such cases other than to swap the feature that would normally do the encoding with this one. """ class _InputPassthroughFeature(InputFeature): def __init__(self, config: BaseFeatureConfig): super().__init__(config) def forward(self, inputs, mask=None): assert isinstance(inputs, torch.Tensor) return {ENCODER_OUTPUT: inputs} @property def input_dtype(self): # Doesn't matter as combiner will need to cast them to float32 anyway return torch.float32 @property def input_shape(self): return feature.encoder_obj.output_shape @property def output_shape(self) -> torch.Size: return feature.encoder_obj.output_shape @staticmethod def update_config_with_metadata(feature_config, feature_metadata, *args, **kwargs): return feature.update_config_with_metadata(feature_config, feature_metadata, *args, **kwargs) @staticmethod def get_schema_cls(): return feature.get_schema_cls() @staticmethod def create_preproc_module(metadata: TrainingSetMetadataDict) -> torch.nn.Module: return PassthroughPreprocModule(feature.create_preproc_module(metadata), feature) @staticmethod def type(): return feature.type() def unskip(self) -> InputFeature: return feature @property def encoder_obj(self) -> torch.nn.Module: return feature.encoder_obj return _InputPassthroughFeature(config) ================================================ FILE: ludwig/features/binary_feature.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import logging import numpy as np import torch from ludwig.constants import BINARY, COLUMN, HIDDEN, LOGITS, NAME, PREDICTIONS, PROBABILITIES, PROBABILITY, PROC_COLUMN from ludwig.error import InputDataError from ludwig.features.base_feature import BaseFeatureMixin, InputFeature, OutputFeature, PredictModule from ludwig.schema.features.binary_feature import BinaryInputFeatureConfig, BinaryOutputFeatureConfig from ludwig.types import ( FeatureConfigDict, FeatureMetadataDict, FeaturePostProcessingOutputDict, ModelConfigDict, PreprocessingConfigDict, TrainingSetMetadataDict, ) from ludwig.utils import calibration, output_feature_utils, strings_utils from ludwig.utils.eval_utils import ( average_precision_score, ConfusionMatrix, precision_recall_curve, roc_auc_score, roc_curve, ) from ludwig.utils.types import DataFrame, TorchscriptPreprocessingInput logger = logging.getLogger(__name__) class _BinaryPreprocessing(torch.nn.Module): def __init__(self, metadata: TrainingSetMetadataDict): super().__init__() str2bool = metadata.get("str2bool") self.str2bool = str2bool or {v: True for v in strings_utils.BOOL_TRUE_STRS} self.should_lower = str2bool is None def forward(self, v: TorchscriptPreprocessingInput) -> torch.Tensor: if torch.jit.isinstance(v, list[tuple[torch.Tensor, int]]): raise ValueError(f"Unsupported input: {v}") if torch.jit.isinstance(v, list[torch.Tensor]): v = torch.stack(v) if torch.jit.isinstance(v, torch.Tensor): return v.to(dtype=torch.float32) v = [s.strip() for s in v] if self.should_lower: v = [s.lower() for s in v] indices = [self.str2bool.get(s, False) for s in v] return torch.tensor(indices, dtype=torch.float32) class _BinaryPostprocessing(torch.nn.Module): def __init__(self, metadata: TrainingSetMetadataDict): super().__init__() bool2str = metadata.get("bool2str") self.bool2str = {i: v for i, v in enumerate(bool2str)} if bool2str is not None else None self.predictions_key = PREDICTIONS self.probabilities_key = PROBABILITIES def forward(self, preds: dict[str, torch.Tensor], feature_name: str) -> FeaturePostProcessingOutputDict: predictions = output_feature_utils.get_output_feature_tensor(preds, feature_name, self.predictions_key) probabilities = output_feature_utils.get_output_feature_tensor(preds, feature_name, self.probabilities_key) if self.bool2str is not None: predictions = predictions.to(dtype=torch.int32) predictions = [self.bool2str.get(pred, self.bool2str[0]) for pred in predictions] probabilities = torch.stack([1 - probabilities, probabilities], dim=-1) return { self.predictions_key: predictions, self.probabilities_key: probabilities, } class _BinaryPredict(PredictModule): def __init__(self, threshold, calibration_module=None): super().__init__() self.threshold = threshold self.calibration_module = calibration_module def forward(self, inputs: dict[str, torch.Tensor], feature_name: str) -> dict[str, torch.Tensor]: logits = output_feature_utils.get_output_feature_tensor(inputs, feature_name, self.logits_key) if self.calibration_module is not None: probabilities = self.calibration_module(logits) else: probabilities = torch.sigmoid(logits) predictions = probabilities >= self.threshold return { self.probabilities_key: probabilities, self.predictions_key: predictions, self.logits_key: logits, } class BinaryFeatureMixin(BaseFeatureMixin): @staticmethod def type(): return BINARY @staticmethod def cast_column(column, backend): """Cast column of dtype object to bool. Unchecked casting to boolean when given a column of dtype object converts all non-empty cells to True. We check the values of the column directly and manually determine the best dtype to use. """ values = backend.df_engine.compute(column.drop_duplicates()) if strings_utils.values_are_pandas_numbers(values): # If numbers, convert to float so it can be converted to bool column = column.astype(float).astype(bool) elif strings_utils.values_are_pandas_bools(values): # If booleans, manually assign boolean values column = backend.df_engine.map_objects( column, lambda x: x.lower() in strings_utils.PANDAS_TRUE_STRS ).astype(bool) else: # If neither numbers or booleans, they are strings (objects) column = column.astype(object) return column @staticmethod def get_feature_meta( config: ModelConfigDict, column: DataFrame, preprocessing_parameters: PreprocessingConfigDict, backend, is_input_feature: bool, ) -> FeatureMetadataDict: if column.dtype != object: return {} distinct_values = backend.df_engine.compute(column.drop_duplicates()) if len(distinct_values) > 2: raise InputDataError( column.name, BINARY, f"expects 2 distinct values, found {distinct_values.values.tolist()}" ) if preprocessing_parameters["fallback_true_label"]: fallback_true_label = preprocessing_parameters["fallback_true_label"] else: fallback_true_label = sorted(distinct_values)[0] preprocessing_parameters["fallback_true_label"] = fallback_true_label try: str2bool = {v: strings_utils.str2bool(v) for v in distinct_values} except Exception as e: logger.warning( f"Binary feature {column.name} has at least 1 unconventional boolean value: {e}. " f"We will now interpret {fallback_true_label} as 1 and the other values as 0. " f"If this is incorrect, please use the category feature type or " f"manually specify the true value with `preprocessing.fallback_true_label`." ) str2bool = {v: strings_utils.str2bool(v, fallback_true_label) for v in distinct_values} bool2str = [k for k, v in sorted(str2bool.items(), key=lambda item: item[1])] return {"str2bool": str2bool, "bool2str": bool2str, "fallback_true_label": fallback_true_label} @staticmethod def add_feature_data( feature_config: FeatureConfigDict, input_df: DataFrame, proc_df: dict[str, DataFrame], metadata: TrainingSetMetadataDict, preprocessing_parameters: PreprocessingConfigDict, backend, skip_save_processed_input: bool, ) -> None: column = input_df[feature_config[COLUMN]] if column.dtype == object: metadata = metadata[feature_config[NAME]] if "str2bool" in metadata: column = backend.df_engine.map_objects(column, lambda x: metadata["str2bool"][str(x)]) else: # No predefined mapping from string to bool, so compute it directly column = backend.df_engine.map_objects(column, strings_utils.str2bool) proc_df[feature_config[PROC_COLUMN]] = column.astype(np.bool_) return proc_df class BinaryInputFeature(BinaryFeatureMixin, InputFeature): def __init__(self, input_feature_config: BinaryInputFeatureConfig, encoder_obj=None, **kwargs): super().__init__(input_feature_config, **kwargs) input_feature_config.encoder.input_size = self.input_shape[-1] if encoder_obj: self.encoder_obj = encoder_obj else: self.encoder_obj = self.initialize_encoder(input_feature_config.encoder) def forward(self, inputs): assert isinstance(inputs, torch.Tensor) assert inputs.dtype in [torch.bool, torch.int64, torch.float32] assert len(inputs.shape) == 1 or (len(inputs.shape) == 2 and inputs.shape[1] == 1) if len(inputs.shape) == 1: inputs = inputs[:, None] # Inputs to the binary encoder could be of dtype torch.bool. Linear layer # weights are of dtype torch.float32. The inputs and the weights need to # be of the same dtype. if inputs.dtype == torch.bool: inputs = inputs.type(torch.float32) encoder_outputs = self.encoder_obj(inputs) return encoder_outputs @property def input_dtype(self): return torch.bool @property def input_shape(self) -> torch.Size: return torch.Size([1]) @property def output_shape(self) -> torch.Size: return self.encoder_obj.output_shape @staticmethod def update_config_with_metadata(feature_config, feature_metadata, *args, **kwargs): pass @staticmethod def get_schema_cls(): return BinaryInputFeatureConfig def create_sample_input(self, batch_size: int = 2): return torch.rand([batch_size]) > 0.5 @classmethod def get_preproc_input_dtype(cls, metadata: TrainingSetMetadataDict) -> str: return "string" if metadata.get("str2bool") else "int32" @staticmethod def create_preproc_module(metadata: TrainingSetMetadataDict) -> torch.nn.Module: return _BinaryPreprocessing(metadata) class BinaryOutputFeature(BinaryFeatureMixin, OutputFeature): def __init__( self, output_feature_config: BinaryOutputFeatureConfig | dict, output_features: dict[str, OutputFeature], **kwargs, ): self.threshold = output_feature_config.threshold super().__init__(output_feature_config, output_features, **kwargs) self.decoder_obj = self.initialize_decoder(output_feature_config.decoder) self._setup_loss() self._setup_metrics() def logits(self, inputs, **kwargs): hidden = inputs[HIDDEN] return self.decoder_obj(hidden) def create_calibration_module(self, feature: BinaryOutputFeatureConfig) -> torch.nn.Module: """Creates the appropriate calibration module based on the feature config. Today, only one type of calibration ("temperature_scaling") is available, but more options may be supported in the future. """ if feature.calibration: calibration_cls = calibration.get_calibration_cls(BINARY, "temperature_scaling") return calibration_cls(binary=True) return None def create_predict_module(self) -> PredictModule: # A lot of code assumes output features have a prediction module, but if we are using a passthrough # decoder then there is no threshold. threshold = getattr(self, "threshold", 0.5) return _BinaryPredict(threshold, calibration_module=self.calibration_module) def get_prediction_set(self): return {PREDICTIONS, PROBABILITIES, LOGITS} @classmethod def get_output_dtype(cls): return torch.bool @property def output_shape(self) -> torch.Size: return torch.Size([1]) @property def input_shape(self) -> torch.Size: return torch.Size([1]) @staticmethod def update_config_with_metadata(feature_config, feature_metadata, *args, **kwargs): pass @staticmethod def calculate_overall_stats(predictions, targets, train_set_metadata): overall_stats = {} confusion_matrix = ConfusionMatrix(targets, predictions[PREDICTIONS], labels=["False", "True"]) overall_stats["confusion_matrix"] = confusion_matrix.cm.tolist() overall_stats["overall_stats"] = confusion_matrix.stats() overall_stats["per_class_stats"] = confusion_matrix.per_class_stats() fpr, tpr, thresholds = roc_curve(targets, predictions[PROBABILITIES]) overall_stats["roc_curve"] = { "false_positive_rate": fpr.tolist(), "true_positive_rate": tpr.tolist(), } overall_stats["roc_auc_macro"] = roc_auc_score(targets, predictions[PROBABILITIES], average="macro") overall_stats["roc_auc_micro"] = roc_auc_score(targets, predictions[PROBABILITIES], average="micro") ps, rs, thresholds = precision_recall_curve(targets, predictions[PROBABILITIES]) overall_stats["precision_recall_curve"] = { "precisions": ps.tolist(), "recalls": rs.tolist(), } overall_stats["average_precision_macro"] = average_precision_score( targets, predictions[PROBABILITIES], average="macro" ) overall_stats["average_precision_micro"] = average_precision_score( targets, predictions[PROBABILITIES], average="micro" ) overall_stats["average_precision_samples"] = average_precision_score( targets, predictions[PROBABILITIES], average="samples" ) return overall_stats def postprocess_predictions( self, result, metadata, ): class_names = ["False", "True"] if "bool2str" in metadata: class_names = metadata["bool2str"] predictions_col = f"{self.feature_name}_{PREDICTIONS}" if predictions_col in result: if "bool2str" in metadata: result[predictions_col] = result[predictions_col].map( lambda pred: metadata["bool2str"][pred], ) probabilities_col = f"{self.feature_name}_{PROBABILITIES}" if probabilities_col in result: false_col = f"{probabilities_col}_{class_names[0]}" true_col = f"{probabilities_col}_{class_names[1]}" prob_col = f"{self.feature_name}_{PROBABILITY}" result = result.assign( **{ false_col: lambda x: 1 - x[probabilities_col], true_col: lambda x: x[probabilities_col], prob_col: np.where( result[probabilities_col] > 0.5, result[probabilities_col], 1 - result[probabilities_col] ), probabilities_col: result[probabilities_col].map(lambda x: [1 - x, x]), }, ) return result @staticmethod def get_schema_cls(): return BinaryOutputFeatureConfig @classmethod def get_postproc_output_dtype(cls, metadata: TrainingSetMetadataDict) -> str: return "string" if metadata.get("bool2str") else "int32" @staticmethod def create_postproc_module(metadata: TrainingSetMetadataDict) -> torch.nn.Module: return _BinaryPostprocessing(metadata) def metric_kwargs(self) -> dict: """Returns arguments that are used to instantiate an instance of each metric class.""" return {"task": "binary"} ================================================ FILE: ludwig/features/category_feature.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import logging from typing import Any import numpy as np import torch from ludwig.constants import ( CATEGORY, CATEGORY_DISTRIBUTION, COLUMN, HIDDEN, LOGITS, NAME, PREDICTIONS, PREPROCESSING, PROBABILITIES, PROBABILITY, PROC_COLUMN, PROJECTION_INPUT, ) from ludwig.error import InputDataError from ludwig.features.base_feature import BaseFeatureMixin, InputFeature, OutputFeature, PredictModule from ludwig.features.vector_feature import VectorFeatureMixin from ludwig.schema.features.category_feature import ( CategoryDistributionOutputFeatureConfig, CategoryInputFeatureConfig, CategoryOutputFeatureConfig, ) from ludwig.schema.features.loss.loss import CORNLossConfig from ludwig.types import ( FeatureMetadataDict, FeaturePostProcessingOutputDict, ModelConfigDict, PreprocessingConfigDict, TrainingSetMetadataDict, ) from ludwig.utils import calibration, output_feature_utils from ludwig.utils.eval_utils import ConfusionMatrix from ludwig.utils.math_utils import int_type, softmax from ludwig.utils.strings_utils import create_vocabulary_single_token, UNKNOWN_SYMBOL from ludwig.utils.types import TorchscriptPreprocessingInput logger = logging.getLogger(__name__) class _CategoryPreprocessing(torch.nn.Module): def __init__(self, metadata: TrainingSetMetadataDict): super().__init__() self.str2idx = metadata["str2idx"] if UNKNOWN_SYMBOL in self.str2idx: self.unk = self.str2idx[UNKNOWN_SYMBOL] else: # self.unk is set to 0 to comply with Torchscript type tracing and will # likely not be used during training, but potentially during inference self.unk = 0 def forward(self, v: TorchscriptPreprocessingInput) -> torch.Tensor: if not torch.jit.isinstance(v, list[str]): raise ValueError(f"Unsupported input: {v}") indices = [self.str2idx.get(s.strip(), self.unk) for s in v] return torch.tensor(indices, dtype=torch.int32) class _CategoryPostprocessing(torch.nn.Module): def __init__(self, metadata: TrainingSetMetadataDict): super().__init__() self.idx2str = {i: v for i, v in enumerate(metadata["idx2str"])} self.unk = UNKNOWN_SYMBOL self.predictions_key = PREDICTIONS self.probabilities_key = PROBABILITIES def forward(self, preds: dict[str, torch.Tensor], feature_name: str) -> FeaturePostProcessingOutputDict: predictions = output_feature_utils.get_output_feature_tensor(preds, feature_name, self.predictions_key) probabilities = output_feature_utils.get_output_feature_tensor(preds, feature_name, self.probabilities_key) inv_preds = [self.idx2str.get(pred, self.unk) for pred in predictions] return { self.predictions_key: inv_preds, self.probabilities_key: probabilities, } class _CategoryPredict(PredictModule): def __init__(self, calibration_module=None, use_cumulative_probs=False): super().__init__() self.calibration_module = calibration_module # Derive the label from the cumulative probability distribution of the ordered category logits. # Taken from CORN loss implementation: # https://github.com/Raschka-research-group/coral-pytorch/blob/main/coral_pytorch/dataset.py#L123 self.use_cumulative_probs = use_cumulative_probs def forward(self, inputs: dict[str, torch.Tensor], feature_name: str) -> dict[str, torch.Tensor]: logits = output_feature_utils.get_output_feature_tensor(inputs, feature_name, self.logits_key) if self.use_cumulative_probs: if self.calibration_module is not None: probabilities = self.calibration_module(logits) else: probabilities = torch.sigmoid(logits) probabilities = torch.cumprod(probabilities, dim=1) predict_levels = probabilities > 0.5 predictions = torch.sum(predict_levels, dim=1) else: if self.calibration_module is not None: probabilities = self.calibration_module(logits) else: probabilities = torch.softmax(logits, -1) predictions = torch.argmax(probabilities, -1) predictions = predictions.long() # EXPECTED SHAPE OF RETURNED TENSORS # predictions: [batch_size] # probabilities: [batch_size, num_classes] # logits: [batch_size, num_classes] return {self.predictions_key: predictions, self.probabilities_key: probabilities, self.logits_key: logits} class CategoryFeatureMixin(BaseFeatureMixin): @staticmethod def type(): return CATEGORY @staticmethod def cast_column(column, backend): return column.astype(str) @staticmethod def get_feature_meta( config: ModelConfigDict, column, preprocessing_parameters: PreprocessingConfigDict, backend, is_input_feature: bool, ) -> FeatureMetadataDict: idx2str, str2idx, str2freq = create_vocabulary_single_token( column, num_most_frequent=preprocessing_parameters["most_common"], processor=backend.df_engine, ) if "vocab" in preprocessing_parameters and preprocessing_parameters["vocab"]: # Check that vocab is non-empty # If vocab was explciitly provided, override the inferred vocab idx2str = preprocessing_parameters["vocab"] str2idx = {s: i for i, s in enumerate(idx2str)} str2freq = {k: str2freq.get(k, 0) for k in idx2str} if "fallback_label" in preprocessing_parameters: # This is a category output feature for LLMs # Check if the fallback label is in the vocab, if not add it. if preprocessing_parameters["fallback_label"] not in str2idx: str2idx[preprocessing_parameters["fallback_label"]] = len(str2idx) idx2str.append(preprocessing_parameters["fallback_label"]) str2freq[preprocessing_parameters["fallback_label"]] = 0 vocab_size = len(str2idx) if not is_input_feature and vocab_size <= 1: # Category output feature with vocab size 1 raise InputDataError( column.name, CATEGORY, f""" At least 2 distinct values are required for category output features, but column only contains {str(idx2str)}. """, ) if vocab_size <= 1: # Category input feature with vocab size 1 logger.info( f"Input feature '{column.name}' contains only 1 distinct value {str(idx2str)}. This is not useful" " for machine learning models because this feature has zero variance. Consider removing this feature" " from your input features." ) return {"idx2str": idx2str, "str2idx": str2idx, "str2freq": str2freq, "vocab_size": vocab_size} @staticmethod def feature_data(backend, column, metadata): def __replace_token_with_idx(value: Any, metadata: TrainingSetMetadataDict, fallback_symbol_idx: int) -> int: stripped_value = value.strip() if stripped_value in metadata["str2idx"]: return metadata["str2idx"][stripped_value] logger.warning(f""" Encountered unknown symbol '{stripped_value}' for '{column.name}' during category feature preprocessing. This should never happen during training. If this happens during inference, this may be an indication that not all possible symbols were present in your training set. Consider re-splitting your data to ensure full representation, or setting preprocessing.most_common parameter to be smaller than this feature's total vocabulary size, {len(metadata["str2idx"])}, which will ensure that the model is architected and trained with an UNKNOWN symbol. Returning the index for the most frequent symbol, {metadata["idx2str"][fallback_symbol_idx]}, instead. """) return fallback_symbol_idx # No unknown symbol in Metadata from preprocessing means that all values # should be mappable to vocabulary if UNKNOWN_SYMBOL not in metadata["str2idx"]: # If no unknown is defined, just use the most popular token's index as the fallback index most_popular_token = max(metadata["str2freq"], key=metadata["str2freq"].get) most_popular_token_idx = metadata["str2idx"].get(most_popular_token) return backend.df_engine.map_objects( column, lambda x: __replace_token_with_idx(x, metadata, most_popular_token_idx), meta=(column.name, int), ).astype(int_type(metadata["vocab_size"])) else: return backend.df_engine.map_objects( column, lambda x: ( metadata["str2idx"][x.strip()] if x.strip() in metadata["str2idx"] else metadata["str2idx"][UNKNOWN_SYMBOL] ), meta=(column.name, int), ).astype(int_type(metadata["vocab_size"])) @staticmethod def add_feature_data( feature_config, input_df, proc_df, metadata, preprocessing_parameters: PreprocessingConfigDict, backend, skip_save_processed_input, ): proc_df[feature_config[PROC_COLUMN]] = CategoryFeatureMixin.feature_data( backend, input_df[feature_config[COLUMN]], metadata[feature_config[NAME]], ) return proc_df class CategoryDistributionFeatureMixin(VectorFeatureMixin): @staticmethod def type(): return CATEGORY_DISTRIBUTION @staticmethod def get_feature_meta( config: ModelConfigDict, column, preprocessing_parameters: PreprocessingConfigDict, backend, is_input_feature: bool, ) -> FeatureMetadataDict: idx2str = preprocessing_parameters["vocab"] str2idx = {s: i for i, s in enumerate(idx2str)} return { "preprocessing": preprocessing_parameters, "idx2str": idx2str, "str2idx": str2idx, "vocab_size": len(idx2str), } class CategoryInputFeature(CategoryFeatureMixin, InputFeature): def __init__(self, input_feature_config: CategoryInputFeatureConfig, encoder_obj=None, **kwargs): super().__init__(input_feature_config, **kwargs) if encoder_obj: self.encoder_obj = encoder_obj else: self.encoder_obj = self.initialize_encoder(input_feature_config.encoder) def forward(self, inputs): assert isinstance(inputs, torch.Tensor) assert inputs.dtype in (torch.int8, torch.int16, torch.int32, torch.int64) assert len(inputs.shape) == 1 or (len(inputs.shape) == 2 and inputs.shape[1] == 1) inputs = inputs.reshape(-1, 1) if inputs.dtype == torch.int8 or inputs.dtype == torch.int16: inputs = inputs.type(torch.int) encoder_output = self.encoder_obj(inputs) return encoder_output @property def input_dtype(self): return torch.int32 @property def input_shape(self) -> torch.Size: return torch.Size([1]) @property def output_shape(self) -> torch.Size: return torch.Size(self.encoder_obj.output_shape) @staticmethod def update_config_with_metadata(feature_config, feature_metadata, *args, **kwargs): feature_config.encoder.vocab = feature_metadata["idx2str"] feature_config.encoder.skip = feature_metadata[PREPROCESSING].get("cache_encoder_embeddings", False) @staticmethod def get_schema_cls(): return CategoryInputFeatureConfig @staticmethod def create_preproc_module(metadata: TrainingSetMetadataDict) -> torch.nn.Module: return _CategoryPreprocessing(metadata) class CategoryOutputFeature(CategoryFeatureMixin, OutputFeature): def __init__( self, output_feature_config: CategoryOutputFeatureConfig | dict, output_features: dict[str, OutputFeature], **kwargs, ): self.num_classes = output_feature_config.num_classes self.top_k = output_feature_config.top_k # TODO(travis): make this more general to other cumulative loss functions self.use_cumulative_probs = isinstance(output_feature_config.loss, CORNLossConfig) super().__init__(output_feature_config, output_features, **kwargs) if hasattr(output_feature_config.decoder, "num_classes"): output_feature_config.decoder.num_classes = output_feature_config.num_classes self.decoder_obj = self.initialize_decoder(output_feature_config.decoder) self._setup_loss() self._setup_metrics() def logits(self, inputs, **kwargs): # hidden hidden = inputs[HIDDEN] # EXPECTED SHAPES FOR RETURNED TENSORS # logits: shape [batch_size, num_classes] # hidden: shape [batch_size, size of final fully connected layer] return {LOGITS: self.decoder_obj(hidden), PROJECTION_INPUT: hidden} def create_calibration_module(self, feature: CategoryOutputFeatureConfig) -> torch.nn.Module: """Creates the appropriate calibration module based on the feature config. Today, only one type of calibration ("temperature_scaling") is available, but more options may be supported in the future. """ if feature.calibration: calibration_cls = calibration.get_calibration_cls(CATEGORY, "temperature_scaling") return calibration_cls(num_classes=self.num_classes) return None def create_predict_module(self) -> PredictModule: return _CategoryPredict( calibration_module=self.calibration_module, use_cumulative_probs=self.use_cumulative_probs ) def get_prediction_set(self): return {PREDICTIONS, PROBABILITIES, LOGITS} @property def input_shape(self) -> torch.Size: return torch.Size([self.input_size]) @classmethod def get_output_dtype(cls): return torch.int64 @property def output_shape(self) -> torch.Size: return torch.Size([1]) def metric_kwargs(self): return {"top_k": self.top_k, "num_classes": self.num_classes} @staticmethod def update_config_with_metadata(feature_config, feature_metadata, *args, **kwargs): feature_config.num_classes = feature_metadata["vocab_size"] feature_config.top_k = min(feature_config.num_classes, feature_config.top_k) # If labels are provided, then this is a classification task for LLMs if hasattr(feature_config.preprocessing, "vocab"): # Enrich the feature config's decoder with str2idx feature_config.decoder.str2idx = feature_metadata["str2idx"] if isinstance(feature_config.loss.class_weights, (list, tuple)): if len(feature_config.loss.class_weights) != feature_config.num_classes: raise ValueError( f"The length of class_weights ({len(feature_config.loss.class_weights)}) is not compatible with " f"the number of classes ({feature_config.num_classes}) for feature {feature_config.column}. " "Check the metadata JSON file to see the classes " "and their order and consider there needs to be a weight " "for the class too." ) if isinstance(feature_config.loss.class_weights, dict): if feature_metadata["str2idx"].keys() != feature_config.loss.class_weights.keys(): raise ValueError( f"The class_weights keys ({feature_config.loss.class_weights.keys()}) are not compatible with " f'the classes ({feature_metadata["str2idx"].keys()}) of feature {feature_config.column}. ' "Check the metadata JSON file to see the classes " "and consider there needs to be a weight " "for the class too." ) else: class_weights = feature_config.loss.class_weights idx2str = feature_metadata["idx2str"] class_weights_list = [class_weights[s] for s in idx2str] feature_config.loss.class_weights = class_weights_list if feature_config.loss.class_similarities_temperature > 0: if feature_config.loss.class_similarities is not None: similarities = feature_config.loss.class_similarities temperature = feature_config.loss.class_similarities_temperature curr_row = 0 first_row_length = 0 is_first_row = True for row in similarities: if is_first_row: first_row_length = len(row) is_first_row = False curr_row += 1 else: curr_row_length = len(row) if curr_row_length != first_row_length: raise ValueError( "The length of row {} of the class_similarities " "of {} is {}, different from the length of " "the first row {}. All rows must have " "the same length.".format( curr_row, feature_config.column, curr_row_length, first_row_length ) ) else: curr_row += 1 all_rows_length = first_row_length if all_rows_length != len(similarities): raise ValueError( "The class_similarities matrix of {} has " "{} rows and {} columns, " "their number must be identical.".format( feature_config.column, len(similarities), all_rows_length ) ) if all_rows_length != feature_config.num_classes: raise ValueError( f"The size of the class_similarities matrix of {feature_config.column} is " f"{all_rows_length}, different from the number of classes ({feature_config.num_classes}). " "Check the metadata JSON file to see the classes " "and their order and " "consider class too." ) similarities = np.array(similarities, dtype=np.float32) for i in range(len(similarities)): similarities[i, :] = softmax(similarities[i, :], temperature=temperature) feature_config.loss.class_similarities = similarities else: raise ValueError( "class_similarities_temperature > 0, " "but no class_similarities are provided " "for feature {}".format(feature_config.column) ) @staticmethod def calculate_overall_stats(predictions, targets, train_set_metadata): overall_stats = {} confusion_matrix = ConfusionMatrix(targets, predictions[PREDICTIONS], labels=train_set_metadata["idx2str"]) overall_stats["confusion_matrix"] = confusion_matrix.cm.tolist() overall_stats["overall_stats"] = confusion_matrix.stats() overall_stats["per_class_stats"] = confusion_matrix.per_class_stats() return overall_stats def postprocess_predictions( self, predictions, metadata, ): predictions_col = f"{self.feature_name}_{PREDICTIONS}" if predictions_col in predictions: if "idx2str" in metadata: predictions[predictions_col] = predictions[predictions_col].map(lambda pred: metadata["idx2str"][pred]) probabilities_col = f"{self.feature_name}_{PROBABILITIES}" if probabilities_col in predictions: prob_col = f"{self.feature_name}_{PROBABILITY}" predictions[prob_col] = predictions[probabilities_col].map(max) predictions[probabilities_col] = predictions[probabilities_col].map(lambda pred: pred.tolist()) if "idx2str" in metadata: for i, label in enumerate(metadata["idx2str"]): key = f"{probabilities_col}_{label}" # Use default param to force a capture before the loop completes, see: # https://stackoverflow.com/questions/2295290/what-do-lambda-function-closures-capture predictions[key] = predictions[probabilities_col].map( lambda prob, i=i: prob[i], ) top_k_col = f"{self.feature_name}_predictions_top_k" if top_k_col in predictions: if "idx2str" in metadata: predictions[top_k_col] = predictions[top_k_col].map( lambda pred_top_k: [metadata["idx2str"][pred] for pred in pred_top_k] ) return predictions @staticmethod def get_schema_cls(): return CategoryOutputFeatureConfig @staticmethod def create_postproc_module(metadata: TrainingSetMetadataDict) -> torch.nn.Module: return _CategoryPostprocessing(metadata) class CategoryDistributionOutputFeature(CategoryDistributionFeatureMixin, CategoryOutputFeature): @property def input_shape(self) -> torch.Size: return torch.Size([self.input_size]) @classmethod def get_output_dtype(cls): return torch.float32 @property def output_shape(self) -> torch.Size: return torch.Size([self.num_classes]) @staticmethod def get_schema_cls(): return CategoryDistributionOutputFeatureConfig ================================================ FILE: ludwig/features/date_feature.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import logging from datetime import date, datetime import numpy as np import torch from ludwig.constants import COLUMN, DATE, PROC_COLUMN from ludwig.features.base_feature import BaseFeatureMixin, InputFeature from ludwig.schema.features.date_feature import DateInputFeatureConfig from ludwig.types import ( FeatureConfigDict, FeatureMetadataDict, ModelConfigDict, PreprocessingConfigDict, TrainingSetMetadataDict, ) from ludwig.utils.date_utils import create_vector_from_datetime_obj, parse_datetime from ludwig.utils.types import DataFrame, TorchscriptPreprocessingInput logger = logging.getLogger(__name__) DATE_VECTOR_LENGTH = 9 class _DatePreprocessing(torch.nn.Module): def __init__(self, metadata: TrainingSetMetadataDict): super().__init__() def forward(self, v: TorchscriptPreprocessingInput) -> torch.Tensor: if torch.jit.isinstance(v, list[torch.Tensor]): v = torch.stack(v) if torch.jit.isinstance(v, torch.Tensor): return v.to(dtype=torch.int) else: raise ValueError(f"Unsupported input: {v}") class DateFeatureMixin(BaseFeatureMixin): @staticmethod def type(): return DATE @staticmethod def cast_column(column, backend): return column @staticmethod def get_feature_meta( config: ModelConfigDict, column, preprocessing_parameters: PreprocessingConfigDict, backend, is_input_feature: bool, ) -> FeatureMetadataDict: return {"preprocessing": preprocessing_parameters} @staticmethod def date_to_list(date_value, datetime_format, preprocessing_parameters): try: if isinstance(date_value, datetime): datetime_obj = date_value elif isinstance(date_value, date): datetime_obj = datetime.combine(date=date_value, time=datetime.min.time()) elif isinstance(date_value, str) and datetime_format is not None: try: datetime_obj = datetime.strptime(date_value, datetime_format) except ValueError: datetime_obj = parse_datetime(date_value) else: datetime_obj = parse_datetime(date_value) except Exception as e: logger.error( f"Error parsing date: '{date_value}' with error '{e}' " "Please provide a datetime format that parses it " "in the preprocessing section of the date feature " "in the config. " "The preprocessing fill in value will be used." "For more details: " "https://ludwig-ai.github.io/ludwig-docs/latest/configuration/features/date_features/#date-features-preprocessing" # noqa ) fill_value = preprocessing_parameters["fill_value"] if fill_value != "": datetime_obj = parse_datetime(fill_value) else: datetime_obj = datetime.now() return create_vector_from_datetime_obj(datetime_obj) @staticmethod def add_feature_data( feature_config: FeatureConfigDict, input_df: DataFrame, proc_df: dict[str, DataFrame], metadata: TrainingSetMetadataDict, preprocessing_parameters: PreprocessingConfigDict, backend, # Union[Backend, str] skip_save_processed_input: bool, ) -> None: datetime_format = preprocessing_parameters["datetime_format"] proc_df[feature_config[PROC_COLUMN]] = backend.df_engine.map_objects( input_df[feature_config[COLUMN]], lambda x: np.array( DateFeatureMixin.date_to_list(x, datetime_format, preprocessing_parameters), dtype=np.int32 ), ) return proc_df class DateInputFeature(DateFeatureMixin, InputFeature): def __init__(self, input_feature_config: DateInputFeatureConfig, encoder_obj=None, **kwargs): super().__init__(input_feature_config, **kwargs) if encoder_obj: self.encoder_obj = encoder_obj else: self.encoder_obj = self.initialize_encoder(input_feature_config.encoder) def forward(self, inputs): assert isinstance(inputs, torch.Tensor), type(inputs) assert inputs.dtype in [torch.int16, torch.int32, torch.int64, torch.float32], inputs.dtype inputs_encoded = self.encoder_obj(inputs) return inputs_encoded @property def input_dtype(self): return torch.int32 @property def input_shape(self) -> torch.Size: return torch.Size([DATE_VECTOR_LENGTH]) @property def output_shape(self) -> torch.Size: return self.encoder_obj.output_shape @staticmethod def update_config_with_metadata(feature_config, feature_metadata, *args, **kwargs): pass def create_sample_input(self, batch_size: int = 2): date = [2013, 2, 26, 1, 57, 0, 0, 0, 0] return torch.Tensor([date for _ in range(batch_size)]).type(torch.int32) @staticmethod def get_schema_cls(): return DateInputFeatureConfig @staticmethod def create_preproc_module(metadata: TrainingSetMetadataDict) -> torch.nn.Module: return _DatePreprocessing(metadata) ================================================ FILE: ludwig/features/feature_registries.py ================================================ # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== from typing import Any, TYPE_CHECKING from ludwig.api_annotations import DeveloperAPI from ludwig.constants import ( AUDIO, BAG, BINARY, CATEGORY, CATEGORY_DISTRIBUTION, DATE, H3, IMAGE, NUMBER, SEQUENCE, SET, TEXT, TIMESERIES, VECTOR, ) from ludwig.features.audio_feature import AudioFeatureMixin, AudioInputFeature from ludwig.features.bag_feature import BagFeatureMixin, BagInputFeature from ludwig.features.binary_feature import BinaryFeatureMixin, BinaryInputFeature, BinaryOutputFeature from ludwig.features.category_feature import ( CategoryDistributionFeatureMixin, CategoryDistributionOutputFeature, CategoryFeatureMixin, CategoryInputFeature, CategoryOutputFeature, ) from ludwig.features.date_feature import DateFeatureMixin, DateInputFeature from ludwig.features.h3_feature import H3FeatureMixin, H3InputFeature from ludwig.features.image_feature import ImageFeatureMixin, ImageInputFeature, ImageOutputFeature from ludwig.features.number_feature import NumberFeatureMixin, NumberInputFeature, NumberOutputFeature from ludwig.features.sequence_feature import SequenceFeatureMixin, SequenceInputFeature, SequenceOutputFeature from ludwig.features.set_feature import SetFeatureMixin, SetInputFeature, SetOutputFeature from ludwig.features.text_feature import TextFeatureMixin, TextInputFeature, TextOutputFeature from ludwig.features.timeseries_feature import TimeseriesFeatureMixin, TimeseriesInputFeature, TimeseriesOutputFeature from ludwig.features.vector_feature import VectorFeatureMixin, VectorInputFeature, VectorOutputFeature from ludwig.utils.misc_utils import get_from_registry if TYPE_CHECKING: from ludwig.models.base import BaseModel from ludwig.schema.model_types.base import ModelConfig @DeveloperAPI def get_base_type_registry() -> dict: return { TEXT: TextFeatureMixin, CATEGORY: CategoryFeatureMixin, SET: SetFeatureMixin, BAG: BagFeatureMixin, BINARY: BinaryFeatureMixin, NUMBER: NumberFeatureMixin, SEQUENCE: SequenceFeatureMixin, TIMESERIES: TimeseriesFeatureMixin, IMAGE: ImageFeatureMixin, AUDIO: AudioFeatureMixin, H3: H3FeatureMixin, DATE: DateFeatureMixin, VECTOR: VectorFeatureMixin, CATEGORY_DISTRIBUTION: CategoryDistributionFeatureMixin, } @DeveloperAPI def get_input_type_registry() -> dict: return { TEXT: TextInputFeature, NUMBER: NumberInputFeature, BINARY: BinaryInputFeature, CATEGORY: CategoryInputFeature, SET: SetInputFeature, SEQUENCE: SequenceInputFeature, IMAGE: ImageInputFeature, AUDIO: AudioInputFeature, TIMESERIES: TimeseriesInputFeature, BAG: BagInputFeature, H3: H3InputFeature, DATE: DateInputFeature, VECTOR: VectorInputFeature, } @DeveloperAPI def get_output_type_registry() -> dict: return { CATEGORY: CategoryOutputFeature, BINARY: BinaryOutputFeature, NUMBER: NumberOutputFeature, SEQUENCE: SequenceOutputFeature, SET: SetOutputFeature, TEXT: TextOutputFeature, TIMESERIES: TimeseriesOutputFeature, VECTOR: VectorOutputFeature, CATEGORY_DISTRIBUTION: CategoryDistributionOutputFeature, IMAGE: ImageOutputFeature, } def update_config_with_metadata(config_obj: "ModelConfig", training_set_metadata: dict[str, Any]): # populate input features fields depending on data for input_feature in config_obj.input_features: feature = get_from_registry(input_feature.type, get_input_type_registry()) feature.update_config_with_metadata(input_feature, training_set_metadata[input_feature.name]) # populate output features fields depending on data for output_feature in config_obj.output_features: feature = get_from_registry(output_feature.type, get_output_type_registry()) feature.update_config_with_metadata(output_feature, training_set_metadata[output_feature.name]) def update_config_with_model(config_obj: "ModelConfig", model: "BaseModel"): """Updates the config with the final input feature params given a model. This function should only be called to update the config after the model is initialized. Currently only implemented for input features because it is only relevant for HuggingFace text encoders. HuggingFace text encoders only know their final config after class initialization. """ for input_feature in config_obj.input_features: model_input_feature = model.input_features.get(input_feature.name) model_input_feature.update_config_after_module_init(input_feature) ================================================ FILE: ludwig/features/feature_utils.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import re import numpy as np import torch from ludwig.constants import NAME, PREPROCESSING, SEQUENCE, TEXT, TIMESERIES, TYPE from ludwig.utils.data_utils import hash_dict from ludwig.utils.strings_utils import get_tokenizer_from_registry, UNKNOWN_SYMBOL SEQUENCE_TYPES = {SEQUENCE, TEXT, TIMESERIES} FEATURE_NAME_SUFFIX = "__ludwig" FEATURE_NAME_SUFFIX_LENGTH = len(FEATURE_NAME_SUFFIX) def should_regularize(regularize_layers): regularize = False if isinstance(regularize_layers, bool) and regularize_layers: regularize = True elif isinstance(regularize_layers, (list, tuple)) and regularize_layers and regularize_layers[-1]: regularize = True return regularize def set_str_to_idx(set_string, feature_dict, tokenizer_name): try: tokenizer = get_tokenizer_from_registry(tokenizer_name)() except ValueError: raise Exception(f"Tokenizer {tokenizer_name} not supported") out = [feature_dict.get(item, feature_dict[UNKNOWN_SYMBOL]) for item in tokenizer(set_string)] return np.array(out, dtype=np.int32) def compute_token_probabilities( probabilities: list | tuple | np.ndarray, ) -> np.ndarray: """Gets the maximum probability per timestep. Args: probabilities: An iterable of iterables or np.ndarray with shape (sequence_length, num_classes) where each inner iterable or np.ndarray is the probability distribution for a single timestep. Returns: An np.ndarray with shape (sequence_length,) containing the maximum probability for each timestep. """ if isinstance(probabilities, (list, tuple)): if not hasattr(probabilities[0], "__len__"): raise ValueError( "Received token probabilities as a flat 1D list. Expected list of list of probabilities " "(sequence_length, vocab_size)." ) max_probs = [] for timestep_probs in probabilities: max_probs.append(np.max(timestep_probs)) max_probs = np.array(max_probs) elif isinstance(probabilities, np.ndarray): if len(probabilities.shape) != 2: raise ValueError( f"Received token probabilities with non 2D shape: {probabilities.shape}. Expected shape: " "(sequence_length, vocab_size)." ) max_probs = np.max(probabilities, axis=-1) else: raise ValueError(f"probabilities type must be in [list, tuple, np.ndarray]. Got {type(probabilities)}") return max_probs def compute_sequence_probability( sequence_probabilities: np.ndarray, max_sequence_length: int | None = None, return_log_prob: bool = True, ) -> float: """Computes the sequence level probability. Args: sequence_probabilities: An iterable of iterables or np.ndarray with shape (sequence_length,) max_sequence_length: The maximum sequence length to use. If None, uses the first dim of `sequence_probabilities` return_log_prob: Whether to return the log probability. Defaults to True. """ if max_sequence_length is None: max_sequence_length = sequence_probabilities.shape[0] sequence_probabilities = sequence_probabilities[:max_sequence_length] if return_log_prob: return np.sum(np.log(np.clip(sequence_probabilities, 1e-10, 1.0))) else: return np.prod(sequence_probabilities) def sanitize(name): """Replaces invalid id characters.""" return re.sub("\\W|^(?=\\d)", "_", name) def compute_feature_hash(feature: dict) -> str: """This function computes a hash for each feature based on the preprocessing dictionary associated with each feature, as well as the feature's type. Args: feature: Feature dictionary Returns: Feature hash name """ feature_data = dict( preprocessing=feature.get(PREPROCESSING, {}), type=feature[TYPE], ) return sanitize(feature[NAME]) + "_" + hash_dict(feature_data).decode("ascii") def get_input_size_with_dependencies( combiner_output_size: int, dependencies: list[str], other_output_features # Dict[str, "OutputFeature"] ): """Returns the input size for the first layer of this output feature's FC stack, accounting for dependencies on other output features. In the forward pass, the hidden states of any dependent output features get concatenated with the combiner's output. If this output feature depends on other output features, then the input size for this feature's FCStack is the sum of the output sizes of other output features + the combiner's output size. """ input_size_with_dependencies = combiner_output_size for feature_name in dependencies: if other_output_features[feature_name].fc_stack.num_layers: input_size_with_dependencies += other_output_features[feature_name].fc_stack.output_shape[-1] else: # 0-layer FCStack. Use the output feature's input size. input_size_with_dependencies += other_output_features[feature_name].input_size return input_size_with_dependencies def get_module_dict_key_from_name(name: str, feature_name_suffix: str = FEATURE_NAME_SUFFIX) -> str: """Returns a key that's guaranteed to be compatible with torch.""" key = name.replace(".", "__ludwig_punct_period__") return key + feature_name_suffix def get_name_from_module_dict_key(key: str, feature_name_suffix_length: int = FEATURE_NAME_SUFFIX_LENGTH) -> str: """Reverse of get_module_dict_key_from_name.""" name = key.replace("__ludwig_punct_period__", ".") return name[:-feature_name_suffix_length] class LudwigFeatureDict(torch.nn.Module): """Torch ModuleDict wrapper that permits keys with any name. Torch's ModuleDict implementation doesn't allow certain keys to be used if they conflict with existing class attributes, e.g. > torch.nn.ModuleDict({'type': torch.nn.Module()}) # Raises KeyError. This class is a simple wrapper around torch's ModuleDict that mitigates possible conflicts by using a key-suffixing protocol. This is also tracked in Pytorch: https://github.com/pytorch/pytorch/issues/71203. """ def __init__(self): super().__init__() self.module_dict = torch.nn.ModuleDict() self.internal_key_to_original_name_map = {} def get(self, key) -> torch.nn.Module: return self.module_dict[get_module_dict_key_from_name(key)] def set(self, key: str, module: torch.nn.Module) -> None: module_dict_key_name = get_module_dict_key_from_name(key) self.internal_key_to_original_name_map[module_dict_key_name] = key self.module_dict[module_dict_key_name] = module def __len__(self) -> int: return len(self.module_dict) def __next__(self) -> None: return next(iter(self)) def __iter__(self) -> None: return iter(self.keys()) def keys(self) -> list[str]: return [ get_name_from_module_dict_key(feature_name) for feature_name in self.internal_key_to_original_name_map.keys() ] def values(self) -> list[torch.nn.Module]: return [module for _, module in self.module_dict.items()] def items(self) -> list[tuple[str, torch.nn.Module]]: return [ (get_name_from_module_dict_key(feature_name), module) for feature_name, module in self.module_dict.items() ] def update(self, modules: dict[str, torch.nn.Module]) -> None: for feature_name, module in modules.items(): self.set(feature_name, module) ================================================ FILE: ludwig/features/h3_feature.py ================================================ # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import logging import numpy as np import torch from ludwig.constants import COLUMN, H3, PROC_COLUMN from ludwig.features.base_feature import BaseFeatureMixin, InputFeature from ludwig.schema.features.h3_feature import H3InputFeatureConfig from ludwig.types import FeatureMetadataDict, ModelConfigDict, PreprocessingConfigDict, TrainingSetMetadataDict from ludwig.utils.h3_util import h3_to_components from ludwig.utils.types import TorchscriptPreprocessingInput logger = logging.getLogger(__name__) MAX_H3_RESOLUTION = 15 H3_VECTOR_LENGTH = MAX_H3_RESOLUTION + 4 H3_PADDING_VALUE = 7 class _H3Preprocessing(torch.nn.Module): def __init__(self, metadata: TrainingSetMetadataDict): super().__init__() self.max_h3_resolution = MAX_H3_RESOLUTION self.h3_padding_value = H3_PADDING_VALUE self.computed_fill_value = float(metadata["preprocessing"]["computed_fill_value"]) def forward(self, v: TorchscriptPreprocessingInput) -> torch.Tensor: if torch.jit.isinstance(v, list[torch.Tensor]): v = torch.stack(v) if not torch.jit.isinstance(v, torch.Tensor): raise ValueError(f"Unsupported input: {v}") v = torch.nan_to_num(v, nan=self.computed_fill_value) v = v.long() outputs: list[torch.Tensor] = [] for v_i in v: components = h3_to_components(v_i) header: list[int] = [ components.mode, components.edge, components.resolution, components.base_cell, ] cells_padding: list[int] = [self.h3_padding_value] * (self.max_h3_resolution - len(components.cells)) output = torch.tensor(header + components.cells + cells_padding, dtype=torch.uint8, device=v.device) outputs.append(output) return torch.stack(outputs) class H3FeatureMixin(BaseFeatureMixin): @staticmethod def type(): return H3 @staticmethod def cast_column(column, backend): try: return column.astype(int) except ValueError: logger.warning("H3Feature could not be read as int directly. Reading as float and converting to int.") return column.astype(float).astype(int) @staticmethod def get_feature_meta( config: ModelConfigDict, column, preprocessing_parameters: PreprocessingConfigDict, backend, is_input_feature: bool, ) -> FeatureMetadataDict: return {} @staticmethod def h3_to_list(h3_int): components = h3_to_components(h3_int) header = [components.mode, components.edge, components.resolution, components.base_cell] cells_padding = [H3_PADDING_VALUE] * (MAX_H3_RESOLUTION - len(components.cells)) return header + components.cells + cells_padding @staticmethod def add_feature_data( feature_config, input_df, proc_df, metadata, preprocessing_parameters: PreprocessingConfigDict, backend, skip_save_processed_input, ): column = input_df[feature_config[COLUMN]] if column.dtype == object: column = backend.df_engine.map_objects(column, int) column = backend.df_engine.map_objects(column, H3FeatureMixin.h3_to_list) proc_df[feature_config[PROC_COLUMN]] = backend.df_engine.map_objects( column, lambda x: np.array(x, dtype=np.uint8) ) return proc_df class H3InputFeature(H3FeatureMixin, InputFeature): def __init__(self, input_feature_config: H3InputFeatureConfig, encoder_obj=None, **kwargs): super().__init__(input_feature_config, **kwargs) if encoder_obj: self.encoder_obj = encoder_obj else: self.encoder_obj = self.initialize_encoder(input_feature_config.encoder) def forward(self, inputs): assert isinstance(inputs, torch.Tensor) assert inputs.dtype in [torch.uint8, torch.int64] assert len(inputs.shape) == 2 inputs_encoded = self.encoder_obj(inputs) return inputs_encoded @property def input_dtype(self): return torch.uint8 @property def input_shape(self) -> torch.Size: return torch.Size([H3_VECTOR_LENGTH]) @property def output_shape(self) -> torch.Size: return self.encoder_obj.output_shape @staticmethod def update_config_with_metadata(feature_config, feature_metadata, *args, **kwargs): pass @staticmethod def create_preproc_module(metadata: TrainingSetMetadataDict) -> torch.nn.Module: return _H3Preprocessing(metadata) @staticmethod def get_schema_cls(): return H3InputFeatureConfig ================================================ FILE: ludwig/features/image_feature.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import logging import os import warnings from collections import Counter from collections.abc import Callable from dataclasses import dataclass from functools import partial from typing import Any import numpy as np import torch from torchvision import transforms from torchvision.transforms import functional as F from torchvision.transforms.functional import normalize from ludwig.constants import ( CHECKSUM, COLUMN, ENCODER, HEIGHT, IMAGE, IMAGENET1K, INFER_IMAGE_DIMENSIONS, INFER_IMAGE_MAX_HEIGHT, INFER_IMAGE_MAX_WIDTH, INFER_IMAGE_NUM_CLASSES, INFER_IMAGE_SAMPLE_SIZE, LOGITS, NAME, NUM_CHANNELS, PREDICTIONS, PREPROCESSING, PROC_COLUMN, REQUIRES_EQUAL_DIMENSIONS, SRC, TRAINING, TYPE, WIDTH, ) from ludwig.data.cache.types import wrap from ludwig.encoders.base import Encoder from ludwig.encoders.image.torchvision import TVModelVariant from ludwig.features.base_feature import BaseFeatureMixin, InputFeature, OutputFeature, PredictModule from ludwig.schema.features.augmentation.base import BaseAugmentationConfig from ludwig.schema.features.augmentation.image import ( AutoAugmentationConfig, RandomBlurConfig, RandomBrightnessConfig, RandomContrastConfig, RandomHorizontalFlipConfig, RandomRotateConfig, RandomVerticalFlipConfig, ) from ludwig.schema.features.image_feature import ImageInputFeatureConfig, ImageOutputFeatureConfig from ludwig.types import ( FeatureMetadataDict, FeaturePostProcessingOutputDict, ModelConfigDict, PreprocessingConfigDict, TrainingSetMetadataDict, ) from ludwig.utils import output_feature_utils from ludwig.utils.augmentation_utils import get_augmentation_op, register_augmentation_op from ludwig.utils.data_utils import get_abs_path from ludwig.utils.dataframe_utils import is_dask_series_or_df from ludwig.utils.fs_utils import has_remote_protocol, upload_h5 from ludwig.utils.image_utils import ( get_class_mask_from_image, get_gray_default_image, get_image_from_class_mask, get_unique_channels, grayscale, num_channels_in_image, read_image_from_bytes_obj, read_image_from_path, resize_image, ResizeChannels, torchvision_model_registry, ) from ludwig.utils.misc_utils import set_default_value from ludwig.utils.types import Series, TorchscriptPreprocessingInput # constants used for Ludwig image preprocessing IMAGENET1K_MEAN = [0.485, 0.456, 0.406] IMAGENET1K_STD = [0.229, 0.224, 0.225] logger = logging.getLogger(__name__) ### # Image specific augmentation operations ### @register_augmentation_op(name="auto_augmentation", features=IMAGE) class AutoAugment(torch.nn.Module): def __init__(self, config: AutoAugmentationConfig): super().__init__() self.auto_augmentation_method = config.method self.augmentation_method = self.get_augmentation_method() def get_augmentation_method(self): if self.auto_augmentation_method == "trivial_augment": return transforms.TrivialAugmentWide() if self.auto_augmentation_method == "auto_augment": return transforms.AutoAugment() if self.auto_augmentation_method == "rand_augment": return transforms.RandAugment() raise ValueError(f"Unsupported auto-augmentation method: {self.auto_augmentation_method}") def forward(self, imgs: torch.Tensor) -> torch.Tensor: method = self.augmentation_method uint8imgs = imgs.to(torch.uint8) augmented_imgs = method(uint8imgs) return augmented_imgs.to(torch.float32) @register_augmentation_op(name="random_vertical_flip", features=IMAGE) class RandomVFlip(torch.nn.Module): def __init__( self, config: RandomVerticalFlipConfig, ): super().__init__() def forward(self, imgs): if torch.rand(1) < 0.5: imgs = F.vflip(imgs) return imgs @register_augmentation_op(name="random_horizontal_flip", features=IMAGE) class RandomHFlip(torch.nn.Module): def __init__( self, config: RandomHorizontalFlipConfig, ): super().__init__() def forward(self, imgs): if torch.rand(1) < 0.5: imgs = F.hflip(imgs) return imgs @register_augmentation_op(name="random_rotate", features=IMAGE) class RandomRotate(torch.nn.Module): def __init__(self, config: RandomRotateConfig): super().__init__() self.degree = config.degree def forward(self, imgs): if torch.rand(1) < 0.5: # map angle to interval (-degree, +degree) angle = (torch.rand(1) * 2 * self.degree - self.degree).item() return F.rotate(imgs, angle) else: return imgs @register_augmentation_op(name="random_contrast", features=IMAGE) class RandomContrast(torch.nn.Module): def __init__(self, config: RandomContrastConfig): super().__init__() self.min_contrast = config.min self.contrast_adjustment_range = config.max - config.min def forward(self, imgs): if torch.rand(1) < 0.5: # random contrast adjustment adjust_factor = (torch.rand(1) * self.contrast_adjustment_range + self.min_contrast).item() return F.adjust_contrast(imgs, adjust_factor) else: return imgs @register_augmentation_op(name="random_brightness", features=IMAGE) class RandomBrightness(torch.nn.Module): def __init__(self, config: RandomBrightnessConfig): super().__init__() self.min_brightness = config.min self.brightness_adjustment_range = config.max - config.min def forward(self, imgs): if torch.rand(1) < 0.5: # random contrast adjustment adjust_factor = (torch.rand(1) * self.brightness_adjustment_range + self.min_brightness).item() return F.adjust_brightness(imgs, adjust_factor) else: return imgs @register_augmentation_op(name="random_blur", features=IMAGE) class RandomBlur(torch.nn.Module): def __init__(self, config: RandomBlurConfig): super().__init__() self.kernel_size = [config.kernel_size, config.kernel_size] def forward(self, imgs): if torch.rand(1) < 0.5: imgs = F.gaussian_blur(imgs, self.kernel_size) return imgs class ImageAugmentation(torch.nn.Module): def __init__( self, augmentation_list: list[BaseAugmentationConfig], normalize_mean: list[float] | None = None, normalize_std: list[float] | None = None, ): super().__init__() logger.debug(f"Creating augmentation pipeline: {augmentation_list}") self.normalize_mean = normalize_mean self.normalize_std = normalize_std if self.training: self.augmentation_steps = torch.nn.Sequential() for aug_config in augmentation_list: try: aug_op = get_augmentation_op(IMAGE, aug_config.type) self.augmentation_steps.append(aug_op(aug_config)) except KeyError: raise ValueError(f"Invalid augmentation operation specification: {aug_config}") else: # TODO: should this raise an exception if not in training mode? self.augmentation_steps = None def forward(self, imgs): if self.augmentation_steps: # convert from float to uint8 values - this is required for the augmentation imgs = self._convert_back_to_uint8(imgs) logger.debug("Executing augmentation pipeline steps: %s", self.augmentation_steps) imgs = self.augmentation_steps(imgs) # convert back to float32 values and renormalize if needed imgs = self._renormalize_image(imgs) return imgs # function to partially undo the TorchVision ImageClassification transformation. # back out the normalization step and convert from float32 to uint8 dtype # to make the tensor displayable as an image # crop size remains the same def _convert_back_to_uint8(self, images): if self.normalize_mean: mean = torch.as_tensor(self.normalize_mean, dtype=torch.float32).view(-1, 1, 1) std = torch.as_tensor(self.normalize_std, dtype=torch.float32).view(-1, 1, 1) return images.mul(std).add(mean).mul(255.0).type(torch.uint8) else: return images.mul(255.0).type(torch.uint8) # function to redo part of the TorchVision ImageClassification transformation. # convert uint8 to float32 # apply the imagenet1k normalization def _renormalize_image(self, images): if self.normalize_mean: mean = torch.as_tensor(self.normalize_mean, dtype=torch.float32).view(-1, 1, 1) std = torch.as_tensor(self.normalize_std, dtype=torch.float32).view(-1, 1, 1) return images.type(torch.float32).div(255.0).sub(mean).div(std) else: return images.type(torch.float32).div(255.0) @dataclass class ImageTransformMetadata: height: int width: int num_channels: int def _get_torchvision_transform( torchvision_parameters: TVModelVariant, ) -> tuple[torch.nn.Module, ImageTransformMetadata]: """Returns a torchvision transform that is compatible with the model variant. Note that the raw torchvision transform is not returned. Instead, a Sequential module that includes image resizing is returned. This is because the raw torchvision transform assumes that the input image has three channels, which is not always the case with images input into Ludwig. Args: torchvision_parameters: The parameters for the torchvision model variant. Returns: (torchvision_transform, transform_metadata): A torchvision transform and the metadata for the transform. """ torchvision_transform_raw = torchvision_parameters.model_weights.DEFAULT.transforms() torchvision_transform = torch.nn.Sequential( ResizeChannels(num_channels=3), torchvision_transform_raw, ) transform_metadata = ImageTransformMetadata( height=torchvision_transform_raw.crop_size[0], width=torchvision_transform_raw.crop_size[0], num_channels=len(torchvision_transform_raw.mean), ) return (torchvision_transform, transform_metadata) def _get_torchvision_parameters(model_type: str, model_variant: str) -> TVModelVariant: return torchvision_model_registry.get(model_type).get(model_variant) def is_torchvision_encoder(encoder_obj: Encoder) -> bool: # TODO(travis): do this through an interface rather than conditional logic from ludwig.encoders.image.torchvision import TVBaseEncoder return isinstance(encoder_obj, TVBaseEncoder) class _ImagePreprocessing(torch.nn.Module): """Torchscript-enabled version of preprocessing done by ImageFeatureMixin.add_feature_data.""" def __init__( self, metadata: TrainingSetMetadataDict, torchvision_transform: torch.nn.Module | None = None, transform_metadata: ImageTransformMetadata | None = None, ): super().__init__() self.resize_method = metadata["preprocessing"]["resize_method"] self.torchvision_transform = torchvision_transform if transform_metadata is not None: self.height = transform_metadata.height self.width = transform_metadata.width self.num_channels = transform_metadata.num_channels self.channel_class_map = torch.Tensor([]) else: self.height = metadata["preprocessing"]["height"] self.width = metadata["preprocessing"]["width"] self.num_channels = metadata["preprocessing"]["num_channels"] self.channel_class_map = torch.ByteTensor(metadata["preprocessing"]["channel_class_map"]) def forward(self, v: TorchscriptPreprocessingInput) -> torch.Tensor: """Takes a list of images and adjusts the size and number of channels as specified in the metadata. If `v` is already a torch.Tensor, we assume that the images are already preprocessed to be the same size. """ # Nested conditional is a workaround to short-circuit boolean evaluation. if not torch.jit.isinstance(v, list[torch.Tensor]): if not torch.jit.isinstance(v, torch.Tensor): raise ValueError(f"Unsupported input: {v}") if self.torchvision_transform is not None: # perform pre-processing for torchvision pretrained model encoders if torch.jit.isinstance(v, list[torch.Tensor]): imgs = [self.torchvision_transform(img) for img in v] else: # convert batch of image tensors to a list and then run torchvision pretrained # model transforms on each image imgs = [self.torchvision_transform(img) for img in torch.unbind(v)] # collect the list of images into a batch imgs_stacked = torch.stack(imgs) else: # perform pre-processing for Ludwig defined image encoders if torch.jit.isinstance(v, list[torch.Tensor]): imgs = [resize_image(img, (self.height, self.width), self.resize_method) for img in v] imgs_stacked = torch.stack(imgs) else: imgs_stacked = v _, num_channels, height, width = imgs_stacked.shape # Ensure images are the size expected by the model if height != self.height or width != self.width: imgs_stacked = resize_image(imgs_stacked, (self.height, self.width), self.resize_method) # Ensures images have the number of channels expected by the model if num_channels != self.num_channels: if self.num_channels == 1: imgs_stacked = grayscale(imgs_stacked) elif num_channels < self.num_channels: extra_channels = self.num_channels - num_channels imgs_stacked = torch.nn.functional.pad(imgs_stacked, [0, 0, 0, 0, 0, extra_channels]) else: raise ValueError( f"Number of channels cannot be reconciled. metadata.num_channels = " f"{self.num_channels}, but imgs.shape[1] = {num_channels}" ) # Create class-masked images if required if self.channel_class_map.shape[0]: masks = [] for img in imgs_stacked: mask = get_class_mask_from_image(self.channel_class_map, img) masks.append(mask) imgs_stacked = torch.stack(masks) else: imgs_stacked = imgs_stacked.type(torch.float32) / 255 return imgs_stacked class _ImagePostprocessing(torch.nn.Module): def __init__(self): super().__init__() self.logits_key = LOGITS self.predictions_key = PREDICTIONS def forward(self, preds: dict[str, torch.Tensor], feature_name: str) -> FeaturePostProcessingOutputDict: predictions = output_feature_utils.get_output_feature_tensor(preds, feature_name, self.predictions_key) logits = output_feature_utils.get_output_feature_tensor(preds, feature_name, self.logits_key) return {self.predictions_key: predictions, self.logits_key: logits} class _ImagePredict(PredictModule): def forward(self, inputs: dict[str, torch.Tensor], feature_name: str) -> dict[str, torch.Tensor]: predictions = output_feature_utils.get_output_feature_tensor(inputs, feature_name, self.predictions_key) logits = output_feature_utils.get_output_feature_tensor(inputs, feature_name, self.logits_key) return {self.predictions_key: predictions, self.logits_key: logits} class ImageFeatureMixin(BaseFeatureMixin): @staticmethod def type(): return IMAGE @staticmethod def cast_column(column, backend): return column @staticmethod def get_feature_meta( config: ModelConfigDict, column, preprocessing_parameters: PreprocessingConfigDict, backend, is_input_feature: bool, ) -> FeatureMetadataDict: return {PREPROCESSING: preprocessing_parameters} @staticmethod def _read_image_if_bytes_obj_and_resize( img_entry: bytes | torch.Tensor | np.ndarray | str, img_width: int, img_height: int, should_resize: bool, num_channels: int, resize_method: str, user_specified_num_channels: bool, standardize_image: str, channel_class_map: torch.Tensor, ) -> np.ndarray | None: """:param img_entry Union[bytes, torch.Tensor, np.ndarray, str]: if str file path to the image else torch.Tensor of the image itself :param img_width: expected width of the image :param img_height: expected height of the image :param should_resize: Should the image be resized? :param resize_method: type of resizing method :param num_channels: expected number of channels in the first image :param user_specified_num_channels: did the user specify num channels? :param standardize_image: specifies whether to standarize image with imagenet1k specifications :param channel_class_map: A tensor mapping channel values to classes, where dim=0 is the class :return: image object as a numpy array. Helper method to read and resize an image according to model definition. If the user doesn't specify a number of channels, we use the first image in the dataset as the source of truth. If any image in the dataset doesn't have the same number of channels as the first image, raise an exception. If the user specifies a number of channels, we try to convert all the images to the specifications by dropping channels/padding 0 channels """ if isinstance(img_entry, bytes): img = read_image_from_bytes_obj(img_entry, num_channels) elif isinstance(img_entry, str): img = read_image_from_path(img_entry, num_channels) elif isinstance(img_entry, np.ndarray): img = torch.from_numpy(np.array(img_entry, copy=True)).permute(2, 0, 1) else: img = img_entry if not isinstance(img, torch.Tensor): warnings.warn(f"Image with value {img} cannot be read") return None img_num_channels = num_channels_in_image(img) # Convert to grayscale if needed. if num_channels == 1 and img_num_channels != 1: img = grayscale(img) img_num_channels = 1 if should_resize: img = resize_image(img, (img_height, img_width), resize_method) if user_specified_num_channels: # Number of channels is specified by the user # img_padded = np.zeros((img_height, img_width, num_channels), # dtype=np.uint8) # min_num_channels = min(num_channels, img_num_channels) # img_padded[:, :, :min_num_channels] = img[:, :, :min_num_channels] # img = img_padded if num_channels > img_num_channels: extra_channels = num_channels - img_num_channels img = torch.nn.functional.pad(img, [0, 0, 0, 0, 0, extra_channels]) if img_num_channels != num_channels: logger.warning( "Image has {} channels, where as {} " "channels are expected. Dropping/adding channels " "with 0s as appropriate".format(img_num_channels, num_channels) ) else: # If the image isn't like the first image, raise exception if img_num_channels != num_channels: raise ValueError( "Image has {} channels, unlike the first image, which " "has {} channels. Make sure all the images have the same " "number of channels or use the num_channels property in " "image preprocessing".format(img_num_channels, num_channels) ) if img.shape[1] != img_height or img.shape[2] != img_width: raise ValueError( "Images are not of the same size. " "Expected size is {}, " "current image size is {}." "Images are expected to be all of the same size " "or explicit image width and height are expected " "to be provided. " "Additional information: " "https://ludwig-ai.github.io/ludwig-docs/latest/configuration/features/image_features" "#image-features-preprocessing".format([img_height, img_width, num_channels], img.shape) ) # Create class-masked image if required if channel_class_map.shape[0]: img = get_class_mask_from_image(channel_class_map, img) else: # casting and rescaling img = img.type(torch.float32) / 255 if standardize_image == IMAGENET1K: img = normalize(img, mean=IMAGENET1K_MEAN, std=IMAGENET1K_STD) return img.numpy() @staticmethod def _read_image_with_pretrained_transform( img_entry: bytes | torch.Tensor | np.ndarray, transform_fn: Callable, ) -> np.ndarray | None: if isinstance(img_entry, bytes): img = read_image_from_bytes_obj(img_entry) elif isinstance(img_entry, str): img = read_image_from_path(img_entry) elif isinstance(img_entry, np.ndarray): img = torch.from_numpy(img_entry).permute(2, 0, 1) else: img = img_entry if not isinstance(img, torch.Tensor): warnings.warn(f"Image with value {img} cannot be read") return None img = transform_fn(img) return img.numpy() @staticmethod def _set_image_and_height_equal_for_encoder( width: int, height: int, preprocessing_parameters: dict, encoder_type: str ) -> tuple[int, int]: """Some pretrained image encoders require images with the same dimension, or images with a specific width and heigh values. The returned width and height are set based on compatibility with the downstream encoder using the encoder parameters for the feature. Args: width: Represents the width of the image. This is either specified in the user config, or inferred using a sample of images. height: Represents the height of the image. This is either specified in the user config, or inferred using a sample of images. preprocessing_parameters: Parameters defining how the image feature should be preprocessed encoder_type: The name of the encoder Return: (width, height) Updated width and height so that they are equal """ if preprocessing_parameters[REQUIRES_EQUAL_DIMENSIONS] and height != width: width = height = min(width, height) # Update preprocessing parameters dictionary to reflect new height and width values preprocessing_parameters["width"] = width preprocessing_parameters["height"] = height logger.info( f"Set image feature height and width to {width} to be compatible with" f" {encoder_type} encoder." ) return width, height @staticmethod def _infer_image_size( image_sample: list[torch.Tensor], max_height: int, max_width: int, preprocessing_parameters: dict, encoder_type: str, ) -> tuple[int, int]: """Infers the size to use from a group of images. The returned height will be the average height of images in image_sample rounded to the nearest integer, or max_height. Likewise for width. Args: image_sample: Sample of images to use to infer image size. Must be formatted as [channels, height, width]. max_height: Maximum height. max_width: Maximum width. preprocessing_parameters: Parameters defining how the image feature should be preprocessed encoder_type: The name of the encoder Return: (height, width) The inferred height and width. """ height_avg = sum(x.shape[1] for x in image_sample) / len(image_sample) width_avg = sum(x.shape[2] for x in image_sample) / len(image_sample) height = min(int(round(height_avg)), max_height) width = min(int(round(width_avg)), max_width) # Update height and width if the downstream encoder requires images # with the same dimension or specific width and height values width, height = ImageFeatureMixin._set_image_and_height_equal_for_encoder( width, height, preprocessing_parameters, encoder_type ) logger.debug(f"Inferring height: {height} and width: {width}") return height, width @staticmethod def _infer_number_of_channels(image_sample: list[torch.Tensor]): """Infers the channel depth to use from a group of images. We make the assumption that the majority of datasets scraped from the web will be RGB, so if we get a mixed bag of images we should default to that. However, if the majority of the sample images have a specific channel depth (other than 3) this is probably intentional so we keep it, but log an info message. """ n_images = len(image_sample) channel_frequency = Counter([num_channels_in_image(x) for x in image_sample]) if channel_frequency[1] > n_images / 2: # If the majority of images in sample are 1 channel, use 1. num_channels = 1 elif channel_frequency[2] > n_images / 2: # If the majority of images in sample are 2 channel, use 2. num_channels = 2 elif channel_frequency[4] > n_images / 2: # If the majority of images in sample are 4 channel, use 4. num_channels = 4 else: # Default case: use 3 channels. num_channels = 3 logger.info(f"Inferring num_channels from the first {n_images} images.") logger.info("\n".join([f" images with {k} channels: {v}" for k, v in sorted(channel_frequency.items())])) if num_channels == max(channel_frequency, key=channel_frequency.get): logger.info( f"Using {num_channels} channels because it is the majority in sample. If an image with" f" a different depth is read, will attempt to convert to {num_channels} channels." ) else: logger.info(f"Defaulting to {num_channels} channels.") logger.info( "To explicitly set the number of channels, define num_channels in the preprocessing dictionary of " "the image input feature config." ) return num_channels @staticmethod def _infer_image_num_classes( image_sample: list[torch.Tensor], num_channels: int, num_classes: int, ) -> torch.Tensor: """Infers the number of channel classes from a group of images (for image segmentation). The returned tensor contains the channel value for each class, where dim=0 is the class. Args: image_sample: Sample of images to use to infer image size. Must be formatted as [channels, height, width]. num_channels: Expected number of channels num_classes: Expected number of channel classes or None Return: channel_class_map: A tensor mapping channel values to classes, where dim=0 is the class. """ n_images = len(image_sample) logger.info(f"Inferring num_classes from the first {n_images} images.") channel_class_map = get_unique_channels(image_sample, num_channels, num_classes) inferred_num_classes = channel_class_map.shape[0] if num_classes: if num_classes < inferred_num_classes: raise ValueError( f"Images inferred num classes {inferred_num_classes} exceeds `num_classes` {num_classes}." ) elif num_classes > inferred_num_classes: logger.warning( "Images inferred num classes {} does not match `num_classes` {}. " "Using inferred num classes {}.".format(inferred_num_classes, num_classes, inferred_num_classes) ) return channel_class_map @staticmethod def _finalize_preprocessing_parameters( preprocessing_parameters: dict, encoder_type: str, column: Series, ) -> tuple: """Helper method to determine the height, width and number of channels for preprocessing the image data. This is achieved by looking at the parameters provided by the user. When there are some missing parameters, we fall back on to the first image in the dataset. The assumption being that all the images in the data are expected be of the same size with the same number of channels. Args: preprocessing_parameters: Parameters defining how the image feature should be preprocessed encoder_type: The name of the encoder column: The data itself. Can be a Pandas, Modin or Dask series. """ explicit_height_width = preprocessing_parameters[HEIGHT] or preprocessing_parameters[WIDTH] explicit_num_channels = NUM_CHANNELS in preprocessing_parameters and preprocessing_parameters[NUM_CHANNELS] if preprocessing_parameters[INFER_IMAGE_DIMENSIONS] and not (explicit_height_width and explicit_num_channels): sample_size = min(len(column), preprocessing_parameters[INFER_IMAGE_SAMPLE_SIZE]) else: sample_size = 1 # Take first image sample = [] sample_num_bytes = [] failed_entries = [] for image_entry in column.head(sample_size): if isinstance(image_entry, bytes): image = read_image_from_bytes_obj(image_entry) elif isinstance(image_entry, str): # Tries to read image as PNG or numpy file from the path. image, num_bytes = read_image_from_path(image_entry, return_num_bytes=True) if num_bytes is not None: sample_num_bytes.append(num_bytes) else: image = image_entry if isinstance(image, torch.Tensor): sample.append(image) elif isinstance(image, np.ndarray): sample.append(torch.from_numpy(image).permute(2, 0, 1)) else: failed_entries.append(image_entry) if len(sample) == 0: failed_entries_repr = "\n\t- ".join(failed_entries) raise ValueError( f"Images dimensions cannot be inferred. Failed to read {sample_size} images as samples:" f"\n\t- {failed_entries_repr}." ) should_resize = False if explicit_height_width: should_resize = True try: height = int(preprocessing_parameters[HEIGHT]) width = int(preprocessing_parameters[WIDTH]) # Update height and width if the downstream encoder requires images # with the same dimension or specific width and height values width, height = ImageFeatureMixin._set_image_and_height_equal_for_encoder( width, height, preprocessing_parameters, encoder_type ) except ValueError as e: raise ValueError("Image height and width must be set and have " "positive integer values: " + str(e)) if height <= 0 or width <= 0: raise ValueError("Image height and width must be positive integers") else: # User hasn't specified height and width. # Default to inferring from sample or first image. if preprocessing_parameters[INFER_IMAGE_DIMENSIONS]: should_resize = True height, width = ImageFeatureMixin._infer_image_size( sample, max_height=preprocessing_parameters[INFER_IMAGE_MAX_HEIGHT], max_width=preprocessing_parameters[INFER_IMAGE_MAX_WIDTH], preprocessing_parameters=preprocessing_parameters, encoder_type=encoder_type, ) else: raise ValueError( "Explicit image width/height are not set, infer_image_dimensions is false, " "and first image cannot be read, so image dimensions are unknown" ) if explicit_num_channels: # User specified num_channels in the model/feature config user_specified_num_channels = True num_channels = preprocessing_parameters[NUM_CHANNELS] else: user_specified_num_channels = False if preprocessing_parameters[INFER_IMAGE_DIMENSIONS]: user_specified_num_channels = True num_channels = ImageFeatureMixin._infer_number_of_channels(sample) elif len(sample) > 0: num_channels = num_channels_in_image(sample[0]) else: raise ValueError( "Explicit image num channels is not set, infer_image_dimensions is false, " "and first image cannot be read, so image num channels is unknown" ) assert isinstance(num_channels, int), ValueError("Number of image channels needs to be an integer") average_file_size = np.mean(sample_num_bytes) if sample_num_bytes else None standardize_image = preprocessing_parameters["standardize_image"] if standardize_image == "imagenet1k" and num_channels != 3: warnings.warn( f"'standardize_image=imagenet1k' is defined only for 'num_channels=3' but " f"detected 'num_channels={num_channels}'. For this situation setting 'standardize_image=None'.", RuntimeWarning, ) standardize_image = None if preprocessing_parameters[INFER_IMAGE_NUM_CLASSES] or preprocessing_parameters["num_classes"]: channel_class_map = ImageFeatureMixin._infer_image_num_classes( sample, num_channels, preprocessing_parameters["num_classes"] ) else: channel_class_map = torch.Tensor([]) return ( should_resize, width, height, num_channels, user_specified_num_channels, average_file_size, standardize_image, channel_class_map, ) @staticmethod def add_feature_data( feature_config, input_df, proc_df, metadata, preprocessing_parameters: PreprocessingConfigDict, backend, skip_save_processed_input, ): set_default_value(feature_config[PREPROCESSING], "in_memory", preprocessing_parameters["in_memory"]) name = feature_config[NAME] column = input_df[feature_config[COLUMN]] encoder_type = feature_config[ENCODER][TYPE] if ENCODER in feature_config.keys() else None src_path = None if SRC in metadata: src_path = os.path.dirname(os.path.abspath(metadata.get(SRC))) abs_path_column = backend.df_engine.map_objects( column, lambda row: get_abs_path(src_path, row) if isinstance(row, str) and not has_remote_protocol(row) else row, ) # determine if specified encoder is a torchvision model model_type = feature_config[ENCODER].get("type", None) if ENCODER in feature_config.keys() else None model_variant = feature_config[ENCODER].get("model_variant") if ENCODER in feature_config.keys() else None if model_variant: torchvision_parameters = _get_torchvision_parameters(model_type, model_variant) else: torchvision_parameters = None if torchvision_parameters: logger.warning( f"Using the transforms specified for the torchvision model {model_type} {model_variant} " f"This includes setting the number of channels is 3 and resizing the image to the needs of the model." ) torchvision_transform, transform_metadata = _get_torchvision_transform(torchvision_parameters) # torchvision_parameters is not None # perform torchvision model transformations read_image_if_bytes_obj_and_resize = partial( ImageFeatureMixin._read_image_with_pretrained_transform, transform_fn=torchvision_transform, ) average_file_size = None # save weight specification in preprocessing section preprocessing_parameters["torchvision_model_default_weights"] = ( f"{torchvision_parameters.model_weights.DEFAULT}" ) # add torchvision model id to preprocessing section for torchscript preprocessing_parameters["torchvision_model_type"] = model_type preprocessing_parameters["torchvision_model_variant"] = model_variant # get required setup parameters for in_memory = False processing height = transform_metadata.height width = transform_metadata.width num_channels = transform_metadata.num_channels channel_class_map = torch.Tensor([]) else: # torchvision_parameters is None # perform Ludwig specified transformations ( should_resize, width, height, num_channels, user_specified_num_channels, average_file_size, standardize_image, channel_class_map, ) = ImageFeatureMixin._finalize_preprocessing_parameters( preprocessing_parameters, encoder_type, abs_path_column ) metadata[name][PREPROCESSING]["height"] = height metadata[name][PREPROCESSING]["width"] = width metadata[name][PREPROCESSING]["num_channels"] = num_channels metadata[name][PREPROCESSING]["num_classes"] = channel_class_map.shape[0] metadata[name][PREPROCESSING]["channel_class_map"] = channel_class_map.tolist() read_image_if_bytes_obj_and_resize = partial( ImageFeatureMixin._read_image_if_bytes_obj_and_resize, img_width=width, img_height=height, should_resize=should_resize, num_channels=num_channels, resize_method=preprocessing_parameters["resize_method"], user_specified_num_channels=user_specified_num_channels, standardize_image=standardize_image, channel_class_map=channel_class_map, ) # TODO: alternatively use get_average_image() for unreachable images if channel_class_map.shape[0]: default_image = get_gray_default_image(1, height, width).squeeze(0) metadata[name]["reshape"] = (height, width) else: default_image = get_gray_default_image(num_channels, height, width) metadata[name]["reshape"] = (num_channels, height, width) in_memory = feature_config[PREPROCESSING]["in_memory"] if in_memory or skip_save_processed_input: proc_col = backend.read_binary_files( abs_path_column, map_fn=read_image_if_bytes_obj_and_resize, file_size=average_file_size ) num_failed_image_reads = ( proc_col.isna().sum().compute() if is_dask_series_or_df(proc_col, backend) else proc_col.isna().sum() ) proc_col = backend.df_engine.map_objects( proc_col, lambda row: default_image if not isinstance(row, np.ndarray) else row ) proc_df[feature_config[PROC_COLUMN]] = proc_col else: num_images = len(abs_path_column) num_failed_image_reads = 0 data_fp = backend.cache.get_cache_path(wrap(metadata.get(SRC)), metadata.get(CHECKSUM), TRAINING) with upload_h5(data_fp) as h5_file: # todo future add multiprocessing/multithreading image_dataset = h5_file.create_dataset( feature_config[PROC_COLUMN] + "_data", (num_images, num_channels, height, width), dtype=np.float32 ) for i, img_entry in enumerate(abs_path_column): res = read_image_if_bytes_obj_and_resize(img_entry) if isinstance(res, np.ndarray): image_dataset[i, :height, :width, :] = res else: logger.warning(f"Failed to read image {img_entry} while preprocessing feature `{name}`. ") image_dataset[i, :height, :width, :] = default_image num_failed_image_reads += 1 h5_file.flush() proc_df[feature_config[PROC_COLUMN]] = np.arange(num_images) if num_failed_image_reads > 0: logger.warning( f"Failed to read {num_failed_image_reads} images while preprocessing feature `{name}`. " "Using default image for these rows in the dataset." ) return proc_df class ImageInputFeature(ImageFeatureMixin, InputFeature): def __init__(self, input_feature_config: ImageInputFeatureConfig, encoder_obj=None, **kwargs): super().__init__(input_feature_config, **kwargs) if encoder_obj: self.encoder_obj = encoder_obj else: self.encoder_obj = self.initialize_encoder(input_feature_config.encoder) # set up for augmentation if it is enabled if input_feature_config.augmentation: # assume no image normalize is required normalize_mean = normalize_std = None # determine if specified encoder is a torchvision model if is_torchvision_encoder(self.encoder_obj): # encoder is a torchvision model normalize_mean = self.encoder_obj.normalize_mean normalize_std = self.encoder_obj.normalize_std else: # encoder is a Ludwig encoder, determine if standardize_image is set to IMAGENET1K if input_feature_config.preprocessing.standardize_image == IMAGENET1K: normalize_mean = IMAGENET1K_MEAN normalize_std = IMAGENET1K_STD # create augmentation pipeline object self.augmentation_pipeline = ImageAugmentation( input_feature_config.augmentation, normalize_mean, normalize_std, ) def forward(self, inputs: torch.Tensor) -> torch.Tensor: assert isinstance(inputs, torch.Tensor), f"inputs to image feature must be a torch tensor, got {type(inputs)}" assert inputs.dtype in [torch.float32], f"inputs to image feature must be a float32 tensor, got {inputs.dtype}" inputs_encoded = self.encoder_obj(inputs) return inputs_encoded @property def input_dtype(self): return torch.float32 @property def input_shape(self) -> torch.Size: return torch.Size(self.encoder_obj.input_shape) @property def output_shape(self) -> torch.Size: return self.encoder_obj.output_shape def update_config_after_module_init(self, feature_config): if is_torchvision_encoder(self.encoder_obj): # update feature preprocessing parameters to reflect used in torchvision pretrained model # Note: image height and width is determined by the encoder crop_size attribute. Source of this # attribute is from the torchvision.transforms._presets.ImageClassification class. This class stores # crop_size as a single element list. the single element in this list is used to set both the height # and width of an image. feature_config.preprocessing.height = self.encoder_obj.crop_size[0] feature_config.preprocessing.width = self.encoder_obj.crop_size[0] feature_config.preprocessing.num_channels = self.encoder_obj.num_channels @staticmethod def update_config_with_metadata(feature_config, feature_metadata, *args, **kwargs): for key in ["height", "width", "num_channels", "standardize_image"]: if hasattr(feature_config.encoder, key): setattr(feature_config.encoder, key, feature_metadata[PREPROCESSING][key]) @staticmethod def get_schema_cls(): return ImageInputFeatureConfig @staticmethod def create_preproc_module(metadata: dict[str, Any]) -> torch.nn.Module: model_type = metadata["preprocessing"].get("torchvision_model_type") model_variant = metadata["preprocessing"].get("torchvision_model_variant") if model_variant: torchvision_parameters = _get_torchvision_parameters(model_type, model_variant) else: torchvision_parameters = None if torchvision_parameters: torchvision_transform, transform_metadata = _get_torchvision_transform(torchvision_parameters) else: torchvision_transform = None transform_metadata = None return _ImagePreprocessing( metadata, torchvision_transform=torchvision_transform, transform_metadata=transform_metadata ) def get_augmentation_pipeline(self): return self.augmentation_pipeline class ImageOutputFeature(ImageFeatureMixin, OutputFeature): def __init__( self, output_feature_config: ImageOutputFeatureConfig | dict, output_features: dict[str, OutputFeature], **kwargs, ): super().__init__(output_feature_config, output_features, **kwargs) self.decoder_obj = self.initialize_decoder(output_feature_config.decoder) self._setup_loss() self._setup_metrics() def logits(self, inputs: dict[str, torch.Tensor], target=None, **kwargs): return self.decoder_obj(inputs, target=target) def metric_kwargs(self): return dict(num_outputs=self.output_shape[0]) def create_predict_module(self) -> PredictModule: return _ImagePredict() def get_prediction_set(self): return self.decoder_obj.get_prediction_set() @classmethod def get_output_dtype(cls): return torch.float32 @property def output_shape(self) -> torch.Size: return self.decoder_obj.output_shape @property def input_shape(self) -> torch.Size: return self.decoder_obj.input_shape @staticmethod def update_config_with_metadata(feature_config, feature_metadata, *args, **kwargs): for key in ["height", "width", "num_channels", "num_classes", "standardize_image"]: if hasattr(feature_config.decoder, key): setattr(feature_config.decoder, key, feature_metadata[PREPROCESSING][key]) @staticmethod def calculate_overall_stats(predictions, targets, metadata): # no overall stats, just return empty dictionary return {} def postprocess_predictions( self, result, metadata, ): predictions_col = f"{self.feature_name}_{PREDICTIONS}" if predictions_col in result: channel_class_map = torch.ByteTensor(metadata[PREPROCESSING]["channel_class_map"]) if channel_class_map.shape[0]: def class_mask2img(row): pred = row[predictions_col] return get_image_from_class_mask(channel_class_map, pred) result[predictions_col] = result.apply(class_mask2img, axis=1) return result @staticmethod def create_postproc_module(metadata: TrainingSetMetadataDict) -> torch.nn.Module: return _ImagePostprocessing(metadata) @staticmethod def get_schema_cls(): return ImageOutputFeatureConfig ================================================ FILE: ludwig/features/number_feature.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import copy import logging from abc import ABC, abstractmethod from typing import Any import numpy as np import pandas as pd import torch from torch import nn from ludwig.constants import COLUMN, HIDDEN, LOGITS, NAME, NUMBER, PREDICTIONS, PROC_COLUMN from ludwig.features.base_feature import BaseFeatureMixin, InputFeature, OutputFeature, PredictModule from ludwig.schema.features.number_feature import NumberInputFeatureConfig, NumberOutputFeatureConfig from ludwig.types import ( FeatureMetadataDict, FeaturePostProcessingOutputDict, ModelConfigDict, PreprocessingConfigDict, TrainingSetMetadataDict, ) from ludwig.utils import output_feature_utils from ludwig.utils.misc_utils import get_from_registry from ludwig.utils.types import TorchscriptPreprocessingInput logger = logging.getLogger(__name__) class NumberTransformer(nn.Module, ABC): @abstractmethod def transform(self, x: np.ndarray) -> np.ndarray: pass @abstractmethod def inverse_transform(self, x: np.ndarray) -> np.ndarray: pass @abstractmethod def transform_inference(self, x: torch.Tensor) -> torch.Tensor: pass @abstractmethod def inverse_transform_inference(self, x: torch.Tensor) -> torch.Tensor: pass @staticmethod @abstractmethod def fit_transform_params(column: np.ndarray, backend: Any) -> dict[str, Any]: pass class ZScoreTransformer(NumberTransformer): def __init__(self, mean: float = None, std: float = None, **kwargs: dict): super().__init__() self.mu = float(mean) if mean is not None else mean self.sigma = float(std) if std is not None else std self.feature_name = kwargs.get(NAME, "") if self.sigma == 0: raise RuntimeError( f"Cannot apply zscore normalization to `{self.feature_name}` since it has a standard deviation of 0. " f"This is most likely because `{self.feature_name}` has a constant value of {self.mu} for all rows in " "the dataset. Consider removing this feature from your Ludwig config since it is not useful for " "your machine learning model." ) def transform(self, x: np.ndarray) -> np.ndarray: return (x - self.mu) / self.sigma def inverse_transform(self, x: np.ndarray) -> np.ndarray: return x * self.sigma + self.mu def transform_inference(self, x: torch.Tensor) -> torch.Tensor: return (x - self.mu) / self.sigma def inverse_transform_inference(self, x: torch.Tensor) -> torch.Tensor: return x * self.sigma + self.mu @staticmethod def fit_transform_params(column: np.ndarray, backend: "Backend") -> dict[str, Any]: # noqa compute = backend.df_engine.compute return { "mean": compute(column.astype(np.float32).mean()), "std": compute(column.astype(np.float32).std()), } class MinMaxTransformer(NumberTransformer): def __init__(self, min: float = None, max: float = None, **kwargs: dict): super().__init__() self.min_value = float(min) if min is not None else min self.max_value = float(max) if max is not None else max if self.min_value is None or self.max_value is None: self.range = None else: self.range = self.max_value - self.min_value def transform(self, x: np.ndarray) -> np.ndarray: return (x - self.min_value) / self.range def inverse_transform(self, x: np.ndarray) -> np.ndarray: if self.range is None: raise ValueError("Numeric transformer needs to be instantiated with " "min and max values.") return x * self.range + self.min_value def transform_inference(self, x: torch.Tensor) -> torch.Tensor: return (x - self.min_value) / self.range def inverse_transform_inference(self, x: torch.Tensor) -> torch.Tensor: if self.range is None: raise ValueError("Numeric transformer needs to be instantiated with " "min and max values.") return x * self.range + self.min_value @staticmethod def fit_transform_params(column: np.ndarray, backend: "Backend") -> dict[str, Any]: # noqa compute = backend.df_engine.compute return { "min": compute(column.astype(np.float32).min()), "max": compute(column.astype(np.float32).max()), } class InterQuartileTransformer(NumberTransformer): def __init__(self, q1: float = None, q2: float = None, q3: float = None, **kwargs: dict): super().__init__() self.q1 = float(q1) if q1 is not None else q1 self.q2 = float(q2) if q2 is not None else q2 self.q3 = float(q3) if q3 is not None else q3 if self.q1 is None or self.q3 is None: self.interquartile_range = None else: self.interquartile_range = self.q3 - self.q1 self.feature_name = kwargs.get(NAME, "") if self.interquartile_range == 0: raise RuntimeError( f"Cannot apply InterQuartileNormalization to `{self.feature_name}` since" "the interquartile range is 0, which will result in a ZeroDivisionError." ) def transform(self, x: np.ndarray) -> np.ndarray: return (x - self.q2) / self.interquartile_range def inverse_transform(self, x: np.ndarray) -> np.ndarray: return x * self.interquartile_range + self.q2 def transform_inference(self, x: torch.Tensor) -> torch.Tensor: return (x - self.q2) / self.interquartile_range def inverse_transform_inference(self, x: torch.Tensor) -> torch.Tensor: return x * self.interquartile_range + self.q2 @staticmethod def fit_transform_params(column: np.ndarray, backend: "Backend") -> dict[str, Any]: # noqa # backend.df_engine.compute is not used here because `percentile` is not parallelized in dask. # We compute the percentile directly. return { "q1": np.percentile(column.astype(np.float32), 25), "q2": np.percentile(column.astype(np.float32), 50), "q3": np.percentile(column.astype(np.float32), 75), } class Log1pTransformer(NumberTransformer): def __init__(self, **kwargs: dict): super().__init__() self.feature_name = kwargs.get(NAME, "") def transform(self, x: np.ndarray) -> np.ndarray: if np.any(x <= 0): raise ValueError( f"One or more values in the `{self.feature_name}` feature are non-positive. " "log1p normalization is defined only for positive values." ) return np.log1p(x) def inverse_transform(self, x: np.ndarray) -> np.ndarray: return np.expm1(x) def transform_inference(self, x: torch.Tensor) -> torch.Tensor: return torch.log1p(x) def inverse_transform_inference(self, x: torch.Tensor) -> torch.Tensor: return torch.expm1(x) @staticmethod def fit_transform_params(column: np.ndarray, backend: "Backend") -> dict[str, Any]: # noqa return {} class IdentityTransformer(NumberTransformer): def __init__(self, **kwargs): super().__init__() def transform(self, x: np.ndarray) -> np.ndarray: return x def inverse_transform(self, x: np.ndarray) -> np.ndarray: return x def transform_inference(self, x: torch.Tensor) -> torch.Tensor: return x def inverse_transform_inference(self, x: torch.Tensor) -> torch.Tensor: return x @staticmethod def fit_transform_params(column: np.ndarray, backend: "Backend") -> dict[str, Any]: # noqa return {} numeric_transformation_registry = { "minmax": MinMaxTransformer, "zscore": ZScoreTransformer, "log1p": Log1pTransformer, "iq": InterQuartileTransformer, None: IdentityTransformer, } def get_transformer(metadata, preprocessing_parameters) -> NumberTransformer: return get_from_registry( preprocessing_parameters.get("normalization", None), numeric_transformation_registry, )(**metadata) class _OutlierReplacer(torch.nn.Module): def __init__(self, metadata: TrainingSetMetadataDict): super().__init__() self.zscore_transformer = ZScoreTransformer(**metadata) self.outlier_threshold = metadata["preprocessing"].get("outlier_threshold") self.computed_outlier_fill_value = float(metadata["preprocessing"]["computed_outlier_fill_value"]) def forward(self, v: torch.Tensor) -> torch.Tensor: outliers = self.zscore_transformer.transform_inference(v).abs().gt(self.outlier_threshold) v_masked = torch.masked_fill(v, outliers, torch.nan) v = torch.nan_to_num(v_masked, nan=self.computed_outlier_fill_value) return v.to(dtype=torch.float32) class _NumberPreprocessing(torch.nn.Module): def __init__(self, metadata: TrainingSetMetadataDict): super().__init__() self.computed_fill_value = float(metadata["preprocessing"]["computed_fill_value"]) self.numeric_transformer = get_transformer(metadata, metadata["preprocessing"]) # Optional outlier replacement self.outlier_replacer = None if metadata["preprocessing"].get("outlier_strategy") is not None: self.outlier_replacer = _OutlierReplacer(metadata) def forward(self, v: TorchscriptPreprocessingInput) -> torch.Tensor: if not torch.jit.isinstance(v, torch.Tensor): raise ValueError(f"Unsupported input: {v}") v = torch.nan_to_num(v, nan=self.computed_fill_value) v = v.to(dtype=torch.float32) # Handle outliers if needed if self.outlier_replacer is not None: v = self.outlier_replacer(v) return self.numeric_transformer.transform_inference(v) class _NumberPostprocessing(torch.nn.Module): def __init__(self, metadata: TrainingSetMetadataDict): super().__init__() self.numeric_transformer = get_transformer(metadata, metadata["preprocessing"]) self.predictions_key = PREDICTIONS def forward(self, preds: dict[str, torch.Tensor], feature_name: str) -> FeaturePostProcessingOutputDict: predictions = output_feature_utils.get_output_feature_tensor(preds, feature_name, self.predictions_key) return {self.predictions_key: self.numeric_transformer.inverse_transform_inference(predictions)} class _NumberPredict(PredictModule): def __init__(self, clip): super().__init__() self.clip = clip def forward(self, inputs: dict[str, torch.Tensor], feature_name: str) -> dict[str, torch.Tensor]: logits = output_feature_utils.get_output_feature_tensor(inputs, feature_name, self.logits_key) predictions = logits if self.clip is not None: predictions = torch.clamp(logits, self.clip[0], self.clip[1]) logger.debug(f" clipped_predictions: {predictions}") return {self.predictions_key: predictions, self.logits_key: logits} class NumberFeatureMixin(BaseFeatureMixin): @staticmethod def type(): return NUMBER @staticmethod def cast_column(column, backend): return backend.df_engine.df_lib.to_numeric(column, errors="coerce").astype(np.float32) @staticmethod def get_feature_meta( config: ModelConfigDict, column, preprocessing_parameters: PreprocessingConfigDict, backend, is_input_feature: bool, ) -> FeatureMetadataDict: numeric_transformer: NumberTransformer = get_from_registry( preprocessing_parameters.get("normalization", None), numeric_transformation_registry, ) params = numeric_transformer.fit_transform_params(column, backend) # Ensure mean and std are computed if we're removing outliers outlier_strategy = preprocessing_parameters.get("outlier_strategy") if outlier_strategy is not None and ("mean" not in params or "std" not in params): params.update(ZScoreTransformer.fit_transform_params(column, backend)) return params @staticmethod def add_feature_data( feature_config, input_df, proc_df, metadata, preprocessing_parameters: PreprocessingConfigDict, backend, skip_save_processed_input, ): # Had to replace normalize() function due to issue #1911 # this comment is to provide context for the change. # original code # def normalize(series: pd.Series) -> pd.Series: # series = series.copy() # numeric_transformer = get_transformer(metadata[feature_config[NAME]], preprocessing_parameters) # series.update(numeric_transformer.transform(series.values)) # return series def normalize(series: pd.Series) -> pd.Series: _feature_metadata = copy.deepcopy(metadata[feature_config[NAME]]) _feature_metadata.update({NAME: feature_config[NAME]}) # retrieve request numeric transformer numeric_transformer = get_transformer(_feature_metadata, preprocessing_parameters) # transform input numeric values with specified transformer transformed_values = numeric_transformer.transform(series.values) # return transformed values with same index values as original series. return pd.Series(transformed_values, index=series.index) input_series = input_df[feature_config[COLUMN]].astype(np.float32) proc_df[feature_config[PROC_COLUMN]] = backend.df_engine.map_partitions( input_series, normalize, meta=input_series ) return proc_df class NumberInputFeature(NumberFeatureMixin, InputFeature): def __init__(self, input_feature_config: NumberInputFeatureConfig, encoder_obj=None, **kwargs): super().__init__(input_feature_config, **kwargs) input_feature_config.encoder.input_size = self.input_shape[-1] if encoder_obj: self.encoder_obj = encoder_obj else: self.encoder_obj = self.initialize_encoder(input_feature_config.encoder) def forward(self, inputs): assert isinstance(inputs, torch.Tensor) assert inputs.dtype == torch.float32 or inputs.dtype == torch.float64 assert len(inputs.shape) == 1 or (len(inputs.shape) == 2 and inputs.shape[1] == 1) if len(inputs.shape) == 1: inputs = inputs[:, None] inputs_encoded = self.encoder_obj(inputs) return inputs_encoded @property def input_shape(self) -> torch.Size: return torch.Size([1]) @property def output_shape(self) -> torch.Size: return torch.Size(self.encoder_obj.output_shape) @staticmethod def update_config_with_metadata(feature_config, feature_metadata, *args, **kwargs): pass @staticmethod def get_schema_cls(): return NumberInputFeatureConfig def create_sample_input(self, batch_size: int = 2): return torch.rand([batch_size]) @classmethod def get_preproc_input_dtype(cls, metadata: TrainingSetMetadataDict) -> str: return "float32" @staticmethod def create_preproc_module(metadata: TrainingSetMetadataDict) -> torch.nn.Module: return _NumberPreprocessing(metadata) class NumberOutputFeature(NumberFeatureMixin, OutputFeature): def __init__( self, output_feature_config: NumberOutputFeatureConfig | dict, output_features: dict[str, OutputFeature], **kwargs, ): self.clip = output_feature_config.clip super().__init__(output_feature_config, output_features, **kwargs) self.decoder_obj = self.initialize_decoder(output_feature_config.decoder) self._setup_loss() self._setup_metrics() def logits(self, inputs, **kwargs): # hidden hidden = inputs[HIDDEN] return self.decoder_obj(hidden) def create_predict_module(self) -> PredictModule: if getattr(self, "clip", None) and not (isinstance(self.clip, (list, tuple)) and len(self.clip) == 2): raise ValueError( f"The clip parameter of {self.feature_name} is {self.clip}. " f"It must be a list or a tuple of length 2." ) return _NumberPredict(getattr(self, "clip", None)) def get_prediction_set(self): return {PREDICTIONS, LOGITS} @property def input_shape(self) -> torch.Size: return torch.Size([self.decoder_obj.config.input_size]) @classmethod def get_output_dtype(cls): return torch.float32 @property def output_shape(self) -> torch.Size: return torch.Size([1]) @staticmethod def update_config_with_metadata(feature_config, feature_metadata, *args, **kwargs): pass @staticmethod def calculate_overall_stats(predictions, targets, metadata): # no overall stats, just return empty dictionary return {} def postprocess_predictions( self, predictions, metadata, ): predictions_col = f"{self.feature_name}_{PREDICTIONS}" if predictions_col in predictions: # as needed convert predictions make to original value space numeric_transformer = get_from_registry( metadata["preprocessing"].get("normalization", None), numeric_transformation_registry, )(**metadata) predictions[predictions_col] = predictions[predictions_col].map( lambda pred: numeric_transformer.inverse_transform(pred) ) return predictions @staticmethod def get_schema_cls(): return NumberOutputFeatureConfig @classmethod def get_postproc_output_dtype(cls, metadata: TrainingSetMetadataDict) -> str: return "float32" @staticmethod def create_postproc_module(metadata: TrainingSetMetadataDict) -> torch.nn.Module: return _NumberPostprocessing(metadata) ================================================ FILE: ludwig/features/sequence_feature.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import logging from functools import partial import numpy as np import torch from ludwig.constants import ( COLUMN, LAST_PREDICTIONS, LENGTHS, NAME, PREDICTIONS, PROBABILITIES, PROBABILITY, PROC_COLUMN, SEQUENCE, ) from ludwig.features.base_feature import BaseFeatureMixin, InputFeature, OutputFeature, PredictModule from ludwig.features.feature_utils import compute_sequence_probability, compute_token_probabilities from ludwig.schema.features.sequence_feature import SequenceInputFeatureConfig, SequenceOutputFeatureConfig from ludwig.types import ( FeatureMetadataDict, FeaturePostProcessingOutputDict, ModelConfigDict, PreprocessingConfigDict, TrainingSetMetadataDict, ) from ludwig.utils import output_feature_utils from ludwig.utils.math_utils import softmax from ludwig.utils.strings_utils import ( build_sequence_matrix, create_vocabulary, SpecialSymbol, START_SYMBOL, STOP_SYMBOL, UNKNOWN_SYMBOL, ) from ludwig.utils.tokenizers import get_tokenizer_from_registry from ludwig.utils.types import TorchscriptPreprocessingInput logger = logging.getLogger(__name__) class _SequencePreprocessing(torch.nn.Module): """Torchscript-enabled version of preprocessing done by SequenceFeatureMixin.add_feature_data.""" def __init__(self, metadata: TrainingSetMetadataDict): super().__init__() self.lowercase = metadata["preprocessing"]["lowercase"] self.tokenizer_type = metadata["preprocessing"]["tokenizer"] self.tokenizer = get_tokenizer_from_registry(self.tokenizer_type)( pretrained_model_name_or_path=metadata["preprocessing"].get("pretrained_model_name_or_path", None) ) if not isinstance(self.tokenizer, torch.nn.Module): raise ValueError(f"tokenizer must be a torch.nn.Module, got {self.tokenizer}") self.padding_symbol = metadata["preprocessing"]["padding_symbol"] self.unknown_symbol = metadata["preprocessing"]["unknown_symbol"] self.start_symbol = START_SYMBOL self.stop_symbol = STOP_SYMBOL self.max_sequence_length = int(metadata["max_sequence_length"]) self.unit_to_id = metadata["str2idx"] self.computed_fill_value = metadata["preprocessing"]["computed_fill_value"] def forward(self, v: TorchscriptPreprocessingInput) -> torch.Tensor: """Takes a list of strings and returns a tensor of token ids.""" if not torch.jit.isinstance(v, list[str]): raise ValueError(f"Unsupported input: {v}") futures: list[torch.jit.Future[torch.Tensor]] = [] for sequence in v: futures.append( torch.jit.fork( self._process_sequence, sequence, ) ) sequence_matrix = [] for future in futures: sequence_matrix.append(torch.jit.wait(future)) return torch.stack(sequence_matrix) def _process_sequence(self, sequence: str) -> torch.Tensor: sequence = self.computed_fill_value if sequence == "nan" else sequence # If tokenizer is HF, we defer lowercase transformation to the tokenizer. if self.lowercase and self.tokenizer_type != "hf_tokenizer": sequence_str: str = sequence.lower() else: sequence_str: str = sequence sequence_vector = torch.full([self.max_sequence_length], self.unit_to_id[self.padding_symbol]) if self.tokenizer_type == "hf_tokenizer": # Handles start, stop, and unknown symbols implicitly unit_sequence = self.tokenizer(sequence) assert torch.jit.isinstance(unit_sequence, list[int]) # Ensures that the sequence lengths are aligned between the input and output tensors. sequence_length = min(len(unit_sequence), self.max_sequence_length) sequence_vector[:sequence_length] = torch.tensor(unit_sequence)[:sequence_length] return sequence_vector # If tokenizer is not HF, we manually convert tokens to IDs and insert start, stop, and unknown symbols. unit_sequence = self.tokenizer(sequence_str) assert torch.jit.isinstance(unit_sequence, list[str]) sequence_vector[0] = self.unit_to_id[self.start_symbol] if len(unit_sequence) + 1 < self.max_sequence_length: sequence_length = len(unit_sequence) sequence_vector[len(unit_sequence) + 1] = self.unit_to_id[self.stop_symbol] else: sequence_length = self.max_sequence_length - 1 for i in range(sequence_length): curr_unit = unit_sequence[i] if curr_unit in self.unit_to_id: curr_id = self.unit_to_id[curr_unit] else: curr_id = self.unit_to_id[self.unknown_symbol] sequence_vector[i + 1] = curr_id return sequence_vector class _SequencePostprocessing(torch.nn.Module): def __init__(self, metadata: TrainingSetMetadataDict): super().__init__() self.max_sequence_length = int(metadata["max_sequence_length"]) self.idx2str = metadata["idx2str"] self.unknown_symbol = UNKNOWN_SYMBOL self.predictions_key = PREDICTIONS self.probabilities_key = PROBABILITIES self.probability_key = PROBABILITY def forward(self, preds: dict[str, torch.Tensor], feature_name: str) -> FeaturePostProcessingOutputDict: pred_predictions = output_feature_utils.get_output_feature_tensor(preds, feature_name, self.predictions_key) pred_probabilities = output_feature_utils.get_output_feature_tensor(preds, feature_name, self.probabilities_key) predictions: list[list[str]] = [] for sequence in pred_predictions: sequence_predictions: list[str] = [] for i in range(self.max_sequence_length): unit_id = int(sequence[i].item()) if unit_id < len(self.idx2str): unit_prediction = self.idx2str[unit_id] else: unit_prediction = self.unknown_symbol sequence_predictions.append(unit_prediction) predictions.append(sequence_predictions) probabilities, _ = torch.max(pred_probabilities, dim=-1) probability = torch.sum(torch.log(probabilities.clamp(min=1e-10)), dim=-1) return { self.predictions_key: predictions, self.probabilities_key: probabilities, self.probability_key: probability, } class _SequencePredict(PredictModule): def forward(self, inputs: dict[str, torch.Tensor], feature_name: str) -> dict[str, torch.Tensor]: logits = output_feature_utils.get_output_feature_tensor(inputs, feature_name, self.logits_key) probabilities = torch.softmax(logits, -1) predictions = torch.argmax(logits, -1) # predictions: [batch_size, sequence_length] # probabilities: [batch_size, sequence_length, vocab_size] # logits: [batch_size, sequence_length, vocab_size] return {self.predictions_key: predictions, self.probabilities_key: probabilities, self.logits_key: logits} class SequenceFeatureMixin(BaseFeatureMixin): @staticmethod def type(): return SEQUENCE @staticmethod def cast_column(column, backend): return column.astype(str) @staticmethod def get_feature_meta( config: ModelConfigDict, column, preprocessing_parameters: PreprocessingConfigDict, backend, is_input_feature: bool, ) -> FeatureMetadataDict: vocabulary = create_vocabulary( column, preprocessing_parameters["tokenizer"], lowercase=preprocessing_parameters["lowercase"], num_most_frequent=preprocessing_parameters["most_common"], vocab_file=preprocessing_parameters["vocab_file"], unknown_symbol=preprocessing_parameters["unknown_symbol"], padding_symbol=preprocessing_parameters["padding_symbol"], ngram_size=preprocessing_parameters["ngram_size"], processor=backend.df_engine, ) logger.info( f"Max length of feature '{column.name}': {vocabulary.max_sequence_length} (without start and stop symbols)" ) # Use sequence_length if provided, otherwise use max length found in dataset. if preprocessing_parameters["sequence_length"] is not None: logger.info( f"Setting max length to sequence_length={preprocessing_parameters['sequence_length']} provided in " f"preprocessing parameters" ) max_sequence_length = preprocessing_parameters["sequence_length"] else: max_sequence_length = vocabulary.max_sequence_length logger.info(f"Setting max length using dataset: {max_sequence_length} (including start and stop symbols)") # If max_sequence_length is None, then use the max length found in the dataset. if ( preprocessing_parameters["max_sequence_length"] is not None and preprocessing_parameters["max_sequence_length"] < max_sequence_length ): logger.info( f"Truncating max length with max_sequence_length={preprocessing_parameters['max_sequence_length']} " f"from preprocessing parameters" ) max_sequence_length = preprocessing_parameters["max_sequence_length"] logger.info(f"Max sequence length is {max_sequence_length} for feature '{column.name}'") return { "idx2str": vocabulary.vocab, "str2idx": vocabulary.str2idx, "str2freq": vocabulary.str2freq, "vocab_size": len(vocabulary.vocab), "max_sequence_length": max_sequence_length, } @staticmethod def feature_data(column, metadata, preprocessing_parameters: PreprocessingConfigDict, backend): sequence_data = build_sequence_matrix( sequences=column, inverse_vocabulary=metadata["str2idx"], tokenizer_type=preprocessing_parameters["tokenizer"], length_limit=metadata["max_sequence_length"], padding_symbol=preprocessing_parameters["padding_symbol"], padding=preprocessing_parameters["padding"], unknown_symbol=preprocessing_parameters["unknown_symbol"], lowercase=preprocessing_parameters["lowercase"], tokenizer_vocab_file=preprocessing_parameters["vocab_file"], processor=backend.df_engine, ) return sequence_data @staticmethod def add_feature_data( feature_config, input_df, proc_df, metadata, preprocessing_parameters: PreprocessingConfigDict, backend, skip_save_processed_input, ): sequence_data = SequenceInputFeature.feature_data( input_df[feature_config[COLUMN]], metadata[feature_config[NAME]], preprocessing_parameters, backend, ) proc_df[feature_config[PROC_COLUMN]] = sequence_data return proc_df class SequenceInputFeature(SequenceFeatureMixin, InputFeature): def __init__(self, input_feature_config: SequenceInputFeatureConfig, encoder_obj=None, **kwargs): super().__init__(input_feature_config, **kwargs) if encoder_obj: self.encoder_obj = encoder_obj else: self.encoder_obj = self.initialize_encoder(input_feature_config.encoder) def forward(self, inputs: torch.Tensor, mask=None): assert isinstance(inputs, torch.Tensor) assert inputs.dtype in [torch.int8, inputs.dtype, torch.int16, torch.int32, torch.int64] assert len(inputs.shape) == 2 inputs_exp = inputs.type(torch.int32) inputs_mask = torch.not_equal(inputs, SpecialSymbol.PADDING.value) lengths = torch.sum(inputs_mask.type(torch.int32), dim=1) encoder_output = self.encoder_obj(inputs_exp, mask=inputs_mask) encoder_output[LENGTHS] = lengths return encoder_output @property def input_dtype(self): return torch.int32 @staticmethod def update_config_with_metadata(feature_config, feature_metadata, *args, **kwargs): feature_config.encoder.vocab = feature_metadata["idx2str"] feature_config.encoder.vocab_size = len(feature_metadata["idx2str"]) feature_config.encoder.max_sequence_length = feature_metadata["max_sequence_length"] @staticmethod def get_schema_cls(): return SequenceInputFeatureConfig @property def input_shape(self) -> torch.Size: return torch.Size([self.encoder_obj.config.max_sequence_length]) @property def output_shape(self) -> torch.Size: return self.encoder_obj.output_shape @staticmethod def create_preproc_module(metadata: TrainingSetMetadataDict) -> torch.nn.Module: return _SequencePreprocessing(metadata) class SequenceOutputFeature(SequenceFeatureMixin, OutputFeature): def __init__( self, output_feature_config: SequenceOutputFeatureConfig | dict, output_features: dict[str, OutputFeature], **kwargs, ): super().__init__(output_feature_config, output_features, **kwargs) self.decoder_obj = self.initialize_decoder(output_feature_config.decoder) self._setup_loss() self._setup_metrics() def logits(self, inputs: dict[str, torch.Tensor], target=None): return self.decoder_obj(inputs, target=target) def create_predict_module(self) -> PredictModule: return _SequencePredict() def get_prediction_set(self): return self.decoder_obj.get_prediction_set() @classmethod def get_output_dtype(cls): return torch.int32 @property def input_shape(self) -> torch.Size: # Dummy implementation. return torch.Size([1]) @property def output_shape(self) -> torch.Size: return torch.Size([self.decoder_obj.config.max_sequence_length]) @staticmethod def update_config_with_metadata(feature_config, feature_metadata, *args, **kwargs): feature_config.decoder.vocab_size = feature_metadata["vocab_size"] feature_config.decoder.max_sequence_length = feature_metadata["max_sequence_length"] if isinstance(feature_config.loss.class_weights, (list, tuple)): if len(feature_config.loss.class_weights) != feature_config.decoder.vocab_size: raise ValueError( f"The length of class_weights ({len(feature_config.loss.class_weights)}) is not compatible with " f"the number of classes ({feature_config.decoder.vocab_size}) for feature {feature_config.column}. " "Check the metadata JSON file to see the classes " "and their order and consider there needs to be a weight " "for the and class too." ) if isinstance(feature_config.loss.class_weights, dict): if feature_metadata["str2idx"].keys() != feature_config.loss.class_weights.keys(): raise ValueError( f"The class_weights keys ({feature_config.loss.class_weights.keys()}) are not compatible with " f'the classes ({feature_metadata["str2idx"].keys()}) of feature {feature_config.column}. ' "Check the metadata JSON file to see the classes " "and consider there needs to be a weight " "for the class too." ) else: class_weights = feature_config.loss.class_weights idx2str = feature_metadata["idx2str"] class_weights_list = [class_weights[s] for s in idx2str] feature_config.loss.class_weights = class_weights_list if feature_config.loss.class_similarities_temperature > 0: if feature_config.loss.class_similarities is not None: similarities = feature_config.loss.class_similarities temperature = feature_config.loss.class_similarities_temperature curr_row = 0 first_row_length = 0 is_first_row = True for row in similarities: if is_first_row: first_row_length = len(row) is_first_row = False curr_row += 1 else: curr_row_length = len(row) if curr_row_length != first_row_length: raise ValueError( "The length of row {} of the class_similarities " "of {} is {}, different from the length of " "the first row {}. All rows must have " "the same length.".format( curr_row, feature_config.column, curr_row_length, first_row_length ) ) else: curr_row += 1 all_rows_length = first_row_length if all_rows_length != len(similarities): raise ValueError( f"The class_similarities matrix of {feature_config.column} has " f"{len(similarities)} rows and {all_rows_length} columns, " "their number must be identical." ) if all_rows_length != feature_config.decoder.vocab_size: raise ValueError( f"The size of the class_similarities matrix of {feature_config.column} is " f"{all_rows_length}, different from the number of classes " f"({feature_config.decoder.vocab_size}). Check the metadata JSON file to see the classes " "and their order and " "consider and class too." ) similarities = np.array(similarities, dtype=np.float32) for i in range(len(similarities)): similarities[i, :] = softmax(similarities[i, :], temperature=temperature) feature_config.loss.class_similarities = similarities else: raise ValueError( "class_similarities_temperature > 0, " "but no class_similarities are provided " f"for feature {feature_config.column}" ) @staticmethod def calculate_overall_stats(predictions, targets, train_set_metadata): # TODO(Justin): Add a confusion matrix, see # https://github.com/ludwig-ai/ludwig/blob/tf-legacy/ludwig/features/sequence_feature.py#L411 return {} def postprocess_predictions( self, result, metadata, ): predictions_col = f"{self.feature_name}_{PREDICTIONS}" lengths_col = f"{self.feature_name}_{LENGTHS}" if predictions_col in result: if "idx2str" in metadata: def idx2str(row): pred = row[predictions_col] length = metadata["max_sequence_length"] return [ metadata["idx2str"][token] if token < len(metadata["idx2str"]) else UNKNOWN_SYMBOL for token in [pred[i] for i in range(length)] ] result[predictions_col] = result.apply(idx2str, axis=1) last_preds_col = f"{self.feature_name}_{LAST_PREDICTIONS}" if last_preds_col in result: if "idx2str" in metadata: def last_idx2str(last_pred): if last_pred < len(metadata["idx2str"]): return metadata["idx2str"][last_pred] return UNKNOWN_SYMBOL result[last_preds_col] = result[last_preds_col].map(last_idx2str) probs_col = f"{self.feature_name}_{PROBABILITIES}" prob_col = f"{self.feature_name}_{PROBABILITY}" if probs_col in result: # currently does not return full probabilties because usually it is huge: # dataset x length x classes # TODO: add a mechanism for letting the user decide to save it result[probs_col] = result[probs_col].map(compute_token_probabilities) result[prob_col] = result[probs_col].map( partial( compute_sequence_probability, max_sequence_length=metadata["max_sequence_length"], return_log_prob=True, ) ) if lengths_col in result: del result[lengths_col] return result @staticmethod def create_postproc_module(metadata: TrainingSetMetadataDict) -> torch.nn.Module: return _SequencePostprocessing(metadata) @staticmethod def get_schema_cls(): return SequenceOutputFeatureConfig ================================================ FILE: ludwig/features/set_feature.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import logging from typing import Any import numpy as np import torch from ludwig.constants import COLUMN, HIDDEN, LOGITS, NAME, PREDICTIONS, PROBABILITIES, PROC_COLUMN, SET from ludwig.features.base_feature import BaseFeatureMixin, InputFeature, OutputFeature, PredictModule from ludwig.features.feature_utils import set_str_to_idx from ludwig.schema.features.set_feature import SetInputFeatureConfig, SetOutputFeatureConfig from ludwig.types import ( FeatureMetadataDict, FeaturePostProcessingOutputDict, ModelConfigDict, PreprocessingConfigDict, TrainingSetMetadataDict, ) from ludwig.utils import output_feature_utils from ludwig.utils.strings_utils import create_vocabulary, UNKNOWN_SYMBOL from ludwig.utils.tokenizers import get_tokenizer_from_registry, TORCHSCRIPT_COMPATIBLE_TOKENIZERS from ludwig.utils.types import TorchscriptPreprocessingInput logger = logging.getLogger(__name__) class _SetPreprocessing(torch.nn.Module): """Torchscript-enabled version of preprocessing done by SetFeatureMixin.add_feature_data. If is_bag is true, forward returns a vector for each sample indicating counts of each token. Else, forward returns a multi-hot vector for each sample indicating presence of each token. """ def __init__(self, metadata: TrainingSetMetadataDict, is_bag: bool = False): super().__init__() if metadata["preprocessing"]["tokenizer"] not in TORCHSCRIPT_COMPATIBLE_TOKENIZERS: raise ValueError( f"{metadata['preprocessing']['tokenizer']} is not supported by torchscript. Please use " f"one of {TORCHSCRIPT_COMPATIBLE_TOKENIZERS}." ) self.lowercase = metadata["preprocessing"]["lowercase"] self.tokenizer = get_tokenizer_from_registry(metadata["preprocessing"]["tokenizer"])() self.vocab_size = metadata["vocab_size"] self.unknown_symbol = UNKNOWN_SYMBOL self.unit_to_id = metadata["str2idx"] self.is_bag = is_bag def forward(self, v: TorchscriptPreprocessingInput) -> torch.Tensor: """Takes a list of strings and returns a tensor of counts for each token.""" if not torch.jit.isinstance(v, list[str]): raise ValueError(f"Unsupported input: {v}") if self.lowercase: sequences = [sequence.lower() for sequence in v] else: sequences = v unit_sequences = self.tokenizer(sequences) # refines type of unit_sequences from Any to List[List[str]] assert torch.jit.isinstance(unit_sequences, list[list[str]]), "unit_sequences is not a list of lists." set_matrix = torch.zeros(len(unit_sequences), self.vocab_size, dtype=torch.float32) for sample_idx, unit_sequence in enumerate(unit_sequences): sequence_length = len(unit_sequence) for i in range(sequence_length): curr_unit = unit_sequence[i] if curr_unit in self.unit_to_id: curr_id = self.unit_to_id[curr_unit] else: curr_id = self.unit_to_id[self.unknown_symbol] if self.is_bag: set_matrix[sample_idx][curr_id] += 1 else: set_matrix[sample_idx][curr_id] = 1 return set_matrix class _SetPostprocessing(torch.nn.Module): """Torchscript-enabled version of postprocessing done by SetFeatureMixin.add_feature_data.""" def __init__(self, metadata: TrainingSetMetadataDict): super().__init__() self.idx2str = {i: v for i, v in enumerate(metadata["idx2str"])} self.predictions_key = PREDICTIONS self.probabilities_key = PROBABILITIES self.unk = UNKNOWN_SYMBOL def forward(self, preds: dict[str, torch.Tensor], feature_name: str) -> FeaturePostProcessingOutputDict: predictions = output_feature_utils.get_output_feature_tensor(preds, feature_name, self.predictions_key) probabilities = output_feature_utils.get_output_feature_tensor(preds, feature_name, self.probabilities_key) inv_preds: list[list[str]] = [] filtered_probs: list[torch.Tensor] = [] for sample_idx, sample in enumerate(predictions): sample_preds: list[str] = [] pos_sample_idxs: list[int] = [] pos_class_idxs: list[int] = [] for class_idx, is_positive in enumerate(sample): if is_positive == 1: sample_preds.append(self.idx2str.get(class_idx, self.unk)) pos_sample_idxs.append(sample_idx) pos_class_idxs.append(class_idx) inv_preds.append(sample_preds) filtered_probs.append(probabilities[pos_sample_idxs, pos_class_idxs]) return { self.predictions_key: inv_preds, self.probabilities_key: filtered_probs, } class _SetPredict(PredictModule): def __init__(self, threshold): super().__init__() self.threshold = threshold def forward(self, inputs: dict[str, torch.Tensor], feature_name: str) -> dict[str, torch.Tensor]: logits = output_feature_utils.get_output_feature_tensor(inputs, feature_name, self.logits_key) probabilities = torch.sigmoid(logits) predictions = torch.greater_equal(probabilities, self.threshold) predictions = predictions.type(torch.int64) return {self.predictions_key: predictions, self.probabilities_key: probabilities, self.logits_key: logits} class SetFeatureMixin(BaseFeatureMixin): @staticmethod def type(): return SET @staticmethod def cast_column(column, backend): return column.astype(str) @staticmethod def get_feature_meta( config: ModelConfigDict, column, preprocessing_parameters: PreprocessingConfigDict, backend, is_input_feature: bool, ) -> FeatureMetadataDict: vocabulary = create_vocabulary( column, preprocessing_parameters["tokenizer"], num_most_frequent=preprocessing_parameters["most_common"], lowercase=preprocessing_parameters["lowercase"], add_special_symbols=False, processor=backend.df_engine, ) return { "idx2str": vocabulary.vocab, "str2idx": vocabulary.str2idx, "str2freq": vocabulary.str2freq, "vocab_size": len(vocabulary.str2idx), "max_set_size": vocabulary.max_sequence_length, } @staticmethod def feature_data(column, metadata, preprocessing_parameters: PreprocessingConfigDict, backend): def to_dense(x): feature_vector = set_str_to_idx(x, metadata["str2idx"], preprocessing_parameters["tokenizer"]) set_vector = np.zeros((len(metadata["str2idx"]),)) set_vector[feature_vector] = 1 return set_vector.astype(np.bool_) return backend.df_engine.map_objects(column, to_dense) @staticmethod def add_feature_data( feature_config, input_df, proc_df, metadata, preprocessing_parameters: PreprocessingConfigDict, backend, skip_save_processed_input, ): proc_df[feature_config[PROC_COLUMN]] = SetFeatureMixin.feature_data( input_df[feature_config[COLUMN]], metadata[feature_config[NAME]], preprocessing_parameters, backend, ) return proc_df class SetInputFeature(SetFeatureMixin, InputFeature): def __init__(self, input_feature_config: SetInputFeatureConfig, encoder_obj=None, **kwargs): super().__init__(input_feature_config, **kwargs) if encoder_obj: self.encoder_obj = encoder_obj else: self.encoder_obj = self.initialize_encoder(input_feature_config.encoder) def forward(self, inputs): assert isinstance(inputs, torch.Tensor) assert inputs.dtype in [torch.bool, torch.int64, torch.float32] encoder_output = self.encoder_obj(inputs) return encoder_output @property def input_dtype(self): return torch.bool @property def input_shape(self) -> torch.Size: return torch.Size([len(self.encoder_obj.config.vocab)]) @staticmethod def update_config_with_metadata(feature_config, feature_metadata, *args, **kwargs): feature_config.encoder.vocab = feature_metadata["idx2str"] @staticmethod def get_schema_cls(): return SetInputFeatureConfig @property def output_shape(self) -> torch.Size: return self.encoder_obj.output_shape @staticmethod def create_preproc_module(metadata: TrainingSetMetadataDict) -> torch.nn.Module: return _SetPreprocessing(metadata) class SetOutputFeature(SetFeatureMixin, OutputFeature): def __init__( self, output_feature_config: SetOutputFeatureConfig | dict, output_features: dict[str, OutputFeature], **kwargs, ): self.threshold = output_feature_config.threshold super().__init__(output_feature_config, output_features, **kwargs) self.decoder_obj = self.initialize_decoder(output_feature_config.decoder) self._setup_loss() self._setup_metrics() def logits(self, inputs, **kwargs): # hidden hidden = inputs[HIDDEN] return self.decoder_obj(hidden) def metric_kwargs(self) -> dict[str, Any]: return {"threshold": self.threshold} def create_predict_module(self) -> PredictModule: return _SetPredict(self.threshold) def get_prediction_set(self): return {PREDICTIONS, PROBABILITIES, LOGITS} @classmethod def get_output_dtype(cls): return torch.bool @property def input_shape(self) -> torch.Size: return self.decoder_obj.input_shape @property def output_shape(self) -> torch.Size: return torch.Size([self.decoder_obj.config.num_classes]) @staticmethod def update_config_with_metadata(feature_config, feature_metadata, *args, **kwargs): feature_config.decoder.num_classes = feature_metadata["vocab_size"] if isinstance(feature_config.loss.class_weights, (list, tuple)): if len(feature_config.loss.class_weights) != feature_config.decoder.num_classes: raise ValueError( f"The length of class_weights ({len(feature_config.loss.class_weights)}) is not compatible with " f"the number of classes ({feature_config.decoder.num_classes}) for feature {feature_config.name}. " "Check the metadata JSON file to see the classes " "and their order and consider there needs to be a weight " "for the and class too." ) if isinstance(feature_config.loss.class_weights, dict): if feature_metadata["str2idx"].keys() != feature_config.loss.class_weights.keys(): raise ValueError( f"The class_weights keys ({feature_config.loss.class_weights.keys()}) are not compatible with " f'the classes ({feature_metadata["str2idx"].keys()}) of feature {feature_config.name}. ' "Check the metadata JSON file to see the classes " "and consider there needs to be a weight " "for the and class too." ) else: class_weights = feature_config.loss.class_weights idx2str = feature_metadata["idx2str"] class_weights_list = [class_weights[s] for s in idx2str] feature_config.loss.class_weights = class_weights_list @staticmethod def calculate_overall_stats(predictions, targets, train_set_metadata): # no overall stats, just return empty dictionary return {} def postprocess_predictions( self, result, metadata, ): predictions_col = f"{self.feature_name}_{PREDICTIONS}" if predictions_col in result: def idx2str(pred_set): return [metadata["idx2str"][i] for i, pred in enumerate(pred_set) if pred] result[predictions_col] = result[predictions_col].map(idx2str) probabilities_col = f"{self.feature_name}_{PROBABILITIES}" if probabilities_col in result: def get_prob(prob_set): # Cast to float32 because empty np.array objects are np.float64, causing mismatch errors during saving. return np.array([prob for prob in prob_set if prob >= self.threshold], dtype=np.float32) result[probabilities_col] = result[probabilities_col].map(get_prob) return result @staticmethod def create_postproc_module(metadata: TrainingSetMetadataDict) -> torch.nn.Module: return _SetPostprocessing(metadata) @staticmethod def get_schema_cls(): return SetOutputFeatureConfig ================================================ FILE: ludwig/features/text_feature.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import logging from functools import partial import numpy as np import torch from torch import Tensor from transformers import PreTrainedTokenizer from ludwig.constants import ( COLUMN, IGNORE_INDEX_TOKEN_ID, LAST_PREDICTIONS, LENGTHS, NAME, PREDICTIONS, PREPROCESSING, PROBABILITIES, PROBABILITY, PROC_COLUMN, RESPONSE, TEXT, ) from ludwig.features.base_feature import BaseFeatureMixin, OutputFeature from ludwig.features.feature_utils import compute_sequence_probability, compute_token_probabilities from ludwig.features.sequence_feature import ( _SequencePostprocessing, _SequencePreprocessing, SequenceInputFeature, SequenceOutputFeature, ) from ludwig.modules.metric_registry import get_metric_tensor_input from ludwig.schema.features.text_feature import TextInputFeatureConfig, TextOutputFeatureConfig from ludwig.types import FeatureMetadataDict, ModelConfigDict, PreprocessingConfigDict, TrainingSetMetadataDict from ludwig.utils.math_utils import softmax from ludwig.utils.strings_utils import ( build_sequence_matrix, create_vocabulary, get_tokenizer, SpecialSymbol, UNKNOWN_SYMBOL, Vocabulary, ) logger = logging.getLogger(__name__) def get_decoded_targets_and_predictions( targets: Tensor, predictions: dict[str, Tensor], tokenizer: PreTrainedTokenizer, ) -> tuple[list[str], list[str]]: """Returns the decoded targets and predictions, accounting for IGNORE_INDEX_TOKEN_ID.""" # Ensure targets and predictions are on the same device pred_tensor = predictions[PREDICTIONS] if targets.device != pred_tensor.device: targets = targets.to(pred_tensor.device) sanitized_targets = torch.where(targets != IGNORE_INDEX_TOKEN_ID, targets, tokenizer.pad_token_id) sanitized_predictions = torch.where( targets != IGNORE_INDEX_TOKEN_ID, pred_tensor, tokenizer.pad_token_id, ) decoded_targets = tokenizer.batch_decode(sanitized_targets, skip_special_tokens=True) decoded_predictions = tokenizer.batch_decode(sanitized_predictions, skip_special_tokens=True) return decoded_targets, decoded_predictions def _get_metadata_reconciled_max_sequence_length( preprocessing_parameters: dict, vocabulary: Vocabulary ) -> tuple[int, int]: """Reconciles the different ways sequence length can be specified in preprocessing parameters. If the max sequence length is explicitly specified, we use the minimum of the true maximum sequence length and the explicitly specified value. If the explicitly specified value is less than the true maximum sequence length, we log a warning. If the max sequence length is not specified, we use the true maximum sequence length. Returns: Tuple(max_sequence_length, sequence_length_99ptile). """ # For sequence features with a fixed length specified by `sequence_length`, use this as the max_sequence_length. if preprocessing_parameters["sequence_length"] is not None: return preprocessing_parameters["sequence_length"], preprocessing_parameters["sequence_length"] # Max sequence length is explicitly set. Use this as the max_sequence_length. if preprocessing_parameters["max_sequence_length"] is not None: if preprocessing_parameters["max_sequence_length"] < vocabulary.max_sequence_length: logger.warning( f"The max sequence length of the data, {vocabulary.max_sequence_length}, is longer than the max " f"sequence length set in the config, {preprocessing_parameters['max_sequence_length']}. Note that this " "will truncate all examples to max_sequence_length=" f"{preprocessing_parameters['max_sequence_length']}." ) return ( min(vocabulary.max_sequence_length, preprocessing_parameters["max_sequence_length"]), min(vocabulary.sequence_length_99ptile, preprocessing_parameters["max_sequence_length"]), ) # Max sequence length is None. Use the max sequence length of the data. return vocabulary.max_sequence_length, vocabulary.sequence_length_99ptile class TextFeatureMixin(BaseFeatureMixin): @staticmethod def type(): return TEXT @staticmethod def cast_column(column, backend): return column.astype(str) @staticmethod def get_feature_meta( config: ModelConfigDict, column, preprocessing_parameters: PreprocessingConfigDict, backend, is_input_feature: bool, ) -> FeatureMetadataDict: """Returns all metadata for the given text feature. Raises: ValueError, if the tokenized prompt template is longer than the max sequence length. """ prompt_template = config.get("prompt", {}).get("template", "") vocabulary: Vocabulary = create_vocabulary( column, tokenizer_type=preprocessing_parameters["tokenizer"], num_most_frequent=preprocessing_parameters["most_common"], lowercase=preprocessing_parameters["lowercase"], vocab_file=preprocessing_parameters["vocab_file"], unknown_symbol=preprocessing_parameters["unknown_symbol"], padding_symbol=preprocessing_parameters["padding_symbol"], pretrained_model_name_or_path=preprocessing_parameters["pretrained_model_name_or_path"], ngram_size=preprocessing_parameters["ngram_size"], compute_idf=preprocessing_parameters["compute_idf"], processor=backend.df_engine, prompt_template=prompt_template, ) # Note: The vocabulary's max_sequence_length includes the prompt template, which is merged into the column prior # to computing feature metadata. logger.info( f"Max length of feature '{column.name}': {vocabulary.max_sequence_length} (without start and stop symbols)" ) max_sequence_length, max_sequence_length_99ptile = _get_metadata_reconciled_max_sequence_length( preprocessing_parameters, vocabulary ) if is_input_feature and max_sequence_length < vocabulary.prompt_template_num_tokens: raise ValueError( f"The input feature's max sequence length ({max_sequence_length}) is shorter than the prompt template " f"length ({vocabulary.prompt_template_num_tokens}). This will truncate all unique information. " "Consider making the template shorter or increasing the input feature's max sequence length to a " f"value >> {vocabulary.prompt_template_num_tokens}." ) logger.info(f"Max sequence length is {max_sequence_length} for feature '{column.name}'") return { "idx2str": vocabulary.vocab, "str2idx": vocabulary.str2idx, "str2freq": vocabulary.str2freq, "str2idf": vocabulary.str2idf, "vocab_size": len(vocabulary.vocab), "max_sequence_length": max_sequence_length, "max_sequence_length_99ptile": max_sequence_length_99ptile, "pad_idx": vocabulary.pad_idx, "padding_symbol": vocabulary.padding_symbol, "unknown_symbol": vocabulary.unknown_symbol, "prompt_template_num_tokens": vocabulary.prompt_template_num_tokens, } @staticmethod def feature_data(column, metadata, preprocessing_parameters: PreprocessingConfigDict, backend) -> np.ndarray: # TODO(1891): Remove backward compatibility hack once all models have been retrained with Ludwig after # https://github.com/ludwig-ai/ludwig/pull/1859. prefix = "" padding_symbol_metadata_key = "padding_symbol" unknown_symbol_metadata_key = "unknown_symbol" if "str2idx" not in metadata: prefix = "word_" padding_symbol_metadata_key = "word_pad_symbol" unknown_symbol_metadata_key = "word_unk_symbol" # ensure preprocessing param values match the metadata determined from dataset preprocessing_parameters["padding_symbol"] = metadata[padding_symbol_metadata_key] preprocessing_parameters["unknown_symbol"] = metadata[unknown_symbol_metadata_key] if preprocessing_parameters["fill_value"] == UNKNOWN_SYMBOL: preprocessing_parameters["fill_value"] = preprocessing_parameters["unknown_symbol"] if ( "computed_fill_value" in preprocessing_parameters and preprocessing_parameters["computed_fill_value"] == UNKNOWN_SYMBOL ): preprocessing_parameters["computed_fill_value"] = preprocessing_parameters["unknown_symbol"] sequences = column return build_sequence_matrix( sequences=sequences, inverse_vocabulary=metadata[f"{prefix}str2idx"], tokenizer_type=preprocessing_parameters[f"{prefix}tokenizer"], length_limit=metadata[f"{prefix}max_sequence_length"], padding_symbol=metadata[padding_symbol_metadata_key], padding=preprocessing_parameters["padding"], unknown_symbol=metadata[unknown_symbol_metadata_key], lowercase=preprocessing_parameters["lowercase"], tokenizer_vocab_file=preprocessing_parameters[f"{prefix}vocab_file"], pretrained_model_name_or_path=preprocessing_parameters["pretrained_model_name_or_path"], processor=backend.df_engine, ) @staticmethod def add_feature_data( feature_config, input_df, proc_df, metadata, preprocessing_parameters: PreprocessingConfigDict, backend, skip_save_processed_input, ): proc_df[feature_config[PROC_COLUMN]] = TextFeatureMixin.feature_data( input_df[feature_config[COLUMN]], metadata[feature_config[NAME]], preprocessing_parameters, backend, ) return proc_df class TextInputFeature(TextFeatureMixin, SequenceInputFeature): def __init__(self, input_feature_config: TextInputFeatureConfig, encoder_obj=None, **kwargs): super().__init__(input_feature_config, encoder_obj=encoder_obj, **kwargs) def forward(self, inputs, mask=None): assert isinstance(inputs, torch.Tensor) assert ( inputs.dtype == torch.int8 or inputs.dtype == torch.int16 or inputs.dtype == torch.int32 or inputs.dtype == torch.int64 ) assert len(inputs.shape) == 2 inputs_mask = torch.not_equal(inputs, SpecialSymbol.PADDING.value) inputs_exp = inputs.type(torch.int32) lengths = torch.sum(inputs_mask.type(torch.int32), dim=1) encoder_output = self.encoder_obj(inputs_exp, mask=inputs_mask) encoder_output[LENGTHS] = lengths return encoder_output @property def input_dtype(self): return torch.int32 @property def input_shape(self): return torch.Size([self.encoder_obj.config.max_sequence_length]) def update_config_after_module_init(self, feature_config): feature_config.encoder = self.encoder_obj.config @staticmethod def update_config_with_metadata(feature_config, feature_metadata, *args, **kwargs): feature_config.encoder.vocab = feature_metadata["idx2str"] feature_config.encoder.vocab_size = len(feature_metadata["idx2str"]) feature_config.encoder.max_sequence_length = feature_metadata["max_sequence_length"] feature_config.encoder.pad_idx = feature_metadata["pad_idx"] feature_config.encoder.num_tokens = len(feature_metadata["idx2str"]) feature_config.encoder.str2freq = feature_metadata["str2freq"] feature_config.encoder.str2idf = feature_metadata["str2idf"] feature_config.encoder.skip = feature_metadata[PREPROCESSING].get("cache_encoder_embeddings", False) @staticmethod def get_schema_cls(): return TextInputFeatureConfig @property def output_shape(self) -> torch.Size: return self.encoder_obj.output_shape @staticmethod def create_preproc_module(metadata: TrainingSetMetadataDict) -> torch.nn.Module: return _SequencePreprocessing(metadata) class TextOutputFeature(TextFeatureMixin, SequenceOutputFeature): def __init__( self, output_feature_config: TextOutputFeatureConfig | dict, output_features: dict[str, OutputFeature], **kwargs, ): super().__init__(output_feature_config, output_features, **kwargs) @classmethod def get_output_dtype(cls): return torch.int32 @property def output_shape(self) -> torch.Size: return torch.Size([self.decoder_obj.config.max_sequence_length]) def update_metrics( self, targets: Tensor, predictions: dict[str, Tensor], tokenizer: PreTrainedTokenizer | None = None, ) -> None: """Updates metrics with the given targets and predictions. If decoded_targets and decoded_predictions are provided, as through LLM model types, then additional response-based metrics like BLEU and ROUGE are also computed. Args: targets: Tensor with target values for this output feature. predictions: Dict of tensors returned by predictions(). """ if tokenizer is not None: # Decode the targets and predictions to compute response-based metrics using the initialized tokenizer. decoded_targets, decoded_predictions = get_decoded_targets_and_predictions(targets, predictions, tokenizer) for metric_name, metric_fn in self._metric_functions.items(): prediction_key = get_metric_tensor_input(metric_name) try: if prediction_key == RESPONSE: if tokenizer is not None: # RESPONSE metrics cannot be computed if decoded texts are not provided. # Decoded texts are only provided using the LLM model type. if decoded_targets is not None and decoded_predictions is not None: # Move metric function to the device of the predictions. # For CUDA, it can be computed on any of the GPUs since it uses allgather to collect # the results from all GPUs and compute the final metric. # We use 'predictions' as the key since it is always present in the predictions dict. device = "cuda" if predictions["predictions"].is_cuda else "cpu" metric_fn = metric_fn.to(device) if metric_name == "bleu": # BLEU takes in targets as a list. metric_fn.update(decoded_predictions, [decoded_targets]) else: metric_fn.update(decoded_predictions, decoded_targets) else: metric_fn = metric_fn.to(predictions[prediction_key].device) metric_fn.update(predictions[prediction_key].detach(), targets) except Exception as e: logger.info(f"Ran into error when calculating metric {metric_name}. Skipping. The error is: {e}") @staticmethod def update_config_with_metadata(feature_config, feature_metadata, *args, **kwargs): feature_config.decoder.vocab_size = feature_metadata["vocab_size"] feature_config.decoder.max_sequence_length = feature_metadata["max_sequence_length"] if isinstance(feature_config.loss.class_weights, (list, tuple)): # [0, 0] for UNK and PAD feature_config.loss.class_weights = [0, 0] + feature_config.loss.class_weights if len(feature_config.loss.class_weights) != feature_config.decoder.vocab_size: raise ValueError( f"The length of class_weights ({len(feature_config.loss.class_weights)}) is not compatible with " f"the number of classes ({feature_config.decoder.vocab_size})" ) if isinstance(feature_config.loss.class_weights, dict): if feature_metadata["str2idx"].keys() != feature_config.loss.class_weights.keys(): raise ValueError( f"The class_weights keys ({feature_config.loss.class_weights.keys()}) are not compatible with " f'the classes ({feature_metadata["str2idx"].keys()}) of feature {feature_config.column}. ' "Check the metadata JSON file to see the classes " "and consider there needs to be a weight " "for the class too." ) else: class_weights = feature_config.loss.class_weights idx2str = feature_metadata["idx2str"] class_weights_list = [class_weights[s] for s in idx2str] feature_config.loss.class_weights = class_weights_list if feature_config.loss.class_similarities_temperature > 0: if feature_config.class_similarities: distances = feature_config.class_similarities temperature = feature_config.loss.class_similarities_temperature for i in range(len(distances)): distances[i, :] = softmax(distances[i, :], temperature=temperature) feature_config.loss.class_similarities = distances else: raise ValueError( "class_similarities_temperature > 0," "but no class similarities are provided " "for feature {}".format(feature_config.column) ) @staticmethod def calculate_overall_stats( predictions, targets, train_set_metadata, ): return {} def postprocess_predictions( self, result, metadata, ): # todo: refactor to reuse SequenceOutputFeature.postprocess_predictions predictions_col = f"{self.feature_name}_{PREDICTIONS}" tokenizer = None if metadata["preprocessing"]["tokenizer"] == "hf_tokenizer": tokenizer = get_tokenizer( metadata["preprocessing"]["tokenizer"], metadata["preprocessing"]["vocab_file"], metadata["preprocessing"]["pretrained_model_name_or_path"], ) if predictions_col in result: token_col = result[predictions_col] def idx2str(pred): if tokenizer is None: return [ metadata["idx2str"][token] if token < len(metadata["idx2str"]) else UNKNOWN_SYMBOL for token in pred ] # Decode each token ID individually. In transformers 5.x, batch_decode # on a 1D array treats it as a single sequence rather than individual tokens. return [tokenizer.tokenizer.decode([int(token_id)], skip_special_tokens=True) for token_id in pred] result[predictions_col] = token_col.map(idx2str) # Add additional response column that represents the predicted text output # as a single string instead of a list of tokens. def idx2response(pred): if tokenizer is None: # This works because we treat each word as a token. return " ".join( [ metadata["idx2str"][token] if token < len(metadata["idx2str"]) else UNKNOWN_SYMBOL for token in pred ] ) return tokenizer.tokenizer.decode(pred, skip_special_tokens=True) result[f"{self.feature_name}_response"] = token_col.map(idx2response) last_preds_col = f"{self.feature_name}_{LAST_PREDICTIONS}" if last_preds_col in result: def last_idx2str(last_pred): if last_pred < len(metadata["idx2str"]): return metadata["idx2str"][last_pred] return UNKNOWN_SYMBOL result[last_preds_col] = result[last_preds_col].map(last_idx2str) probs_col = f"{self.feature_name}_{PROBABILITIES}" prob_col = f"{self.feature_name}_{PROBABILITY}" # "Summarizes" the `result`'s probability-related output: # - result[probs_col]: # Each row is now a list of "max" probabilities. Each element is the probability of the argmax token for # the given time step. # # Note that we intentionally do not return full list of probabilties for each time step because the output # of postprocess_predictions is saved to disk and the full probability distribution can be huge, # especially for large vocab sizes: # dataset_size x sequence_length x vocab_size # # TODO: Add a mechanism that lets the user save the full probability distribution if they want. # - result[prob_col]: # Each row is the overall probability of the sequence. This is the product of the max probabilities over # all time steps. if probs_col in result: # result[probs_col]: From PredictModule, each row has a list of size (sequence_length) of a list of # probabiltiies of (vocab_size). compute_token_probabilities gets the maximum probability per timestep. result[probs_col] = result[probs_col].map(compute_token_probabilities) result[prob_col] = result[probs_col].map( partial( compute_sequence_probability, max_sequence_length=metadata["max_sequence_length"], return_log_prob=True, ), ) lengths_col = f"{self.feature_name}_{LENGTHS}" if lengths_col in result: del result[lengths_col] return result @staticmethod def create_postproc_module(metadata: TrainingSetMetadataDict) -> torch.nn.Module: return _SequencePostprocessing(metadata) @staticmethod def get_schema_cls(): return TextOutputFeatureConfig ================================================ FILE: ludwig/features/timeseries_feature.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import logging from typing import TYPE_CHECKING import numpy as np import torch from ludwig.constants import COLUMN, HIDDEN, LOGITS, NAME, PREDICTIONS, PROC_COLUMN, TIMESERIES from ludwig.features.base_feature import BaseFeatureMixin, OutputFeature, PredictModule from ludwig.features.sequence_feature import SequenceInputFeature from ludwig.features.vector_feature import _VectorPostprocessing, _VectorPredict from ludwig.schema.features.timeseries_feature import TimeseriesInputFeatureConfig, TimeseriesOutputFeatureConfig from ludwig.types import FeatureMetadataDict, ModelConfigDict, PreprocessingConfigDict, TrainingSetMetadataDict from ludwig.utils.tokenizers import get_tokenizer_from_registry, TORCHSCRIPT_COMPATIBLE_TOKENIZERS from ludwig.utils.types import Series, TorchscriptPreprocessingInput if TYPE_CHECKING: from ludwig.backend.base import Backend logger = logging.getLogger(__name__) def create_time_delay_embedding( series: Series, window_size: int, horizon: int, padding_value: int, backend: "Backend" ) -> Series: """Time delay embedding from: https://towardsdatascience.com/machine-learning-for-forecasting-transformations-and-feature-extraction-bbbea9de0ac2 Args: series: Column-major timeseries data. window_size: Size of the lookback sliding window for timeseries inputs. horizon: Size of the forward-looking horizon for timeseries outputs. padding_value: Value to pad out the window when there is not enough data around the observation. Returns: A column of timeseries window arrays in row-major format for training. """ # Replace default fill value of "" with nan as we will be assuming numeric values here series = series.replace("", np.nan) # Create the list of shifts we want to perform over the series. # For backwards looking shifts, we want to include the current element, while for forward looking shifts we do not. # Example: # window_size=3, horizon=0 --> shift_offsets=[2, 1, 0] # window_size=0, horizon=2 --> shift_offsets=[-1, -2] shift_offsets = list(range(window_size - 1, -(horizon + 1), -1)) shifts = [series.shift(i) for i in shift_offsets] df = backend.df_engine.df_lib.concat(shifts, axis=1) df.columns = [f"__tmp_column_{j}" for j in shift_offsets] return df.apply(lambda x: np.nan_to_num(np.array(x.tolist()).astype(np.float32), nan=padding_value), axis=1) class _TimeseriesPreprocessing(torch.nn.Module): """Torchscript-enabled version of preprocessing done by TimeseriesFeatureMixin.add_feature_data.""" def __init__(self, metadata: TrainingSetMetadataDict): super().__init__() if metadata["preprocessing"]["tokenizer"] not in TORCHSCRIPT_COMPATIBLE_TOKENIZERS: raise ValueError( f"{metadata['preprocessing']['tokenizer']} is not supported by torchscript. Please use " f"one of {TORCHSCRIPT_COMPATIBLE_TOKENIZERS}." ) self.tokenizer = get_tokenizer_from_registry(metadata["preprocessing"]["tokenizer"])() self.padding = metadata["preprocessing"]["padding"] self.padding_value = float(metadata["preprocessing"]["padding_value"]) self.max_timeseries_length = int(metadata["max_timeseries_length"]) self.computed_fill_value = metadata["preprocessing"]["computed_fill_value"] def _process_str_sequence(self, sequence: list[str], limit: int) -> torch.Tensor: float_sequence = [float(s) for s in sequence[:limit]] return torch.tensor(float_sequence) def _nan_to_fill_value(self, v: torch.Tensor) -> torch.Tensor: if v.isnan().any(): tokenized_fill_value = self.tokenizer(self.computed_fill_value) # refines type of sequences from Any to List[str] assert torch.jit.isinstance(tokenized_fill_value, list[str]) return self._process_str_sequence(tokenized_fill_value, self.max_timeseries_length) return v def forward_list_of_tensors(self, v: list[torch.Tensor]) -> torch.Tensor: v = [self._nan_to_fill_value(v_i) for v_i in v] if self.padding == "right": timeseries_matrix = torch.nn.utils.rnn.pad_sequence(v, batch_first=True, padding_value=self.padding_value) timeseries_matrix = timeseries_matrix[:, : self.max_timeseries_length] else: reversed_timeseries = [torch.flip(v_i[: self.max_timeseries_length], dims=(0,)) for v_i in v] reversed_timeseries_padded = torch.nn.utils.rnn.pad_sequence( reversed_timeseries, batch_first=True, padding_value=self.padding_value ) timeseries_matrix = torch.flip(reversed_timeseries_padded, dims=(1,)) return timeseries_matrix def forward_list_of_strs(self, v: list[str]) -> torch.Tensor: v = [self.computed_fill_value if s == "nan" else s for s in v] sequences = self.tokenizer(v) # refines type of sequences from Any to List[List[str]] assert torch.jit.isinstance(sequences, list[list[str]]), "sequences is not a list of lists." timeseries_matrix = torch.full( [len(sequences), self.max_timeseries_length], self.padding_value, dtype=torch.float32 ) for sample_idx, str_sequence in enumerate(sequences): limit = min(len(str_sequence), self.max_timeseries_length) float_sequence = self._process_str_sequence(str_sequence, limit) if self.padding == "right": timeseries_matrix[sample_idx][:limit] = float_sequence else: # if self.padding == 'left timeseries_matrix[sample_idx][self.max_timeseries_length - limit :] = float_sequence return timeseries_matrix def forward(self, v: TorchscriptPreprocessingInput) -> torch.Tensor: """Takes a list of float values and creates a padded torch.Tensor.""" if torch.jit.isinstance(v, list[torch.Tensor]): return self.forward_list_of_tensors(v) if torch.jit.isinstance(v, list[str]): return self.forward_list_of_strs(v) raise ValueError(f"Unsupported input: {v}") class TimeseriesFeatureMixin(BaseFeatureMixin): @staticmethod def type(): return TIMESERIES @staticmethod def cast_column(column, backend): return column @staticmethod def get_feature_meta( config: ModelConfigDict, column, preprocessing_parameters: PreprocessingConfigDict, backend, is_input_feature: bool, ) -> FeatureMetadataDict: window_size = preprocessing_parameters.get("window_size", 0) or preprocessing_parameters.get("horizon", 0) if window_size > 0: # Column-major data return {"max_timeseries_length": window_size} column = column.astype(str) tokenizer = get_tokenizer_from_registry(preprocessing_parameters["tokenizer"])() max_length = 0 for timeseries in column: processed_line = tokenizer(timeseries) max_length = max(max_length, len(processed_line)) max_length = min(preprocessing_parameters["timeseries_length_limit"], max_length) return {"max_timeseries_length": max_length} @staticmethod def build_matrix(timeseries, tokenizer_name, length_limit, padding_value, padding, backend): tokenizer = get_tokenizer_from_registry(tokenizer_name)() ts_vectors = backend.df_engine.map_objects( timeseries, lambda ts: np.nan_to_num(np.array(tokenizer(ts)).astype(np.float32), nan=padding_value) ) max_length = backend.df_engine.compute(ts_vectors.map(len).max()) if max_length < length_limit: logger.debug(f"max length of {tokenizer_name}: {max_length} < limit: {length_limit}") max_length = length_limit def pad(vector): padded = np.full((max_length,), padding_value, dtype=np.float32) limit = min(vector.shape[0], max_length) if padding == "right": padded[:limit] = vector[:limit] else: # if padding == 'left padded[max_length - limit :] = vector[:limit] return padded return backend.df_engine.map_objects(ts_vectors, pad) @staticmethod def feature_data(column, metadata, preprocessing_parameters: PreprocessingConfigDict, backend): padding_value = preprocessing_parameters["padding_value"] window_size = preprocessing_parameters.get("window_size", 0) horizon = preprocessing_parameters.get("horizon", 0) if window_size > 0 or horizon > 0: # Column-major data. Convert the column into the row-major embedding return create_time_delay_embedding(column, window_size, horizon, padding_value, backend) timeseries_data = TimeseriesFeatureMixin.build_matrix( column, preprocessing_parameters["tokenizer"], metadata["max_timeseries_length"], padding_value, preprocessing_parameters["padding"], backend, ) return timeseries_data @staticmethod def add_feature_data( feature_config, input_df, proc_df, metadata, preprocessing_parameters: PreprocessingConfigDict, backend, skip_save_processed_input, ): proc_df[feature_config[PROC_COLUMN]] = TimeseriesFeatureMixin.feature_data( input_df[feature_config[COLUMN]].astype(str), metadata[feature_config[NAME]], preprocessing_parameters, backend, ) return proc_df class TimeseriesInputFeature(TimeseriesFeatureMixin, SequenceInputFeature): def __init__(self, input_feature_config: TimeseriesInputFeatureConfig, encoder_obj=None, **kwargs): # add required sequence encoder parameters for time series input_feature_config.encoder.embedding_size = 1 input_feature_config.encoder.should_embed = False # SequenceInputFeauture's constructor initializes the encoder. super().__init__(input_feature_config, encoder_obj=encoder_obj, **kwargs) def forward(self, inputs, mask=None): assert isinstance(inputs, torch.Tensor) assert inputs.dtype in [torch.float16, torch.float32, torch.float64] assert len(inputs.shape) == 2 inputs_exp = inputs.type(torch.float32) encoder_output = self.encoder_obj(inputs_exp, mask=mask) return encoder_output @property def input_shape(self) -> torch.Size: return self.encoder_obj.input_shape @property def input_dtype(self): return torch.float32 @staticmethod def update_config_with_metadata(feature_config, feature_metadata, *args, **kwargs): feature_config.encoder.input_size = feature_metadata["max_timeseries_length"] feature_config.encoder.max_sequence_length = feature_metadata["max_timeseries_length"] @staticmethod def get_schema_cls(): return TimeseriesInputFeatureConfig @staticmethod def create_preproc_module(metadata: TrainingSetMetadataDict) -> torch.nn.Module: return _TimeseriesPreprocessing(metadata) class TimeseriesOutputFeature(TimeseriesFeatureMixin, OutputFeature): def __init__( self, output_feature_config: TimeseriesOutputFeatureConfig | dict, output_features: dict[str, OutputFeature], **kwargs, ): self.horizon = output_feature_config.horizon super().__init__(output_feature_config, output_features, **kwargs) output_feature_config.decoder.output_size = self.horizon self.decoder_obj = self.initialize_decoder(output_feature_config.decoder) self._setup_loss() self._setup_metrics() def logits(self, inputs, **kwargs): # hidden hidden = inputs[HIDDEN] return self.decoder_obj(hidden) def loss_kwargs(self): return self.loss.to_dict() def metric_kwargs(self): return dict(num_outputs=self.output_shape[0]) def create_predict_module(self) -> PredictModule: return _VectorPredict() def get_prediction_set(self): return {PREDICTIONS, LOGITS} @classmethod def get_output_dtype(cls): return torch.float32 @property def output_shape(self) -> torch.Size: return torch.Size([self.horizon]) @property def input_shape(self) -> torch.Size: return torch.Size([self.input_size]) @staticmethod def update_config_with_metadata(feature_config, feature_metadata, *args, **kwargs): feature_config.horizon = feature_metadata["max_timeseries_length"] @staticmethod def calculate_overall_stats(predictions, targets, train_set_metadata): # no overall stats, just return empty dictionary return {} def postprocess_predictions( self, result, metadata, ): predictions_col = f"{self.feature_name}_{PREDICTIONS}" if predictions_col in result: result[predictions_col] = result[predictions_col].map(lambda pred: pred.tolist()) return result @staticmethod def create_postproc_module(metadata: TrainingSetMetadataDict) -> torch.nn.Module: return _VectorPostprocessing() @staticmethod def get_schema_cls(): return TimeseriesOutputFeatureConfig ================================================ FILE: ludwig/features/vector_feature.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import logging import numpy as np import torch from ludwig.constants import COLUMN, HIDDEN, LOGITS, NAME, PREDICTIONS, PROC_COLUMN, VECTOR from ludwig.features.base_feature import InputFeature, OutputFeature, PredictModule from ludwig.schema.features.vector_feature import VectorInputFeatureConfig, VectorOutputFeatureConfig from ludwig.types import ( FeatureMetadataDict, FeaturePostProcessingOutputDict, ModelConfigDict, PreprocessingConfigDict, TrainingSetMetadataDict, ) from ludwig.utils import output_feature_utils from ludwig.utils.types import TorchscriptPreprocessingInput logger = logging.getLogger(__name__) class _VectorPreprocessing(torch.nn.Module): def forward(self, v: TorchscriptPreprocessingInput) -> torch.Tensor: if torch.jit.isinstance(v, torch.Tensor): out = v elif torch.jit.isinstance(v, list[torch.Tensor]): out = torch.stack(v) elif torch.jit.isinstance(v, list[str]): vectors = [] for sample in v: vector = torch.tensor([float(x) for x in sample.split()], dtype=torch.float32) vectors.append(vector) out = torch.stack(vectors) else: raise ValueError(f"Unsupported input: {v}") if out.isnan().any(): raise ValueError("Scripted NaN handling not implemented for Vector feature") return out class _VectorPostprocessing(torch.nn.Module): def __init__(self): super().__init__() self.predictions_key = PREDICTIONS self.logits_key = LOGITS def forward(self, preds: dict[str, torch.Tensor], feature_name: str) -> FeaturePostProcessingOutputDict: predictions = output_feature_utils.get_output_feature_tensor(preds, feature_name, self.predictions_key) logits = output_feature_utils.get_output_feature_tensor(preds, feature_name, self.logits_key) return {self.predictions_key: predictions, self.logits_key: logits} class _VectorPredict(PredictModule): def forward(self, inputs: dict[str, torch.Tensor], feature_name: str) -> dict[str, torch.Tensor]: logits = output_feature_utils.get_output_feature_tensor(inputs, feature_name, self.logits_key) return {self.predictions_key: logits, self.logits_key: logits} class VectorFeatureMixin: @staticmethod def type(): return VECTOR @staticmethod def cast_column(column, backend): return column @staticmethod def get_feature_meta( config: ModelConfigDict, column, preprocessing_parameters: PreprocessingConfigDict, backend, is_input_feature: bool, ) -> FeatureMetadataDict: return {"preprocessing": preprocessing_parameters} @staticmethod def add_feature_data( feature_config, input_df, proc_df, metadata, preprocessing_parameters: PreprocessingConfigDict, backend, skip_save_processed_input, ): """Expects all the vectors to be of the same size. The vectors need to be whitespace delimited strings. Missing values are not handled. """ if len(input_df[feature_config[COLUMN]]) == 0: raise ValueError("There are no vectors in the dataset provided") # Convert the string of features into a numpy array try: proc_df[feature_config[PROC_COLUMN]] = backend.df_engine.map_objects( input_df[feature_config[COLUMN]], lambda x: np.array(x.split(), dtype=np.float32) ) except ValueError: logger.error( "Unable to read the vector data. Make sure that all the vectors" " are of the same size and do not have missing/null values." ) raise # Determine vector size vector_size = backend.df_engine.compute(proc_df[feature_config[PROC_COLUMN]].map(len).max()) vector_size_param = preprocessing_parameters.get("vector_size") if vector_size_param is not None: # TODO(travis): do we even need a user param for vector size if we're going to auto-infer it in all # cases? Is this only useful as a sanity check for the user to make sure their data conforms to # expectations? if vector_size != vector_size_param: raise ValueError( "The user provided value for vector size ({}) does not " "match the value observed in the data: {}".format(preprocessing_parameters, vector_size) ) else: logger.debug(f"Detected vector size: {vector_size}") metadata[feature_config[NAME]]["vector_size"] = vector_size return proc_df class VectorInputFeature(VectorFeatureMixin, InputFeature): def __init__(self, input_feature_config: VectorInputFeatureConfig, encoder_obj=None, **kwargs): super().__init__(input_feature_config, **kwargs) # input_feature_config.encoder.input_size = input_feature_config.encoder.vector_size if encoder_obj: self.encoder_obj = encoder_obj else: self.encoder_obj = self.initialize_encoder(input_feature_config.encoder) def forward(self, inputs: torch.Tensor) -> torch.Tensor: assert isinstance(inputs, torch.Tensor) assert inputs.dtype in [torch.float32, torch.float64] assert len(inputs.shape) == 2 inputs_encoded = self.encoder_obj(inputs) return inputs_encoded @property def input_shape(self) -> torch.Size: return torch.Size([self.encoder_obj.config.input_size]) @property def output_shape(self) -> torch.Size: return self.encoder_obj.output_shape @staticmethod def update_config_with_metadata(feature_config, feature_metadata, *args, **kwargs): feature_config.encoder.input_size = feature_metadata["vector_size"] @staticmethod def create_preproc_module(metadata: TrainingSetMetadataDict) -> torch.nn.Module: return _VectorPreprocessing() @staticmethod def get_schema_cls(): return VectorInputFeatureConfig class VectorOutputFeature(VectorFeatureMixin, OutputFeature): def __init__( self, output_feature_config: VectorOutputFeatureConfig | dict, output_features: dict[str, OutputFeature], **kwargs, ): self.vector_size = output_feature_config.vector_size super().__init__(output_feature_config, output_features, **kwargs) output_feature_config.decoder.output_size = self.vector_size self.decoder_obj = self.initialize_decoder(output_feature_config.decoder) self._setup_loss() self._setup_metrics() def logits(self, inputs, **kwargs): # hidden hidden = inputs[HIDDEN] return self.decoder_obj(hidden) def metric_kwargs(self): return dict(num_outputs=self.output_shape[0]) def create_predict_module(self) -> PredictModule: return _VectorPredict() def get_prediction_set(self): return {PREDICTIONS, LOGITS} @classmethod def get_output_dtype(cls): return torch.float32 @property def output_shape(self) -> torch.Size: return torch.Size([self.vector_size]) @property def input_shape(self) -> torch.Size: return torch.Size([self.input_size]) @staticmethod def update_config_with_metadata(feature_config, feature_metadata, *args, **kwargs): feature_config.vector_size = feature_metadata["vector_size"] @staticmethod def calculate_overall_stats(predictions, targets, train_set_metadata): # no overall stats, just return empty dictionary return {} def postprocess_predictions( self, result, metadata, ): predictions_col = f"{self.feature_name}_{PREDICTIONS}" if predictions_col in result: result[predictions_col] = result[predictions_col].map(lambda pred: pred.tolist()) return result @staticmethod def create_postproc_module(metadata: TrainingSetMetadataDict) -> torch.nn.Module: return _VectorPostprocessing() @staticmethod def get_schema_cls(): return VectorOutputFeatureConfig ================================================ FILE: ludwig/forecast.py ================================================ import argparse import logging import sys import pandas as pd from ludwig.api import LudwigModel from ludwig.backend import ALL_BACKENDS, Backend, initialize_backend from ludwig.callbacks import Callback from ludwig.contrib import add_contrib_callback_args from ludwig.globals import LUDWIG_VERSION from ludwig.utils.print_utils import get_logging_level_registry, print_ludwig logger = logging.getLogger(__name__) def forecast_cli( model_path: str, dataset: str | dict | pd.DataFrame = None, data_format: str | None = None, horizon: int = 1, output_directory: str | None = None, output_format: str = "parquet", callbacks: list[Callback] = None, backend: Backend | str = None, logging_level: int = logging.INFO, **kwargs, ) -> None: """Loads pre-trained model to forecast on the provided dataset. # Inputs :param model_path: (str) filepath to pre-trained model. :param dataset: (Union[str, dict, pandas.DataFrame], default: `None`) source containing the entire dataset to be used in the prediction. :param data_format: (str, default: `None`) format to interpret data sources. Will be inferred automatically if not specified. :param horizon: How many samples into the future to forecast. :param output_directory: (str, default: `'results'`) the directory that will contain the forecasted values. :param output_format: (str) format of the output dataset. :param callbacks: (list, default: `None`) a list of `ludwig.callbacks.Callback` objects that provide hooks into the Ludwig pipeline. :param backend: (Union[Backend, str]) `Backend` or string name of backend to use to execute preprocessing / training steps. :param logging_level: (int) Log level that will be sent to stderr. # Returns :return: ('None') """ model = LudwigModel.load( model_path, logging_level=logging_level, backend=backend, callbacks=callbacks, ) model.forecast( dataset=dataset, data_format=data_format, horizon=horizon, output_directory=output_directory, output_format=output_format, ) def cli(sys_argv): parser = argparse.ArgumentParser( description="This script loads a pretrained model and uses it to forecast", prog="ludwig forecast", usage="%(prog)s [options]", ) parser.add_argument( "-n", "--horizon", help="horizon, or number of steps in the future to forecast", type=int, default=1 ) # --------------- # Data parameters # --------------- parser.add_argument("--dataset", help="input data file path", required=True) parser.add_argument( "--data_format", help="format of the input data", default="auto", choices=[ "auto", "csv", "excel", "feather", "fwf", "hdf5", "html", "tables", "json", "jsonl", "parquet", "pickle", "sas", "spss", "stata", "tsv", ], ) # ---------------- # Model parameters # ---------------- parser.add_argument("-m", "--model_path", help="model to load", required=True) # ------------------------- # Output results parameters # ------------------------- parser.add_argument( "-od", "--output_directory", type=str, default="results", help="directory that contains the results" ) parser.add_argument( "-of", "--output_format", help="format to write the output dataset", default="parquet", choices=[ "csv", "parquet", ], ) parser.add_argument( "-b", "--backend", help="specifies backend to use for parallel / distributed execution, " "defaults to local execution", choices=ALL_BACKENDS, ) parser.add_argument( "-l", "--logging_level", default="info", help="the level of logging to use", choices=["critical", "error", "warning", "info", "debug", "notset"], ) add_contrib_callback_args(parser) args = parser.parse_args(sys_argv) args.callbacks = args.callbacks or [] for callback in args.callbacks: callback.on_cmdline("forecast", *sys_argv) args.logging_level = get_logging_level_registry()[args.logging_level] logging.getLogger("ludwig").setLevel(args.logging_level) global logger logger = logging.getLogger("ludwig.forecast") args.backend = initialize_backend(args.backend) if args.backend.is_coordinator(): print_ludwig("Forecast", LUDWIG_VERSION) logger.info(f"Dataset path: {args.dataset}") logger.info(f"Model path: {args.model_path}") logger.info("") forecast_cli(**vars(args)) if __name__ == "__main__": cli(sys.argv[1:]) ================================================ FILE: ludwig/globals.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== LUDWIG_VERSION = "0.11.2" MODEL_FILE_NAME = "model" MODEL_WEIGHTS_FILE_NAME = "model_weights" MODEL_HYPERPARAMETERS_FILE_NAME = "model_hyperparameters.json" TRAIN_SET_METADATA_FILE_NAME = "training_set_metadata.json" TRAINING_PROGRESS_TRACKER_FILE_NAME = "training_progress.json" TRAINING_CHECKPOINTS_DIR_PATH = "training_checkpoints" TEST_STATISTICS_FILE_NAME = "test_statistics.json" DESCRIPTION_FILE_NAME = "description.json" PREDICTIONS_PARQUET_FILE_NAME = "predictions.parquet" PREDICTIONS_SHAPES_FILE_NAME = "predictions.shapes.json" TRAINING_PREPROC_FILE_NAME = "training.hdf5" HYPEROPT_STATISTICS_FILE_NAME = "hyperopt_statistics.json" CONFIG_YAML = "config.yaml" DISABLE_PROGRESSBAR = False def set_disable_progressbar(value): global DISABLE_PROGRESSBAR DISABLE_PROGRESSBAR = value def is_progressbar_disabled(): return DISABLE_PROGRESSBAR ================================================ FILE: ludwig/hyperopt/__init__.py ================================================ ================================================ FILE: ludwig/hyperopt/execution.py ================================================ import contextlib import copy import datetime import glob import json import logging import os import shutil import sys import tempfile import threading import time import traceback import uuid from collections.abc import Callable from functools import lru_cache from inspect import signature from pathlib import Path from typing import Any import ray from ray import tune from ray.tune import ExperimentAnalysis, PlacementGroupFactory, register_trainable, Stopper from ray.tune.schedulers.resource_changing_scheduler import DistributeResources, ResourceChangingScheduler from ray.tune.search import BasicVariantGenerator, ConcurrencyLimiter, SEARCH_ALG_IMPORT from ray.tune.utils import wait_for_gpu from ray.util.queue import Queue as RayQueue from ludwig.api import LudwigModel from ludwig.backend import initialize_backend, RAY from ludwig.backend.ray import initialize_ray from ludwig.callbacks import Callback from ludwig.constants import MAXIMIZE, TEST, TRAINER, TRAINING, TYPE, VALIDATION from ludwig.hyperopt.results import HyperoptResults, TrialResults from ludwig.hyperopt.search_algos import get_search_algorithm from ludwig.hyperopt.utils import load_json_values, substitute_parameters from ludwig.modules.metric_modules import get_best_function from ludwig.schema.model_types.utils import merge_with_defaults from ludwig.utils import metric_utils from ludwig.utils.data_utils import hash_dict, NumpyEncoder from ludwig.utils.defaults import default_random_seed from ludwig.utils.fs_utils import has_remote_protocol, safe_move_file from ludwig.utils.misc_utils import get_from_registry logger = logging.getLogger(__name__) def _patch_bohb_configspace_conversion(): """Monkey-patch TuneBOHB.convert_search_space for ConfigSpace 1.x compatibility. ConfigSpace 1.x removed the `q` (quantization) parameter from hyperparameter classes. Ray Tune's BOHB integration still passes `q=...`, so we patch the converter to drop it. """ try: # Check if ConfigSpace 1.x (no 'q' parameter) import inspect import math import ConfigSpace from ray.tune.search.bohb.bohb_search import TuneBOHB from ray.tune.search.sample import Categorical, Float, Integer, LogUniform, Normal, Quantized, Uniform from ray.tune.search.variant_generator import parse_spec_vars from ray.tune.utils import flatten_dict sig = inspect.signature(ConfigSpace.UniformFloatHyperparameter.__init__) if "q" in sig.parameters: return # Old ConfigSpace, no patching needed @staticmethod def convert_search_space(spec): resolved_vars, domain_vars, grid_vars = parse_spec_vars(spec) if grid_vars: raise ValueError( "Grid search parameters cannot be automatically converted " "to a TuneBOHB search space." ) spec = flatten_dict(spec, prevent_delimiter=True) resolved_vars, domain_vars, grid_vars = parse_spec_vars(spec) def resolve_value(par, domain): quantize = None sampler = domain.get_sampler() if isinstance(sampler, Quantized): quantize = sampler.q sampler = sampler.sampler if isinstance(domain, Float): if isinstance(sampler, LogUniform): lower = domain.lower upper = domain.upper if quantize: lower = math.ceil(domain.lower / quantize) * quantize upper = math.floor(domain.upper / quantize) * quantize return ConfigSpace.UniformFloatHyperparameter(par, lower=lower, upper=upper, log=True) elif isinstance(sampler, Uniform): lower = domain.lower upper = domain.upper if quantize: lower = math.ceil(domain.lower / quantize) * quantize upper = math.floor(domain.upper / quantize) * quantize return ConfigSpace.UniformFloatHyperparameter(par, lower=lower, upper=upper, log=False) elif isinstance(sampler, Normal): return ConfigSpace.hyperparameters.NormalFloatHyperparameter( par, mu=sampler.mean, sigma=sampler.sd, log=False ) elif isinstance(domain, Integer): if isinstance(sampler, LogUniform): lower = domain.lower upper = domain.upper if quantize: lower = math.ceil(domain.lower / quantize) * quantize upper = math.floor(domain.upper / quantize) * quantize else: upper -= 1 return ConfigSpace.UniformIntegerHyperparameter(par, lower=lower, upper=upper, log=True) elif isinstance(sampler, Uniform): lower = domain.lower upper = domain.upper if quantize: lower = math.ceil(domain.lower / quantize) * quantize upper = math.floor(domain.upper / quantize) * quantize else: upper -= 1 return ConfigSpace.UniformIntegerHyperparameter(par, lower=lower, upper=upper, log=False) elif isinstance(domain, Categorical): if isinstance(sampler, Uniform): return ConfigSpace.CategoricalHyperparameter(par, choices=domain.categories) raise ValueError( "TuneBOHB does not support parameters of type " "`{}` with samplers of type `{}`".format(type(domain).__name__, type(domain.sampler).__name__) ) cs = ConfigSpace.ConfigurationSpace() for path, domain in domain_vars: par = "/".join(str(p) for p in path) value = resolve_value(par, domain) cs.add_hyperparameter(value) return cs TuneBOHB.convert_search_space = convert_search_space logger.info("Patched TuneBOHB.convert_search_space for ConfigSpace 1.x compatibility") except ImportError: pass # BOHB not installed _patch_bohb_configspace_conversion() try: from ludwig.backend.ray import RayBackend # TODO: refactor this into an interface def _is_ray_backend(backend) -> bool: if isinstance(backend, str): return backend == RAY return isinstance(backend, RayBackend) except ImportError as e: logger.warning( f"ImportError (execution.py) failed to import RayBackend with error: \n\t{e}. " "The LocalBackend will be used instead. If you want to use the RayBackend, please install ludwig[distributed]." ) class RayBackend: pass def _is_ray_backend(backend) -> bool: return False def identity(x): return x def _get_relative_checkpoints_dir_parts(path: Path): return path.parts[-2:] # Follwing disabled at the moment, expect to be re-enabled pending https://github.com/ludwig-ai/ludwig/issues/2039 def ray_resource_allocation_function( trial_runner: "trial_runner.TrialRunner", # noqa trial: "Trial", # noqa result: dict[str, Any], scheduler: "ResourceChangingScheduler", ): """Determine resources to allocate to running trials.""" pgf = DistributeResources(trial_runner, trial, result, scheduler) # restore original base trial resources # create bundles if scheduler.base_trial_resources.required_resources.get("GPU", 0): bundles = [{"CPU": 1, "GPU": 1}] * int(pgf.required_resources["GPU"]) else: bundles = [{"CPU": 1}] * (int(pgf.required_resources["CPU"] - 0.001)) # we can't set Trial actor's CPUs to 0 so we just go very low bundles = [{"CPU": 0.001}] + bundles pgf = PlacementGroupFactory(bundles) return pgf def _create_tune_checkpoint(save_path): """Create a Ray Tune Checkpoint from a model save path.""" def ignore_dot_files(src, files): return [f for f in files if f.startswith(".")] tmpdir = tempfile.mkdtemp() checkpoint_model = os.path.join(tmpdir, "model") if os.path.exists(save_path): copy_id = uuid.uuid4() tmp_dst = f"{checkpoint_model}.{copy_id}.tmp" shutil.copytree(save_path, tmp_dst, ignore=ignore_dot_files) try: os.rename(tmp_dst, checkpoint_model) except Exception: shutil.rmtree(tmp_dst) return tune.Checkpoint.from_directory(tmpdir) class RayTuneExecutor: def __init__( self, parameters: dict, output_feature: str, metric: str, goal: str, split: str, search_alg: dict | None = None, cpu_resources_per_trial: int = None, gpu_resources_per_trial: int = None, kubernetes_namespace: str = None, time_budget_s: int | float | datetime.timedelta = None, max_concurrent_trials: int | None = None, num_samples: int = 1, scheduler: dict | None = None, **kwargs, ) -> None: if ray is None: raise ImportError("ray module is not installed. To install it, try running pip install ray") self.output_feature = output_feature self.metric = metric self.split = split initialize_ray() self.search_space, self.decode_ctx = self._get_search_space(parameters) self.num_samples = num_samples self.goal = goal self.search_algorithm = get_search_algorithm(search_alg) self.scheduler = None if scheduler is None else tune.create_scheduler(scheduler[TYPE], **scheduler) self.output_feature = output_feature self.metric = metric self.split = split self.trial_id = 0 self.cpu_resources_per_trial = cpu_resources_per_trial self.gpu_resources_per_trial = gpu_resources_per_trial self.kubernetes_namespace = kubernetes_namespace self.time_budget_s = time_budget_s self.max_concurrent_trials = max_concurrent_trials self.sync_config = None self.sync_client = None # Head node is the node to which all checkpoints are synced if running on a K8s cluster. self.head_node_ip = ray.util.get_node_ip_address() def _get_search_space(self, parameters: dict) -> tuple[dict, dict]: """Encode search space parameters as JSON with context for decoding.""" config = {} ctx = {} for param, values in parameters.items(): # Encode list and dict types as JSON encoded strings to # workaround type limitations of the underlying frameworks values = self.encode_values(param, values, ctx) param_search_type = values["space"].lower() if hasattr(tune, param_search_type): param_search_space = getattr(tune, param_search_type) else: raise ValueError(f"'{param_search_type}' is not a supported Ray Tune search space") param_search_input_args = {} param_search_space_sig = signature(param_search_space) for arg in param_search_space_sig.parameters.values(): if arg.name in values: param_search_input_args[arg.name] = values[arg.name] else: if arg.default is arg.empty: raise ValueError(f"Parameter '{arg}' not defined for {param}") config[param] = param_search_space(**param_search_input_args) return config, ctx @staticmethod def encode_values(param: str, values: dict, ctx: dict) -> dict: """JSON encodes any search spaces whose values are lists / dicts. Only applies to grid search and choice options. See here for details: https://docs.ray.io/en/master/tune/api_docs/search_space.html#random-distributions-api """ values = values.copy() for key in ["values", "categories"]: if key in values and not isinstance(values[key][0], (int, float)): values[key] = [json.dumps(v) for v in values[key]] ctx[param] = json.loads return values @staticmethod def decode_values(config: dict, ctx: dict) -> dict: """Decode config values with the decode function in the context. Uses the identity function if no encoding is needed. """ return {key: ctx.get(key, identity)(value) for key, value in config.items()} def _has_metric(self, stats, split): if not stats: return False if split is not None: if split not in stats: return False stats = stats[split] if self.output_feature not in stats: return False stats = stats[self.output_feature] if self.metric not in stats: return False stats = stats[self.metric] return len(stats) > 0 def _has_eval_metric(self, stats): if stats is None: return False if self.output_feature not in stats: return False stats = stats[self.output_feature] for metric_part in self.metric.split("."): if not isinstance(stats, dict) or metric_part not in stats: return False stats = stats[metric_part] return isinstance(stats, float) def get_metric_score(self, train_stats) -> float: if self._has_metric(train_stats, VALIDATION): logger.info("Returning metric score from training (validation) statistics") return self.get_metric_score_from_train_stats(train_stats, VALIDATION) elif self._has_metric(train_stats, TRAINING): logger.info("Returning metric score from training split statistics, " "as no validation was given") return self.get_metric_score_from_train_stats(train_stats, TRAINING) else: raise RuntimeError("Unable to obtain metric score from missing training (validation) statistics") def get_metric_score_from_eval_stats(self, eval_stats) -> float | list: stats = eval_stats[self.output_feature] for metric_part in self.metric.split("."): if isinstance(stats, dict): if metric_part in stats: stats = stats[metric_part] else: raise ValueError(f"Evaluation statistics do not contain the metric {self.metric}") else: raise ValueError(f"Evaluation statistics do not contain the metric {self.metric}") if not isinstance(stats, float): raise ValueError(f"The metric {self.metric} in evaluation statistics is not a numerical value: {stats}") return stats def get_metric_score_from_train_stats(self, train_stats, select_split=None) -> float: select_split = select_split or VALIDATION # grab the results of the model with highest validation test performance train_valiset_stats = train_stats[select_split] validation_field_result = train_valiset_stats[self.output_feature] best_function = get_best_function(self.metric) # results of the model with highest validation test performance epoch_best_validation_metric, best_validation_metric = best_function( enumerate(validation_field_result[self.metric]), key=lambda pair: pair[1] ) return best_validation_metric def sort_hyperopt_results(self, hyperopt_results): return sorted( hyperopt_results, key=lambda hp_res: hp_res.metric_score, reverse=self.hyperopt_sampler.goal == MAXIMIZE ) @property def _cpu_resources_per_trial_non_none(self): return self.cpu_resources_per_trial if self.cpu_resources_per_trial is not None else 1 @property def _gpu_resources_per_trial_non_none(self): return self.gpu_resources_per_trial if self.gpu_resources_per_trial is not None else 0 def _get_remote_checkpoint_dir(self, trial_dir: Path) -> str | tuple[str, str] | None: """Get the path to remote checkpoint directory.""" if self.sync_config is None: return None if self.sync_config.upload_dir is not None: # Cloud storage sync config remote_checkpoint_dir = os.path.join( self.sync_config.upload_dir, *_get_relative_checkpoints_dir_parts(trial_dir) ) return remote_checkpoint_dir elif self.kubernetes_namespace is not None: # Kubernetes sync config. Returns driver node name and path. # When running on kubernetes, each trial is rsynced to the node running the main process. node_name = self._get_kubernetes_node_address_by_ip()(self.head_node_ip) return (node_name, trial_dir) else: logger.warning( "Checkpoint syncing disabled as syncing is only supported to remote cloud storage or on Kubernetes " "clusters is supported. To use syncing, set the kubernetes_namespace in the config or use a cloud URI " "as the output directory." ) return None @lru_cache(maxsize=1) def _get_kubernetes_node_address_by_ip(self) -> Callable: """Returns a method to get the node name by IP address within a K8s cluster.""" assert self.kubernetes_namespace is not None from ray.tune.integration.kubernetes import KubernetesSyncer # Initialized with null local and remote directories as we only need to use get_node_address_by_ip. kubernetes_syncer = KubernetesSyncer(None, None) return kubernetes_syncer.get_node_address_by_ip # For specified [stopped] trial, remove checkpoint marker on any partial checkpoints @staticmethod def _remove_partial_checkpoints(trial_path: str): marker_paths = glob.glob(os.path.join(glob.escape(trial_path), "checkpoint_*/.is_checkpoint")) for marker_path in marker_paths: chkpt_dir = os.path.dirname(marker_path) metadata_file = glob.glob(os.path.join(glob.escape(chkpt_dir), "*.tune_metadata")) # glob.glob: filenames starting with a dot are special cases # that are not matched by '*' and '?' patterns. metadata_file += glob.glob(os.path.join(glob.escape(chkpt_dir), ".tune_metadata")) metadata_file = list(set(metadata_file)) # avoid duplication if len(metadata_file) < 1: # Remove checkpoint marker on incomplete directory os.remove(marker_path) @contextlib.contextmanager def _get_best_model_path(self, trial_or_path, analysis: ExperimentAnalysis) -> str: # Accept either a Trial object or a path string from ray.tune.experiment.trial import Trial if isinstance(trial_or_path, str): trial_path = trial_or_path else: trial_path = trial_or_path.local_path remote_checkpoint_dir = self._get_remote_checkpoint_dir(Path(trial_path)) if remote_checkpoint_dir is not None and self.sync_client is not None: self.sync_client.sync_down(remote_checkpoint_dir, trial_path) self.sync_client.wait_or_retry() self._remove_partial_checkpoints(trial_path) # needed by get_best_checkpoint # get_best_checkpoint requires a Trial object in Ray 2.x if isinstance(trial_or_path, Trial): trial = trial_or_path else: # Try to find the trial by matching its path trial = None for t in analysis.trials: if t.local_path and t.local_path.rstrip("/") == trial_path.rstrip("/"): trial = t break try: if trial is not None: checkpoint = analysis.get_best_checkpoint(trial) else: checkpoint = None except Exception: logger.warning( f"Cannot get best model path for {trial_path} due to exception below:" f"\n{traceback.format_exc()}" ) yield None return if checkpoint is not None: with checkpoint.as_directory() as path: yield path else: yield checkpoint @staticmethod def _evaluate_best_model( trial, trial_path, best_model_path, dataset, data_format, skip_save_unprocessed_output, skip_save_predictions, skip_save_eval_stats, gpus, gpu_memory_limit, allow_parallel_threads, backend, debug, ): model_path = os.path.join(best_model_path, "model") if not os.path.isdir(model_path): logger.warning( f"Best model path {model_path} does not exist or is incomplete. " "This can happen when time budget expires mid-checkpoint. Skipping evaluation." ) return best_model = LudwigModel.load( model_path, backend=backend, gpus=gpus, gpu_memory_limit=gpu_memory_limit, allow_parallel_threads=allow_parallel_threads, from_checkpoint=True, ) if best_model.config[TRAINER]["eval_batch_size"]: batch_size = best_model.config[TRAINER]["eval_batch_size"] else: batch_size = best_model.config[TRAINER]["batch_size"] try: eval_stats, _, _ = best_model.evaluate( dataset=dataset, data_format=data_format, batch_size=batch_size, output_directory=trial_path, skip_save_unprocessed_output=skip_save_unprocessed_output, skip_save_predictions=skip_save_predictions, skip_save_eval_stats=skip_save_eval_stats, collect_predictions=False, collect_overall_stats=True, return_type="dict", debug=debug, ) trial["eval_stats"] = json.dumps(eval_stats, cls=NumpyEncoder) except NotImplementedError: logger.warning( "Skipping evaluation as the necessary methods are not " "supported. Full exception below:\n" f"{traceback.format_exc()}" ) def _run_experiment( self, config, checkpoint_dir, hyperopt_dict, decode_ctx, is_using_ray_backend=False, ): # Ray Tune redirects stdout/stderr through a Tee object that may not # implement isatty(), which ray.data's progress bar code requires. # Patch it to avoid AttributeError. for stream in (sys.stdout, sys.stderr): if not hasattr(stream, "isatty"): stream.isatty = lambda: False for gpu_id in ray.get_gpu_ids(): # Previous trial may not have freed its memory yet, so wait to avoid OOM wait_for_gpu(gpu_id) # Some config values may be JSON encoded as strings, so decode them here config = self.decode_values(config, decode_ctx) # Remove mlflow injected config parameters: https://github.com/ludwig-ai/ludwig/issues/2288 if "mlflow" in config: del config["mlflow"] trial_id = tune.get_context().get_trial_id() trial_dir = Path(tune.get_context().get_trial_dir()) modified_config = substitute_parameters(copy.deepcopy(hyperopt_dict["config"]), config) modified_config = merge_with_defaults(modified_config) hyperopt_dict["config"] = modified_config hyperopt_dict["experiment_name "] = f'{hyperopt_dict["experiment_name"]}_{trial_id}' hyperopt_dict["output_directory"] = str(trial_dir) tune_executor = self if is_using_ray_backend: ray_queue = RayQueue(actor_options={"num_cpus": 0}) else: ray_queue = None def report(progress_tracker, save_path=None): # The progress tracker's metrics are nested dictionaries of TrainerMetrics: feature_name -> metric_name -> # List[TrainerMetric], with one entry per training checkpoint, according to steps_per_checkpoint. # We reduce the dictionary of TrainerMetrics to a simple list of floats for interfacing with Ray Tune. train_stats = { TRAINING: metric_utils.reduce_trainer_metrics_dict(progress_tracker.train_metrics), VALIDATION: metric_utils.reduce_trainer_metrics_dict(progress_tracker.validation_metrics), TEST: metric_utils.reduce_trainer_metrics_dict(progress_tracker.test_metrics), } metric_score = tune_executor.get_metric_score(train_stats) report_kwargs = { "metrics": { "parameters": json.dumps(config, cls=NumpyEncoder), "metric_score": metric_score, "training_stats": json.dumps(train_stats, cls=NumpyEncoder), "eval_stats": "{}", "trial_id": tune.get_context().get_trial_id(), "trial_dir": str(tune.get_context().get_trial_dir()), } } if save_path is not None: report_kwargs["checkpoint"] = _create_tune_checkpoint(save_path) tune.report(**report_kwargs) class RayTuneReportCallback(Callback): def __init__(self): super().__init__() self.last_steps = 0 self.resume_ckpt_dir = None def _get_remote_checkpoint_dir(self) -> str | tuple[str, str] | None: # sync client has to be recreated to avoid issues with serialization return tune_executor._get_remote_checkpoint_dir(trial_dir) def _checkpoint_progress(self, trainer, progress_tracker, save_path) -> None: """Checkpoints the progress tracker.""" if is_using_ray_backend: # Pass the save_path directly through the queue. On single-node clusters, # the trial driver and training workers share the same filesystem. # For multi-node, the checkpoint should be on shared storage. ray_queue.put((progress_tracker, save_path)) return # For non-Ray backend, report metrics + checkpoint together report(progress_tracker, save_path=save_path) def on_train_start(self, model, config: dict[str, Any], config_fp: str | None): if is_using_ray_backend and checkpoint_dir: # Store the checkpoint directory path for syncing to the trainer worker. self.resume_ckpt_dir = checkpoint_dir def on_trainer_train_setup(self, trainer, save_path, is_coordinator): # Check local rank before manipulating files, as otherwise there will be a race condition # between multiple workers running on the same node. if self.resume_ckpt_dir is not None and trainer.local_rank == 0: # Resume from a previous checkpoint by syncing files from the checkpoint # directory to the save_path. ckpt_path = self.resume_ckpt_dir # Attempt an atomic move from the ckpt_path to the save_path # This may first require removing the existing save_path tmp_path = save_path + ".tmp" if os.path.exists(save_path): os.rename(save_path, tmp_path) try: model_path = os.path.join(ckpt_path, "model") if os.path.exists(model_path): safe_move_file(model_path, save_path) elif os.path.exists(ckpt_path): safe_move_file(ckpt_path, save_path) except Exception: # Rollback from partial changes. Remove the save_path # and move the original save_path back. if os.path.exists(save_path): shutil.rmtree(save_path) if os.path.exists(tmp_path): os.rename(tmp_path, save_path) raise # Cleanup the backup save_path as it's no longer needed if os.path.exists(tmp_path): shutil.rmtree(tmp_path) # Sync all workers here before continuing to training trainer.barrier() def on_eval_end(self, trainer, progress_tracker, save_path): progress_tracker.tune_checkpoint_num += 1 self.last_steps = progress_tracker.steps self._checkpoint_progress(trainer, progress_tracker, save_path) def on_trainer_train_teardown(self, trainer, progress_tracker, save_path, is_coordinator): if is_coordinator and progress_tracker.steps > self.last_steps: # Note: Calling tune.report in both on_eval_end() and here can cause multiprocessing issues # for some ray samplers if not steps have happened since the last eval. self._checkpoint_progress(trainer, progress_tracker, save_path) callbacks = hyperopt_dict.get("callbacks") or [] hyperopt_dict["callbacks"] = callbacks + [RayTuneReportCallback()] # set tune resources if is_using_ray_backend: resources = tune.get_context().get_trial_resources() # check if we are using at least 1 gpu per trial use_gpu = bool(self._gpu_resources_per_trial_non_none) # get the resources assigned to the current trial num_gpus = resources.required_resources.get("GPU", 0) num_cpus = resources.required_resources.get("CPU", 1) if num_gpus == 0 else 0 distributed_kwargs = { "num_workers": int(num_gpus) if use_gpu else 1, "use_gpu": use_gpu, "resources_per_worker": { "CPU": num_cpus, "GPU": 1 if use_gpu else 0, }, } hyperopt_dict["backend"].set_distributed_kwargs(**distributed_kwargs) logger.debug(f"Trial distributed kwargs: {distributed_kwargs}") stats = [] thread_error = [None] # Use list to allow mutation from nested function def _run(): try: train_stats, eval_stats = run_experiment( **hyperopt_dict, model_resume_path=checkpoint_dir, parameters=config, ) stats.append((train_stats, eval_stats)) except Exception as e: thread_error[0] = e logger.error(f"Error in hyperopt trial thread: {e}") if is_using_ray_backend: # We have to pull the results to the trial actor # from worker actors, as the Tune session is running # only on the trial actor thread = threading.Thread(target=_run) thread.daemon = True thread.start() def check_queue(): qsize = ray_queue.qsize() if qsize: results = ray_queue.get_nowait_batch(qsize) for progress_tracker, save_path in results: report(progress_tracker, save_path=save_path) while thread.is_alive(): thread.join(timeout=0) check_queue() time.sleep(0.1) thread.join() check_queue() else: # remove threading overhead _run() if thread_error[0] is not None: raise RuntimeError(f"Experiment failed: {thread_error[0]}") from thread_error[0] if not stats: raise RuntimeError("Experiment did not complete.") train_stats, eval_stats = stats.pop() metric_score = self.get_metric_score(train_stats) tune.report( metrics={ "parameters": json.dumps(config, cls=NumpyEncoder), "metric_score": metric_score, "training_stats": json.dumps(train_stats, cls=NumpyEncoder), "eval_stats": json.dumps(eval_stats, cls=NumpyEncoder), "trial_id": tune.get_context().get_trial_id(), "trial_dir": str(tune.get_context().get_trial_dir()), } ) def execute( self, config, dataset=None, training_set=None, validation_set=None, test_set=None, training_set_metadata=None, data_format=None, experiment_name="hyperopt", model_name="run", resume=None, skip_save_training_description=False, skip_save_training_statistics=False, skip_save_model=False, skip_save_progress=False, skip_save_log=False, skip_save_processed_input=True, skip_save_unprocessed_output=False, skip_save_predictions=False, skip_save_eval_stats=False, output_directory="results", gpus=None, gpu_memory_limit=None, allow_parallel_threads=True, callbacks=None, tune_callbacks=None, backend=None, random_seed=default_random_seed, debug=False, hyperopt_log_verbosity=3, **kwargs, ) -> HyperoptResults: if isinstance(dataset, str) and not has_remote_protocol(dataset) and not os.path.isabs(dataset): dataset = os.path.abspath(dataset) # Ray Tune / PyArrow requires absolute paths or URIs for storage_path if not has_remote_protocol(output_directory) and not os.path.isabs(output_directory): output_directory = os.path.abspath(output_directory) if isinstance(backend, str): backend = initialize_backend(backend) if gpus is not None: raise ValueError( "Parameter `gpus` is not supported when using Ray Tune. " "Configure GPU resources with Ray and set `gpu_resources_per_trial` in your " "hyperopt config." ) if gpu_memory_limit is None and 0 < self._gpu_resources_per_trial_non_none < 1: # Enforce fractional GPU utilization gpu_memory_limit = self.gpu_resources_per_trial hyperopt_dict = dict( config=config, dataset=dataset, training_set=training_set, validation_set=validation_set, test_set=test_set, training_set_metadata=training_set_metadata, data_format=data_format, experiment_name=experiment_name, model_name=model_name, eval_split=self.split, skip_save_training_description=skip_save_training_description, skip_save_training_statistics=skip_save_training_statistics, skip_save_model=skip_save_model, skip_save_progress=skip_save_progress, skip_save_log=skip_save_log, skip_save_processed_input=skip_save_processed_input, skip_save_unprocessed_output=skip_save_unprocessed_output, skip_save_predictions=skip_save_predictions, skip_save_eval_stats=skip_save_eval_stats, output_directory=output_directory, gpus=gpus, gpu_memory_limit=gpu_memory_limit, allow_parallel_threads=allow_parallel_threads, callbacks=callbacks, backend=backend, random_seed=random_seed, debug=debug, ) mode = "min" if self.goal != MAXIMIZE else "max" metric = "metric_score" # if random seed not set, use Ludwig seed self.search_algorithm.check_for_random_seed(random_seed) if self.search_algorithm.search_alg_dict is not None: if TYPE not in self.search_algorithm.search_alg_dict: candiate_search_algs = [search_alg for search_alg in SEARCH_ALG_IMPORT.keys()] logger.warning( "WARNING: search_alg type parameter missing, using 'variant_generator' as default. " f"These are possible values for the type parameter: {candiate_search_algs}." ) search_alg = None else: search_alg_type = self.search_algorithm.search_alg_dict[TYPE] search_alg = tune.create_searcher( search_alg_type, metric=metric, mode=mode, **self.search_algorithm.search_alg_dict ) else: search_alg = None if self.max_concurrent_trials: assert ( self.max_concurrent_trials > 0 ), f"`max_concurrent_trials` must be greater than 0, got {self.max_concurrent_trials}" if isinstance(search_alg, BasicVariantGenerator) or search_alg is None: search_alg = BasicVariantGenerator(max_concurrent=self.max_concurrent_trials) elif isinstance(search_alg, ConcurrencyLimiter): raise ValueError( "You have specified `max_concurrent_trials`, but the search " "algorithm is already a `ConcurrencyLimiter`. FIX THIS " "by setting `max_concurrent_trials=None`." ) else: search_alg = ConcurrencyLimiter(search_alg, max_concurrent=self.max_concurrent_trials) resources_per_trial = { "cpu": self._cpu_resources_per_trial_non_none, "gpu": self._gpu_resources_per_trial_non_none, } def run_experiment_trial(config, local_hyperopt_dict, checkpoint_dir=None): return self._run_experiment( config, checkpoint_dir, local_hyperopt_dict, self.decode_ctx, _is_ray_backend(backend), ) tune_config = {} _tune_callbacks = list(tune_callbacks or []) for callback in callbacks or []: run_experiment_trial, tune_config = callback.prepare_ray_tune( run_experiment_trial, tune_config, _tune_callbacks, ) tune_callbacks = _tune_callbacks if _is_ray_backend(backend): # for now, we do not do distributed training on cpu (until spread scheduling is implemented for Ray Train) # but we do want to enable it when GPUs are specified resources_per_trial = PlacementGroupFactory( [{}] + ([{"CPU": 0, "GPU": 1}] * self._gpu_resources_per_trial_non_none) if self._gpu_resources_per_trial_non_none else [{}] + [{"CPU": self._cpu_resources_per_trial_non_none}] ) if has_remote_protocol(output_directory): # In Ray 2.x, remote storage is handled via RunConfig storage_path self.sync_config = tune.SyncConfig() self.sync_client = None # output_directory will be used as storage_path elif self.kubernetes_namespace: logger.warning( "Kubernetes-specific syncing is no longer supported in Ray 2.x. " "Use cloud storage (S3, GCS) as the output directory instead." ) run_experiment_trial_params = tune.with_parameters(run_experiment_trial, local_hyperopt_dict=hyperopt_dict) @ray.remote def _register(name, trainable): register_trainable(name, trainable) ray.get(_register.remote(f"trainable_func_f{hash_dict(config).decode('ascii')}", run_experiment_trial_params)) # Note that resume="AUTO" will attempt to resume the experiment if possible, and # otherwise will start a new experiment: # https://docs.ray.io/en/latest/tune/tutorials/tune-stopping.html should_resume = "AUTO" if resume is None else resume # If the output directory is an S3 path and AWS_ENDPOINT_URL is set, # configure a custom S3 filesystem for Ray Tune. We use fsspec's s3fs # wrapped in PyArrow's FSSpecHandler because PyArrow's native S3 C++ # client doesn't read AWS_ENDPOINT_URL and its chunked transfer encoding # is incompatible with some S3-compatible stores (e.g. MinIO). storage_filesystem = None if output_directory and str(output_directory).startswith("s3://"): endpoint_url = os.environ.get("AWS_ENDPOINT_URL") if endpoint_url: import pyarrow.fs import s3fs s3 = s3fs.S3FileSystem( endpoint_url=endpoint_url, key=os.environ.get("AWS_ACCESS_KEY_ID"), secret=os.environ.get("AWS_SECRET_ACCESS_KEY"), ) storage_filesystem = pyarrow.fs.PyFileSystem(pyarrow.fs.FSSpecHandler(s3)) # When storage_filesystem is set, storage_path must be a plain # path (bucket/key...), not a URI (s3://bucket/key...). output_directory = str(output_directory).removeprefix("s3://") try: analysis = tune.run( f"trainable_func_f{hash_dict(config).decode('ascii')}", name=experiment_name, config={ **self.search_space, **tune_config, }, scheduler=self.scheduler, search_alg=search_alg, num_samples=self.num_samples, checkpoint_config=tune.CheckpointConfig(num_to_keep=1), max_failures=1, # retry a trial failure once resources_per_trial=resources_per_trial, time_budget_s=self.time_budget_s, sync_config=self.sync_config, storage_path=output_directory, storage_filesystem=storage_filesystem, metric=metric, mode=mode, trial_name_creator=lambda trial: f"trial_{trial.trial_id}", trial_dirname_creator=lambda trial: f"trial_{trial.trial_id}", callbacks=tune_callbacks, stop=CallbackStopper(callbacks), verbose=hyperopt_log_verbosity, resume=should_resume, log_to_file=True, ) except Exception as e: # Explicitly raise a RuntimeError if an error is encountered during a Ray trial. # NOTE: Cascading the exception with "raise _ from e" still results in hanging. raise RuntimeError(f"Encountered Ray Tune error: {e}") if "metric_score" in analysis.results_df.columns: ordered_trials = analysis.results_df.sort_values("metric_score", ascending=self.goal != MAXIMIZE) # Catch nans in edge case where the trial doesn't complete temp_ordered_trials = [] for kwargs in ordered_trials.to_dict(orient="records"): for key in ["parameters", "training_stats", "eval_stats"]: if isinstance(kwargs[key], float): kwargs[key] = {} temp_ordered_trials.append(kwargs) # Trials w/empty eval_stats fields & non-empty training_stats fields ran intermediate # tune.report call(s) but were terminated before reporting eval_stats from post-train # evaluation (e.g., trial stopped due to time budget or relatively poor performance.) # For any such trials, run model evaluation for the best model in that trial & record # results in ordered_trials which is returned & is persisted in hyperopt_statistics.json. for trial in temp_ordered_trials: if trial["eval_stats"] == "{}" and trial["training_stats"] != "{}": # Evaluate the best model on the eval_split, which is validation_set if validation_set is not None and validation_set.size > 0: trial_path = trial["trial_dir"] with self._get_best_model_path(trial_path, analysis) as best_model_path: if best_model_path is not None: try: self._evaluate_best_model( trial, trial_path, best_model_path, validation_set, data_format, skip_save_unprocessed_output, skip_save_predictions, skip_save_eval_stats, gpus, gpu_memory_limit, allow_parallel_threads, backend, debug, ) except Exception: logger.warning( f"Failed to evaluate best model for trial {trial_path}. " "This can happen with incomplete checkpoints from early stopping. " f"Full exception:\n{traceback.format_exc()}" ) else: logger.warning("Skipping evaluation as no model checkpoints were available") else: logger.warning("Skipping evaluation as no validation set was provided") ordered_trials = [TrialResults.from_dict(load_json_values(kwargs)) for kwargs in temp_ordered_trials] else: logger.warning("No trials reported results; check if time budget lower than epoch latency") ordered_trials = [] return HyperoptResults(ordered_trials=ordered_trials, experiment_analysis=analysis) class CallbackStopper(Stopper): """Ray Tune Stopper that triggers the entire job to stop if one callback returns True.""" def __init__(self, callbacks: list[Callback] | None): self.callbacks = callbacks or [] def __call__(self, trial_id, result): return False def stop_all(self): for callback in self.callbacks: if callback.should_stop_hyperopt(): return True return False def get_build_hyperopt_executor(executor_type): return get_from_registry(executor_type, executor_registry) executor_registry = {"ray": RayTuneExecutor} def set_values(params: dict[str, Any], model_dict: dict[str, Any]): for key, value in params.items(): if isinstance(value, dict): for sub_key, sub_value in value.items(): if key not in model_dict: model_dict[key] = dict() model_dict[key][sub_key] = sub_value else: model_dict[key] = value def run_experiment( config, parameters=None, dataset=None, training_set=None, validation_set=None, test_set=None, training_set_metadata=None, data_format=None, experiment_name="hyperopt", model_name="run", model_resume_path=None, eval_split=VALIDATION, skip_save_training_description=False, skip_save_training_statistics=False, skip_save_model=False, skip_save_progress=False, skip_save_log=False, skip_save_processed_input=False, skip_save_unprocessed_output=False, skip_save_predictions=False, skip_save_eval_stats=False, output_directory="results", gpus=None, gpu_memory_limit=None, allow_parallel_threads=True, callbacks=None, backend=None, random_seed=default_random_seed, debug=False, **kwargs, ): for callback in callbacks or []: callback.on_hyperopt_trial_start(parameters) # Collect training and validation losses and metrics # & append it to `results` model = LudwigModel( config=config, backend=backend, gpus=gpus, gpu_memory_limit=gpu_memory_limit, allow_parallel_threads=allow_parallel_threads, callbacks=callbacks, ) eval_stats, train_stats, _, _ = model.experiment( dataset=dataset, training_set=training_set, validation_set=validation_set, test_set=test_set, training_set_metadata=training_set_metadata, data_format=data_format, experiment_name=experiment_name, model_name=model_name, model_resume_path=model_resume_path, eval_split=eval_split, skip_save_training_description=skip_save_training_description, skip_save_training_statistics=skip_save_training_statistics, skip_save_model=skip_save_model, skip_save_progress=skip_save_progress, skip_save_log=skip_save_log, skip_save_processed_input=skip_save_processed_input, skip_save_unprocessed_output=skip_save_unprocessed_output, skip_save_predictions=skip_save_predictions, skip_save_eval_stats=skip_save_eval_stats, output_directory=output_directory, skip_collect_predictions=True, skip_collect_overall_stats=False, random_seed=random_seed, debug=debug, ) for callback in callbacks or []: callback.on_hyperopt_trial_end(parameters) return train_stats, eval_stats def _run_experiment_unary(kwargs): """Unary function is needed by Fiber to map a list of args.""" return run_experiment(**kwargs) ================================================ FILE: ludwig/hyperopt/results.py ================================================ # !/usr/bin/env python from dataclasses import dataclass from typing import Any from dataclasses_json import dataclass_json try: from ray.tune import ExperimentAnalysis except ImportError: ExperimentAnalysis = Any @dataclass_json @dataclass class TrialResults: parameters: dict metric_score: float training_stats: dict eval_stats: dict @dataclass class HyperoptResults: ordered_trials: list[TrialResults] experiment_analysis: ExperimentAnalysis ================================================ FILE: ludwig/hyperopt/run.py ================================================ import copy import logging import os from pprint import pformat import pandas as pd import yaml from tabulate import tabulate from ludwig.api import LudwigModel from ludwig.backend import Backend, initialize_backend, LocalBackend from ludwig.callbacks import Callback from ludwig.constants import ( AUTO, COMBINED, EXECUTOR, GOAL, HYPEROPT, LOSS, MAX_CONCURRENT_TRIALS, METRIC, NAME, OUTPUT_FEATURES, PARAMETERS, PREPROCESSING, SEARCH_ALG, SPLIT, TEST, TRAINING, TYPE, VALIDATION, ) from ludwig.data.split import get_splitter from ludwig.hyperopt.results import HyperoptResults from ludwig.hyperopt.utils import ( log_warning_if_all_grid_type_parameters, print_hyperopt_results, save_hyperopt_stats, should_tune_preprocessing, update_hyperopt_params_with_defaults, ) from ludwig.schema.model_config import ModelConfig from ludwig.utils.backward_compatibility import upgrade_config_dict_to_latest_version from ludwig.utils.dataset_utils import generate_dataset_statistics from ludwig.utils.defaults import default_random_seed from ludwig.utils.fs_utils import makedirs, open_file try: from ray.tune import Callback as TuneCallback from ludwig.backend.ray import RayBackend except ImportError: TuneCallback = object class RayBackend: pass logger = logging.getLogger(__name__) def hyperopt( config: str | dict, dataset: str | dict | pd.DataFrame = None, training_set: str | dict | pd.DataFrame = None, validation_set: str | dict | pd.DataFrame = None, test_set: str | dict | pd.DataFrame = None, training_set_metadata: str | dict = None, data_format: str = None, experiment_name: str = "hyperopt", model_name: str = "run", resume: bool | None = None, skip_save_training_description: bool = False, skip_save_training_statistics: bool = False, skip_save_model: bool = False, skip_save_progress: bool = False, skip_save_log: bool = False, skip_save_processed_input: bool = True, skip_save_unprocessed_output: bool = False, skip_save_predictions: bool = False, skip_save_eval_stats: bool = False, skip_save_hyperopt_statistics: bool = False, output_directory: str = "results", gpus: str | int | list[int] = None, gpu_memory_limit: float | None = None, allow_parallel_threads: bool = True, callbacks: list[Callback] = None, tune_callbacks: list[TuneCallback] = None, backend: Backend | str = None, random_seed: int = default_random_seed, hyperopt_log_verbosity: int = 3, **kwargs, ) -> HyperoptResults: """This method performs an hyperparameter optimization. # Inputs :param config: (Union[str, dict]) config which defines the different parameters of the model, features, preprocessing and training. If `str`, filepath to yaml configuration file. :param dataset: (Union[str, dict, pandas.DataFrame], default: `None`) source containing the entire dataset to be used in the experiment. If it has a split column, it will be used for splitting (0 for train, 1 for validation, 2 for test), otherwise the dataset will be randomly split. :param training_set: (Union[str, dict, pandas.DataFrame], default: `None`) source containing training data. :param validation_set: (Union[str, dict, pandas.DataFrame], default: `None`) source containing validation data. :param test_set: (Union[str, dict, pandas.DataFrame], default: `None`) source containing test data. :param training_set_metadata: (Union[str, dict], default: `None`) metadata JSON file or loaded metadata. Intermediate preprocessed structure containing the mappings of the input dataset created the first time an input file is used in the same directory with the same name and a '.meta.json' extension. :param data_format: (str, default: `None`) format to interpret data sources. Will be inferred automatically if not specified. Valid formats are `'auto'`, `'csv'`, `'df'`, `'dict'`, `'excel'`, `'feather'`, `'fwf'`, `'hdf5'` (cache file produced during previous training), `'html'` (file containing a single HTML `
`), `'json'`, `'jsonl'`, `'parquet'`, `'pickle'` (pickled Pandas DataFrame), `'sas'`, `'spss'`, `'stata'`, `'tsv'`. :param experiment_name: (str, default: `'experiment'`) name for the experiment. :param model_name: (str, default: `'run'`) name of the model that is being used. :param resume: (bool) If true, continue hyperopt from the state of the previous run in the output directory with the same experiment name. If false, will create new trials, ignoring any previous state, even if they exist in the output_directory. By default, will attempt to resume if there is already an existing experiment with the same name, and will create new trials if not. :param skip_save_training_description: (bool, default: `False`) disables saving the description JSON file. :param skip_save_training_statistics: (bool, default: `False`) disables saving training statistics JSON file. :param skip_save_model: (bool, default: `False`) disables saving model weights and hyperparameters each time the model improves. By default Ludwig saves model weights after each epoch the validation metric improves, but if the model is really big that can be time consuming. If you do not want to keep the weights and just find out what performance a model can get with a set of hyperparameters, use this parameter to skip it, but the model will not be loadable later on and the returned model will have the weights obtained at the end of training, instead of the weights of the epoch with the best validation performance. :param skip_save_progress: (bool, default: `False`) disables saving progress each epoch. By default Ludwig saves weights and stats after each epoch for enabling resuming of training, but if the model is really big that can be time consuming and will uses twice as much space, use this parameter to skip it, but training cannot be resumed later on. :param skip_save_log: (bool, default: `False`) disables saving TensorBoard logs. By default Ludwig saves logs for the TensorBoard, but if it is not needed turning it off can slightly increase the overall speed. :param skip_save_processed_input: (bool, default: `False`) if input dataset is provided it is preprocessed and cached by saving an HDF5 and JSON files to avoid running the preprocessing again. If this parameter is `False`, the HDF5 and JSON file are not saved. :param skip_save_unprocessed_output: (bool, default: `False`) by default predictions and their probabilities are saved in both raw unprocessed numpy files containing tensors and as postprocessed CSV files (one for each output feature). If this parameter is True, only the CSV ones are saved and the numpy ones are skipped. :param skip_save_predictions: (bool, default: `False`) skips saving test predictions CSV files. :param skip_save_eval_stats: (bool, default: `False`) skips saving test statistics JSON file. :param skip_save_hyperopt_statistics: (bool, default: `False`) skips saving hyperopt stats file. :param output_directory: (str, default: `'results'`) the directory that will contain the training statistics, TensorBoard logs, the saved model and the training progress files. :param gpus: (list, default: `None`) list of GPUs that are available for training. :param gpu_memory_limit: (float: default: `None`) maximum memory fraction [0, 1] allowed to allocate per GPU device. :param allow_parallel_threads: (bool, default: `True`) allow PyTorch to use multithreading parallelism to improve performance at the cost of determinism. :param callbacks: (list, default: `None`) a list of `ludwig.callbacks.Callback` objects that provide hooks into the Ludwig pipeline. :param backend: (Union[Backend, str]) `Backend` or string name of backend to use to execute preprocessing / training steps. :param random_seed: (int: default: 42) random seed used for weights initialization, splits and any other random function. :param hyperopt_log_verbosity: (int: default: 3) controls verbosity of ray tune log messages. Valid values: 0 = silent, 1 = only status updates, 2 = status and brief trial results, 3 = status and detailed trial results. # Return :return: (List[dict]) List of results for each trial, ordered by descending performance on the target metric. """ from ludwig.hyperopt.execution import get_build_hyperopt_executor, RayTuneExecutor # check if config is a path or a dict if isinstance(config, str): # assume path with open_file(config, "r") as def_file: config_dict = yaml.safe_load(def_file) else: config_dict = config if HYPEROPT not in config_dict: raise ValueError("Hyperopt Section not present in config") # backwards compatibility upgraded_config = upgrade_config_dict_to_latest_version(config_dict) # Initialize config object config_obj = ModelConfig.from_dict(upgraded_config) # Retain pre-merged config for hyperopt schema generation premerged_config = copy.deepcopy(upgraded_config) # Get full config with defaults full_config = config_obj.to_dict() # TODO (Connor): Refactor to use config object hyperopt_config = full_config[HYPEROPT] # Explicitly default to a local backend to avoid picking up Ray # backend from the environment. backend = backend or config_dict.get("backend") or "local" backend = initialize_backend(backend) update_hyperopt_params_with_defaults(hyperopt_config) # Check if all features are grid type parameters and log UserWarning if needed log_warning_if_all_grid_type_parameters(hyperopt_config) # Infer max concurrent trials if hyperopt_config[EXECUTOR].get(MAX_CONCURRENT_TRIALS) == AUTO: hyperopt_config[EXECUTOR][MAX_CONCURRENT_TRIALS] = backend.max_concurrent_trials(hyperopt_config) logger.info(f"Setting max_concurrent_trials to {hyperopt_config[EXECUTOR][MAX_CONCURRENT_TRIALS]}") # Print hyperopt config logger.info("Hyperopt Config") logger.info(pformat(hyperopt_config, indent=4)) logger.info("\n") search_alg = hyperopt_config[SEARCH_ALG] executor = hyperopt_config[EXECUTOR] parameters = hyperopt_config[PARAMETERS] split = hyperopt_config[SPLIT] output_feature = hyperopt_config["output_feature"] metric = hyperopt_config[METRIC] goal = hyperopt_config[GOAL] ###################### # check validity of output_feature / metric/ split combination ###################### splitter = get_splitter(**full_config[PREPROCESSING]["split"]) if split == TRAINING: if training_set is None and not splitter.has_split(0): raise ValueError( 'The data for the specified split for hyperopt "{}" ' "was not provided, " "or the split amount specified in the preprocessing section " "of the config is not greater than 0".format(split) ) elif split == VALIDATION: if validation_set is None and not splitter.has_split(1): raise ValueError( 'The data for the specified split for hyperopt "{}" ' "was not provided, " "or the split amount specified in the preprocessing section " "of the config is not greater than 0".format(split) ) elif split == TEST: if test_set is None and not splitter.has_split(2): raise ValueError( 'The data for the specified split for hyperopt "{}" ' "was not provided, " "or the split amount specified in the preprocessing section " "of the config is not greater than 0".format(split) ) else: raise ValueError( 'unrecognized hyperopt split "{}". ' "Please provide one of: {}".format(split, {TRAINING, VALIDATION, TEST}) ) if output_feature == COMBINED: if metric != LOSS: raise ValueError('The only valid metric for "combined" output feature is "loss"') else: output_feature_names = {of[NAME] for of in full_config[OUTPUT_FEATURES]} if output_feature not in output_feature_names: raise ValueError( 'The output feature specified for hyperopt "{}" ' "cannot be found in the config. " 'Available ones are: {} and "combined"'.format(output_feature, output_feature_names) ) hyperopt_executor = get_build_hyperopt_executor(executor[TYPE])( parameters, output_feature, metric, goal, split, search_alg=search_alg, **executor ) # Explicitly default to a local backend to avoid picking up Ray # backend from the environment. backend = backend or config_dict.get("backend") or "local" backend = initialize_backend(backend) if not ( isinstance(backend, LocalBackend) or (isinstance(hyperopt_executor, RayTuneExecutor) and isinstance(backend, RayBackend)) ): raise ValueError( "Hyperopt requires using a `local` backend at this time, or " "`ray` backend with `ray` executor." ) for callback in callbacks or []: callback.on_hyperopt_init(experiment_name) if not should_tune_preprocessing(full_config): # preprocessing is not being tuned, so generate it once before starting trials for callback in callbacks or []: callback.on_hyperopt_preprocessing_start(experiment_name) model = LudwigModel( config=full_config, backend=backend, gpus=gpus, gpu_memory_limit=gpu_memory_limit, allow_parallel_threads=allow_parallel_threads, callbacks=callbacks, ) training_set, validation_set, test_set, training_set_metadata = model.preprocess( dataset=dataset, training_set=training_set, validation_set=validation_set, test_set=test_set, training_set_metadata=training_set_metadata, data_format=data_format, skip_save_processed_input=skip_save_processed_input, random_seed=random_seed, ) dataset = None dataset_statistics = generate_dataset_statistics(training_set, validation_set, test_set) logger.info("\nDataset Statistics") logger.info(tabulate(dataset_statistics, headers="firstrow", tablefmt="fancy_grid")) for callback in callbacks or []: callback.on_hyperopt_preprocessing_end(experiment_name) for callback in callbacks or []: callback.on_hyperopt_start(experiment_name) hyperopt_results = hyperopt_executor.execute( premerged_config, dataset=dataset, training_set=training_set, validation_set=validation_set, test_set=test_set, training_set_metadata=training_set_metadata, data_format=data_format, experiment_name=experiment_name, model_name=model_name, resume=resume, skip_save_training_description=skip_save_training_description, skip_save_training_statistics=skip_save_training_statistics, skip_save_model=skip_save_model, skip_save_progress=skip_save_progress, skip_save_log=skip_save_log, skip_save_processed_input=skip_save_processed_input, skip_save_unprocessed_output=skip_save_unprocessed_output, skip_save_predictions=skip_save_predictions, skip_save_eval_stats=skip_save_eval_stats, output_directory=output_directory, gpus=gpus, gpu_memory_limit=gpu_memory_limit, allow_parallel_threads=allow_parallel_threads, callbacks=callbacks, tune_callbacks=tune_callbacks, backend=backend, random_seed=random_seed, hyperopt_log_verbosity=hyperopt_log_verbosity, **kwargs, ) if backend.is_coordinator(): print_hyperopt_results(hyperopt_results) if not skip_save_hyperopt_statistics: with backend.storage.artifacts.use_credentials(): results_directory = os.path.join(output_directory, experiment_name) makedirs(results_directory, exist_ok=True) hyperopt_stats = { "hyperopt_config": hyperopt_config, "hyperopt_results": [t.to_dict() for t in hyperopt_results.ordered_trials], } save_hyperopt_stats(hyperopt_stats, results_directory) logger.info(f"Hyperopt stats saved to: {results_directory}") for callback in callbacks or []: callback.on_hyperopt_end(experiment_name) callback.on_hyperopt_finish(experiment_name) logger.info("Finished hyperopt") return hyperopt_results ================================================ FILE: ludwig/hyperopt/search_algos.py ================================================ import logging from abc import ABC from importlib import import_module from ludwig.constants import TYPE from ludwig.utils.misc_utils import get_from_registry logger = logging.getLogger(__name__) def _is_package_installed(package_name: str, search_algo_name: str) -> bool: try: import_module(package_name) return True except ImportError: raise ImportError( f"Search algorithm {search_algo_name} requires package {package_name}, however package is not installed." " Please refer to Ray Tune documentation for packages required for this search algorithm." ) class SearchAlgorithm(ABC): def __init__(self, search_alg_dict: dict) -> None: self.search_alg_dict = search_alg_dict self.random_seed_attribute_name = None def check_for_random_seed(self, ludwig_random_seed: int) -> None: if self.random_seed_attribute_name not in self.search_alg_dict: self.search_alg_dict[self.random_seed_attribute_name] = ludwig_random_seed class BasicVariantSA(SearchAlgorithm): def __init__(self, search_alg_dict: dict) -> None: super().__init__(search_alg_dict) self.random_seed_attribute_name = "random_state" class HyperoptSA(SearchAlgorithm): def __init__(self, search_alg_dict: dict) -> None: _is_package_installed("hyperopt", "hyperopt") super().__init__(search_alg_dict) self.random_seed_attribute_name = "random_state_seed" class BOHBSA(SearchAlgorithm): def __init__(self, search_alg_dict: dict) -> None: _is_package_installed("hpbandster", "bohb") _is_package_installed("ConfigSpace", "bohb") super().__init__(search_alg_dict) self.random_seed_attribute_name = "seed" class AxSA(SearchAlgorithm): def __init__(self, search_alg_dict: dict) -> None: _is_package_installed("sqlalchemy", "ax") _is_package_installed("ax", "ax") super().__init__(search_alg_dict) # override parent method, this search algorithm does not support # setting random seed def check_for_random_seed(self, ludwig_random_seed: int) -> None: pass class BayesOptSA(SearchAlgorithm): def __init__(self, search_alg_dict: dict) -> None: _is_package_installed("bayes_opt", "bayesopt") super().__init__(search_alg_dict) self.random_seed_attribute_name = "random_state" class BlendsearchSA(SearchAlgorithm): def __init__(self, search_alg_dict: dict) -> None: _is_package_installed("flaml", "blendsearch") super().__init__(search_alg_dict) # override parent method, this search algorithm does not support # setting random seed def check_for_random_seed(self, ludwig_random_seed: int) -> None: pass class CFOSA(SearchAlgorithm): def __init__(self, search_alg_dict: dict) -> None: _is_package_installed("flaml", "cfo") super().__init__(search_alg_dict) self.random_seed_attribute_name = "seed" # override parent method, this search algorithm does not support # setting random seed def check_for_random_seed(self, ludwig_random_seed: int) -> None: pass class DragonflySA(SearchAlgorithm): def __init__(self, search_alg_dict: dict) -> None: _is_package_installed("dragonfly", "dragonfly") super().__init__(search_alg_dict) self.random_seed_attribute_name = "random_state_seed" class HEBOSA(SearchAlgorithm): def __init__(self, search_alg_dict: dict) -> None: _is_package_installed("hebo", "hebo") super().__init__(search_alg_dict) self.random_seed_attribute_name = "random_state_seed" class SkoptSA(SearchAlgorithm): def __init__(self, search_alg_dict: dict) -> None: _is_package_installed("skopt", "skopt") super().__init__(search_alg_dict) # override parent method, this search algorithm does not support # setting random seed def check_for_random_seed(self, ludwig_random_seed: int) -> None: pass class NevergradSA(SearchAlgorithm): def __init__(self, search_alg_dict: dict) -> None: _is_package_installed("nevergrad", "nevergrad") super().__init__(search_alg_dict) # override parent method, this search algorithm does not support # setting random seed def check_for_random_seed(self, ludwig_random_seed: int) -> None: pass class OptunaSA(SearchAlgorithm): def __init__(self, search_alg_dict: dict) -> None: _is_package_installed("optuna", "optuna") super().__init__(search_alg_dict) self.random_seed_attribute_name = "seed" class ZooptSA(SearchAlgorithm): def __init__(self, search_alg_dict: dict) -> None: _is_package_installed("zoopt", "zoopt") super().__init__(search_alg_dict) # override parent method, this search algorithm does not support # setting random seed def check_for_random_seed(self, ludwig_random_seed: int) -> None: pass def get_search_algorithm(search_algo): search_algo_name = search_algo.get(TYPE, None) return get_from_registry(search_algo_name, search_algo_registry)(search_algo) search_algo_registry = { None: BasicVariantSA, "variant_generator": BasicVariantSA, "random": BasicVariantSA, "hyperopt": HyperoptSA, "bohb": BOHBSA, "ax": AxSA, "bayesopt": BayesOptSA, "blendsearch": BlendsearchSA, "cfo": CFOSA, "dragonfly": DragonflySA, "hebo": HEBOSA, "skopt": SkoptSA, "nevergrad": NevergradSA, "optuna": OptunaSA, "zoopt": ZooptSA, } ================================================ FILE: ludwig/hyperopt/utils.py ================================================ import copy import dataclasses import json import logging import os import warnings from typing import Any from ludwig.api_annotations import DeveloperAPI from ludwig.constants import ( AUTO, COMBINED, EXECUTOR, GOAL, GRID_SEARCH, HYPEROPT, INPUT_FEATURES, LOSS, MAX_CONCURRENT_TRIALS, METRIC, MINIMIZE, NAME, NUM_SAMPLES, OUTPUT_FEATURES, PARAMETERS, PREPROCESSING, RAY, SPACE, SPLIT, TYPE, VALIDATION, ) from ludwig.globals import HYPEROPT_STATISTICS_FILE_NAME from ludwig.hyperopt.results import HyperoptResults, TrialResults from ludwig.types import HyperoptConfigDict, ModelConfigDict from ludwig.utils.data_utils import save_json from ludwig.utils.misc_utils import ( get_class_attributes, get_from_registry, merge_dict, set_default_value, set_default_values, ) from ludwig.utils.print_utils import print_boxed logger = logging.getLogger(__name__) def print_hyperopt_results(hyperopt_results: HyperoptResults): print_boxed("HYPEROPT RESULTS", print_fun=logger.info) for trial_results in hyperopt_results.ordered_trials: if not isinstance(trial_results.metric_score, str): logger.info(f"score: {trial_results.metric_score:.6f} | parameters: {trial_results.parameters}") logger.info("") def save_hyperopt_stats(hyperopt_stats, hyperopt_dir_name): hyperopt_stats_fn = os.path.join(hyperopt_dir_name, HYPEROPT_STATISTICS_FILE_NAME) save_json(hyperopt_stats_fn, hyperopt_stats) def load_json_value(v): try: return json.loads(v) except Exception as e: logger.warning(f"While loading json, encountered exception: {e}") return v # define set containing names to return for TrialResults TRIAL_RESULTS_NAMES_SET = {f.name for f in dataclasses.fields(TrialResults)} def load_json_values(d): # ensure metric_score is a string for the json load to eliminate extraneous exception message d["metric_score"] = str(d["metric_score"]) # load only data required for TrialResults return {k: load_json_value(v) for k, v in d.items() if k in TRIAL_RESULTS_NAMES_SET} def should_tune_preprocessing(config): parameters = config[HYPEROPT][PARAMETERS] for param_name in parameters.keys(): if f"{PREPROCESSING}." in param_name: return True return False def parameter_to_dict(name, value): if name == ".": # Parameter name ".", means top-level config return value parameter_dict = {} curr_dict = parameter_dict name_list = name.split(".") for i, name_elem in enumerate(name_list): if i == len(name_list) - 1: curr_dict[name_elem] = value else: name_dict = curr_dict.get(name_elem, {}) curr_dict[name_elem] = name_dict curr_dict = name_dict return parameter_dict def feature_list_to_dict(config: ModelConfigDict) -> ModelConfigDict: input_features_dict = {} for feature in config[INPUT_FEATURES]: input_features_dict[feature[NAME]] = feature output_features_dict = {} for feature in config[OUTPUT_FEATURES]: output_features_dict[feature[NAME]] = feature config = copy.copy(config) config[INPUT_FEATURES] = input_features_dict config[OUTPUT_FEATURES] = output_features_dict return config def feature_dict_to_list(config: ModelConfigDict) -> ModelConfigDict: # This works because Python dicts are order-preserving, so we do not need to # do anything special to map from a key in the dict to an index in a list input_features_list = [] for feature in config[INPUT_FEATURES].values(): input_features_list.append(feature) output_features_list = [] for feature in config[OUTPUT_FEATURES].values(): output_features_list.append(feature) config = copy.copy(config) config[INPUT_FEATURES] = input_features_list config[OUTPUT_FEATURES] = output_features_list return config def substitute_parameters( config: ModelConfigDict, parameters: dict[str, Any], ): """Update Ludwig config with parameters sampled from the Hyperopt sampler.""" # Collect the sets of names for each feature grouping so we can map feature names to # groups input_feature_names = {feature[NAME] for feature in config[INPUT_FEATURES]} output_feature_names = {feature[NAME] for feature in config[OUTPUT_FEATURES]} # Features in the user config are provided as a list, but in hyperopt we reference # features by name, so convert temporarily to a dict to simplify the mergep process. config = feature_list_to_dict(config) # Merge parameters into the user configuration in order. As such, if there are conflicting # params, the later params will take precedence. for name, value in parameters.items(): # User params are provided as ., but we group input / output features # together during the merge to make it easier and unambiguous to convert back and forth # TODO(travis): we should revisit the user format here, as it silently breaks situations # where the user has a feature named "trainer", "combiner", etc. prefix = name.split(".")[0] if prefix in input_feature_names: name = f"{INPUT_FEATURES}.{name}" elif prefix in output_feature_names: name = f"{OUTPUT_FEATURES}.{name}" param_dict = parameter_to_dict(name, value) config = merge_dict(config, param_dict) # Now that all features have been merged, convert back to the original list format. config = feature_dict_to_list(config) return config @DeveloperAPI def get_num_duplicate_trials(hyperopt_config: HyperoptConfigDict) -> int: """Returns the number of duplicate trials that will be created. Duplicate trials are only created when there are grid type parameters and num_samples > 1. """ num_samples = hyperopt_config[EXECUTOR].get(NUM_SAMPLES, 1) if num_samples == 1: return 0 total_grid_search_trials = 1 for _, param_info in hyperopt_config[PARAMETERS].items(): if param_info.get(SPACE, None) == GRID_SEARCH: total_grid_search_trials *= len(param_info.get("values", [])) num_duplicate_trials = (total_grid_search_trials * num_samples) - total_grid_search_trials return num_duplicate_trials def log_warning_if_all_grid_type_parameters(hyperopt_config: HyperoptConfigDict) -> None: """Logs warning if all parameters have a grid type search space and num_samples > 1 since this will result in duplicate trials being created.""" num_duplicate_trials = get_num_duplicate_trials(hyperopt_config) if num_duplicate_trials == 0: return num_samples = hyperopt_config[EXECUTOR].get(NUM_SAMPLES, 1) warnings.warn( "All hyperopt parameters in Ludwig config are using grid_search space, but number of samples " f"({num_samples}) is greater than 1. This will result in {num_duplicate_trials} duplicate trials being " "created. Consider setting `num_samples` to 1 in the hyperopt executor to prevent trial duplication.", RuntimeWarning, ) def update_hyperopt_params_with_defaults(hyperopt_params: HyperoptConfigDict) -> None: """Updates user's Ludwig config with default hyperopt parameters.""" from ludwig.hyperopt.execution import executor_registry set_default_value(hyperopt_params, EXECUTOR, {}) set_default_value(hyperopt_params, SPLIT, VALIDATION) set_default_value(hyperopt_params, "output_feature", COMBINED) set_default_value(hyperopt_params, METRIC, LOSS) set_default_value(hyperopt_params, GOAL, MINIMIZE) set_default_values( hyperopt_params[EXECUTOR], {TYPE: RAY, NUM_SAMPLES: 1, MAX_CONCURRENT_TRIALS: AUTO}, ) if hyperopt_params[EXECUTOR].get("trial_driver_resources") is None: hyperopt_params[EXECUTOR]["trial_driver_resources"] = {"CPU": 1, "GPU": 0} executor = get_from_registry(hyperopt_params[EXECUTOR][TYPE], executor_registry) executor_defaults = {k: v for k, v in executor.__dict__.items() if k in get_class_attributes(executor)} set_default_values( hyperopt_params[EXECUTOR], executor_defaults, ) ================================================ FILE: ludwig/hyperopt_cli.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import argparse import logging import sys from ludwig.backend import ALL_BACKENDS, Backend, initialize_backend from ludwig.callbacks import Callback from ludwig.contrib import add_contrib_callback_args from ludwig.globals import LUDWIG_VERSION from ludwig.hyperopt.run import hyperopt from ludwig.utils.data_utils import load_config_from_str, load_yaml from ludwig.utils.defaults import default_random_seed from ludwig.utils.print_utils import get_logging_level_registry, print_ludwig logger = logging.getLogger(__name__) def hyperopt_cli( config: str | dict, dataset: str = None, training_set: str = None, validation_set: str = None, test_set: str = None, training_set_metadata: str = None, data_format: str = None, experiment_name: str = "experiment", model_name: str = "run", # model_load_path=None, # model_resume_path=None, skip_save_training_description: bool = False, skip_save_training_statistics: bool = False, skip_save_model: bool = False, skip_save_progress: bool = False, skip_save_log: bool = False, skip_save_processed_input: bool = False, skip_save_unprocessed_output: bool = False, skip_save_predictions: bool = False, skip_save_eval_stats: bool = False, skip_save_hyperopt_statistics: bool = False, output_directory: str = "results", gpus: str | int | list[int] = None, gpu_memory_limit: float | None = None, allow_parallel_threads: bool = True, callbacks: list[Callback] = None, backend: Backend | str = None, random_seed: int = default_random_seed, hyperopt_log_verbosity: int = 3, **kwargs, ): """Searches for optimal hyperparameters. # Inputs :param config: (Union[str, dict]) in-memory representation of config or string path to a YAML config file. :param dataset: (Union[str, dict, pandas.DataFrame], default: `None`) source containing the entire dataset to be used for training. If it has a split column, it will be used for splitting (0 for train, 1 for validation, 2 for test), otherwise the dataset will be randomly split. :param training_set: (Union[str, dict, pandas.DataFrame], default: `None`) source containing training data. :param validation_set: (Union[str, dict, pandas.DataFrame], default: `None`) source containing validation data. :param test_set: (Union[str, dict, pandas.DataFrame], default: `None`) source containing test data. :param training_set_metadata: (Union[str, dict], default: `None`) metadata JSON file or loaded metadata. Intermediate preprocessed structure containing the mappings of the input dataset created the first time an input file is used in the same directory with the same name and a '.meta.json' extension. :param data_format: (str, default: `None`) format to interpret data sources. Will be inferred automatically if not specified. Valid formats are `'auto'`, `'csv'`, `'excel'`, `'feather'`, `'fwf'`, `'hdf5'` (cache file produced during previous training), `'html'` (file containing a single HTML `
`), `'json'`, `'jsonl'`, `'parquet'`, `'pickle'` (pickled Pandas DataFrame), `'sas'`, `'spss'`, `'stata'`, `'tsv'`. :param experiment_name: (str, default: `'experiment'`) name for the experiment. :param model_name: (str, default: `'run'`) name of the model that is being used. :param skip_save_training_description: (bool, default: `False`) disables saving the description JSON file. :param skip_save_training_statistics: (bool, default: `False`) disables saving training statistics JSON file. :param skip_save_model: (bool, default: `False`) disables saving model weights and hyperparameters each time the model improves. By default Ludwig saves model weights after each epoch the validation metric improves, but if the model is really big that can be time consuming. If you do not want to keep the weights and just find out what performance a model can get with a set of hyperparameters, use this parameter to skip it, but the model will not be loadable later on and the returned model will have the weights obtained at the end of training, instead of the weights of the epoch with the best validation performance. :param skip_save_progress: (bool, default: `False`) disables saving progress each epoch. By default Ludwig saves weights and stats after each epoch for enabling resuming of training, but if the model is really big that can be time consuming and will uses twice as much space, use this parameter to skip it, but training cannot be resumed later on. :param skip_save_log: (bool, default: `False`) disables saving TensorBoard logs. By default Ludwig saves logs for the TensorBoard, but if it is not needed turning it off can slightly increase the overall speed. :param skip_save_processed_input: (bool, default: `False`) if input dataset is provided it is preprocessed and cached by saving an HDF5 and JSON files to avoid running the preprocessing again. If this parameter is `False`, the HDF5 and JSON file are not saved. :param skip_save_unprocessed_output: (bool, default: `False`) by default predictions and their probabilities are saved in both raw unprocessed numpy files containing tensors and as postprocessed CSV files (one for each output feature). If this parameter is True, only the CSV ones are saved and the numpy ones are skipped. :param skip_save_predictions: (bool, default: `False`) skips saving test predictions CSV files :param skip_save_eval_stats: (bool, default: `False`) skips saving test statistics JSON file :param skip_save_hyperopt_statistics: (bool, default: `False`) skips saving hyperopt stats file. :param output_directory: (str, default: `'results'`) the directory that will contain the training statistics, TensorBoard logs, the saved model and the training progress files. :param gpus: (list, default: `None`) list of GPUs that are available for training. :param gpu_memory_limit: (float: default: `None`) maximum memory fraction [0, 1] allowed to allocate per GPU device. :param allow_parallel_threads: (bool, default: `True`) allow PyTorch to use multithreading parallelism to improve performance at the cost of determinism. :param callbacks: (list, default: `None`) a list of `ludwig.callbacks.Callback` objects that provide hooks into the Ludwig pipeline. :param backend: (Union[Backend, str]) `Backend` or string name of backend to use to execute preprocessing / training steps. :param random_seed: (int: default: 42) random seed used for weights initialization, splits and any other random function. :param hyperopt_log_verbosity: (int: default: 3) Controls verbosity of ray tune log messages. Valid values: 0 = silent, 1 = only status updates, 2 = status and brief trial results, 3 = status and detailed trial results. # Return :return" (`None`) """ return hyperopt( config=config, dataset=dataset, training_set=training_set, validation_set=validation_set, test_set=test_set, training_set_metadata=training_set_metadata, data_format=data_format, experiment_name=experiment_name, model_name=model_name, # model_load_path=model_load_path, # model_resume_path=model_resume_path, skip_save_training_description=skip_save_training_description, skip_save_training_statistics=skip_save_training_statistics, skip_save_model=skip_save_model, skip_save_progress=skip_save_progress, skip_save_log=skip_save_log, skip_save_processed_input=skip_save_processed_input, skip_save_unprocessed_output=skip_save_unprocessed_output, skip_save_predictions=skip_save_predictions, skip_save_eval_stats=skip_save_eval_stats, skip_save_hyperopt_statistics=skip_save_hyperopt_statistics, output_directory=output_directory, gpus=gpus, gpu_memory_limit=gpu_memory_limit, allow_parallel_threads=allow_parallel_threads, callbacks=callbacks, backend=backend, random_seed=random_seed, hyperopt_log_verbosity=hyperopt_log_verbosity, **kwargs, ) def cli(sys_argv): parser = argparse.ArgumentParser( description="This script searches for optimal Hyperparameters", prog="ludwig hyperopt", usage="%(prog)s [options]", ) # ------------------- # Hyperopt parameters # ------------------- parser.add_argument( "-sshs", "--skip_save_hyperopt_statistics", help="skips saving hyperopt statistics file", action="store_true", default=False, ) # ---------------------------- # Experiment naming parameters # ---------------------------- parser.add_argument( "--output_directory", type=str, default="results", help="directory that contains the results", ) parser.add_argument("--experiment_name", type=str, default="hyperopt", help="experiment name") parser.add_argument("--model_name", type=str, default="run", help="name for the model") # --------------- # Data parameters # --------------- parser.add_argument( "--dataset", help="input data file path. " "If it has a split column, it will be used for splitting " "(0: train, 1: validation, 2: test), " "otherwise the dataset will be randomly split", ) parser.add_argument("--training_set", help="input train data file path") parser.add_argument("--validation_set", help="input validation data file path") parser.add_argument("--test_set", help="input test data file path") parser.add_argument( "--training_set_metadata", help="input metadata JSON file path. An intermediate preprocessed file " "containing the mappings of the input file created " "the first time a file is used, in the same directory " "with the same name and a .json extension", ) parser.add_argument( "--data_format", help="format of the input data", default="auto", choices=[ "auto", "csv", "excel", "feather", "fwf", "hdf5", "html" "tables", "json", "jsonl", "parquet", "pickle", "sas", "spss", "stata", "tsv", ], ) parser.add_argument( "-sspi", "--skip_save_processed_input", help="skips saving intermediate HDF5 and JSON files", action="store_true", default=False, ) # ---------------- # Model parameters # ---------------- config = parser.add_mutually_exclusive_group(required=True) config.add_argument( "-c", "--config", type=load_yaml, help="Path to the YAML file containing the model configuration", ) config.add_argument( "-cs", "--config_str", dest="config", type=load_config_from_str, help="JSON or YAML serialized string of the model configuration", ) parser.add_argument( "-mlp", "--model_load_path", help="path of a pretrained model to load as initialization", ) parser.add_argument( "-mrp", "--model_resume_path", help="path of the model directory to resume training of", ) parser.add_argument( "-sstd", "--skip_save_training_description", action="store_true", default=False, help="disables saving the description JSON file", ) parser.add_argument( "-ssts", "--skip_save_training_statistics", action="store_true", default=False, help="disables saving training statistics JSON file", ) parser.add_argument( "-ssm", "--skip_save_model", action="store_true", default=False, help="disables saving weights each time the model improves. " "By default Ludwig saves weights after each epoch " "the validation metric (improves, but if the model is really big " "that can be time consuming. If you do not want to keep " "the weights and just find out what performance a model can get " "with a set of hyperparameters, use this parameter to skip it", ) parser.add_argument( "-ssp", "--skip_save_progress", action="store_true", default=False, help="disables saving weights after each epoch. By default ludwig saves " "weights after each epoch for enabling resuming of training, but " "if the model is really big that can be time consuming and will " "save twice as much space, use this parameter to skip it", ) parser.add_argument( "-ssl", "--skip_save_log", action="store_true", default=False, help="disables saving TensorBoard logs. By default Ludwig saves " "logs for the TensorBoard, but if it is not needed turning it off " "can slightly increase the overall speed", ) # ------------------ # Runtime parameters # ------------------ parser.add_argument( "-rs", "--random_seed", type=int, default=42, help="a random seed that is going to be used anywhere there is a call " "to a random number generator: data splitting, parameter " "initialization and training set shuffling", ) parser.add_argument( "-hlv", "--hyperopt_log_verbosity", type=int, default=3, choices=[0, 1, 2, 3], help="Controls verbosity of ray tune log messages. Valid values: " "0 = silent, 1 = only status updates, 2 = status and brief trial " "results, 3 = status and detailed trial results.", ) parser.add_argument("-g", "--gpus", nargs="+", type=int, default=None, help="list of gpus to use") parser.add_argument( "-gml", "--gpu_memory_limit", type=float, default=None, help="maximum memory fraction [0, 1] allowed to allocate per GPU device", ) parser.add_argument( "-b", "--backend", help="specifies backend to use for parallel / distributed execution, " "defaults to local execution", choices=ALL_BACKENDS, ) parser.add_argument( "-l", "--logging_level", default="info", help="the level of logging to use", choices=["critical", "error", "warning", "info", "debug", "notset"], ) add_contrib_callback_args(parser) args = parser.parse_args(sys_argv) args.callbacks = args.callbacks or [] for callback in args.callbacks: callback.on_cmdline("hyperopt", *sys_argv) args.logging_level = get_logging_level_registry()[args.logging_level] logging.getLogger("ludwig").setLevel(args.logging_level) global logger logger = logging.getLogger("ludwig.hyperopt") args.backend = initialize_backend(args.backend or args.config.get("backend")) if args.backend.is_coordinator(): print_ludwig("Hyperopt", LUDWIG_VERSION) hyperopt_cli(**vars(args)) if __name__ == "__main__": cli(sys.argv[1:]) ================================================ FILE: ludwig/model_export/base_model_exporter.py ================================================ from abc import ABC, abstractmethod import torch class LudwigTorchWrapper(torch.nn.Module): """Base class that establishes the contract for exporting to different file formats.""" def __init__(self, model): super().__init__() self.model = model def forward(self, x): return self.model({"image_path": x}) class BaseModelExporter(ABC): @abstractmethod def export(self, model_path, export_path, export_args_override): pass @abstractmethod def check_model_export(self, path): pass ================================================ FILE: ludwig/model_export/onnx_exporter.py ================================================ import os import torch from ludwig.api import LudwigModel from ludwig.model_export.base_model_exporter import BaseModelExporter, LudwigTorchWrapper # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== class OnnxExporter(BaseModelExporter): """Class that abstracts the convertion of torch to onnx.""" def export(self, model_path, export_path, output_model_name): ludwig_model = LudwigModel.load(model_path) model = LudwigTorchWrapper(ludwig_model.model) # Wrap the model model.eval() # inference mode, is this needed.. I think onnx export does this for us width = ludwig_model.config["input_features"][0]["preprocessing"]["width"] height = ludwig_model.config["input_features"][0]["preprocessing"]["height"] example_input = torch.randn(1, 3, width, height, requires_grad=True) torch.onnx.export( model, example_input, os.path.join(export_path, output_model_name), opset_version=18, export_params=True, do_constant_folding=True, input_names=["input"], output_names=["combiner_hidden_1", "output", "combiner_hidden_2"], ) def check_model_export(self, path): import onnx onnx_model = onnx.load(path) onnx.checker.check_model(onnx_model) ================================================ FILE: ludwig/models/__init__.py ================================================ ================================================ FILE: ludwig/models/base.py ================================================ import contextlib import logging from abc import ABCMeta, abstractmethod from collections import OrderedDict from typing import Any import numpy as np import torch import torchmetrics from ludwig.combiners.combiners import Combiner from ludwig.constants import COMBINED, LOSS, NAME from ludwig.encoders.base import Encoder from ludwig.features.base_feature import create_passthrough_input_feature, InputFeature, ModuleWrapper, OutputFeature from ludwig.features.feature_registries import get_input_type_registry, get_output_type_registry from ludwig.features.feature_utils import LudwigFeatureDict from ludwig.modules.metric_modules import LudwigMetric from ludwig.modules.training_hooks import TrainingHook from ludwig.schema.features.base import BaseInputFeatureConfig, BaseOutputFeatureConfig, FeatureCollection from ludwig.utils.algorithms_utils import topological_sort_feature_dependencies from ludwig.utils.metric_utils import get_scalar_from_ludwig_metric from ludwig.utils.misc_utils import get_from_registry from ludwig.utils.torch_utils import DEVICE, LudwigModule, reg_loss from ludwig.utils.types import TorchDevice logger = logging.getLogger(__name__) class BaseModel(LudwigModule, metaclass=ABCMeta): """Base model for use in LudwigModule. Implementations of this class should implement the following methods: - type() - forward() """ @staticmethod @abstractmethod def type() -> str: """Returns the model type.""" def __init__(self, random_seed: int = None): self._random_seed = random_seed # TODO: with change to misc_utils.set_random_seed() this may be redundant # seems to be required for test_api.py::test_api_training_determinism if random_seed is not None: torch.random.manual_seed(random_seed) super().__init__() self.input_features = self.create_feature_dict() self.output_features = self.create_feature_dict() # ================ Combined loss metric ================ self._eval_loss_metric = ModuleWrapper(torchmetrics.MeanMetric()) self._eval_additional_losses_metrics = ModuleWrapper(torchmetrics.MeanMetric()) # ================ Training Hook Handles ================ self._forward_hook_handles: list[TrainingHook] = [] def create_feature_dict(self) -> LudwigFeatureDict: """Creates and returns a LudwigFeatureDict.""" return LudwigFeatureDict() def to_device(self, device): return self.to(device) def metrics_to_device(self, device: str): self._eval_loss_metric.module = self._eval_loss_metric.module.to(device) self._eval_additional_losses_metrics.module = self._eval_additional_losses_metrics.module.to(device) for feature in self.output_features.values(): feature._eval_loss_metric.module = feature._eval_loss_metric.module.to(device) @classmethod def build_inputs(cls, input_feature_configs: FeatureCollection[BaseInputFeatureConfig]) -> dict[str, InputFeature]: """Builds and returns input features in topological order.""" input_features = OrderedDict() input_features_def = topological_sort_feature_dependencies(input_feature_configs.to_list()) for input_feature_def in input_features_def: input_features[input_feature_def[NAME]] = cls.build_single_input( getattr(input_feature_configs, input_feature_def[NAME]), input_features ) return input_features @staticmethod def build_single_input( feature_config: BaseInputFeatureConfig, other_input_features: dict[str, InputFeature] | None ) -> InputFeature: """Builds a single input feature from the input feature definition.""" logger.debug(f"Input {feature_config.type} feature {feature_config.name}") encoder_obj = None if feature_config.tied is not None: tied_input_feature_name = feature_config.tied if tied_input_feature_name in other_input_features: encoder_obj = other_input_features[tied_input_feature_name].encoder_obj return create_input_feature(feature_config, encoder_obj) @classmethod def build_outputs( cls, output_feature_configs: FeatureCollection[BaseOutputFeatureConfig], combiner: Combiner ) -> dict[str, OutputFeature]: """Builds and returns output features in topological order.""" output_features_def = topological_sort_feature_dependencies(output_feature_configs.to_list()) output_features = {} for output_feature_def in output_features_def: # TODO(Justin): Check that the semantics of input_size align with what the combiner's output shape returns # for seq2seq. setattr(getattr(output_feature_configs, output_feature_def[NAME]), "input_size", combiner.output_shape[-1]) output_features[output_feature_def[NAME]] = cls.build_single_output( getattr(output_feature_configs, output_feature_def[NAME]), output_features ) return output_features @staticmethod def build_single_output( feature_config: BaseOutputFeatureConfig, output_features: dict[str, OutputFeature] | None ) -> OutputFeature: """Builds a single output feature from the output feature definition.""" logger.debug(f"Output {feature_config.type} feature {feature_config.name}") output_feature_class = get_from_registry(feature_config.type, get_output_type_registry()) output_feature_obj = output_feature_class(feature_config, output_features=output_features) return output_feature_obj def get_model_inputs(self): """Returns a dict of feature name -> sample model input.""" device = next(self.parameters()).device inputs = { input_feature_name: input_feature.create_sample_input().to(device) for input_feature_name, input_feature in self.input_features.items() } return inputs def get_model_size(self) -> int: """Returns total number of parameters in model.""" model_tensors = self.collect_weights() total_size = 0 for tnsr in model_tensors: total_size += tnsr[1].detach().cpu().numpy().size return total_size def to_torchscript(self, device: TorchDevice | None = None): """Converts the ECD model as a TorchScript model.""" if device is None: device = DEVICE self.eval() model_inputs = self.get_model_inputs() model_to_script = self.to(device) model_inputs_to_script = {k: v.to(device) for k, v in model_inputs.items()} # We set strict=False to enable dict inputs and outputs. return torch.jit.trace(model_to_script, model_inputs_to_script, strict=False) def save_torchscript(self, save_path, device: TorchDevice | None = None): """Saves the ECD model as a TorchScript model.""" if device is None: device = DEVICE traced = self.to_torchscript(device) traced.save(save_path) @property def input_shape(self): """Returns the shape of the model's input.""" # TODO(justin): Remove dummy implementation. Make input_shape and output_shape functions. return torch.Size([1, 1]) @abstractmethod def forward( self, inputs: ( dict[str, torch.Tensor] | dict[str, np.ndarray] | tuple[dict[str, torch.Tensor], dict[str, torch.Tensor]] ), mask=None, ) -> dict[str, torch.Tensor]: """Forward pass of the model. Args: inputs: Inputs to the model. Can be a dictionary of input names to input tensors or a tuple of (inputs, targets) where inputs is a dictionary of input names to input tensors and targets is a dictionary of target names to target tensors. mask: A mask for the inputs. Returns: A dictionary of output {feature name}::{tensor_name} -> output tensor. """ def predictions(self, inputs): """Returns the model's predictions for the given inputs.""" outputs = self(inputs) return self.outputs_to_predictions(outputs) def outputs_to_predictions(self, outputs: dict[str, torch.Tensor]) -> dict[str, dict[str, torch.Tensor]]: """Returns the model's predictions given the raw model outputs.""" predictions = {} for of_name in self.output_features: predictions[of_name] = self.output_features.get(of_name).predictions(outputs, of_name) return predictions def evaluation_step(self, inputs, targets): """Predict the inputs and update evaluation metrics.""" predictions = self.predictions(inputs) self.update_metrics(targets, predictions) return predictions def predict_step(self, inputs): """Predict the inputs.""" return self.predictions(inputs) def train_loss( self, targets, predictions, regularization_type: str | None = None, regularization_lambda: float | None = None, ) -> tuple[torch.Tensor, dict[str, torch.Tensor]]: """Computes the training loss for the model. Args: targets: A dictionary of target names to target tensors. predictions: A dictionary of output names to output tensors. regularization_type: One of 'l1', 'l2', 'l1_l2', or None. regularization_lambda: The regularization lambda. Returns: A tuple of the loss tensor and a dictionary of loss for every output feature. """ train_loss = 0 of_train_losses = {} for of_name, of_obj in self.output_features.items(): of_train_loss = of_obj.train_loss(targets[of_name], predictions, of_name) train_loss += of_obj.loss.weight * of_train_loss of_train_losses[of_name] = of_train_loss additional_losses = self.losses() if additional_losses: train_loss += torch.sum(torch.stack(additional_losses)) # other losses # Add regularization loss if regularization_type is not None and regularization_lambda != 0: train_loss += reg_loss(self, regularization_type, l1=regularization_lambda, l2=regularization_lambda) return train_loss, of_train_losses def eval_loss(self, targets, predictions): """Computes all evaluation losses for the model given targets and predictions. Args: targets: A dictionary of target names to target tensors. predictions: A dictionary of output names to output tensors. Returns: A tuple of loss values for eval losses and additional losses. """ eval_loss = 0 for of_name, of_obj in self.output_features.items(): of_eval_loss = of_obj.eval_loss(targets[of_name], predictions[of_name]) eval_loss += of_obj.loss.weight * of_eval_loss additional_loss = 0 additional_losses = self.losses() if additional_losses: additional_loss = torch.sum(torch.stack(additional_losses)) # other losses return eval_loss, additional_loss def update_metrics(self, targets, predictions): """Updates the model's metrics given targets and predictions.""" for of_name, of_obj in self.output_features.items(): of_obj.update_metrics(targets[of_name], predictions[of_name]) eval_loss, additional_losses = self.eval_loss(targets, predictions) self.eval_loss_metric.update(eval_loss) self.eval_additional_losses_metrics.update(additional_losses) @property def eval_loss_metric(self) -> LudwigMetric: return self._eval_loss_metric.module @eval_loss_metric.setter def eval_loss_metric(self, value: LudwigMetric) -> None: self._eval_loss_metric.module = value @property def eval_additional_losses_metrics(self) -> LudwigMetric: return self._eval_additional_losses_metrics.module def get_metrics(self) -> dict[str, dict[str, float]]: """Returns a dictionary of metrics for each output feature of the model.""" all_of_metrics = {} for of_name, of_obj in self.output_features.items(): all_of_metrics[of_name] = of_obj.get_metrics() all_of_metrics[COMBINED] = { LOSS: get_scalar_from_ludwig_metric(self.eval_loss_metric) + get_scalar_from_ludwig_metric(self.eval_additional_losses_metrics) } return all_of_metrics def reset_metrics(self): """Resets the model's metrics.""" for of_obj in self.output_features.values(): of_obj.reset_metrics() self.eval_loss_metric.reset() def collect_weights(self, tensor_names=None, **kwargs): """Returns named parameters filtered against `tensor_names` if not None.""" if not tensor_names: return self.named_parameters() # Check for bad tensor names. weight_names = {name for name, _ in self.named_parameters()} for name in tensor_names: if name not in weight_names: raise ValueError(f'Requested tensor name filter "{name}" not present in the model graph') # noqa: E713 # Apply filter. tensor_set = set(tensor_names) return [named_param for named_param in self.named_parameters() if named_param[0] in tensor_set] def unskip(self): """Converts all skipped features into their fully encoded versions.""" @abstractmethod def save(self, save_path: str): """Saves the model to the given path.""" @abstractmethod def load(self, save_path: str): """Loads the model from the given path.""" @abstractmethod def get_args(self): """Returns init arguments for constructing this model.""" @contextlib.contextmanager def use_generation_config(self, generation_config: dict[str, Any]): if generation_config is not None: raise NotImplementedError(f"{self.__class__.__name__} does not support generation_config. ") yield def _activate_forward_hooks(self): """Activates/registers forward hooks for the model.""" def _deactivate_forward_hooks(self) -> None: """Deactivates/de-registers forward hooks for the model (if needed).""" for handle in self._forward_hook_handles: handle.deactivate_hook() def create_input_feature(feature_config: BaseInputFeatureConfig, encoder_obj: Encoder | None) -> InputFeature: input_feature_cls = get_from_registry(feature_config.type, get_input_type_registry()) input_feature = input_feature_cls(feature_config, encoder_obj=encoder_obj) if not feature_config.encoder.skip: return input_feature return create_passthrough_input_feature(input_feature, feature_config) ================================================ FILE: ludwig/models/calibrator.py ================================================ #! /usr/bin/env python # Copyright (c) 2022 Predibase, 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. # ============================================================================== import numpy as np from ludwig.backend import Backend from ludwig.models.ecd import ECD class Calibrator: """Calibrator calibrates the output probabilities of a model.""" def __init__(self, model: ECD, backend: Backend, batch_size: int = 128): self.model = model self.backend = backend self.batch_size = batch_size def calibration_enabled(self): """Calibration is enabled if the config requests calibration for any output feature. If no output features have calibration enabled, the calibration phase should be skipped. """ return any(o.calibration_module is not None for o in self.model.output_features.values()) def train_calibration(self, dataset, dataset_name: str): """Calibrates model output probabilities on validation set after training. This works well for most datasets, though it may fail for some difficult or extremely imbalanced datasets. """ if not self.calibration_enabled(): # Early out if no output features have calibration enabled. return with self.backend.create_predictor(self.model, batch_size=self.batch_size) as predictor: metrics, predictions = predictor.batch_evaluation( dataset, collect_predictions=True, collect_logits=True, dataset_name=dataset_name ) dataset_df = dataset.to_df() for output_feature in self.model.output_features.values(): if output_feature.calibration_module is not None: feature_logits_key = f"{output_feature.feature_name}_logits" if feature_logits_key in predictions: feature_logits = self.backend.df_engine.compute(predictions[feature_logits_key]) feature_labels = self.backend.df_engine.compute(dataset_df[output_feature.proc_column]) output_feature.calibration_module.train_calibration( np.stack(feature_logits.values, axis=0), np.stack(feature_labels.values, axis=0) ) ================================================ FILE: ludwig/models/ecd.py ================================================ import logging import os import numpy as np import torch from ludwig.accounting.used_tokens import get_used_tokens_for_ecd from ludwig.combiners.combiners import create_combiner from ludwig.constants import MODEL_ECD, MODEL_LLM, USED_TOKENS from ludwig.globals import MODEL_WEIGHTS_FILE_NAME from ludwig.models.base import BaseModel from ludwig.schema.model_types.ecd import ECDModelConfig from ludwig.utils import output_feature_utils from ludwig.utils.augmentation_utils import AugmentationPipelines from ludwig.utils.data_utils import clear_data_cache from ludwig.utils.fs_utils import open_file from ludwig.utils.state_dict_backward_compatibility import update_state_dict from ludwig.utils.torch_utils import get_torch_device logger = logging.getLogger(__name__) class ECD(BaseModel): @staticmethod def type() -> str: return MODEL_ECD def __init__( self, config_obj: ECDModelConfig, random_seed=None, **_kwargs, ): self.config_obj = config_obj self._random_seed = random_seed super().__init__(random_seed=self._random_seed) # ================ Inputs ================ try: self.input_features.update(self.build_inputs(input_feature_configs=self.config_obj.input_features)) except KeyError as e: raise KeyError( f"An input feature has a name that conflicts with a class attribute of torch's ModuleDict: {e}" ) from e # ================ Combiner ================ logger.debug(f"Combiner {self.config_obj.combiner.type}") self.combiner = create_combiner(self.config_obj.combiner, input_features=self.input_features) # ================ Outputs ================ self.output_features.update( self.build_outputs(output_feature_configs=self.config_obj.output_features, combiner=self.combiner) ) # After constructing all layers, clear the cache to free up memory clear_data_cache() def prepare_for_training(self): # 1/10/23: For parity with how the LLM model type sets up adapters and quantization, LLM encoders should call # `prepare_for_training` at training time rather than at initialization. This loop searches for input features # using the LLM encoder and calls `prepare_for_training` on those encoders only. No other changes should be # made to the ECD model itself or any other encoders. for feature in self.config_obj.input_features: encoder_type = feature.encoder.type if encoder_type == MODEL_LLM: feature_name = feature.name encoder = self.input_features.get(feature_name) encoder.prepare_for_training() def encode( self, inputs: ( dict[str, torch.Tensor] | dict[str, np.ndarray] | tuple[dict[str, torch.Tensor], dict[str, torch.Tensor]] ), ): # Convert inputs to tensors. for input_feature_name, input_values in inputs.items(): if not isinstance(input_values, torch.Tensor): inputs[input_feature_name] = torch.from_numpy(input_values) else: inputs[input_feature_name] = input_values encoder_outputs = {} for input_feature_name, input_values in inputs.items(): encoder = self.input_features.get(input_feature_name) encoder_output = encoder(input_values) encoder_outputs[input_feature_name] = encoder_output return encoder_outputs def combine(self, encoder_outputs): return self.combiner(encoder_outputs) def decode(self, combiner_outputs, targets, mask): # Invoke output features. output_logits = {} output_last_hidden = {} for output_feature_name, output_feature in self.output_features.items(): # Use the presence or absence of targets to signal training or prediction. target = targets[output_feature_name] if targets is not None else None decoder_outputs = output_feature(combiner_outputs, output_last_hidden, mask=mask, target=target) # Add decoder outputs to overall output dictionary. for decoder_output_name, tensor in decoder_outputs.items(): output_feature_utils.set_output_feature_tensor( output_logits, output_feature_name, decoder_output_name, tensor ) # Save the hidden state of the output feature (for feature dependencies). output_last_hidden[output_feature_name] = decoder_outputs["last_hidden"] return output_logits def forward( self, inputs: ( dict[str, torch.Tensor] | dict[str, np.ndarray] | tuple[dict[str, torch.Tensor], dict[str, torch.Tensor]] ), mask=None, ) -> dict[str, torch.Tensor]: """Forward pass of the model. Args: inputs: Inputs to the model. Can be a dictionary of input names to input tensors or a tuple of (inputs, targets) where inputs is a dictionary of input names to input tensors and targets is a dictionary of target names to target tensors. mask: A mask for the inputs. Returns: A dictionary of output {feature name}::{tensor_name} -> output tensor. """ if isinstance(inputs, tuple): inputs, targets = inputs # Convert targets to tensors. for target_feature_name, target_value in targets.items(): if not isinstance(target_value, torch.Tensor): targets[target_feature_name] = torch.from_numpy(target_value) else: targets[target_feature_name] = target_value else: targets = None assert list(inputs.keys()) == self.input_features.keys() encoder_outputs = self.encode(inputs) combiner_outputs = self.combine(encoder_outputs) decoder_outputs = self.decode(combiner_outputs, targets, mask) # Compute the number of used tokens. decoder_outputs[USED_TOKENS] = get_used_tokens_for_ecd(inputs, targets) return decoder_outputs def unskip(self): for k in self.input_features.keys(): self.input_features.set(k, self.input_features.get(k).unskip()) def save(self, save_path): """Saves the model to the given path.""" weights_save_path = os.path.join(save_path, MODEL_WEIGHTS_FILE_NAME) torch.save(self.state_dict(), weights_save_path) # Ensure the file is fully flushed to disk before any other process reads it with open(weights_save_path, "rb") as f: os.fsync(f.fileno()) def load(self, save_path): """Loads the model from the given path.""" weights_save_path = os.path.join(save_path, MODEL_WEIGHTS_FILE_NAME) device = torch.device(get_torch_device()) with open_file(weights_save_path, "rb") as f: state_dict = torch.load(f, map_location=device) self.load_state_dict(update_state_dict(state_dict)) def get_args(self): """Returns init arguments for constructing this model.""" return ( self.config_obj.input_features.to_list(), self.config_obj.combiner.to_dict(), self.config_obj.output_features.to_list(), self._random_seed, ) def get_augmentation_pipelines(self) -> AugmentationPipelines: """Returns the augmentation pipeline for this model.""" # dictionary to hold any augmentation pipeline augmentation_pipelines = {} # loop through all input features and add their augmentation pipeline to the dictionary for input_feature in self.config_obj.input_features: # if augmentation was specified for this input feature, add AugmentationPipeline to dictionary if input_feature.has_augmentation(): # use input feature proc_column as key because that is what is used in the Batcher augmentation_pipelines[input_feature.proc_column] = self.input_features.get( input_feature.name ).get_augmentation_pipeline() return AugmentationPipelines(augmentation_pipelines) ================================================ FILE: ludwig/models/embedder.py ================================================ from collections.abc import Callable import numpy as np import pandas as pd import torch from ludwig.api_annotations import DeveloperAPI from ludwig.constants import ENCODER_OUTPUT, MODEL_ECD, NAME, PROC_COLUMN, TYPE from ludwig.features.feature_registries import get_input_type_registry from ludwig.features.feature_utils import LudwigFeatureDict from ludwig.models.base import BaseModel from ludwig.schema.features.base import BaseInputFeatureConfig, FeatureCollection from ludwig.schema.features.utils import get_input_feature_cls from ludwig.types import FeatureConfigDict, TrainingSetMetadataDict from ludwig.utils.batch_size_tuner import BatchSizeEvaluator from ludwig.utils.dataframe_utils import from_numpy_dataset from ludwig.utils.misc_utils import get_from_registry from ludwig.utils.torch_utils import get_torch_device, LudwigModule @DeveloperAPI class Embedder(LudwigModule): def __init__(self, feature_configs: list[FeatureConfigDict], metadata: TrainingSetMetadataDict): super().__init__() self.input_features = LudwigFeatureDict() input_feature_configs = [] for feature in feature_configs: feature_cls = get_from_registry(feature[TYPE], get_input_type_registry()) # TODO(travis): this assumes ECD is the selected model type. The best solution is to change the # input params from FeatureConfigDict types to BaseInputFeatureConfig types, which will require a # refactor of preprocessing to use the schema, not the dict types. feature_obj = get_input_feature_cls(MODEL_ECD, feature[TYPE]).from_dict(feature) feature_cls.update_config_with_metadata(feature_obj, metadata[feature[NAME]]) # When running prediction or eval, we need the preprocessing to use the original pretrained # weights, which requires unsetting this field. In the future, we could avoid this by plumbing # through the saved weights and loading them dynamically after building the model. feature_obj.encoder.saved_weights_in_checkpoint = False input_feature_configs.append(feature_obj) feature_collection = FeatureCollection[BaseInputFeatureConfig](input_feature_configs) try: self.input_features.update(BaseModel.build_inputs(input_feature_configs=feature_collection)) except KeyError as e: raise KeyError( f"An input feature has a name that conflicts with a class attribute of torch's ModuleDict: {e}" ) def forward(self, inputs: dict[str, torch.Tensor]): encoder_outputs = {} for input_feature_name, input_values in inputs.items(): encoder = self.input_features.get(input_feature_name) encoder_output = encoder(input_values) encoder_outputs[input_feature_name] = encoder_output[ENCODER_OUTPUT] return encoder_outputs @DeveloperAPI def create_embed_batch_size_evaluator( features_to_encode: list[FeatureConfigDict], metadata: TrainingSetMetadataDict ) -> BatchSizeEvaluator: class _EmbedBatchSizeEvaluator(BatchSizeEvaluator): def __init__(self): embedder = Embedder(features_to_encode, metadata) self.device = get_torch_device() self.embedder = embedder.to(self.device) self.embedder.eval() def step(self, batch_size: int, global_max_sequence_length: int | None = None): inputs = { input_feature_name: input_feature.create_sample_input(batch_size=batch_size).to(self.device) for input_feature_name, input_feature in self.embedder.input_features.items() } with torch.no_grad(): self.embedder(inputs) return _EmbedBatchSizeEvaluator @DeveloperAPI def create_embed_transform_fn( features_to_encode: list[FeatureConfigDict], metadata: TrainingSetMetadataDict ) -> Callable: class EmbedTransformFn: def __init__(self): embedder = Embedder(features_to_encode, metadata) self.device = get_torch_device() self.embedder = embedder.to(self.device) self.embedder.eval() def __call__(self, df: pd.DataFrame) -> pd.DataFrame: batch = _prepare_batch(df, features_to_encode, metadata) name_to_proc = {i_feat.feature_name: i_feat.proc_column for i_feat in self.embedder.input_features.values()} inputs = { i_feat.feature_name: torch.from_numpy(np.array(batch[i_feat.proc_column], copy=True)).to(self.device) for i_feat in self.embedder.input_features.values() } with torch.no_grad(): encoder_outputs = self.embedder(inputs) encoded = {name_to_proc[k]: v.detach().cpu().float().numpy() for k, v in encoder_outputs.items()} output_df = from_numpy_dataset(encoded) for c in output_df.columns: df[c] = output_df[c] return df return EmbedTransformFn # TODO(travis): consolidate with implementation in data/ray.py def _prepare_batch( df: pd.DataFrame, features: list[FeatureConfigDict], metadata: TrainingSetMetadataDict ) -> dict[str, np.ndarray]: batch = {} for feature in features: c = feature[PROC_COLUMN] if df[c].values.dtype == "object": # Ensure columns stacked instead of turned into np.array([np.array, ...], dtype=object) objects batch[c] = np.stack(df[c].values) else: batch[c] = df[c].to_numpy() for feature in features: c = feature[PROC_COLUMN] reshape = metadata.get(feature[NAME], {}).get("reshape") if reshape is not None: batch[c] = batch[c].reshape((-1, *reshape)) return batch ================================================ FILE: ludwig/models/inference.py ================================================ import logging import os from typing import Any, TYPE_CHECKING import pandas as pd import torch from torch import nn from ludwig.constants import NAME, POSTPROCESSOR, PREDICTOR, PREPROCESSOR, TYPE from ludwig.data.postprocessing import convert_dict_to_df from ludwig.data.preprocessing import load_metadata from ludwig.features.feature_registries import get_input_type_registry from ludwig.features.feature_utils import get_module_dict_key_from_name, get_name_from_module_dict_key from ludwig.globals import MODEL_HYPERPARAMETERS_FILE_NAME, TRAIN_SET_METADATA_FILE_NAME from ludwig.types import ModelConfigDict, TrainingSetMetadataDict from ludwig.utils import output_feature_utils from ludwig.utils.data_utils import load_json, save_json from ludwig.utils.inference_utils import get_filename_from_stage, to_inference_module_input_from_dataframe from ludwig.utils.misc_utils import get_from_registry from ludwig.utils.output_feature_utils import get_feature_name_from_concat_name, get_tensor_name_from_concat_name from ludwig.utils.torch_utils import DEVICE from ludwig.utils.types import TorchDevice, TorchscriptPreprocessingInput # Prevents circular import errors from typing. if TYPE_CHECKING: from ludwig.models.base import BaseModel logger = logging.getLogger(__name__) class InferenceModule(nn.Module): """A nn.Module subclass that wraps the inference preprocessor, predictor, and postprocessor.""" def __init__( self, preprocessor: torch.jit.ScriptModule, predictor: torch.jit.ScriptModule, postprocessor: torch.jit.ScriptModule, config: ModelConfigDict | None = None, training_set_metadata: TrainingSetMetadataDict | None = None, ): super().__init__() self.preprocessor = preprocessor self.predictor = predictor self.postprocessor = postprocessor self.config = config # Do not remove – used by Predibase app self.training_set_metadata = training_set_metadata def preprocessor_forward(self, inputs: dict[str, TorchscriptPreprocessingInput]) -> dict[str, torch.Tensor]: """Forward pass through the preprocessor.""" return self.preprocessor(inputs) def predictor_forward(self, preproc_inputs: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]: """Forward pass through the predictor. Ensures that the inputs are on the correct device. The outputs are on the same device as self.predictor. """ for k, v in preproc_inputs.items(): preproc_inputs[k] = v.to(self.predictor.device) with torch.no_grad(): # Ensure model params do not compute gradients predictions_flattened = self.predictor(preproc_inputs) return predictions_flattened def postprocessor_forward(self, predictions_flattened: dict[str, torch.Tensor]) -> dict[str, dict[str, Any]]: """Forward pass through the postprocessor.""" postproc_outputs_flattened: dict[str, Any] = self.postprocessor(predictions_flattened) # Turn flat inputs into nested predictions per feature name postproc_outputs: dict[str, dict[str, Any]] = _unflatten_dict_by_feature_name(postproc_outputs_flattened) return postproc_outputs def forward(self, inputs: dict[str, TorchscriptPreprocessingInput]) -> dict[str, dict[str, Any]]: preproc_inputs: dict[str, torch.Tensor] = self.preprocessor_forward(inputs) predictions_flattened: dict[str, torch.Tensor] = self.predictor_forward(preproc_inputs) postproc_outputs: dict[str, dict[str, Any]] = self.postprocessor_forward(predictions_flattened) return postproc_outputs @torch.jit.unused def predict(self, dataset: pd.DataFrame, return_type: dict | pd.DataFrame = pd.DataFrame) -> pd.DataFrame | dict: """Predict on a batch of data with an interface similar to LudwigModel.predict.""" inputs = to_inference_module_input_from_dataframe(dataset, self.config, load_paths=True) preds = self(inputs) if return_type == pd.DataFrame: preds = convert_dict_to_df(preds) return preds, None # Second return value is for compatibility with LudwigModel.predict @torch.jit.unused @classmethod def from_ludwig_model( cls: "InferenceModule", model: "BaseModel", config: ModelConfigDict, training_set_metadata: TrainingSetMetadataDict, device: TorchDevice | None = None, ): """Create an InferenceModule from a trained LudwigModel.""" if device is None: logger.info(f'No device specified. Loading using device "{DEVICE}".') device = DEVICE stage_to_module = _init_inference_stages_from_ludwig_model( model, config, training_set_metadata, device=device, scripted=True ) return cls( stage_to_module[PREPROCESSOR], stage_to_module[PREDICTOR], stage_to_module[POSTPROCESSOR], config=config, training_set_metadata=training_set_metadata, ) @torch.jit.unused @classmethod def from_directory( cls: "InferenceModule", directory: str, device: TorchDevice | None = None, ): """Create an InferenceModule from a directory containing a model, config, and training set metadata.""" if device is None: logger.info(f'No device specified. Loading using device "{DEVICE}".') device = DEVICE stage_to_module = _init_inference_stages_from_directory(directory, device=device) config_path = os.path.join(directory, MODEL_HYPERPARAMETERS_FILE_NAME) config = load_json(config_path) if os.path.exists(config_path) else None metadata_path = os.path.join(directory, TRAIN_SET_METADATA_FILE_NAME) training_set_metadata = load_metadata(metadata_path) if os.path.exists(metadata_path) else None return cls( stage_to_module[PREPROCESSOR], stage_to_module[PREDICTOR], stage_to_module[POSTPROCESSOR], config=config, training_set_metadata=training_set_metadata, ) class _InferencePreprocessor(nn.Module): """Wraps preprocessing modules into a single nn.Module. TODO(geoffrey): Implement torchscript-compatible feature_utils.LudwigFeatureDict to replace get_module_dict_key_from_name and get_name_from_module_dict_key usage. """ def __init__(self, config: ModelConfigDict, training_set_metadata: TrainingSetMetadataDict): super().__init__() self.preproc_modules = nn.ModuleDict() for feature_config in config["input_features"]: feature_name = feature_config[NAME] feature = get_from_registry(feature_config[TYPE], get_input_type_registry()) # prevents collisions with reserved keywords module_dict_key = get_module_dict_key_from_name(feature_name) self.preproc_modules[module_dict_key] = feature.create_preproc_module(training_set_metadata[feature_name]) def forward(self, inputs: dict[str, TorchscriptPreprocessingInput]) -> dict[str, torch.Tensor]: preproc_inputs = {} for module_dict_key, preproc in self.preproc_modules.items(): feature_name = get_name_from_module_dict_key(module_dict_key) preproc_inputs[feature_name] = preproc(inputs[feature_name]) return preproc_inputs class _InferencePredictor(nn.Module): """Wraps model forward pass + predictions into a single nn.Module. The forward call of this module returns a flattened dictionary in order to support Triton input/output. TODO(geoffrey): Implement torchscript-compatible feature_utils.LudwigFeatureDict to replace get_module_dict_key_from_name and get_name_from_module_dict_key usage. """ def __init__(self, model: "BaseModel", device: TorchDevice): super().__init__() self.device = torch.device(device) self.model = model.to_torchscript(self.device) self.predict_modules = nn.ModuleDict() for feature_name, feature in model.output_features.items(): # prevents collisions with reserved keywords module_dict_key = get_module_dict_key_from_name(feature_name) self.predict_modules[module_dict_key] = feature.prediction_module.to(device=self.device) def forward(self, preproc_inputs: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]: model_outputs = self.model(preproc_inputs) predictions_flattened: dict[str, torch.Tensor] = {} for module_dict_key, predict in self.predict_modules.items(): feature_name = get_name_from_module_dict_key(module_dict_key) feature_predictions = predict(model_outputs, feature_name) # Flatten out the predictions to support Triton input/output for predict_key, tensor_values in feature_predictions.items(): predict_concat_key = output_feature_utils.get_feature_concat_name(feature_name, predict_key) predictions_flattened[predict_concat_key] = tensor_values return predictions_flattened class _InferencePostprocessor(nn.Module): """Wraps postprocessing modules into a single nn.Module. The forward call of this module returns a flattened dictionary in order to support Triton input/output. TODO(geoffrey): Implement torchscript-compatible feature_utils.LudwigFeatureDict to replace get_module_dict_key_from_name and get_name_from_module_dict_key usage. """ def __init__(self, model: "BaseModel", training_set_metadata: TrainingSetMetadataDict): super().__init__() self.postproc_modules = nn.ModuleDict() for feature_name, feature in model.output_features.items(): # prevents collisions with reserved keywords module_dict_key = get_module_dict_key_from_name(feature_name) self.postproc_modules[module_dict_key] = feature.create_postproc_module(training_set_metadata[feature_name]) def forward(self, predictions_flattened: dict[str, torch.Tensor]) -> dict[str, Any]: postproc_outputs_flattened: dict[str, Any] = {} for module_dict_key, postproc in self.postproc_modules.items(): feature_name = get_name_from_module_dict_key(module_dict_key) feature_postproc_outputs = postproc(predictions_flattened, feature_name) # Flatten out the predictions to support Triton input/output for postproc_key, tensor_values in feature_postproc_outputs.items(): postproc_concat_key = output_feature_utils.get_feature_concat_name(feature_name, postproc_key) postproc_outputs_flattened[postproc_concat_key] = tensor_values return postproc_outputs_flattened def save_ludwig_model_for_inference( save_path: str, model: "BaseModel", config: ModelConfigDict, training_set_metadata: TrainingSetMetadataDict, device: TorchDevice | None = None, model_only: bool = False, ) -> None: """Saves a LudwigModel (a BaseModel model, config, and training_set_metadata) for inference.""" if device is None: logger.info(f'No device specified. Saving using device "{DEVICE}".') device = DEVICE stage_to_filenames = { stage: get_filename_from_stage(stage, device) for stage in [PREPROCESSOR, PREDICTOR, POSTPROCESSOR] } stage_to_module = _init_inference_stages_from_ludwig_model( model, config, training_set_metadata, device, scripted=True ) if model_only: stage_to_module[PREDICTOR].save(os.path.join(save_path, stage_to_filenames[PREDICTOR])) else: config_path = os.path.join(save_path, MODEL_HYPERPARAMETERS_FILE_NAME) if not os.path.exists(config_path): save_json(config_path, config) logger.info(f"Saved model config to {config_path}") training_set_metadata_path = os.path.join(save_path, TRAIN_SET_METADATA_FILE_NAME) if not os.path.exists(training_set_metadata_path): save_json(training_set_metadata_path, training_set_metadata) logger.info(f"Saved training set metadata to {training_set_metadata_path}") for stage, module in stage_to_module.items(): module.save(os.path.join(save_path, stage_to_filenames[stage])) logger.info(f"Saved torchscript module for {stage} to {stage_to_filenames[stage]}.") def _init_inference_stages_from_directory( directory: str, device: TorchDevice, ) -> dict[str, torch.nn.Module]: """Initializes inference stage modules from directory.""" stage_to_filenames = { stage: get_filename_from_stage(stage, device) for stage in [PREPROCESSOR, PREDICTOR, POSTPROCESSOR] } stage_to_module = {} for stage in [PREPROCESSOR, PREDICTOR, POSTPROCESSOR]: stage_to_module[stage] = torch.jit.load(os.path.join(directory, stage_to_filenames[stage])) print(f"Loaded torchscript module for {stage} from {stage_to_filenames[stage]}.") return stage_to_module def _init_inference_stages_from_ludwig_model( model: "BaseModel", config: ModelConfigDict, training_set_metadata: TrainingSetMetadataDict, device: TorchDevice, scripted: bool = True, ) -> dict[str, torch.nn.Module]: """Initializes inference stage modules from a LudwigModel (a BaseModel model, config, and training_set_metadata).""" preprocessor = _InferencePreprocessor(config, training_set_metadata) predictor = _InferencePredictor(model, device=device) postprocessor = _InferencePostprocessor(model, training_set_metadata) stage_to_module = { PREPROCESSOR: preprocessor, PREDICTOR: predictor, POSTPROCESSOR: postprocessor, } if scripted: stage_to_module = {stage: torch.jit.script(module) for stage, module in stage_to_module.items()} return stage_to_module def _unflatten_dict_by_feature_name(flattened_dict: dict[str, Any]) -> dict[str, dict[str, Any]]: """Convert a flattened dictionary of objects to a nested dictionary of outputs per feature name.""" outputs: dict[str, dict[str, Any]] = {} for concat_key, tensor_values in flattened_dict.items(): feature_name = get_feature_name_from_concat_name(concat_key) tensor_name = get_tensor_name_from_concat_name(concat_key) feature_outputs: dict[str, Any] = {} if feature_name not in outputs: outputs[feature_name] = feature_outputs else: feature_outputs = outputs[feature_name] feature_outputs[tensor_name] = tensor_values return outputs ================================================ FILE: ludwig/models/llm.py ================================================ import contextlib import logging import os from typing import Any import numpy as np import torch from transformers import AutoConfig, GenerationConfig from ludwig.accounting.used_tokens import get_used_tokens_for_llm from ludwig.constants import IGNORE_INDEX_TOKEN_ID, LOGITS, MODEL_LLM, PREDICTIONS, TEXT, USED_TOKENS from ludwig.features.base_feature import ModuleWrapper, OutputFeature from ludwig.features.feature_utils import LudwigFeatureDict from ludwig.features.text_feature import TextOutputFeature from ludwig.globals import MODEL_WEIGHTS_FILE_NAME from ludwig.models.base import BaseModel from ludwig.modules.training_hooks import NEFTuneHook from ludwig.schema.features.base import BaseOutputFeatureConfig, FeatureCollection from ludwig.schema.model_types.llm import LLMModelConfig from ludwig.utils.augmentation_utils import AugmentationPipelines from ludwig.utils.data_utils import clear_data_cache from ludwig.utils.llm_quantization_utils import convert_quantized_linear_to_linear from ludwig.utils.llm_utils import ( add_left_padding, generate_merged_ids, get_context_len, get_realigned_target_and_prediction_tensors_for_inference, initialize_adapter, load_pretrained_from_config, pad_target_tensor_for_fine_tuning, remove_left_padding, to_device, ) from ludwig.utils.logging_utils import log_once from ludwig.utils.output_feature_utils import set_output_feature_tensor from ludwig.utils.tokenizers import HFTokenizer from ludwig.utils.torch_utils import reg_loss logger = logging.getLogger(__name__) class DictWrapper: """Wrapper for a LudwigFeatureDict module that allows for iteration over keys. The purpose of this class is to avoid exposing input and output features as modules of the LLM. This is because we only wish to train the underlying model, and having these additional modules can confuse systems like DeepSpeed. """ def __init__(self, obj: LudwigFeatureDict): self.obj = obj def get(self, key) -> torch.nn.Module: return self.obj.get(key) def set(self, key: str, module: torch.nn.Module) -> None: self.obj.set(key, module) def __len__(self) -> int: return len(self.obj) def __next__(self) -> None: return next(iter(self.obj)) def __iter__(self) -> None: return iter(self.obj.keys()) def keys(self) -> list[str]: return self.obj.keys() def values(self) -> list[torch.nn.Module]: return self.obj.values() def items(self) -> list[tuple[str, torch.nn.Module]]: return self.obj.items() def update(self, modules: dict[str, torch.nn.Module]) -> None: self.obj.update(modules) class LLM(BaseModel): @staticmethod def type() -> str: return MODEL_LLM def __init__( self, config_obj: LLMModelConfig, random_seed=None, _device=None, **_kwargs, ): super().__init__(random_seed=random_seed) self.config_obj = config_obj self._random_seed = random_seed self.model_name = self.config_obj.base_model self.model_config = AutoConfig.from_pretrained( self.config_obj.base_model, trust_remote_code=self.config_obj.trust_remote_code, ) self.model = load_pretrained_from_config(self.config_obj, model_config=self.model_config) self.curr_device = next(self.model.parameters()).device logger.info("Done.") self.context_len = get_context_len(self.model_config) # TODO(Arnav): This needs be more flexible to account for RoPE Scaling # When merging input IDs and target IDs for LLM fine-tuning, we want to make sure that the merged tensor is # not longer than the global maximum sequence length. This is provided in the preprocessing config. We never # want to exceed the maximum possible context length so we also check for that. if self.config_obj.preprocessing.global_max_sequence_length: global_max_sequence_length = self.config_obj.preprocessing.global_max_sequence_length self.global_max_sequence_length = ( global_max_sequence_length if global_max_sequence_length <= self.context_len else self.context_len ) else: self.global_max_sequence_length = self.context_len # Initialize tokenizer self.tokenizer = HFTokenizer( self.config_obj.base_model, trust_remote_code=self.config_obj.trust_remote_code, ).tokenizer self._set_generation_config(self.config_obj.generation.to_dict()) # ================ Inputs ================ try: self.input_features.update(self.build_inputs(input_feature_configs=self.config_obj.input_features)) except KeyError as e: raise KeyError( f"An input feature has a name that conflicts with a class attribute of torch's ModuleDict: {e}" ) from e # This is used to store the model inputs during the forward pass when fine-tuning LLMs. This allows us to have # access to the joint model inputs (input_ids and target_ids) when computing metrics. In particular, the target # ids are needed to correctly compute next token softmax cross entropy loss. self.model_inputs = None # ================ Outputs ================ self.output_feature_type = self.config_obj.output_features[0].type self.output_features.update( self.build_outputs( output_feature_configs=self.config_obj.output_features, # Set the input size to the model vocab size instead of the tokenizer vocab size # because the model has additional "head" layers that are used to predict the next # token in the sequence. These head layers can add additional dimensions to the # logits tensor, beyond the vocab_size dimension. input_size=self.input_shape[-1] if self.output_feature_type == TEXT else self.model_config.vocab_size, ) ) # Extract the decoder object for the forward pass self._output_feature_decoder = ModuleWrapper(self.output_features.items()[0][1]) self.attention_masks = None clear_data_cache() def create_feature_dict(self) -> DictWrapper: return DictWrapper(LudwigFeatureDict()) @contextlib.contextmanager def use_generation_config(self, generation_config_dict: dict[str, Any] | None = None): """Sets the generation config for the model.""" # Save the original generation config so that we can reset it if/when we change it when self.generation gets is # dynamically mutated during 1-off predict calls after fine-tuning. original_generation_config_dict = self.generation.to_dict() try: # no-op if generation_config is None if generation_config_dict is not None: # unwrap the original generation config, update it with the new generation config new_generation_config_dict = {**original_generation_config_dict, **generation_config_dict} self._set_generation_config(new_generation_config_dict) yield finally: self._set_generation_config(original_generation_config_dict) def _set_generation_config(self, new_generation_config_dict: dict[str, Any]): self.generation = GenerationConfig(**new_generation_config_dict) # We need to manually set the pad_token_id to the tokenizer's pad_token_id for certain models like GPT and # CodeLlama to avoid getting an error. This workaround can be found here: # (https://github.com/huggingface/transformers/issues/25353#issuecomment-1669339754) self.generation.pad_token_id = self.tokenizer.pad_token_id self.max_new_tokens = self.generation.max_new_tokens # max input length value copied from FastChat # https://github.com/lm-sys/FastChat/blob/0e958b852a14f4bef5f0e9d7a5e7373477329cf2/fastchat/serve/inference.py#L183 # noqa E501 self.max_input_length = self.context_len - self.max_new_tokens - 8 @property def output_feature_decoder(self) -> OutputFeature: return self._output_feature_decoder.module def initialize_adapter(self): """If an adapter config is provided, we want to wrap the model with a PEFT model for fine-tuning.""" if self.config_obj.adapter: if self.config_obj.trainer.type != "finetune" and not self.config_obj.adapter.pretrained_adapter_weights: raise ValueError( "Adapter config was provided, but trainer type is not set to `finetune`. Either set the trainer to " "`finetune` or remove the adapter config." ) self.model = initialize_adapter(self.model, self.config_obj) logger.info("==================================================") logger.info("Trainable Parameter Summary For Fine-Tuning") logger.info(f"Fine-tuning with adapter: {self.config_obj.adapter.type}") self.model.print_trainable_parameters() logger.info("==================================================") def prepare_for_training(self): # TODO: this implementation will not work if resuming from a previous checkpoint. Need to fix this. if self.config_obj.quantization: self.prepare_for_quantized_training() self.initialize_adapter() def prepare_for_quantized_training(self): from peft import prepare_model_for_kbit_training self.model = prepare_model_for_kbit_training(self.model, use_gradient_checkpointing=False) def to_device(self, device): # Always refresh curr_device from actual parameter location, since # nn.Module.to() can move parameters without updating curr_device. self.curr_device = next(self.model.parameters()).device self.model, device = to_device(self.model, device, self.config_obj, self.curr_device) self.curr_device = device return self @classmethod def build_outputs( cls, output_feature_configs: FeatureCollection[BaseOutputFeatureConfig], input_size: int ) -> dict[str, OutputFeature]: """Builds and returns output feature.""" # TODO: only single task currently if len(output_feature_configs) > 1: raise ValueError("The LLM model type only supports a single output feature.") output_feature_config = output_feature_configs[0] output_feature_config.input_size = input_size output_features = {} output_feature = cls.build_single_output(output_feature_config, output_features) output_features[output_feature_config.name] = output_feature return output_features def forward( self, inputs: ( dict[str, torch.Tensor] | dict[str, np.ndarray] | tuple[dict[str, torch.Tensor], dict[str, torch.Tensor]] ), mask=None, ) -> dict[str, torch.Tensor]: """Produces logits tensor for finetuning the model. Args: inputs: Inputs to the model. Can be a dictionary of input names to input tensors or a tuple of (inputs, targets) where inputs is a dictionary of input names to input tensors and targets is a dictionary of target names to target tensors. mask: A mask for the inputs. Returns: A dictionary of output {feature name}::{tensor_name} -> output tensor. """ input_ids, target_ids = self._unpack_inputs(inputs) # Generate merged input_id, target_id pairs for the model, and create corresponding attention masks # We save them as class variables so that we can use them when realigning target and prediction tensors self.model_inputs, self.attention_masks = generate_merged_ids( input_ids, target_ids, self.tokenizer, self.global_max_sequence_length ) # TODO (jeffkinnison): Determine why the 8-bit `SCB` and `CB` matrices are deleted in the forward pass model_outputs = self.model(input_ids=self.model_inputs, attention_mask=self.attention_masks).get(LOGITS) if self.output_feature_type != TEXT: # Pass generated tokens through decoder after averaging the token probabilities # This is required for the classification head for the classifier decoder model_outputs = torch.mean(model_outputs, dim=1) if self.output_feature_type == TEXT: decoder_outputs = model_outputs else: decoder_outputs = self.output_feature_decoder.decoder_obj(model_outputs) # Set the output feature tensor to the decoder outputs (logits) outputs = {} of_name = self.config_obj.output_features[0].name set_output_feature_tensor(outputs, of_name, LOGITS, decoder_outputs) # Get predictions, probabilities and logits tensor from the output feature's predictions function outputs = self.output_features.get(of_name).predictions(outputs, of_name) # Cast to float32 for metric computation incase we're using deespeed with # reduced precision such as bfloat16. for prediction_key, prediction_tensor in outputs.items(): if prediction_key != PREDICTIONS: # Skipping casting it to float32 since the predictions are tokens and they should be int64 # (which is already the case) outputs[prediction_key] = prediction_tensor.type(torch.float32) # Add token usage. outputs[USED_TOKENS] = get_used_tokens_for_llm(self.model_inputs, self.tokenizer) return outputs def generate( self, inputs: ( dict[str, torch.Tensor] | dict[str, np.ndarray] | tuple[dict[str, torch.Tensor], dict[str, torch.Tensor]] ), mask=None, ) -> dict[str, torch.Tensor]: """Generates tokens using the model.""" log_once(f"For generating text, using: {self.generation}") input_ids, _ = self._unpack_inputs(inputs) with torch.no_grad(): input_lengths = [] sequences_list = [] for input_ids_sample in input_ids: input_ids_sample_no_padding = remove_left_padding(input_ids_sample, self.tokenizer) if input_ids_sample_no_padding.shape[1] > self.max_input_length: logger.warning( f"Input length {input_ids_sample_no_padding.shape[1]} is " f"greater than max input length {self.max_input_length}. Truncating." ) input_ids_sample_no_padding = input_ids_sample_no_padding[:, -self.max_input_length :] # noqa E203 input_lengths.append(input_ids_sample_no_padding.shape[1]) # Ensure input_ids are on the same device as the model model_device = next(self.model.parameters()).device input_ids_sample_no_padding = input_ids_sample_no_padding.to(model_device) # Generate text using the model model_outputs = self.model.generate( input_ids=input_ids_sample_no_padding, attention_mask=mask, generation_config=self.generation, return_dict_in_generate=True, output_scores=True, ) sequences_list.append(model_outputs.sequences[0]) # Extract the predictions, probabilities and logits from the model outputs # through the forward pass of the output feature outputs = self.output_feature_decoder.decoder_obj.forward( sequences_list, input_lengths, self.max_new_tokens, ) return outputs def is_merge_and_unload_set(self) -> bool: """Check if the "adapter" configuration section exists and, if affirmative, that it contains the "postprocessor" subsection and the "merge_adapter_into_base_model" and "progressbar" directives. # Return :return (bool): whether merge_and_unload should be done. """ return ( self.config_obj.adapter is not None and self.config_obj.adapter.postprocessor is not None and self.config_obj.adapter.postprocessor.merge_adapter_into_base_model ) def merge_and_unload(self, progressbar: bool = False) -> None: """This method merges the LoRa layers into the base model. This is needed if someone wants to use the base model as a standalone model. The implementation calls merge_and_unload() of the underlying LoraModel class (in peft). Args: progressbar (bool): whether to show a progressbar indicating the unload and merge process """ from peft import LoraModel if isinstance(self.model.base_model, LoraModel): self.model.base_model.merge_and_unload(progressbar=progressbar) else: raise ValueError("This operation requires an LLM model trained with a LoRA adapter.") def _unpack_inputs( self, inputs: ( dict[str, torch.Tensor] | dict[str, np.ndarray] | tuple[dict[str, torch.Tensor], dict[str, torch.Tensor]] ), ) -> tuple[torch.Tensor, torch.Tensor | None]: """Converts input tensors to input ids.""" if isinstance(inputs, tuple): inputs, targets = inputs # Convert targets to tensors. for target_feature_name, target_value in targets.items(): if not isinstance(target_value, torch.Tensor): targets[target_feature_name] = torch.from_numpy(target_value) else: targets[target_feature_name] = target_value else: targets = None assert list(inputs.keys()) == self.input_features.keys() input_ids = self.get_input_ids(inputs) target_ids = self.get_target_ids(targets) if targets else None return input_ids, target_ids def get_input_ids( self, inputs: ( dict[str, torch.Tensor] | dict[str, np.ndarray] | tuple[dict[str, torch.Tensor], dict[str, torch.Tensor]] ), ) -> torch.Tensor: """Returns the input ids for the text feature input.""" return inputs[self.config_obj.input_features[0].name].type(torch.int32) def get_target_ids(self, outputs: dict[str, torch.Tensor]) -> torch.Tensor: """Returns the output ids for the text feature output.""" return outputs[self.config_obj.output_features[0].name].type(torch.int32) def update_metrics(self, targets, predictions): """Updates the model's metrics given targets and predictions for zero-shot/few-shot.""" for of_name, of_obj in self.output_features.items(): if isinstance(of_obj, TextOutputFeature): # Align the target length with the predictions length to enable text metric evaluation. _targets, _predictions = get_realigned_target_and_prediction_tensors_for_inference( targets, predictions, of_name, self.tokenizer ) of_obj.update_metrics(_targets[of_name], _predictions[of_name], self.tokenizer) else: of_obj.update_metrics(targets[of_name], predictions[of_name]) # HACK (Tim): get the device of the targets to transfer self.eval_loss_metric to the same device target_device = list(targets.values())[0].device eval_loss, additional_losses = self.eval_loss(targets, predictions) self.eval_loss_metric = self.eval_loss_metric.to(target_device) self.eval_loss_metric.update(eval_loss) self.eval_additional_losses_metrics.update(additional_losses) def update_metrics_finetune_llm(self, targets, predictions): """Updates the model's metrics given targets and predictions for fine-tuning.""" _targets, _predictions = targets, predictions for of_name, of_obj in self.output_features.items(): if isinstance(of_obj, TextOutputFeature): # Update the target tensor to enable text metric evaluation. This pads the target tensor with -100s # to match the prediction length and depends on how much of the target tensor was included in the # forward pass. _targets = self._update_target_tensor_for_finetuning(_targets, _predictions, of_name) if isinstance(of_obj, TextOutputFeature): of_obj.update_metrics(_targets[of_name], _predictions[of_name], self.tokenizer) else: of_obj.update_metrics(_targets[of_name], _predictions[of_name]) continue of_obj.update_metrics(_targets[of_name], _predictions[of_name]) eval_loss, additional_losses = self.eval_loss(_targets, _predictions) self.eval_loss_metric.update(eval_loss) self.eval_additional_losses_metrics.update(additional_losses) def train_loss( self, targets, predictions, regularization_type: str | None = None, regularization_lambda: float | None = None, ) -> tuple[torch.Tensor, dict[str, torch.Tensor]]: """Computes the training loss for the model. Args: targets: A dictionary of target names to target tensors. predictions: A dictionary of output names to output tensors. regularization_type: One of 'l1', 'l2', 'l1_l2', or None. regularization_lambda: The regularization lambda. Returns: A tuple of the loss tensor and a dictionary of loss for every output feature. """ train_loss = 0 of_train_losses = {} for of_name, of_obj in self.output_features.items(): _targets, _predictions = targets, predictions if isinstance(of_obj, TextOutputFeature): _predictions = {of_name: _predictions} # Update the target tensor to enable text metric evaluation. This pads the target tensor with -100s # to match the prediction length and depends on how much of the target tensor was included in the # forward pass. _targets = self._update_target_tensor_for_finetuning(_targets, _predictions, of_name) # TODO(Arnav): Seems like doing this again and going between these format types in unnecessary, but # refactor so that we don't have to do this at a later point. predictions = {} for key, _ in _predictions[of_name].items(): set_output_feature_tensor(predictions, of_name, key, _predictions[of_name][key]) _predictions = predictions of_train_loss = of_obj.train_loss(_targets[of_name], _predictions, of_name) train_loss += of_obj.loss.weight * of_train_loss of_train_losses[of_name] = of_train_loss additional_losses = self.losses() if additional_losses: train_loss += torch.sum(torch.stack(additional_losses)) # other losses # Add regularization loss if regularization_type is not None and regularization_lambda != 0: train_loss += reg_loss(self, regularization_type, l1=regularization_lambda, l2=regularization_lambda) return train_loss, of_train_losses def eval_loss(self, targets, predictions): """Computes all evaluation losses for the model given targets and predictions. Args: targets: A dictionary of target names to target tensors. predictions: A dictionary of output names to output tensors. Returns: A tuple of loss values for eval losses and additional losses. """ eval_loss = 0 for of_name, of_obj in self.output_features.items(): if isinstance(of_obj, TextOutputFeature): # Align the target length with the predictions length to enable text metric evaluation. _targets, _predictions = get_realigned_target_and_prediction_tensors_for_inference( targets, predictions, of_name, self.tokenizer ) of_eval_loss = of_obj.eval_loss(_targets[of_name], _predictions[of_name]) else: # HACK(geoffrey): we need a non-empty loss, so we just fill it with zeros of_eval_loss = torch.tensor(0.0).to(predictions[of_name][LOGITS].device) eval_loss += of_obj.loss.weight * of_eval_loss additional_loss = 0 additional_losses = self.losses() if additional_losses: additional_loss = torch.sum(torch.stack(additional_losses)) # other losses return eval_loss, additional_loss def outputs_to_predictions(self, outputs: dict[str, torch.Tensor]) -> dict[str, dict[str, torch.Tensor]]: """Returns the model's predictions for each output feature.""" predictions = {} for of_name in self.output_features: # TODO(travis): this will need to change when we support multiple output features predictions[of_name] = outputs return predictions def save(self, save_path): """Saves the model to the given path.""" # TODO(travis): use the implementation of trainer itself to decide whether to save the model, to # avoid this hack if self.config_obj.trainer.type != "none": weights_save_path = os.path.join(save_path, MODEL_WEIGHTS_FILE_NAME) # We initialize the model's generation configuration; otherwise, we get a validation error. self.model.generation_config = self.generation self.model.save_pretrained(weights_save_path) else: logger.info("Skipped saving LLM without weight adjustments.") def save_base_model(self, save_path): """Saves the base LLM model to the given path.""" # TODO: see the "TODO" statement from "LLM.save()" in this module. if self.config_obj.trainer.type != "none": weights_save_path = os.path.join(save_path, MODEL_WEIGHTS_FILE_NAME) self.model.base_model.save_pretrained(weights_save_path) # While this class initializes the tokenizer (from the base_model) automatically, and hence does not # need to be saved if inference is to be done using LudwigModel.predict(), the rationale for saving the # tokenizer to HuggingFace Hub is to provide access to models fine-tuned and persisted to HuggingFace Hub # using Ludwig at a later time, with the ability to perform inference, independently of Ludwig itself. self.tokenizer.save_pretrained(weights_save_path) else: logger.info("Skipped saving LLM without weight adjustments.") def save_dequantized_base_model(self, save_path: str) -> None: """Upscales quantized weights of a model to fp16 and saves the result in a folder specified by save_path. Args: save_path (str): The path to the folder where the upscaled model weights will be saved. Returns: None """ from peft import PeftModel if isinstance(self.model, PeftModel): # Get the base model back by removing all the adapter modules without merging. logger.warning( "LLM model is currently wrapped in a PeftModel. Removing the adapter layers and saving the base model." "Reload the model via LudwigModel.load() to use your trained adapter layers for inference." ) self.model = self.model.unload() # Dequantize the model weights and cast them to fp16 - replace quantized layers with appropriate # linear layers in-place. logger.info("Upscaling quantized weights to fp16...") convert_quantized_linear_to_linear(self.model) logger.info("Done.") # Remove the quantization configuration from the model # The reason we can't delete the quantization config is because it is a property of the model and # HF does some weird serialization of the config that causes an error when trying to access `self.model.config` # after you try and delete a key from the config: TypeError: Object of type dtype is not JSON serializable. self.model.config.quantization_config = {} # Override properties of the model to indicate that it is no longer quantized. # This is also necessary to ensure that the model can be saved, otherwise it will raise an error like # "You are calling `save_pretrained` on a 4-bit converted model. This is currently not supported" # See: https://github.com/huggingface/transformers/blob/0ad4e7e6dad670a7151aaceb1af3c272a3bf73a8/src/transformers/modeling_utils.py#L2054 # noqa self.model.is_loaded_in_4bit = False self.model.is_loaded_in_8bit = False # Save the model logger.info(f"Saving upscaled model to {save_path}") self.model.save_pretrained(save_path) logger.info("Done.") # Save the tokenizer logger.info(f"Saving tokenizer to {save_path}") self.tokenizer.save_pretrained(save_path) logger.info("Done.") def load(self, save_path): """Loads the model from the given path.""" weights_save_path = os.path.join(save_path, MODEL_WEIGHTS_FILE_NAME) if self.config_obj.adapter: # Check if the saved weights are merged (no adapter_config.json) or adapter-only adapter_config_path = os.path.join(weights_save_path, "adapter_config.json") if os.path.exists(adapter_config_path): from peft import PeftModel # noqa if isinstance(self.model, PeftModel): # Unwrap and reload PeftModel self.model = self.model.base_model self.model = PeftModel.from_pretrained(self.model, weights_save_path) else: # Weights were already merged (merge_and_unload was done before save), # so load as a regular pretrained model. logger.info("Loading merged LoRA weights (no adapter_config.json found).") self.model = load_pretrained_from_config( self.config_obj, model_config=self.model_config, weights_save_path=weights_save_path ) elif self.config_obj.trainer.type != "none": self.model = load_pretrained_from_config( self.config_obj, model_config=self.model_config, weights_save_path=weights_save_path ) else: logger.info("Skipped loading LLM without weight adjustments.") def get_args(self): """Returns init arguments for constructing this model.""" return ( self.config_obj.input_features.to_list(), self.config_obj.output_features.to_list(), self._random_seed, ) def _update_target_tensor_for_finetuning( self, targets: dict[str, torch.Tensor], predictions: dict[str, torch.Tensor], of_name: str ) -> dict[str, torch.Tensor]: """Update target tensor for fine-tuning. This method removes left padding from target tensors, adds a eos token to the end of the target tensors, and pads the target tensors with -100 to ensure equal length for loss computation. It then realigns the target tensors with the prediction tensors. Args: targets (Dict[str, torch.Tensor]): A dictionary containing the target tensors. predictions (Dict[str, torch.Tensor]): A dictionary containing the predicted tensors. of_name (str): The name of the target tensor. Returns: Dict[str, torch.Tensor]: A dictionary containing the updated target tensors aligned with predictions. """ # Remove left padding from target tensors since we also do this for the model's forward pass when we # concatenate the input_ids with the target_ids. We also need to add the pad token to the end of the # target tensors. targets_without_padding = [] lengths = [] eos_token_tensor = torch.tensor([self.tokenizer.eos_token_id]) for target in targets[of_name]: target = remove_left_padding(target, self.tokenizer)[0] target = torch.cat([target, eos_token_tensor.to(device=target.device)], dim=-1).unsqueeze(0) targets_without_padding.append(target) lengths.append(target.shape[1]) # We need all target tensors to have the same length for the loss computation. We pad the target # tensors with -100 since we want to negate all tokens that are not target_ids during the softmax # cross entropy loss computation. This ensures that the loss is computed only for the target tokens. max_length = max(lengths) for i, target in enumerate(targets_without_padding): targets_without_padding[i] = add_left_padding( targets_without_padding[i][0], max_length, IGNORE_INDEX_TOKEN_ID, ) targets[of_name] = torch.stack(targets_without_padding, dim=0).to( dtype=targets[of_name].dtype, device=targets[of_name].device, ) # Re-align target tensors without padding to have equal length before realigning with the prediction # tensors. Padding left with -100 to match the length of the target tensor masks the input ids during # softmax cross entropy loss computation. This ensures that the loss is computed only for the target # token IDs. Examples: # BERTLMHead: https://github.com/huggingface/transformers/blob/v4.29.1/src/transformers/models/bert/modeling_bert.py#L1216-L1219 # noqa # GPTNeoForCausalLM: https://github.com/huggingface/transformers/blob/v4.29.1/src/transformers/models/gpt_neo/modeling_gpt_neo.py#L736 # noqa _targets = pad_target_tensor_for_fine_tuning(targets, predictions, self.model_inputs, of_name) return _targets def _activate_forward_hooks(self): """Activates/registers forward hooks for the model.""" if not self.config_obj.model_parameters: return # Initialize forward hook handles if self.config_obj.model_parameters.neftune_noise_alpha: self._forward_hook_handles.append( NEFTuneHook(neftune_noise_alpha=self.config_obj.model_parameters.neftune_noise_alpha) ) # Activate forward hooks iteratively for hook in self._forward_hook_handles: # Update the model with the forward hooks in place self.model = hook.activate_hook(self.model) @staticmethod def get_augmentation_pipelines() -> AugmentationPipelines: """Returns the augmentation pipeline for this model.""" return AugmentationPipelines({}) ================================================ FILE: ludwig/models/predictor.py ================================================ import logging import os import sys from abc import ABC, abstractmethod from collections import defaultdict, OrderedDict from pprint import pformat import numpy as np import pandas as pd import psutil import torch from torch import nn from ludwig.constants import COMBINED, LAST_HIDDEN, LOGITS, MODEL_ECD, MODEL_LLM from ludwig.data.dataset.base import Dataset from ludwig.data.utils import convert_to_dict from ludwig.distributed.base import DistributedStrategy, LocalStrategy from ludwig.globals import is_progressbar_disabled, PREDICTIONS_PARQUET_FILE_NAME, TEST_STATISTICS_FILE_NAME from ludwig.models.base import BaseModel from ludwig.progress_bar import LudwigProgressBar from ludwig.utils.data_utils import save_csv, save_json from ludwig.utils.dataframe_utils import from_numpy_dataset from ludwig.utils.print_utils import repr_ordered_dict from ludwig.utils.registry import Registry from ludwig.utils.strings_utils import make_safe_filename from ludwig.utils.torch_utils import get_torch_device EXCLUDE_PRED_SET = {LOGITS, LAST_HIDDEN} SKIP_EVAL_METRICS = {"confusion_matrix", "roc_curve"} STATS_SAMPLE_SIZE = 10000 logger = logging.getLogger(__name__) class BasePredictor(ABC): @abstractmethod def batch_predict(self, dataset, dataset_name=None): raise NotImplementedError() @abstractmethod def predict_single(self, batch): raise NotImplementedError() @abstractmethod def batch_evaluation(self, dataset, collect_predictions=False, collect_logits=False, dataset_name=None): raise NotImplementedError() @abstractmethod def batch_collect_activations(self, layer_names, dataset, bucketing_field=None): raise NotImplementedError() # Remote implementations may override this def shutdown(self): pass # Functions needed to treat Trainer as a context manager def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): self.shutdown() _predictor_registry = Registry[BasePredictor]() def register_predictor(model_types: list[str]): def wrap(cls): for model_type in model_types: _predictor_registry[model_type] = cls return cls return wrap def get_predictor_cls(model_type: str) -> type[BasePredictor]: return _predictor_registry[model_type] @register_predictor([MODEL_ECD]) class Predictor(BasePredictor): """Predictor is a class that uses a model to predict and evaluate.""" def __init__( self, dist_model: nn.Module, batch_size: int = 128, distributed: DistributedStrategy = None, report_tqdm_to_ray: bool = False, model: BaseModel | None = None, remote: bool = False, **kwargs, ): """ :param dist_model: model to use for prediction, post-wrap for distributed training :param batch_size: batch size to use for prediction :param distributed: distributed strategy to use for prediction :param report_tqdm_to_ray: whether to report tqdm progress to Ray :param model: Ludwig BaseModel before being wrapped for distributed training. Used to call Ludwig helper functions. """ model = model or dist_model assert isinstance(model, BaseModel) self._batch_size = batch_size self._distributed = distributed if distributed is not None else LocalStrategy() self.report_tqdm_to_ray = report_tqdm_to_ray device = get_torch_device() self.device = device self.dist_model = dist_model self.model = model self.model.metrics_to_device(device) if remote: # Only return results from rank 0 to reduce network overhead self.batch_predict = self._distributed.return_first(self.batch_predict) self.batch_evaluation = self._distributed.return_first(self.batch_evaluation) def batch_predict(self, dataset: Dataset, dataset_name: str = None, collect_logits: bool = False): self.dist_model = self._distributed.to_device(self.dist_model) prev_model_training_mode = self.dist_model.training # store previous model training mode self.dist_model.eval() # set model to eval mode with torch.no_grad(): with dataset.initialize_batcher(self._batch_size, should_shuffle=False) as batcher: progress_bar_config = { "desc": "Prediction" if dataset_name is None else f"Prediction {dataset_name: <5.5}", "total": batcher.steps_per_epoch, "file": sys.stdout, "disable": is_progressbar_disabled(), } progress_bar = LudwigProgressBar(self.report_tqdm_to_ray, progress_bar_config, self.is_coordinator()) predictions = defaultdict(list) while not batcher.last_batch(): batch = batcher.next_batch() preds = self._predict(batch) self._accumulate_preds( preds, predictions, exclude_pred_set={LAST_HIDDEN} if collect_logits else EXCLUDE_PRED_SET ) progress_bar.update(1) progress_bar.close() # consolidate predictions from each batch to a single tensor self._concat_preds(predictions) self.dist_model.train(prev_model_training_mode) return from_numpy_dataset(predictions) def predict_single(self, batch, collect_logits: bool = False): prev_model_training_mode = self.dist_model.training # store previous model training mode self.dist_model.eval() # set model to eval mode with torch.no_grad(): predictions = defaultdict(list) preds = self._predict(batch) self._accumulate_preds( preds, predictions, exclude_pred_set={LAST_HIDDEN} if collect_logits else EXCLUDE_PRED_SET ) self._concat_preds(predictions) # reset model to its original training mode self.dist_model.train(prev_model_training_mode) return from_numpy_dataset(predictions) def _predict(self, batch: dict[str, np.ndarray]) -> dict[str, np.ndarray]: """Predict a batch of data. Params: model: BaseModel model batch: batch of data Returns: predictions: dictionary of predictions """ inputs = { i_feat.feature_name: torch.from_numpy(np.array(batch[i_feat.proc_column], copy=True)).to(self.device) for i_feat in self.model.input_features.values() } outputs = self._predict_on_inputs(inputs) return self.model.outputs_to_predictions(outputs) def _accumulate_preds(self, preds, predictions, exclude_pred_set=EXCLUDE_PRED_SET): # accumulate predictions from batch for each output feature for of_name, of_preds in preds.items(): for pred_name, pred_values in of_preds.items(): if pred_name not in exclude_pred_set: key = f"{of_name}_{pred_name}" predictions[key].append(pred_values.detach().cpu()) def _concat_preds(self, predictions): for key, pred_value_list in predictions.items(): # Without detaching, a runtime error is raised since pred_value_list # is a tensor that requires grad. predictions[key] = torch.cat(pred_value_list, dim=0).numpy() def batch_evaluation(self, dataset, collect_predictions=False, collect_logits=False, dataset_name=None): """Batch evaluate model on dataset. Params: dataset (Union[str, dict, pandas.DataFrame]): source containing the entire dataset to be evaluated. collect_predictions: Return model predictions. collect_logits: Return model logits and final layer activations. Returns: Tuple of dictionaries of (metrics, predictions). The keys of metrics are determined by the metrics in the model config. The keys of the predictions dictionary depend on which values are requested by the caller: collect_predictions, collect_logits. """ self.dist_model = self._distributed.to_device(self.dist_model) prev_model_training_mode = self.dist_model.training # store previous model training mode self.dist_model.eval() # set model to eval mode with torch.no_grad(): with dataset.initialize_batcher( self._batch_size, should_shuffle=False, distributed=self._distributed ) as batcher: progress_bar_config = { "desc": "Evaluation" if dataset_name is None else f"Evaluation {dataset_name: <5.5}", "total": batcher.steps_per_epoch, "file": sys.stdout, "disable": is_progressbar_disabled(), "position": 0, # Necessary to disable extra new line artifacts in training logs. } progress_bar = LudwigProgressBar(self.report_tqdm_to_ray, progress_bar_config, self.is_coordinator()) predictions = defaultdict(list) eval_steps = ( self.dist_model.config_obj.trainer.eval_steps if hasattr(self.dist_model, "config_obj") and hasattr(self.dist_model.config_obj.trainer, "eval_steps") else None ) eval_steps_counter = 0 while not batcher.last_batch(): if eval_steps and eval_steps_counter >= eval_steps: logger.info(f"Reached evaluation step {eval_steps}. Ending evaluation.") break batch = batcher.next_batch() logger.debug( f"evaluation for {dataset_name}: obtained next batch " f"memory used: {psutil.Process(os.getpid()).memory_info()[0] / 1e6:0.2f}MB" ) inputs = { i_feat.feature_name: torch.from_numpy(np.array(batch[i_feat.proc_column], copy=True)).to( self.device ) for i_feat in self.model.input_features.values() } targets = { o_feat.feature_name: torch.from_numpy(np.array(batch[o_feat.proc_column], copy=True)).to( self.device ) for o_feat in self.model.output_features.values() } outputs = self._predict_on_inputs(inputs) preds = self.model.outputs_to_predictions(outputs) self.model.update_metrics(targets, preds) # accumulate predictions from batch for each output feature if collect_predictions: self._accumulate_preds( preds, predictions, exclude_pred_set={LAST_HIDDEN} if collect_logits else EXCLUDE_PRED_SET ) progress_bar.update(1) eval_steps_counter += 1 if self.is_coordinator(): logger.debug( f"evaluation for {dataset_name}: completed batch {progress_bar.total_steps} " f"memory used: {psutil.Process(os.getpid()).memory_info()[0] / 1e6:0.2f}MB" ) progress_bar.close() # consolidate predictions from each batch to a single tensor if collect_predictions: self._concat_preds(predictions) metrics = self.model.get_metrics() self.model.reset_metrics() self.dist_model.train(prev_model_training_mode) # Restores previous model training mode. return metrics, from_numpy_dataset(predictions) def batch_collect_activations(self, layer_names, dataset, bucketing_field=None): if bucketing_field: raise ValueError("BucketedBatcher is not supported yet") self.dist_model = self._distributed.to_device(self.dist_model) prev_model_training_mode = self.dist_model.training # store previous model training mode self.dist_model.eval() # set model to eval mode with torch.no_grad(): with dataset.initialize_batcher( self._batch_size, should_shuffle=False, distributed=self._distributed ) as batcher: progress_bar_config = { "desc": "Collecting Tensors", "total": batcher.steps_per_epoch, "file": sys.stdout, "disable": is_progressbar_disabled(), } progress_bar = LudwigProgressBar(self.report_tqdm_to_ray, progress_bar_config, self.is_coordinator()) collected_tensors = [] while not batcher.last_batch(): batch = batcher.next_batch() inputs = { i_feat.feature_name: torch.from_numpy(np.array(batch[i_feat.proc_column], copy=True)).to( self.device ) for i_feat in self.model.input_features.values() } outputs = self._predict_on_inputs(inputs) collected_tensors = [(concat_name, tensor) for concat_name, tensor in outputs.items()] progress_bar.update(1) progress_bar.close() self.dist_model.train(prev_model_training_mode) # Restores previous model training mode. return collected_tensors def _predict_on_inputs(self, inputs: dict) -> dict: return self.dist_model(inputs) def is_coordinator(self): return self._distributed.rank() == 0 @register_predictor([MODEL_LLM]) class LlmPredictor(Predictor): def _predict_on_inputs(self, inputs: dict) -> dict: return self.dist_model.generate(inputs) class LlmFineTunePredictor(Predictor): def batch_evaluation(self, dataset, collect_predictions=False, collect_logits=False, dataset_name=None): """Batch evaluate model on dataset. Params: dataset (Union[str, dict, pandas.DataFrame]): source containing the entire dataset to be evaluated. collect_predictions: Return model predictions. collect_logits: Return model logits and final layer activations. Returns: Tuple of dictionaries of (metrics, predictions, input/target/output dictionary). The keys of metrics are determined by the metrics in the model config. The keys of the predictions dictionary depend on which values are requested by the caller: collect_predictions, collect_logits. The keys of the input/target/output dictionary are "inputs", "targets", and "outputs". The values of each of these keys are dictionaries of feature names to lists of tensors. The tensors are the inputs, targets, and outputs for each batch. """ prev_model_training_mode = self.dist_model.training # store previous model training mode self.dist_model.eval() # set model to eval mode example_inputs = defaultdict(list) example_targets = defaultdict(list) example_outputs = defaultdict(list) with torch.no_grad(): with dataset.initialize_batcher( self._batch_size, should_shuffle=False, distributed=self._distributed ) as batcher: progress_bar_config = { "desc": "Evaluation" if dataset_name is None else f"Evaluation {dataset_name: <5.5}", "total": batcher.steps_per_epoch, "file": sys.stdout, "disable": is_progressbar_disabled(), "position": 0, # Necessary to disable extra new line artifacts in training logs. } progress_bar = LudwigProgressBar(self.report_tqdm_to_ray, progress_bar_config, self.is_coordinator()) predictions = defaultdict(list) eval_steps = ( self.dist_model.config_obj.trainer.eval_steps if hasattr(self.dist_model, "config_obj") and hasattr(self.dist_model.config_obj.trainer, "eval_steps") else None ) eval_steps_counter = 0 while not batcher.last_batch(): if eval_steps and eval_steps_counter >= eval_steps: logger.info(f"Reached evaluation step {eval_steps}. Ending evaluation.") break batch = batcher.next_batch() logger.debug( f"evaluation for {dataset_name}: obtained next batch " f"memory used: {psutil.Process(os.getpid()).memory_info()[0] / 1e6:0.2f}MB" ) inputs = { i_feat.feature_name: torch.from_numpy(np.array(batch[i_feat.proc_column], copy=True)).to( self.device ) for i_feat in self.model.input_features.values() } targets = { o_feat.feature_name: torch.from_numpy(np.array(batch[o_feat.proc_column], copy=True)).to( self.device ) for o_feat in self.model.output_features.values() } outputs = self._predict_on_inputs((inputs, targets)) preds = self.model.outputs_to_predictions(outputs) for key in inputs: example_inputs[key].extend(inputs[key]) for key in targets: example_targets[key].extend(targets[key]) for key in preds: example_outputs[key].extend(preds[key]["predictions"]) # Need to pass through a custom fine-tune metric function because we need to transform # the targets into the right format for loss calculation (requires padding with -100s to the left) # and other tensor alignment. self.model.update_metrics_finetune_llm(targets, preds) # accumulate predictions from batch for each output feature if collect_predictions: self._accumulate_preds( preds, predictions, exclude_pred_set={LAST_HIDDEN} if collect_logits else EXCLUDE_PRED_SET ) progress_bar.update(1) eval_steps_counter += 1 if self.is_coordinator(): logger.debug( f"evaluation for {dataset_name}: completed batch {progress_bar.total_steps} " f"memory used: {psutil.Process(os.getpid()).memory_info()[0] / 1e6:0.2f}MB" ) progress_bar.close() # consolidate predictions from each batch to a single tensor if collect_predictions: for key, pred_value_list in predictions.items(): predictions[key] = torch.cat(pred_value_list, dim=0).detach().cpu().numpy() metrics = self.model.get_metrics() self.model.reset_metrics() input_target_output_dict = { "inputs": example_inputs, "targets": example_targets, "outputs": example_outputs, } self.dist_model.train(prev_model_training_mode) # Restores previous model training mode. return metrics, from_numpy_dataset(predictions), input_target_output_dict def calculate_overall_stats(output_features, predictions, dataset, training_set_metadata): overall_stats = {} for of_name, output_feature in output_features.items(): feature_metadata = training_set_metadata[output_feature.feature_name] feature_metadata.update(training_set_metadata[output_feature.feature_name]) feature_df = predictions.loc[:, predictions.columns.str.startswith(of_name)] feature_df = feature_df.rename(columns=lambda c: c[len(of_name) + 1 :]) target = dataset.loc[:, output_feature.proc_column] if not isinstance(feature_df, pd.DataFrame): logger.warning( "Full computation of stats only supported for pandas dataframes. " "Sampling the first 10000 rows of the feature and target dataframes for computing overall stats." ) feature_df = feature_df.head(n=STATS_SAMPLE_SIZE, npartitions=-1, compute=True) target = target.head(n=STATS_SAMPLE_SIZE, npartitions=-1, compute=True) overall_stats[of_name] = output_feature.calculate_overall_stats( feature_df, # predictions target, feature_metadata, # output feature metadata ) return overall_stats def save_prediction_outputs( postprocessed_output, output_features, output_directory, backend, ): backend.df_engine.write_predictions( postprocessed_output, os.path.join(output_directory, PREDICTIONS_PARQUET_FILE_NAME) ) if not backend.df_engine.partitioned: # csv can only be written out for unpartitioned df format (i.e., pandas) postprocessed_dict = convert_to_dict(postprocessed_output, output_features) csv_filename = os.path.join(output_directory, "{}_{}.csv") for output_field, outputs in postprocessed_dict.items(): for output_name, values in outputs.items(): save_csv(csv_filename.format(output_field, make_safe_filename(output_name)), values) def save_evaluation_stats(test_stats, output_directory): test_stats_fn = os.path.join(output_directory, TEST_STATISTICS_FILE_NAME) save_json(test_stats_fn, test_stats) def print_evaluation_stats(test_stats): for output_field, result in test_stats.items(): if output_field != COMBINED or (output_field == COMBINED and len(test_stats) > 2): logger.info(f"\n===== {output_field} =====") for metric in sorted(list(result)): if metric not in SKIP_EVAL_METRICS: value = result[metric] if isinstance(value, OrderedDict): value_repr = repr_ordered_dict(value) else: value_repr = pformat(result[metric], indent=2) logger.info(f"{metric}: {value_repr}") def get_output_columns(output_features, include_logits: bool = False): output_columns = [] for of_name, feature in output_features.items(): for pred in feature.get_prediction_set(): if pred not in EXCLUDE_PRED_SET or (pred == LOGITS and include_logits): output_columns.append(f"{of_name}_{pred}") return output_columns ================================================ FILE: ludwig/models/registry.py ================================================ import logging from ludwig.constants import MODEL_ECD, MODEL_LLM from ludwig.models.ecd import ECD from ludwig.models.llm import LLM logger = logging.getLogger(__name__) model_type_registry = { MODEL_ECD: ECD, MODEL_LLM: LLM, } ================================================ FILE: ludwig/models/retrieval.py ================================================ import hashlib import json import os from abc import ABC, abstractmethod from collections.abc import Callable from typing import Any, TYPE_CHECKING import numpy as np import pandas as pd from tqdm import tqdm from ludwig.vector_index import FAISS, get_vector_index_cls from ludwig.vector_index.base import VectorIndex if TYPE_CHECKING: from sentence_transformers import SentenceTransformer from ludwig.backend.base import Backend from ludwig.utils.batch_size_tuner import BatchSizeEvaluator from ludwig.utils.torch_utils import get_torch_device def df_checksum(df: pd.DataFrame) -> str: return hashlib.sha1(pd.util.hash_pandas_object(df).values).hexdigest() def df_to_row_strs(df: pd.DataFrame) -> list[str]: rows = df.to_dict(orient="records") row_strs = [json.dumps(r) for r in rows] return row_strs class RetrievalModel(ABC): @abstractmethod def create_dataset_index(self, df: pd.DataFrame, backend: "Backend", columns_to_index: list[str] | None = None): """Creates an index for the dataset. If `columns_to_index` is None, all columns are indexed. Otherwise, only the columns in `columns_to_index` are used for indexing, but all columns in `df` are returned in the search results. """ @abstractmethod def search( self, df, backend: "Backend", k: int = 10, return_data: bool = False ) -> list[int] | list[dict[str, Any]]: """Retrieve the top k results for the given query. If `return_data` is True, returns the data associated with the indices. Otherwise, returns the indices. """ @abstractmethod def save_index(self, name: str, cache_directory: str): """Saves the index to the cache directory.""" @abstractmethod def load_index(self, name: str, cache_directory: str): """Loads the index from the cache directory.""" class RandomRetrieval(RetrievalModel): """Random retrieval model. Gets k random indices from the dataset regardless of the query. """ def __init__(self, **kwargs): self.index = None self.index_data = None def create_dataset_index(self, df: pd.DataFrame, backend: "Backend", columns_to_index: list[str] | None = None): self.index = np.array(range(len(df))) self.index_data = df def search( self, df, backend: "Backend", k: int = 10, return_data: bool = False ) -> list[int] | list[dict[str, Any]]: results = [] for _ in tqdm(range(len(df))): indices = np.random.choice(self.index, k, replace=False) if return_data: result = self.index_data.iloc[indices].to_dict(orient="records") else: result = indices results.append(result) return results def save_index(self, name: str, cache_directory: str): index_file_path = os.path.join(cache_directory, name + ".index") # open file to prevent using the .npy extension # https://numpy.org/doc/stable/reference/generated/numpy.save.html with open(index_file_path, "wb") as f: np.save(f, self.index) index_data_file_path = os.path.join(cache_directory, name + "_data.csv") self.index_data.to_csv(index_data_file_path, index=False) def load_index(self, name: str, cache_directory: str): index_file_path = os.path.join(cache_directory, name + ".index") self.index = np.load(index_file_path) index_data_file_path = os.path.join(cache_directory, name + "_data.csv") self.index_data = pd.read_csv(index_data_file_path) class SemanticRetrieval(RetrievalModel): """Semantic retrieval model. Uses a sentence transformer model to encode the dataset and retrieve the top k most similar results to the query. """ def __init__(self, model_name, **kwargs): self.model_name = model_name self.model = get_semantic_retrieval_model(self.model_name) self.index: VectorIndex = None self.index_data: pd.DataFrame = None # best batch size computed during the encoding step self.best_batch_size = None def create_dataset_index(self, df: pd.DataFrame, backend: "Backend", columns_to_index: list[str] | None = None): if columns_to_index is None: columns_to_index = df.columns df_to_index = df[columns_to_index] row_strs = df_to_row_strs(df_to_index) embeddings = self._encode(row_strs, backend) self.index = get_vector_index_cls(FAISS).from_embeddings(embeddings) # Save the entire df so we can return the full row when searching self.index_data = df def _encode(self, row_strs: list[str], backend: "Backend") -> np.ndarray: # only do this step once if self.best_batch_size is None: self.best_batch_size = backend.tune_batch_size( create_semantic_retrieval_model_evaluator(self.model, row_strs), len(row_strs) ) transform_fn = create_semantic_retrieval_model_fn(self.model, self.best_batch_size) df = backend.df_engine.from_pandas(pd.DataFrame({"data": row_strs})) df = backend.batch_transform(df, self.best_batch_size, transform_fn) df = backend.df_engine.compute(df) embeddings = np.stack(df["data"].values).astype(np.float32) return embeddings def search( self, df: pd.DataFrame, backend: "Backend", k: int = 10, return_data: bool = False ) -> list[int] | list[dict[str, Any]]: row_strs = df_to_row_strs(df) query_vectors = self._encode(row_strs, backend) results = [] # TODO(geoffrey): figure out why self.index.search segfaults with larger batch sizes for query_vector in tqdm(query_vectors, total=query_vectors.shape[0]): indices = self.index.search(query_vector.reshape(1, -1), k) if return_data: result = self.index_data.iloc[indices].to_dict(orient="records") else: result = indices results.append(result) return results def save_index(self, name: str, cache_directory: str): index_file_path = os.path.join(cache_directory, name + ".index") self.index.save(index_file_path) index_data_file_path = os.path.join(cache_directory, name + "_data.csv") self.index_data.to_csv(index_data_file_path, index=False) def load_index(self, name: str, cache_directory: str): index_file_path = os.path.join(cache_directory, name + ".index") self.index = get_vector_index_cls(FAISS).from_path(index_file_path) index_data_file_path = os.path.join(cache_directory, name + "_data.csv") self.index_data = pd.read_csv(index_data_file_path) def create_semantic_retrieval_model_evaluator( model: "SentenceTransformer", samples: list[str] ) -> type[BatchSizeEvaluator]: class _RetrievalModelEvaluator(BatchSizeEvaluator): def __init__(self): self.model = model.to(get_torch_device()) self.samples = samples def step(self, batch_size: int, global_max_sequence_length: int | None = None): self.model.encode(self.samples[:batch_size], batch_size=batch_size, show_progress_bar=False) return _RetrievalModelEvaluator def create_semantic_retrieval_model_fn( model: "SentenceTransformer", batch_size: int ) -> Callable[[pd.DataFrame], np.ndarray]: class _RetrievalModelFn: def __init__(self): self.model = model.to(get_torch_device()) self.batch_size = batch_size def __call__(self, df: pd.DataFrame) -> np.ndarray: row_strs = df["data"].tolist() result = self.model.encode(row_strs, batch_size=self.batch_size, show_progress_bar=False) df["data"] = result.tolist() return df return _RetrievalModelFn def get_semantic_retrieval_model(model_name: str) -> "SentenceTransformer": from sentence_transformers import SentenceTransformer return SentenceTransformer(model_name, device=get_torch_device()) def get_retrieval_model(type: str, **kwargs) -> RetrievalModel: if type == "random": return RandomRetrieval(**kwargs) elif type == "semantic": return SemanticRetrieval(**kwargs) else: raise ValueError(f"Unsupported retrieval model type: {type}") ================================================ FILE: ludwig/modules/__init__.py ================================================ ================================================ FILE: ludwig/modules/attention_modules.py ================================================ # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import logging import torch from torch import nn from torch.nn import functional as F from ludwig.utils.torch_utils import get_activation, LudwigModule logger = logging.getLogger(__name__) class FeedForwardAttentionReducer(LudwigModule): def __init__(self, input_size, hidden_size=256, activation="tanh"): super().__init__() self.fc_layer1 = nn.Linear(input_size, hidden_size) self.fc_layer1_activation = get_activation(activation) self.fc_layer2 = nn.Linear(hidden_size, 1, bias=False) self.input_shape_var = None self.output_shape_var = None def forward(self, inputs, mask=None): # current_inputs shape [b, s, h] self.input_shape_var = inputs.size()[1:] hidden = self.fc_layer1(inputs) # [b, s, h'] hidden = self.fc_layer1_activation(hidden) hidden = self.fc_layer2(hidden) # [b, s, 1] attention = F.softmax(hidden, dim=1) gated_inputs = torch.sum(attention * inputs, dim=1) self.output_shape_var = gated_inputs.size()[1:] return gated_inputs # [b, h] @property def input_shape(self) -> torch.Size: return self.input_shape_var @property def output_shape(self) -> torch.Size: return self.output_shape_var class MultiHeadSelfAttention(LudwigModule): def __init__(self, input_size, hidden_size, num_heads=8): super().__init__() self.embedding_size = hidden_size self.num_heads = num_heads if hidden_size % num_heads != 0: raise ValueError( f"When using multi-head attention, `hidden_size` ({hidden_size}), should be divisible by " f"`num_heads` ({num_heads}). Please update the `transformer` section of the model config." ) self.projection_dim = hidden_size // num_heads self.query_dense = nn.Linear(input_size, hidden_size) self.key_dense = nn.Linear(input_size, hidden_size) self.value_dense = nn.Linear(input_size, hidden_size) self.combine_heads = nn.Linear(hidden_size, hidden_size) def separate_heads(self, inputs, batch_size): inputs = torch.reshape(inputs, (batch_size, -1, self.num_heads, self.projection_dim)) return torch.permute(inputs, (0, 2, 1, 3)) def forward(self, inputs: torch.Tensor, mask=None): # inputs.shape = [batch_size, seq_len, embedding_dim] batch_size = inputs.shape[0] query = self.query_dense(inputs) # (batch_size, seq_len, h) key = self.key_dense(inputs) # (batch_size, seq_len, h) value = self.value_dense(inputs) # (batch_size, seq_len, h) query = self.separate_heads(query, batch_size) # (batch_size, num_heads, seq_len, projection_dim) key = self.separate_heads(key, batch_size) # (batch_size, num_heads, seq_len, projection_dim) value = self.separate_heads(value, batch_size) # (batch_size, num_heads, seq_len, projection_dim) attn_mask = mask if mask is not None else None outputs = F.scaled_dot_product_attention(query, key, value, attn_mask=attn_mask) outputs = torch.permute(outputs, (0, 2, 1, 3)) # (batch_size, seq_len, num_heads, projection_dim) concat_outputs = torch.reshape(outputs, (batch_size, -1, self.embedding_size)) # (batch_size, seq_len, h) projected_outputs = self.combine_heads(concat_outputs) # (batch_size, seq_len, h) return projected_outputs @property def output_shape(self): return torch.Size([self.embedding_size]) class TransformerBlock(LudwigModule): def __init__( self, input_size: int, max_sequence_length: int, hidden_size: int, num_heads: int, output_size: int, dropout: float = 0.1, ): super().__init__() self.input_size = input_size self.max_sequence_length = max_sequence_length self.hidden_size = hidden_size self.self_attention = MultiHeadSelfAttention(input_size, hidden_size, num_heads=num_heads) self.dropout1 = nn.Dropout(dropout) self.layernorm1 = nn.LayerNorm(hidden_size, eps=1e-6) self.fully_connected = nn.Sequential( nn.Linear(input_size, output_size), get_activation("relu"), nn.Linear(output_size, hidden_size) ) self.dropout2 = nn.Dropout(dropout) self.layernorm2 = nn.LayerNorm(hidden_size, eps=1e-6) @property def input_shape(self) -> torch.Size: return torch.Size([self.max_sequence_length, self.input_size]) def forward(self, inputs, mask=None): # inputs [b, s, h] attn_output = self.self_attention(inputs) # [b, s, h] attn_output = self.dropout1(attn_output) # [b, s, h] ln1_output = self.layernorm1(inputs + attn_output) # [b, s, h] fc_output = self.fully_connected(ln1_output) # [b, s, h] fc_output = self.dropout2(fc_output) # [b, s, h] return self.layernorm2(ln1_output + fc_output) # [b, s, h] @property def output_shape(self) -> torch.Size: return torch.Size([self.max_sequence_length, self.hidden_size]) class TransformerStack(LudwigModule): def __init__( self, input_size: int, max_sequence_length: int, hidden_size: int = 256, num_heads: int = 8, output_size: int = 256, num_layers: int = 1, dropout: float = 0.1, **kwargs, ): super().__init__() self.supports_masking = True self.max_sequence_length = max_sequence_length self.input_size = input_size self.hidden_size = hidden_size self.layers = nn.ModuleList() prior_input_size = input_size for i in range(num_layers): layer = TransformerBlock( input_size=prior_input_size, max_sequence_length=max_sequence_length, hidden_size=hidden_size, num_heads=num_heads, output_size=output_size, dropout=dropout, ) self.layers.append(layer) prior_input_size = self.layers[i].output_shape[-1] for layer in self.layers: logger.debug(f" {layer._get_name()}") @property def input_shape(self) -> torch.Size: return torch.Size([self.max_sequence_length, self.input_size]) def forward(self, inputs, mask=None): hidden = inputs for layer in self.layers: hidden = layer(hidden, mask=mask) return hidden @property def output_shape(self) -> torch.Size: return torch.Size([self.max_sequence_length, self.hidden_size]) ================================================ FILE: ludwig/modules/convolutional_modules.py ================================================ # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import logging from functools import partial from typing import Any import torch import torch.nn as nn from ludwig.utils.image_utils import get_img_output_shape from ludwig.utils.torch_utils import get_activation, LudwigModule logger = logging.getLogger(__name__) class Conv1DLayer(LudwigModule): def __init__( self, in_channels=1, out_channels=256, max_sequence_length=None, kernel_size=3, strides=1, padding="same", dilation=1, groups=1, use_bias=True, weights_initializer="xavier_uniform", bias_initializer="zeros", norm=None, norm_params=None, activation="relu", dropout=0, pool_function="max", pool_size=2, pool_strides=None, pool_padding="valid", ): super().__init__() self.in_channels = in_channels self.out_channels = out_channels self.max_sequence_length = max_sequence_length self.kernel_size = kernel_size self.stride = strides self.padding = padding self.dilation = dilation self.groups = groups self.pool_size = pool_size if pool_strides is None: self.pool_strides = pool_size else: self.pool_strides = pool_strides if pool_padding == "same" and pool_size is not None: self.pool_padding = (self.pool_size - 1) // 2 else: self.pool_padding = 0 self.layers = nn.ModuleList() self.layers.append( nn.Conv1d( in_channels=in_channels, out_channels=out_channels, kernel_size=(kernel_size,), stride=(strides,), padding=padding, dilation=(dilation,), ) ) if norm and norm_params is None: norm_params = {} if norm == "batch": self.layers.append(nn.BatchNorm1d(num_features=out_channels, **norm_params)) elif norm == "layer": self.layers.append(nn.LayerNorm(normalized_shape=[out_channels, self.max_sequence_length], **norm_params)) self.layers.append(get_activation(activation)) if dropout > 0: self.layers.append(nn.Dropout(dropout)) if pool_size is not None: pool = nn.MaxPool1d if pool_function in {"average", "avg", "mean"}: pool = nn.AvgPool1d self.layers.append(pool(kernel_size=self.pool_size, stride=self.pool_strides, padding=self.pool_padding)) for layer in self.layers: logger.debug(f" {layer._get_name()}") @property def input_shape(self): """Returns the size of the input tensor without the batch dimension.""" return torch.Size([self.max_sequence_length, self.in_channels]) def forward(self, inputs, training=None, mask=None): # inputs: [batch_size, seq_size, in_channels] # in Torch nomenclature (N, L, C) hidden = inputs # put in torch compatible form [batch_size, in_channels, seq_size] hidden = hidden.transpose(1, 2) for layer in self.layers: hidden = layer(hidden) # revert back to normal form [batch_size, seq_size, out_channels] hidden = hidden.transpose(1, 2) return hidden # (batch_size, seq_size, out_channels) class Conv1DStack(LudwigModule): def __init__( self, in_channels=1, max_sequence_length=None, layers=None, num_layers=None, default_num_filters=256, default_filter_size=3, default_strides=1, default_padding="same", default_dilation_rate=1, default_use_bias=True, default_weights_initializer="xavier_uniform", default_bias_initializer="zeros", default_norm=None, default_norm_params=None, default_activation="relu", default_dropout=0, default_pool_function="max", default_pool_size=2, default_pool_strides=None, default_pool_padding="same", **kwargs, ): super().__init__() self.max_sequence_length = max_sequence_length self.in_channels = in_channels if layers is None: if num_layers is None: self.layers = [ {"filter_size": 7, "pool_size": 3}, {"filter_size": 7, "pool_size": 3}, {"filter_size": 3, "pool_size": None}, {"filter_size": 3, "pool_size": None}, {"filter_size": 3, "pool_size": None}, {"filter_size": 3, "pool_size": 3}, ] else: self.layers = [] for i in range(num_layers): self.layers.append( { "filter_size": default_filter_size, "num_filters": default_num_filters, "pool_size": default_pool_size, "pool_strides": default_pool_strides, } ) else: self.layers = layers for layer in self.layers: if "num_filters" not in layer: layer["num_filters"] = default_num_filters if "filter_size" not in layer: layer["filter_size"] = default_filter_size if "strides" not in layer: layer["strides"] = default_strides if "padding" not in layer: layer["padding"] = default_padding if "dilation_rate" not in layer: layer["dilation_rate"] = default_dilation_rate if "use_bias" not in layer: layer["use_bias"] = default_use_bias if "weights_initializer" not in layer: layer["weights_initializer"] = default_weights_initializer if "bias_initializer" not in layer: layer["bias_initializer"] = default_bias_initializer if "norm" not in layer: layer["norm"] = default_norm if "norm_params" not in layer: layer["norm_params"] = default_norm_params if "activation" not in layer: layer["activation"] = default_activation if "dropout" not in layer: layer["dropout"] = default_dropout if "pool_function" not in layer: layer["pool_function"] = default_pool_function if "pool_size" not in layer: layer["pool_size"] = default_pool_size if "pool_strides" not in layer: layer["pool_strides"] = default_pool_strides if "pool_padding" not in layer: layer["pool_padding"] = default_pool_padding self.stack = nn.ModuleList() prior_layer_channels = in_channels l_in = self.max_sequence_length # torch L_in for i, layer in enumerate(self.layers): logger.debug(f" stack layer {i}") self.stack.append( Conv1DLayer( in_channels=prior_layer_channels, out_channels=layer["num_filters"], max_sequence_length=l_in, kernel_size=layer["filter_size"], strides=layer["strides"], padding=layer["padding"], dilation=layer["dilation_rate"], use_bias=layer["use_bias"], weights_initializer=layer["weights_initializer"], bias_initializer=layer["bias_initializer"], norm=layer["norm"], norm_params=layer["norm_params"], activation=layer["activation"], dropout=layer["dropout"], pool_function=layer["pool_function"], pool_size=layer["pool_size"], pool_strides=layer["pool_strides"], pool_padding=layer["pool_padding"], ) ) # retrieve number of channels from prior layer input_shape = self.stack[i].input_shape output_shape = self.stack[i].output_shape logger.debug(f"{self.__class__.__name__}: " f"input_shape {input_shape}, output shape {output_shape}") # pass along shape for the input to the next layer l_in, prior_layer_channels = output_shape @property def input_shape(self): """Returns the size of the input tensor without the batch dimension.""" return torch.Size([self.max_sequence_length, self.in_channels]) def forward(self, inputs, mask=None): hidden = inputs # todo: enumerate for debugging, remove after testing for i, layer in enumerate(self.stack): hidden = layer(hidden) if hidden.shape[1] == 0: raise ValueError( "The output of the conv stack has the second dimension " "(length of the sequence) equal to 0. " "This means that the combination of filter_size, padding, " "stride, pool_size, pool_padding and pool_stride reduces " "the sequence length more than is possible. " 'Try using "same" padding and reducing or eliminating stride ' "and pool." ) return hidden class ParallelConv1D(LudwigModule): def __init__( self, in_channels=1, max_sequence_length=None, layers=None, default_num_filters=256, default_filter_size=3, default_strides=1, default_padding="same", default_dilation_rate=1, default_use_bias=True, default_weights_initializer="xavier_uniform", default_bias_initializer="zeros", default_norm=None, default_norm_params=None, default_activation="relu", default_dropout=0, default_pool_function="max", default_pool_size=None, default_pool_strides=None, default_pool_padding="valid", **kwargs, ): super().__init__() self.in_channels = in_channels self.max_sequence_length = max_sequence_length if layers is None: self.layers = [{"filter_size": 2}, {"filter_size": 3}, {"filter_size": 4}, {"filter_size": 5}] else: self.layers = layers for layer in self.layers: if "num_filters" not in layer: layer["num_filters"] = default_num_filters if "filter_size" not in layer: layer["filter_size"] = default_filter_size if "strides" not in layer: layer["strides"] = default_strides if "padding" not in layer: layer["padding"] = default_padding if "dilation_rate" not in layer: layer["dilation_rate"] = default_dilation_rate if "use_bias" not in layer: layer["use_bias"] = default_use_bias if "weights_initializer" not in layer: layer["weights_initializer"] = default_weights_initializer if "bias_initializer" not in layer: layer["bias_initializer"] = default_bias_initializer if "norm" not in layer: layer["norm"] = default_norm if "norm_params" not in layer: layer["norm_params"] = default_norm_params if "activation" not in layer: layer["activation"] = default_activation if "dropout" not in layer: layer["dropout"] = default_dropout if "pool_function" not in layer: layer["pool_function"] = default_pool_function if "pool_size" not in layer: layer["pool_size"] = default_pool_size if "pool_strides" not in layer: layer["pool_strides"] = default_pool_strides if "pool_padding" not in layer: layer["pool_padding"] = default_pool_padding self.parallel_layers = nn.ModuleList() for i, layer in enumerate(self.layers): logger.debug(f" parallel layer {i}") self.parallel_layers.append( Conv1DLayer( in_channels=self.in_channels, out_channels=layer["num_filters"], max_sequence_length=self.max_sequence_length, kernel_size=layer["filter_size"], strides=layer["strides"], padding=layer["padding"], dilation=layer["dilation_rate"], use_bias=layer["use_bias"], weights_initializer=layer["weights_initializer"], bias_initializer=layer["bias_initializer"], norm=layer["norm"], norm_params=layer["norm_params"], activation=layer["activation"], dropout=layer["dropout"], pool_function=layer["pool_function"], pool_size=layer["pool_size"], pool_strides=layer["pool_strides"], pool_padding=layer["pool_padding"], ) ) logger.debug( f"{self.__class__.__name__} layer {i}, input shape " f"{self.parallel_layers[i].input_shape}, output shape " f"{self.parallel_layers[i].output_shape}" ) @property def input_shape(self) -> torch.Size: """Returns the size of the input tensor without the batch dimension.""" return torch.Size([self.max_sequence_length, self.in_channels]) def forward(self, inputs, mask=None): # inputs: [batch_size, seq_size, in_channels) hidden = inputs hiddens = [] for layer in self.parallel_layers: hiddens.append(layer(hidden)) hidden = torch.cat(hiddens, 2) if hidden.shape[1] == 0: raise ValueError( "The output of the conv stack has the second dimension " "(length of the sequence) equal to 0. " "This means that the combination of filter_size, padding, " "stride, pool_size, pool_padding and pool_stride reduces " "the sequence length more than is possible. " 'Try using "same" padding and reducing or eliminating stride ' "and pool." ) # (batch_size, seq_size, len(parallel_layers) * out_channels) return hidden class ParallelConv1DStack(LudwigModule): def __init__( self, in_channels=None, stacked_layers=None, max_sequence_length=None, default_num_filters=64, default_filter_size=3, default_strides=1, default_padding="same", default_dilation_rate=1, default_use_bias=True, default_weights_initializer="xavier_uniform", default_bias_initializer="zeros", default_norm=None, default_norm_params=None, default_activation="relu", default_dropout=0, default_pool_function="max", default_pool_size=None, default_pool_strides=None, default_pool_padding="valid", **kwargs, ): super().__init__() self.max_sequence_length = max_sequence_length self.in_channels = in_channels if stacked_layers is None: self.stacked_parallel_layers = [ [{"filter_size": 2}, {"filter_size": 3}, {"filter_size": 4}, {"filter_size": 5}], [{"filter_size": 2}, {"filter_size": 3}, {"filter_size": 4}, {"filter_size": 5}], [{"filter_size": 2}, {"filter_size": 3}, {"filter_size": 4}, {"filter_size": 5}], ] else: self.stacked_parallel_layers = stacked_layers for i, parallel_layers in enumerate(self.stacked_parallel_layers): for j in range(len(parallel_layers)): layer = parallel_layers[j] if "num_filters" not in layer: layer["num_filters"] = default_num_filters if "filter_size" not in layer: layer["filter_size"] = default_filter_size if "strides" not in layer: layer["strides"] = default_strides if "padding" not in layer: layer["padding"] = default_padding if "dilation_rate" not in layer: layer["dilation_rate"] = default_dilation_rate if "use_bias" not in layer: layer["use_bias"] = default_use_bias if "weights_initializer" not in layer: layer["weights_initializer"] = default_weights_initializer if "bias_initializer" not in layer: layer["bias_initializer"] = default_bias_initializer if "norm" not in layer: layer["norm"] = default_norm if "norm_params" not in layer: layer["norm_params"] = default_norm_params if "activation" not in layer: layer["activation"] = default_activation if "dropout" not in layer: layer["dropout"] = default_dropout if "pool_function" not in layer: layer["pool_function"] = default_pool_function if "pool_size" not in layer: if i == len(self.stacked_parallel_layers) - 1: layer["pool_size"] = default_pool_size else: layer["pool_size"] = None if "pool_strides" not in layer: layer["pool_strides"] = default_pool_strides if "pool_padding" not in layer: layer["pool_padding"] = default_pool_padding self.stack = nn.ModuleList() num_channels = self.in_channels sequence_length = self.max_sequence_length for i, parallel_layers in enumerate(self.stacked_parallel_layers): logger.debug(f" stack layer {i}") self.stack.append(ParallelConv1D(num_channels, sequence_length, layers=parallel_layers)) logger.debug( f"{self.__class__.__name__} layer {i}, input shape " f"{self.stack[i].input_shape}, output shape " f"{self.stack[i].output_shape}" ) # set input specification for the layer num_channels = self.stack[i].output_shape[1] sequence_length = self.stack[i].output_shape[0] @property def input_shape(self): """Returns the size of the input tensor without the batch dimension.""" return torch.Size([self.max_sequence_length, self.in_channels]) def forward(self, inputs, mask=None): hidden = inputs for layer in self.stack: hidden = layer(hidden) if hidden.shape[2] == 0: raise ValueError( "The output of the conv stack has the second dimension " "(length of the sequence) equal to 0. " "This means that the combination of filter_size, padding, " "stride, pool_size, pool_padding and pool_stride is reduces " "the sequence length more than is possible. " 'Try using "same" padding and reducing or eliminating stride ' "and pool." ) return hidden class Conv2DLayer(LudwigModule): def __init__( self, img_height: int, img_width: int, in_channels: int, out_channels: int = 256, kernel_size: int | tuple[int] = 3, stride: int | tuple[int] = 1, padding: int | tuple[int] | str = "valid", dilation: int | tuple[int] = 1, groups: int = 1, use_bias: bool = True, padding_mode: str = "zeros", norm: str | None = None, norm_params: dict[str, Any] | None = None, activation: str = "relu", dropout: float = 0, pool_function: int = "max", pool_kernel_size: int | tuple[int] = None, pool_stride: int | None = None, pool_padding: int | tuple[int] = 0, pool_dilation: int | tuple[int] = 1, ): super().__init__() self.layers = torch.nn.ModuleList() self._input_shape = (in_channels, img_height, img_width) pool_stride = pool_stride or pool_kernel_size self.layers.append( nn.Conv2d( in_channels=in_channels, out_channels=out_channels, kernel_size=kernel_size, stride=stride, padding=padding, dilation=dilation, groups=groups, bias=use_bias, padding_mode=padding_mode, ) ) out_height, out_width = get_img_output_shape(img_height, img_width, kernel_size, stride, padding, dilation) if norm and norm_params is None: norm_params = {} if norm == "batch": # Batch norm over channels self.layers.append(nn.BatchNorm2d(num_features=out_channels, **norm_params)) elif norm == "layer": # Layer norm over image height and width self.layers.append(nn.LayerNorm(normalized_shape=(out_height, out_width), **norm_params)) self.layers.append(get_activation(activation)) if dropout > 0: self.layers.append(nn.Dropout(dropout)) if pool_kernel_size is not None: pool = partial(nn.MaxPool2d, dilation=pool_dilation) if pool_function in {"average", "avg", "mean"}: pool = nn.AvgPool2d self.layers.append(pool(kernel_size=pool_kernel_size, stride=pool_stride, padding=pool_padding)) out_height, out_width = get_img_output_shape( img_height=out_height, img_width=out_width, kernel_size=pool_kernel_size, stride=pool_stride, padding=pool_padding, dilation=pool_dilation, ) for layer in self.layers: logger.debug(f" {layer._get_name()}") self._output_shape = (out_channels, out_height, out_width) def forward(self, inputs): hidden = inputs for layer in self.layers: hidden = layer(hidden) return hidden @property def output_shape(self) -> torch.Size: return torch.Size(self._output_shape) @property def input_shape(self) -> torch.Size: return torch.Size(self._input_shape) class Conv2DStack(LudwigModule): def __init__( self, img_height: int, img_width: int, layers: list[dict] | None = None, num_layers: int | None = None, first_in_channels: int | None = None, default_out_channels: int = 256, default_kernel_size: int | tuple[int] = 3, default_stride: int | tuple[int] = 1, default_padding: int | tuple[int] | str = "valid", default_dilation: int | tuple[int] = 1, default_groups: int = 1, default_use_bias: bool = True, default_padding_mode: str = "zeros", default_norm: str | None = None, default_norm_params: dict[str, Any] | None = None, default_activation: str = "relu", default_dropout: int = 0, default_pool_function: int = "max", default_pool_kernel_size: int | tuple[int] = 2, default_pool_stride: int | tuple[int] = None, default_pool_padding: int | tuple[int] = 0, default_pool_dilation: int | tuple[int] = 1, ): super().__init__() # Confirm that all inputs are consistent first_in_channels = self._check_in_channels(first_in_channels, layers) default_pool_stride = default_pool_stride or default_pool_kernel_size if layers is not None and num_layers is not None: raise Warning("Both layers and num_layers are not None." "Default to using layers.") if ( first_in_channels is not None and layers is not None and len(layers) > 0 and "in_channels" in layers[0] and layers[0]["in_channels"] != first_in_channels ): raise Warning( "Input channels is set via layers[0]['in_channels'] and first_in_channels." "Default to using first_in_channels." ) self._input_shape = (first_in_channels, img_height, img_width) if layers is None: if num_layers is None: self.layers = [ {"out_channels": 32}, {"out_channels": 64}, ] else: self.layers = [] for i in range(num_layers): self.layers.append( { "kernel_size": default_kernel_size, "out_channels": default_out_channels, "pool_kernel_size": default_pool_kernel_size, } ) else: self.layers = layers for layer in self.layers: if "out_channels" not in layer: layer["out_channels"] = default_out_channels if "kernel_size" not in layer: layer["kernel_size"] = default_kernel_size if "stride" not in layer: layer["stride"] = default_stride if "padding" not in layer: layer["padding"] = default_padding if "dilation" not in layer: layer["dilation"] = default_dilation if "groups" not in layer: layer["groups"] = default_groups if "use_bias" not in layer: layer["use_bias"] = default_use_bias if "padding_mode" not in layer: layer["padding_mode"] = default_padding_mode if "norm" not in layer: layer["norm"] = default_norm if "norm_params" not in layer: layer["norm_params"] = default_norm_params if "activation" not in layer: layer["activation"] = default_activation if "dropout" not in layer: layer["dropout"] = default_dropout if "pool_function" not in layer: layer["pool_function"] = default_pool_function if "pool_kernel_size" not in layer: layer["pool_kernel_size"] = default_pool_kernel_size if "pool_stride" not in layer: layer["pool_stride"] = default_pool_stride if "pool_padding" not in layer: layer["pool_padding"] = default_pool_padding if "pool_dilation" not in layer: layer["pool_dilation"] = default_pool_dilation self.stack = torch.nn.ModuleList() in_channels = first_in_channels for i, layer in enumerate(self.layers): logger.debug(f" stack layer {i}") self.stack.append( Conv2DLayer( img_height=img_height, img_width=img_width, in_channels=in_channels, out_channels=layer["out_channels"], kernel_size=layer["kernel_size"], stride=layer["stride"], padding=layer["padding"], dilation=layer["dilation"], groups=layer["groups"], use_bias=layer["use_bias"], padding_mode=layer["padding_mode"], norm=layer["norm"], norm_params=layer["norm_params"], activation=layer["activation"], dropout=layer["dropout"], pool_function=layer["pool_function"], pool_kernel_size=layer["pool_kernel_size"], pool_stride=layer["pool_stride"], pool_padding=layer["pool_padding"], pool_dilation=layer["pool_dilation"], ) ) in_channels, img_height, img_width = self.stack[-1].output_shape self._output_shape = (in_channels, img_height, img_width) def forward(self, inputs): hidden = inputs for layer in self.stack: hidden = layer(hidden) return hidden def _check_in_channels(self, first_in_channels: int | None, layers: list[dict] | None) -> None: """Confirms that in_channels for first layer of the stack exists.""" if first_in_channels is not None: return first_in_channels elif layers is not None and len(layers) > 0 and "in_channels" in layers[0]: return layers[0]["in_channels"] raise ValueError( "In_channels for first layer should be specified either via " "`first_in_channels` or `layers` arguments." ) @property def output_shape(self) -> torch.Size: return torch.Size(self._output_shape) @property def input_shape(self) -> torch.Size: return torch.size(self._input_shape) class Conv2DLayerFixedPadding(LudwigModule): def __init__( self, img_height: int, img_width: int, in_channels: int, out_channels=256, kernel_size=3, stride=1, dilation=1, groups=1, use_bias=False, ): super().__init__() self.layers = torch.nn.ModuleList() self._input_shape = (in_channels, img_height, img_width) padding = "same" if stride > 1: padding = (kernel_size - 1) // 2 self.layers.append( nn.Conv2d( in_channels=in_channels, out_channels=out_channels, kernel_size=kernel_size, stride=stride, padding=padding, dilation=dilation, groups=groups, bias=use_bias, ) ) img_height, img_width = get_img_output_shape( img_height=img_height, img_width=img_width, kernel_size=kernel_size, stride=stride, padding=padding, dilation=dilation, ) for layer in self.layers: logger.debug(f" {layer._get_name()}") self._output_shape = (out_channels, img_height, img_width) def forward(self, inputs): hidden = inputs for layer in self.layers: hidden = layer(hidden) return hidden @property def input_shape(self) -> torch.Size: return torch.Size(self._input_shape) @property def output_shape(self) -> torch.Size: return torch.Size(self._output_shape) class ResNetBlock(LudwigModule): def __init__( self, img_height: int, img_width: int, first_in_channels: int, out_channels: int, stride: int = 1, batch_norm_momentum: float = 0.1, batch_norm_epsilon: float = 0.001, projection_shortcut: LudwigModule | None = None, ): """Resnet blocks used for ResNet34 and smaller. stride: A single int specifying the stride of the first convolution. The last convolution will have stride of 1. """ super().__init__() self._input_shape = (first_in_channels, img_height, img_width) self.conv1 = Conv2DLayerFixedPadding( img_height=img_height, img_width=img_width, in_channels=first_in_channels, out_channels=out_channels, kernel_size=3, stride=stride, ) in_channels, img_height, img_width = self.conv1.output_shape self.norm1 = nn.BatchNorm2d(num_features=in_channels, eps=batch_norm_epsilon, momentum=batch_norm_momentum) self.relu1 = get_activation("relu") self.conv2 = Conv2DLayerFixedPadding( img_height=img_height, img_width=img_width, in_channels=out_channels, out_channels=out_channels, kernel_size=3, stride=1, ) self.norm2 = nn.BatchNorm2d(num_features=out_channels, eps=batch_norm_epsilon, momentum=batch_norm_momentum) self.relu2 = get_activation("relu") for layer in [self.conv1, self.norm1, self.relu1, self.conv2, self.norm2, self.relu2]: logger.debug(f" {layer._get_name()}") self._output_shape = self.conv2.output_shape self.projection_shortcut = projection_shortcut if self.projection_shortcut is not None and self.projection_shortcut.output_shape != self._output_shape: raise ValueError( f"Output shapes of ResnetBlock and projection_shortcut should " f"match but are {self._output_shape} and " f"{self.projection_shortcut.output_shape} respectively." ) if self.projection_shortcut is None and self._input_shape != self._output_shape: self.projection_shortcut = Conv2DLayer( img_height=self._input_shape[1], img_width=self._input_shape[2], in_channels=first_in_channels, out_channels=out_channels, kernel_size=1, stride=stride, ) def forward(self, inputs): shortcut = inputs if self.projection_shortcut is not None: shortcut = self.projection_shortcut(shortcut) hidden = self.conv1(inputs) hidden = self.norm1(hidden) hidden = self.relu1(hidden) hidden = self.conv2(hidden) hidden = self.norm2(hidden) return self.relu2(hidden + shortcut) @property def input_shape(self) -> torch.Size: return torch.Size(self._input_shape) @property def output_shape(self) -> torch.Size: return torch.Size(self._output_shape) # TODO(shreya): Combine with ResNetBlock by adding a flag. class ResNetBottleneckBlock(LudwigModule): def __init__( self, img_height: int, img_width: int, first_in_channels: int, out_channels: int, stride: int = 1, batch_norm_momentum: float = 0.1, batch_norm_epsilon: float = 0.001, projection_shortcut: LudwigModule | None = None, ): """Resnet bottleneck blocks used for ResNet50 and larger. stride: A single int specifying the stride of the middle convolution. The first and last convolution will have stride of 1. """ super().__init__() self._input_shape = (first_in_channels, img_height, img_width) self.conv1 = Conv2DLayerFixedPadding( img_height=img_height, img_width=img_width, in_channels=first_in_channels, out_channels=out_channels, kernel_size=1, stride=1, ) in_channels, img_height, img_width = self.conv1.output_shape self.norm1 = nn.BatchNorm2d(num_features=in_channels, eps=batch_norm_epsilon, momentum=batch_norm_momentum) self.relu1 = get_activation("relu") self.conv2 = Conv2DLayerFixedPadding( img_height=img_height, img_width=img_width, in_channels=in_channels, out_channels=out_channels, kernel_size=3, stride=stride, ) in_channels, img_height, img_width = self.conv2.output_shape self.norm2 = nn.BatchNorm2d(num_features=in_channels, eps=batch_norm_epsilon, momentum=batch_norm_momentum) self.relu2 = get_activation("relu") self.conv3 = Conv2DLayerFixedPadding( img_height=img_height, img_width=img_width, in_channels=in_channels, out_channels=4 * out_channels, kernel_size=1, stride=1, ) self.norm3 = nn.BatchNorm2d(num_features=4 * out_channels, eps=batch_norm_epsilon, momentum=batch_norm_momentum) self.relu3 = get_activation("relu") for layer in [ self.conv1, self.norm1, self.relu1, self.conv2, self.norm2, self.relu2, self.conv3, self.norm3, self.relu3, ]: logger.debug(f" {layer._get_name()}") self._output_shape = self.conv3.output_shape self.projection_shortcut = projection_shortcut if self.projection_shortcut is not None and self.projection_shortcut.output_shape != self._output_shape: raise ValueError( f"Output shapes of ResnetBlock and projection_shortcut should " f"match but are {self._output_shape} and " f"{self.projection_shortcut.output_shape} respectively." ) if self.projection_shortcut is None and self._input_shape != self._output_shape: self.projection_shortcut = Conv2DLayer( img_height=self._input_shape[1], img_width=self._input_shape[2], in_channels=first_in_channels, out_channels=4 * out_channels, kernel_size=1, stride=stride, ) def forward(self, inputs): shortcut = inputs if self.projection_shortcut is not None: shortcut = self.projection_shortcut(shortcut) hidden = self.conv1(inputs) hidden = self.norm1(hidden) hidden = self.relu1(hidden) hidden = self.conv2(hidden) hidden = self.norm2(hidden) hidden = self.relu2(hidden) hidden = self.conv3(hidden) hidden = self.norm3(hidden) return self.relu3(hidden + shortcut) @property def output_shape(self) -> torch.Size: return torch.Size(self._output_shape) @property def input_shape(self) -> torch.Size: return torch.Size(self._input_shape) class ResNetBlockLayer(LudwigModule): def __init__( self, img_height: int, img_width: int, first_in_channels: int, out_channels: int, is_bottleneck: bool, block_fn: ResNetBlock | ResNetBottleneckBlock, num_blocks: int, stride: int | tuple[int] = 1, batch_norm_momentum: float = 0.1, batch_norm_epsilon: float = 0.001, ): super().__init__() self._input_shape = (first_in_channels, img_height, img_width) # Bottleneck blocks end with 4x the number of channels as they start with projection_out_channels = out_channels * 4 if is_bottleneck else out_channels projection_shortcut = Conv2DLayerFixedPadding( img_height=img_height, img_width=img_width, in_channels=first_in_channels, out_channels=projection_out_channels, kernel_size=1, stride=stride, ) self.layers = torch.nn.ModuleList( [ block_fn( img_height, img_width, first_in_channels, out_channels, stride, batch_norm_momentum, batch_norm_epsilon, projection_shortcut, ) ] ) in_channels, img_height, img_width = self.layers[-1].output_shape for _ in range(1, num_blocks): self.layers.append( block_fn( img_height=img_height, img_width=img_width, first_in_channels=in_channels, out_channels=out_channels, stride=1, batch_norm_momentum=batch_norm_momentum, batch_norm_epsilon=batch_norm_epsilon, ) ) in_channels, img_height, img_width = self.layers[-1].output_shape for layer in self.layers: logger.debug(f" {layer._get_name()}") self._output_shape = (in_channels, img_height, img_width) def forward(self, inputs): hidden = inputs for layer in self.layers: hidden = layer(hidden) return hidden @property def output_shape(self) -> torch.Size: return torch.Size(self._output_shape) @property def input_shape(self) -> torch.Size: return torch.Size(self._input_shape) class ResNet(LudwigModule): def __init__( self, img_height: int, img_width: int, first_in_channels: int, out_channels: int, resnet_size: int = 34, kernel_size: int | tuple[int] = 7, conv_stride: int | tuple[int] = 2, first_pool_kernel_size: int | tuple[int] = 3, first_pool_stride: int | tuple[int] = 2, block_sizes: list[int] = None, block_strides: list[int | tuple[int]] = None, batch_norm_momentum: float = 0.1, batch_norm_epsilon: float = 0.001, ): """Creates a model obtaining an image representation. Implements ResNet v2: Identity Mappings in Deep Residual Networks https://arxiv.org/pdf/1603.05027.pdf by Kaiming He, Xiangyu Zhang, Shaoqing Ren, and Jian Sun, Jul 2016. Args: resnet_size: A single integer for the size of the ResNet model. is_bottleneck: Use regular blocks or bottleneck blocks. out_channels: The number of filters to use for the first block layer of the model. This number is then doubled for each subsequent block layer. kernel_size: The kernel size to use for convolution. conv_stride: stride size for the initial convolutional layer first_pool_kernel_size: Pool size to be used for the first pooling layer. If none, the first pooling layer is skipped. first_pool_stride: stride size for the first pooling layer. Not used if first_pool_kernel_size is None. block_sizes: A list containing n values, where n is the number of sets of block layers desired. Each value should be the number of blocks in the i-th set. block_strides: List of integers representing the desired stride size for each of the sets of block layers. Should be same length as block_sizes. Raises: ValueError: if invalid version is selected. """ super().__init__() self._input_shape = (first_in_channels, img_height, img_width) is_bottleneck = self.get_is_bottleneck(resnet_size, block_sizes) block_class = self.get_block_fn(is_bottleneck) block_sizes, block_strides = self.get_blocks(resnet_size, block_sizes, block_strides) self.layers = torch.nn.ModuleList() self.layers.append( Conv2DLayerFixedPadding( img_height=img_height, img_width=img_width, in_channels=first_in_channels, out_channels=out_channels, kernel_size=kernel_size, stride=conv_stride, ) ) in_channels, img_height, img_width = self.layers[-1].output_shape self.layers.append( nn.BatchNorm2d(num_features=out_channels, eps=batch_norm_epsilon, momentum=batch_norm_momentum) ) self.layers.append(get_activation("relu")) if first_pool_kernel_size: self.layers.append(nn.MaxPool2d(kernel_size=first_pool_kernel_size, stride=first_pool_stride, padding=1)) img_height, img_width = get_img_output_shape( img_height=img_height, img_width=img_width, kernel_size=first_pool_kernel_size, stride=first_pool_stride, padding=1, dilation=1, ) for i, num_blocks in enumerate(block_sizes): self.layers.append( ResNetBlockLayer( img_height=img_height, img_width=img_width, first_in_channels=in_channels, out_channels=out_channels, is_bottleneck=is_bottleneck, block_fn=block_class, num_blocks=num_blocks, stride=block_strides[i], batch_norm_momentum=batch_norm_momentum, batch_norm_epsilon=batch_norm_epsilon, ) ) out_channels *= 2 in_channels, img_height, img_width = self.layers[-1].output_shape for layer in self.layers: logger.debug(f" {layer._get_name()}") self._output_shape = (in_channels, img_height, img_width) def get_is_bottleneck(self, resnet_size: int, block_sizes: list[int]) -> bool: if (resnet_size is not None and resnet_size >= 50) or (block_sizes is not None and sum(block_sizes) >= 16): return True return False def get_block_fn(self, is_bottleneck: bool) -> ResNetBlock | ResNetBottleneckBlock: if is_bottleneck: return ResNetBottleneckBlock return ResNetBlock def get_blocks(self, resnet_size: int, block_sizes: list[int], block_strides: list[int]) -> tuple[list[int]]: if block_sizes is None: block_sizes = get_resnet_block_sizes(resnet_size) if block_strides is None: block_strides = [1] + [2 for _ in range(len(block_sizes) - 1)] return block_sizes, block_strides def forward(self, inputs: torch.Tensor) -> torch.Tensor: hidden = inputs for layer in self.layers: hidden = layer(hidden) return hidden @property def output_shape(self) -> torch.Size: return torch.Size(self._output_shape) @property def input_shape(self) -> torch.Size: return torch.Size(self._input_shape) ################################################################################ # The following code for ResNet is adapted from the TensorFlow implementation # https://github.com/tensorflow/models/blob/master/official/resnet/resnet_model.py ################################################################################ ################################################################################ # Convenience functions for building the ResNet model. ################################################################################ resnet_choices = { 8: [1, 2, 2], 14: [1, 2, 2], 18: [2, 2, 2, 2], 34: [3, 4, 6, 3], 50: [3, 4, 6, 3], 101: [3, 4, 23, 3], 152: [3, 8, 36, 3], 200: [3, 24, 36, 3], } def get_resnet_block_sizes(resnet_size): """Retrieve the size of each block_layer in the ResNet model. The number of block layers used for the Resnet model varies according to the size of the model. This helper grabs the layer set we want, throwing an error if a non-standard size has been selected. Args: resnet_size: The number of convolutional layers needed in the model. Returns: A list of block sizes to use in building the model. Raises: KeyError: if invalid resnet_size is received. """ try: return resnet_choices[resnet_size] except KeyError: err = "Could not find layers for selected Resnet size.\n" "Size received: {}; sizes allowed: {}.".format( resnet_size, resnet_choices.keys() ) raise ValueError(err) class UNetDoubleConvLayer(LudwigModule): def __init__( self, img_height: int, img_width: int, in_channels: int, out_channels: int, norm: str = None, ): """Two Conv2d layers, each followed by a ReLU, used for U-Net. Args: img_height: the input image height img_width: the input image width in_channels: the number of input channels out_channels: the number of output channels norm: the normalization to be applied """ super().__init__() self.layers = nn.ModuleList() self.layers.append(nn.Conv2d(in_channels, out_channels, kernel_size=3, padding=1)) if norm == "batch": self.layers.append(nn.BatchNorm2d(out_channels)) self.layers.append(nn.ReLU()) self.layers.append(nn.Conv2d(out_channels, out_channels, kernel_size=3, padding=1)) if norm == "batch": self.layers.append(nn.BatchNorm2d(out_channels)) self.layers.append(nn.ReLU()) self._input_shape = (in_channels, img_height, img_width) self._output_shape = (out_channels, img_height, img_width) def forward(self, inputs): hidden = inputs for layer in self.layers: hidden = layer(hidden) return hidden @property def output_shape(self) -> torch.Size: return torch.Size(self._output_shape) @property def input_shape(self) -> torch.Size: return torch.Size(self._input_shape) class UNetDownStack(LudwigModule): def __init__( self, img_height: int, img_width: int, in_channels: int, norm: str = None, stack_depth: int = 4, ): """Creates the contracting downsampling path of a U-Net stack. Implements U-Net: Convolutional Networks for Biomedical Image Segmentation https://arxiv.org/abs/1505.04597 by Olaf Ronneberger, Philipp Fischer, Thomas Brox, May 2015. Args: img_height: the input image height img_width: the input image width in_channels: the number of input channels norm: the normalization to be applied stack_depth: the depth of the unet stack """ super().__init__() self.conv_layers = nn.ModuleList() self.down_layers = nn.ModuleList() height = img_height width = img_width in_c = in_channels out_c = 64 self._input_shape = (in_c, height, width) for i in range(stack_depth): self.conv_layers.append(UNetDoubleConvLayer(height, width, in_c, out_c, norm)) in_c = out_c out_c = out_c * 2 self.down_layers.append(nn.MaxPool2d(kernel_size=2, stride=2)) height = height // 2 width = width // 2 self.bottleneck = UNetDoubleConvLayer(height, width, in_c, out_c, norm) self._output_shape = (out_c, height, width) def forward(self, inputs): skips = [] # skip connections hidden = inputs for conv_layer, down_layer in zip(self.conv_layers, self.down_layers): hidden = conv_layer(hidden) skips.append(hidden) hidden = down_layer(hidden) hidden = self.bottleneck(hidden) return hidden, skips @property def output_shape(self) -> torch.Size: return torch.Size(self._output_shape) @property def input_shape(self) -> torch.Size: return torch.Size(self._input_shape) class UNetUpStack(LudwigModule): def __init__( self, img_height: int, img_width: int, out_channels: int, norm: str = None, stack_depth: int = 4, ): """Creates the expansive upsampling path of a U-Net stack. Implements U-Net: Convolutional Networks for Biomedical Image Segmentation https://arxiv.org/abs/1505.04597 by Olaf Ronneberger, Philipp Fischer, Thomas Brox, May 2015. Args: img_height: the output image height img_width: the output image width out_channels: the number of output classes norm: the normalization to be applied stack_depth: the depth of the unet stack """ super().__init__() self.conv_layers = nn.ModuleList() self.up_layers = nn.ModuleList() height = img_height >> stack_depth width = img_width >> stack_depth in_c = 64 << stack_depth out_c = in_c // 2 self._input_shape = (in_c, height, width) for i in range(stack_depth): self.up_layers.append(nn.ConvTranspose2d(in_c, out_c, kernel_size=2, stride=2)) height = height * 2 width = width * 2 self.conv_layers.append(UNetDoubleConvLayer(height, width, out_c * 2, out_c, norm)) in_c = out_c out_c = out_c // 2 self.last_conv = nn.Conv2d(in_c, out_channels, kernel_size=1, padding=0) self._output_shape = (out_channels, img_height, img_width) def forward(self, inputs, skips): hidden = inputs for conv_layer, up_layer in zip(self.conv_layers, self.up_layers): hidden = up_layer(hidden) skip = skips.pop() hidden = torch.cat([hidden, skip], axis=1) hidden = conv_layer(hidden) hidden = self.last_conv(hidden) return hidden @property def output_shape(self) -> torch.Size: return torch.Size(self._output_shape) @property def input_shape(self) -> torch.Size: return torch.Size(self._input_shape) ================================================ FILE: ludwig/modules/embedding_modules.py ================================================ # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import logging import torch from torch import nn from ludwig.constants import TYPE from ludwig.modules.initializer_modules import get_initializer from ludwig.utils.data_utils import load_pretrained_embeddings from ludwig.utils.torch_utils import get_torch_device, LudwigModule logger = logging.getLogger(__name__) DEVICE = get_torch_device() def embedding_matrix( vocab: list[str], embedding_size: int, representation: str = "dense", embeddings_trainable: bool = True, pretrained_embeddings: str | None = None, force_embedding_size: bool = False, embedding_initializer: str | dict | None = None, ) -> tuple[nn.Module, int]: """Returns initialized torch.nn.Embedding module and embedding size.""" vocab_size = len(vocab) if representation == "dense": if pretrained_embeddings: embeddings_matrix = load_pretrained_embeddings(pretrained_embeddings, vocab) if embeddings_matrix.shape[-1] != embedding_size: if not force_embedding_size: embedding_size = embeddings_matrix.shape[-1] logger.info(f"Setting embedding size to be equal to {embeddings_matrix.shape[-1]}.") else: raise ValueError( f"The size of the pretrained embeddings is " f"{embeddings_matrix.shape[-1]}, but the specified " f"embedding_size is {embedding_size}. Please change " f"the embedding_size accordingly." ) embedding_initializer_obj = torch.tensor(embeddings_matrix, dtype=torch.float32) else: if vocab_size < embedding_size and not force_embedding_size: logger.info( f" embedding_size ({embedding_size}) is greater than " f"vocab_size ({vocab_size}). Setting embedding size to be " f"equal to vocab_size." ) embedding_size = vocab_size if embedding_initializer is not None: embedding_initializer_obj_ref = get_initializer(embedding_initializer) else: embedding_initializer_obj_ref = get_initializer({TYPE: "uniform", "a": -1.0, "b": 1.0}) embedding_initializer_obj = embedding_initializer_obj_ref([vocab_size, embedding_size]) embeddings = embedding_initializer_obj elif representation == "sparse": embedding_size = vocab_size embeddings = get_initializer("identity")([vocab_size, embedding_size]) embeddings.requires_grad = False else: raise Exception(f"Embedding representation {representation} not supported.") embeddings = nn.Embedding.from_pretrained(embeddings, freeze=not embeddings_trainable) return embeddings, embedding_size def embedding_matrix_on_device( vocab: list[str], embedding_size: int, representation: str = "dense", embeddings_trainable: bool = True, pretrained_embeddings: str | None = None, force_embedding_size: bool = False, embeddings_on_cpu: bool = False, embedding_initializer: str | None = None, ) -> tuple[nn.Module, int]: embeddings, embedding_size = embedding_matrix( vocab, embedding_size, representation=representation, embeddings_trainable=embeddings_trainable, pretrained_embeddings=pretrained_embeddings, force_embedding_size=force_embedding_size, embedding_initializer=embedding_initializer, ) if embeddings_on_cpu: embeddings.to("cpu") elif not embeddings_on_cpu and torch.cuda.is_available(): embeddings.to(device="cuda") return embeddings, embedding_size class Embed(LudwigModule): """Module to embed Category, Date, and H3 data types.""" def __init__( self, vocab: list[str], embedding_size: int, representation: str = "dense", embeddings_trainable: bool = True, pretrained_embeddings: str | None = None, force_embedding_size: bool = False, embeddings_on_cpu: bool = False, dropout: float = 0.0, embedding_initializer: str | dict | None = None, ): super().__init__() self.supports_masking = True self.vocab_size = len(vocab) self.embeddings, self.embedding_size = embedding_matrix_on_device( vocab, embedding_size, representation=representation, embeddings_trainable=embeddings_trainable, pretrained_embeddings=pretrained_embeddings, force_embedding_size=force_embedding_size, embeddings_on_cpu=embeddings_on_cpu, embedding_initializer=embedding_initializer, ) if dropout > 0: self.dropout = torch.nn.Dropout(p=dropout) else: self.dropout = None def forward(self, inputs: torch.Tensor, mask: torch.Tensor | None = None) -> torch.Tensor: if inputs.ndim != 2 or inputs.shape[1] != 1: raise RuntimeError( f"Embed only takes inputs of shape [batch x 1]. Received inputs with size: {inputs.size()}" ) embedded = self.embeddings(inputs.long()) embedded = torch.squeeze(embedded, dim=1) if self.dropout: embedded = self.dropout(embedded) return embedded @property def input_shape(self) -> torch.Size: return torch.Size([1]) @property def output_shape(self) -> torch.Size: return torch.Size([self.embedding_size]) class EmbedSet(LudwigModule): """Module to embed Set data types, works on multi-hot encoded input.""" def __init__( self, vocab: list[str], embedding_size: int, representation: str = "dense", embeddings_trainable: bool = True, pretrained_embeddings: str | None = None, force_embedding_size: bool = False, embeddings_on_cpu: bool = False, dropout: float = 0.0, embedding_initializer: str | dict | None = None, aggregation_function: str = "sum", ): super().__init__() self.supports_masking = True self.vocab_size = len(vocab) self.embeddings, self.embedding_size = embedding_matrix_on_device( vocab, embedding_size, representation=representation, embeddings_trainable=embeddings_trainable, pretrained_embeddings=pretrained_embeddings, force_embedding_size=force_embedding_size, embeddings_on_cpu=embeddings_on_cpu, embedding_initializer=embedding_initializer, ) if dropout > 0: self.dropout = torch.nn.Dropout(p=dropout) else: self.dropout = None if aggregation_function == "sum": self.aggregation_function = torch.sum elif aggregation_function == "avg": self.aggregation_function = torch.mean else: raise ValueError(f"Unsupported aggregation function {aggregation_function}") self.register_buffer("vocab_indices", torch.arange(self.vocab_size)) def forward(self, inputs: torch.Tensor, mask: torch.Tensor | None = None) -> torch.Tensor: """ Params: inputs: Boolean multi-hot tensor of size [batch x vocab_size], where inputs[b, i] indicates that token i is present in sample b. """ # Convert multi-hot input to input of indices inputs = inputs.int() * self.vocab_indices embedded = self.embeddings(inputs.long()) # Mask out the 0th embedding mask = torch.unsqueeze(inputs, -1) embedded = embedded * mask # Sum over all positive tokens embedded = self.aggregation_function(embedded, dim=1) if self.dropout: embedded = self.dropout(embedded) return embedded @property def input_shape(self) -> torch.Size: return torch.Size([self.vocab_size]) @property def output_shape(self) -> torch.Size: return torch.Size([self.embedding_size]) @property def input_dtype(self): return torch.bool class EmbedWeighted(LudwigModule): """Module to embed Bag data type, works on input of token frequencies.""" def __init__( self, vocab: list[str], embedding_size: int, representation: str = "dense", embeddings_trainable: bool = True, pretrained_embeddings: str | None = None, force_embedding_size: bool = False, embeddings_on_cpu: bool = False, dropout: float = 0.0, embedding_initializer: str | None = None, ): super().__init__() self.embeddings, self.embedding_size = embedding_matrix_on_device( vocab, embedding_size, representation=representation, embeddings_trainable=embeddings_trainable, pretrained_embeddings=pretrained_embeddings, force_embedding_size=force_embedding_size, embeddings_on_cpu=embeddings_on_cpu, embedding_initializer=embedding_initializer, ) self.vocab_size = len(vocab) if dropout > 0: self.dropout = nn.Dropout(dropout) else: self.dropout = None self.register_buffer("vocab_indices", torch.arange(self.vocab_size, dtype=torch.int32)) def forward(self, inputs: torch.Tensor, mask: torch.Tensor | None = None) -> torch.Tensor: """ Params: inputs: Tensor of frequencies, where inputs[b, i] represents frequency of token i in sample b of batch. """ # Convert to multi-hot input signed_input = (inputs != 0).type(torch.int32) multiple_hot_indexes = signed_input * self.vocab_indices embedded = self.embeddings(multiple_hot_indexes) # Mask out the 0th embedding mask = torch.unsqueeze(inputs, -1) weighted_embedded = embedded * mask # Sum over the all the positive indices embedded_reduced = torch.sum(weighted_embedded, dim=1) if self.dropout: embedded_reduced = self.dropout(embedded_reduced) return embedded_reduced @property def input_shape(self) -> torch.Size: return torch.Size([self.vocab_size]) @property def output_shape(self) -> torch.Size: return torch.Size([self.embedding_size]) class EmbedSequence(LudwigModule): def __init__( self, vocab: list[str], embedding_size: int, max_sequence_length: int, representation: str = "dense", embeddings_trainable: bool = True, pretrained_embeddings: str | None = None, force_embedding_size: bool = False, embeddings_on_cpu: bool = False, dropout: float = 0.0, embedding_initializer: str | None = None, ): super().__init__() self.supports_masking = True self.vocab_size = len(vocab) self.max_sequence_length = max_sequence_length self.embeddings, self.embedding_size = embedding_matrix_on_device( vocab, embedding_size, representation=representation, embeddings_trainable=embeddings_trainable, pretrained_embeddings=pretrained_embeddings, force_embedding_size=force_embedding_size, embeddings_on_cpu=embeddings_on_cpu, embedding_initializer=embedding_initializer, ) if dropout > 0: self.dropout = nn.Dropout(dropout) else: self.dropout = None def forward(self, inputs: torch.Tensor, mask: torch.Tensor | None = None): if inputs.dtype not in [torch.int, torch.long]: raise RuntimeError( f"Expected tensor of type torch.int or torch.long as input." f"Received {inputs.dtype} instead." ) embedded = self.embeddings(inputs) if self.dropout: embedded = self.dropout(embedded) return embedded @property def input_shape(self) -> torch.Size: return torch.Size([self.max_sequence_length]) @property def output_shape(self) -> torch.Size: return torch.Size([self.max_sequence_length, self.embedding_size]) class TokenAndPositionEmbedding(LudwigModule): def __init__( self, max_sequence_length, vocab, embedding_size, representation="dense", embeddings_trainable=True, pretrained_embeddings=None, force_embedding_size=False, embeddings_on_cpu=False, dropout=0.0, embedding_initializer=None, ): super().__init__() self.max_sequence_length = max_sequence_length self.embedding_size = embedding_size self.token_embed = EmbedSequence( vocab=vocab, embedding_size=embedding_size, max_sequence_length=max_sequence_length, representation=representation, embeddings_trainable=embeddings_trainable, pretrained_embeddings=pretrained_embeddings, force_embedding_size=force_embedding_size, embeddings_on_cpu=embeddings_on_cpu, dropout=dropout, embedding_initializer=embedding_initializer, ) self.position_embed = nn.Embedding( num_embeddings=max_sequence_length, embedding_dim=self.token_embed.embedding_size ) self.register_buffer("positions", torch.arange(0, max_sequence_length)) @property def input_shape(self) -> torch.Size: return torch.Size([self.max_sequence_length]) @property def output_shape(self) -> torch.Size: return self.token_embed.output_shape def forward(self, inputs, mask: torch.Tensor | None = None): positions_hidden = self.position_embed(self.positions) token_hidden = self.token_embed(inputs) return token_hidden + positions_hidden ================================================ FILE: ludwig/modules/fully_connected_modules.py ================================================ # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import logging from copy import deepcopy import torch from torch.nn import Dropout, Linear, ModuleList from ludwig.modules.normalization_modules import create_norm_layer from ludwig.utils.torch_utils import activations, initializer_registry, LudwigModule logger = logging.getLogger(__name__) class FCLayer(LudwigModule): """A torch.nn.Linear wrapper that declares input and output shapes, and enables the customization of: 1. how weights and biases are initialized 2. normalization (layer and batch) 3. activations 4. dropout """ def __init__( self, input_size: int, input_rank: int = 2, output_size: int = 256, use_bias: bool = True, weights_initializer: str = "xavier_uniform", bias_initializer: str = "zeros", norm: str | None = None, norm_params: dict | None = None, activation: str = "relu", dropout: float = 0, ): super().__init__() self.layers = ModuleList() self.input_size = input_size self.output_size = output_size fc = Linear(in_features=input_size, out_features=output_size, bias=use_bias) self.layers.append(fc) weights_initializer = initializer_registry[weights_initializer] weights_initializer(fc.weight) if use_bias: bias_initializer = initializer_registry[bias_initializer] bias_initializer(fc.bias) if norm is not None: norm_params = norm_params or {} self.layers.append(create_norm_layer(norm, input_rank, output_size, **norm_params)) # Dict for activation objects in pytorch? self.layers.append(activations[activation]()) if dropout > 0: self.layers.append(Dropout(dropout)) def forward(self, inputs, mask=None): hidden = inputs for layer in self.layers: hidden = layer(hidden) return hidden @property def input_shape(self) -> torch.Size: return torch.Size([self.input_size]) @property def output_shape(self) -> torch.Size: return torch.Size([self.output_size]) class FCStack(LudwigModule): """A stack of FCLayers. The specification of each FCLayer is specified by the `layers` dictionary parameter, whose keys correspond with an FCLayer's constructor arguments, i.e. [ {"input_size": 2, "output_size": 4}, {"output_size": 4, "use_bias": False}, ] `default_*` parameters dictate default values to use for each FCLayer, if not specified by `layers`. If `layers` is `None`, then a stack of size `num_layers` of `FCLayer`s configured with all of the `default_*` parameters is used. If `layers` is None and `num_layers` is 0, then there are no fully connected layers and this module serves as a trivial passthrough. """ def __init__( self, first_layer_input_size: int, layers: list[dict] | None = None, num_layers: int = 1, default_input_rank: int = 2, default_output_size: int = 256, default_use_bias: bool = True, default_weights_initializer: str = "xavier_uniform", default_bias_initializer: str = "zeros", default_norm: str | None = None, default_norm_params: dict | None = None, default_activation: str = "relu", default_dropout: float = 0, residual: bool = False, **kwargs, ): super().__init__() self.input_size = first_layer_input_size self.norm_layer = None if default_norm is not None: norm_params = default_norm_params or {} self.norm_layer = create_norm_layer(default_norm, default_input_rank, self.input_size, **norm_params) self.dropout = None if default_dropout > 0: self.dropout = torch.nn.Dropout(default_dropout) if layers is None: self.layers = [] for i in range(num_layers): self.layers.append({}) else: # deep copy the layer definitions so that we don't modify the original self.layers = deepcopy(layers) if len(self.layers) > 0 and "input_size" not in self.layers[0]: self.layers[0]["input_size"] = first_layer_input_size for i, layer in enumerate(self.layers): if i != 0: layer["input_size"] = self.layers[i - 1]["output_size"] if "input_rank" not in layer: layer["input_rank"] = default_input_rank if "output_size" not in layer: layer["output_size"] = default_output_size if "use_bias" not in layer: layer["use_bias"] = default_use_bias if "weights_initializer" not in layer: layer["weights_initializer"] = default_weights_initializer if "bias_initializer" not in layer: layer["bias_initializer"] = default_bias_initializer if "norm" not in layer: layer["norm"] = default_norm if "norm_params" not in layer: layer["norm_params"] = default_norm_params if "activation" not in layer: layer["activation"] = default_activation if "dropout" not in layer: layer["dropout"] = default_dropout self.stack = ModuleList() for i, layer in enumerate(self.layers): self.stack.append( FCLayer( input_size=layer["input_size"], input_rank=layer["input_rank"], output_size=layer["output_size"], use_bias=layer["use_bias"], weights_initializer=layer["weights_initializer"], bias_initializer=layer["bias_initializer"], norm=layer["norm"], norm_params=layer["norm_params"], activation=layer["activation"], dropout=layer["dropout"], ) ) self.residual = residual def forward(self, inputs, mask=None): hidden = inputs if self.norm_layer is not None: hidden = self.norm_layer(hidden) if self.dropout is not None: hidden = self.dropout(hidden) prev_fc_layer_size = self.input_size for layer in self.stack: out = layer(hidden) if self.residual and layer.output_size == prev_fc_layer_size: hidden = hidden + out else: hidden = out prev_fc_layer_size = layer.layers[0].out_features return hidden @property def num_layers(self) -> int: return len(self.layers) @property def input_shape(self) -> torch.Size: return torch.Size([self.input_size]) @property def output_shape(self) -> torch.Size: if len(self.stack) > 0: return self.stack[-1].output_shape return torch.Size([self.input_size]) ================================================ FILE: ludwig/modules/initializer_modules.py ================================================ # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import torch from ludwig.constants import TYPE from ludwig.utils.misc_utils import get_from_registry from ludwig.utils.torch_utils import initializer_registry def _create_and_init(init_fn, init_kwargs, *args, **kwargs): t = torch.empty(*args, **kwargs) init_fn(t, **init_kwargs) return t def get_initializer(parameters): if parameters is None: return lambda *args, **kwargs: _create_and_init(initializer_registry[parameters], {}, *args, **kwargs) elif isinstance(parameters, str): initializer_fun = get_from_registry(parameters, initializer_registry) return lambda *args, **kwargs: _create_and_init(initializer_fun, {}, *args, **kwargs) elif isinstance(parameters, dict): initializer_fun = get_from_registry(parameters[TYPE], initializer_registry) init_kwargs = parameters.copy() del init_kwargs[TYPE] return lambda *args, **kwargs: _create_and_init(initializer_fun, init_kwargs, *args, **kwargs) else: raise ValueError( f"Initializers parameters should be either strings or dictionaries, " f"but the provided parameters are a {type(parameters)}. " f"Parameters values: {parameters}" ) ================================================ FILE: ludwig/modules/loss_implementations/__init__.py ================================================ ================================================ FILE: ludwig/modules/loss_implementations/corn.py ================================================ # Source: https://github.com/Raschka-research-group/coral-pytorch/blob/main/coral_pytorch/losses.py # Sebastian Raschka 2020-2021 # coral_pytorch # Author: Sebastian Raschka # # License: MIT import torch import torch.nn.functional as F def corn_loss(logits, y_train, num_classes): """Computes the CORN loss described in our forthcoming 'Deep Neural Networks for Rank Consistent Ordinal Regression based on Conditional Probabilities' manuscript. Parameters ---------- logits : torch.tensor, shape=(num_examples, num_classes-1) Outputs of the CORN layer. y_train : torch.tensor, shape=(num_examples) Torch tensor containing the class labels. num_classes : int Number of unique class labels (class labels should start at 0). Returns ---------- loss : torch.tensor A torch.tensor containing a single loss value. Examples ---------- >>> # Consider 8 training examples >>> _ = torch.manual_seed(123) >>> X_train = torch.rand(8, 99) >>> y_train = torch.tensor([0, 1, 2, 2, 2, 3, 4, 4]) >>> NUM_CLASSES = 5 >>> # >>> # >>> # def __init__(self): >>> corn_net = torch.nn.Linear(99, NUM_CLASSES-1) >>> # >>> # >>> # def forward(self, X_train): >>> logits = corn_net(X_train) >>> logits.shape torch.Size([8, 4]) >>> corn_loss(logits, y_train, NUM_CLASSES) tensor(0.7127, grad_fn=) """ sets = [] for i in range(num_classes - 1): label_mask = y_train > i - 1 label_tensor = (y_train[label_mask] > i).to(torch.int64) sets.append((label_mask, label_tensor)) num_examples = 0 losses = 0.0 for task_index, s in enumerate(sets): train_examples = s[0] train_labels = s[1] if len(train_labels) < 1: continue num_examples += len(train_labels) pred = logits[train_examples, task_index] loss = -torch.sum(F.logsigmoid(pred) * train_labels + (F.logsigmoid(pred) - pred) * (1 - train_labels)) losses += loss return losses / num_examples ================================================ FILE: ludwig/modules/loss_modules.py ================================================ # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import torch from torch import nn, Tensor from torch.nn import HuberLoss as _HuberLoss from torch.nn import L1Loss from torch.nn import MSELoss as _MSELoss from torchmetrics.functional import mean_absolute_percentage_error import ludwig.utils.loss_utils as utils from ludwig.constants import LOGITS from ludwig.modules.loss_implementations.corn import corn_loss from ludwig.schema.features.loss.loss import ( BaseLossConfig, BWCEWLossConfig, CORNLossConfig, HuberLossConfig, MAELossConfig, MAPELossConfig, MSELossConfig, NextTokenSoftmaxCrossEntropyLossConfig, RMSELossConfig, RMSPELossConfig, SequenceSoftmaxCrossEntropyLossConfig, SigmoidCrossEntropyLossConfig, SoftmaxCrossEntropyLossConfig, ) from ludwig.utils import strings_utils from ludwig.utils.registry import Registry # used for Laplace smoothing for candidate samplers EPSILON = 1.0e-10 loss_impl_registry = Registry[type[nn.Module]]() def register_loss(config_cls: type[BaseLossConfig]): def wrap(cls: type[nn.Module]): loss_impl_registry[config_cls] = cls return cls return wrap def create_loss(config: BaseLossConfig) -> nn.Module: return loss_impl_registry[type(config)](config) class LogitsInputsMixin: @classmethod def get_loss_inputs(cls): """Maps loss to the desired predicted input type.""" return LOGITS @register_loss(MSELossConfig) class MSELoss(_MSELoss, LogitsInputsMixin): """Mean squared error.""" def __init__(self, config: MSELossConfig): super().__init__() @register_loss(MAELossConfig) class MAELoss(L1Loss, LogitsInputsMixin): """Mean absolute error.""" def __init__(self, config: MAELossConfig): super().__init__() @register_loss(MAPELossConfig) class MAPELoss(nn.Module, LogitsInputsMixin): """Mean absolute error.""" def __init__(self, config: MAPELossConfig): super().__init__() def forward(self, preds: Tensor, target: Tensor) -> Tensor: return mean_absolute_percentage_error(preds, target) @register_loss(RMSELossConfig) class RMSELoss(nn.Module, LogitsInputsMixin): """Root mean square error.""" def __init__(self, config: RMSELossConfig): super().__init__() self.mse = nn.MSELoss() def forward(self, preds: Tensor, target: Tensor) -> Tensor: return torch.sqrt(self.mse(preds, target)) @register_loss(RMSPELossConfig) class RMSPELoss(nn.Module, LogitsInputsMixin): """Root mean square percentage error.""" def __init__(self, config: RMSPELossConfig): super().__init__() def forward(self, preds: Tensor, target: Tensor) -> Tensor: loss = utils.rmspe_loss(target, preds) return loss @register_loss(BWCEWLossConfig) class BWCEWLoss(nn.Module, LogitsInputsMixin): """Binary weighted cross entropy loss.""" def __init__(self, config: BWCEWLossConfig): super().__init__() if config.positive_class_weight: self.loss_fn = nn.BCEWithLogitsLoss(pos_weight=torch.Tensor([config.positive_class_weight])) else: self.loss_fn = nn.BCEWithLogitsLoss(pos_weight=config.positive_class_weight) self.robust_lambda = config.robust_lambda self.confidence_penalty = config.confidence_penalty def forward(self, preds: torch.Tensor, target: torch.Tensor): train_loss = self.loss_fn(preds, target.float()) # robust lambda if self.robust_lambda > 0: train_loss = (1 - self.robust_lambda) * train_loss + self.robust_lambda / 2 train_mean_loss = torch.mean(train_loss) # confidence penalty if self.confidence_penalty > 0: probabilities = torch.sigmoid(preds) mean_penalty = utils.mean_confidence_penalty(probabilities, 2) train_mean_loss += self.confidence_penalty * mean_penalty return train_mean_loss @register_loss(SoftmaxCrossEntropyLossConfig) class SoftmaxCrossEntropyLoss(nn.Module, LogitsInputsMixin): def __init__(self, config: SoftmaxCrossEntropyLossConfig): """ Params: class_weights: List or 1D tensor of length equal to number of classes. """ super().__init__() if config.class_weights: self.loss_fn = nn.CrossEntropyLoss(weight=torch.Tensor(config.class_weights)) else: self.loss_fn = nn.CrossEntropyLoss() def forward(self, preds: Tensor, target: Tensor) -> Tensor: """ Params: preds: Tensor of shape [batch x num_classes] or shape [batch x num_classes x H x W] target: Tensor of shape [batch], where each element is integral between 0 and num_classes. or shape [batch x H x W], where each element is integral between 0 and num_classes. """ if len(target.shape) == 1 or len(target.shape) == 3: # Assumes we are providing the target as a single class, rather than a distribution # The target shape can be a 3D tensor [batch x H x W], for image segmentation target = target.long() return self.loss_fn(preds, target) @register_loss(SequenceSoftmaxCrossEntropyLossConfig) class SequenceSoftmaxCrossEntropyLoss(nn.Module, LogitsInputsMixin): def __init__(self, config: SequenceSoftmaxCrossEntropyLossConfig): """ Params: class_weights: List or 1D tensor of length equal to number of classes. """ super().__init__() if config.class_weights: self.loss_fn = nn.CrossEntropyLoss( weight=torch.Tensor(config.class_weights), ignore_index=strings_utils.SpecialSymbol.PADDING.value ) else: self.loss_fn = nn.CrossEntropyLoss(ignore_index=strings_utils.SpecialSymbol.PADDING.value) def forward(self, preds: Tensor, target: Tensor) -> Tensor: """ Params: preds: Tensor of shape [batch x sequence_length x vocab_size] target: Tensor of shape [batch x sequence_length], where each element is integral between 0 and vocab_size. """ target = target.long() return self.loss_fn(preds[1:].view(-1, preds.size(-1)), target[1:].view(-1)) @register_loss(NextTokenSoftmaxCrossEntropyLossConfig) class NextTokenSoftmaxCrossEntropyLoss(nn.Module, LogitsInputsMixin): def __init__(self, config: NextTokenSoftmaxCrossEntropyLossConfig): super().__init__() self.loss_fn = nn.CrossEntropyLoss() def forward(self, preds: Tensor, target: Tensor) -> Tensor: """ Params: preds: Tensor of shape [batch x sequence_length x vocab_size] target: Tensor of shape [batch x sequence_length], where each element is integral between 0 and vocab_size. Reference implementation: https://github.com/huggingface/transformers/blob/v4.29.1/src/transformers/models/bert/modeling_bert.py#LL1253C1-L1260C1 # noqa """ target = target.long() _, _, vocab_size = preds.shape # logits for all tensors except n+1 since each logit tensor at position i represents the log probabilities for # the next token i+1 if we were to do argmax on the logits ensor at position i. shifted_predictions = preds[:, :-1, :] # Shift by 1 since the logits at position 0 in predictions represent the log likelihood of target token 1 shifted_targets = target[:, 1:] return self.loss_fn(shifted_predictions.reshape(-1, vocab_size), shifted_targets.reshape(-1)) @register_loss(SigmoidCrossEntropyLossConfig) class SigmoidCrossEntropyLoss(nn.Module, LogitsInputsMixin): def __init__(self, config: SigmoidCrossEntropyLossConfig): """ Params: class_weights: List or 1D tensor of length equal to number of classes. """ super().__init__() if config.class_weights: self.loss_fn = nn.BCEWithLogitsLoss(pos_weight=torch.Tensor(config.class_weights)) else: self.loss_fn = nn.BCEWithLogitsLoss() def forward(self, preds: Tensor, target: Tensor) -> Tensor: if preds.ndim != 2: raise RuntimeError("SigmoidCrossEntropyLoss currently only supported for 2D tensors.") return self.loss_fn(preds.type(torch.float32), target.type(torch.float32)) @register_loss(HuberLossConfig) class HuberLoss(_HuberLoss, LogitsInputsMixin): """Huber loss.""" def __init__(self, config: HuberLossConfig): super().__init__(delta=config.delta) @register_loss(CORNLossConfig) class CORNLoss(nn.Module, LogitsInputsMixin): """CORN loss.""" def __init__(self, config: CORNLossConfig): super().__init__() def forward(self, preds: Tensor, target: Tensor) -> Tensor: num_classes = preds.shape[1] return corn_loss(preds, target, num_classes=num_classes) ================================================ FILE: ludwig/modules/lr_scheduler.py ================================================ import logging import math from collections.abc import Callable from typing import Any from torch.optim import Optimizer from torch.optim.lr_scheduler import CosineAnnealingWarmRestarts, LambdaLR, ReduceLROnPlateau, SequentialLR from ludwig.constants import MINIMIZE, TRAINING, VALIDATION from ludwig.modules.metric_registry import get_metric_objective from ludwig.schema.lr_scheduler import LRSchedulerConfig from ludwig.utils.metric_utils import TrainerMetric from ludwig.utils.trainer_utils import ProgressTracker logger = logging.getLogger(__name__) class ReduceLROnPLateauCappedDecreases(ReduceLROnPlateau): def __init__(self, optimizer: Optimizer, mode: str, reduce_limit: int, factor: float, patience: int): super().__init__(optimizer, mode=mode, factor=factor, patience=patience) self.reduce_limit = reduce_limit self._num_reduce_lr = 0 def step(self, metrics): if self._num_reduce_lr >= self.reduce_limit: # Already reduced the LR as many times as we will allow return return super().step(metrics) @property def num_reduce_lr(self) -> int: return self._num_reduce_lr def _reduce_lr(self, epoch=None): """Overrides the base ReduceLROnPlateau implementation.""" self._num_reduce_lr += 1 self.apply_lr() def apply_lr(self): if self._num_reduce_lr == 0: return for i, param_group in enumerate(self.optimizer.param_groups): old_lr = float(param_group["lr"]) new_lr = max(old_lr * math.pow(self.factor, self._num_reduce_lr), self.min_lrs[i]) if old_lr - new_lr > self.eps: param_group["lr"] = new_lr logger.info(f"From ReduceLROnPLateauCappedDecreases, reducing learning rate to {new_lr}") class LRScheduler: def __init__( self, config: LRSchedulerConfig, optimizer: Optimizer, steps_per_checkpoint: int, total_steps: int, ): self.config = config self.optimizer = optimizer # Scheduler updated each training step self.step_info = StepInfo(steps_per_checkpoint, total_steps, self.config) self._train_scheduler = get_schedule_with_warmup_and_decay(self.config, self.optimizer, self.step_info) # Scheduler updated each eval step self._eval_scheduler = None if self.config.reduce_on_plateau > 0: mode = "min" if get_metric_objective(self.config.reduce_eval_metric) == MINIMIZE else "max" self._eval_scheduler = ReduceLROnPLateauCappedDecreases( optimizer=self.optimizer, mode=mode, reduce_limit=self.config.reduce_on_plateau, factor=self.config.reduce_on_plateau_rate, patience=self.config.reduce_on_plateau_patience, ) def step(self): """Called every step of training.""" self._train_scheduler.step() if self._eval_scheduler is not None: # We apply this scheduler every eval step, not train step, so we don't want to call step() here. # However, we need to re-apply the LR reduction to the LR from the train scheduler, as the first scheduler # resets the LR back to the base LR. self._eval_scheduler.apply_lr() def eval_step(self, progress_tracker: ProgressTracker, validation_field: str): """Called every checkpoint evaluation step.""" if self._eval_scheduler is None: # No reduce on plateau return if self.config.reduce_eval_split == TRAINING: split_metrics = progress_tracker.train_metrics elif self.config.reduce_eval_split == VALIDATION: split_metrics = progress_tracker.validation_metrics else: # if self.config.reduce_eval_split == TEST: split_metrics = progress_tracker.test_metrics validation_metric = self.config.reduce_eval_metric last_metric: TrainerMetric = split_metrics[validation_field][validation_metric][-1] last_metric_value = last_metric[-1] prev_num_reductions = self._eval_scheduler.num_reduce_lr self._eval_scheduler.step(last_metric_value) num_reductions = self._eval_scheduler.num_reduce_lr if num_reductions > prev_num_reductions: # LR reduction -> update progress tracker progress_tracker.last_learning_rate_reduction_steps = progress_tracker.steps progress_tracker.last_learning_rate_reduction = 0 progress_tracker.num_reductions_learning_rate += 1 else: progress_tracker.last_learning_rate_reduction = ( progress_tracker.steps - progress_tracker.last_learning_rate_reduction_steps ) def state_dict(self) -> dict[str, Any]: return { "train_scheduler_state": self._train_scheduler.state_dict(), "eval_scheduler_state": self._eval_scheduler.state_dict() if self._eval_scheduler is not None else {}, } def load_state_dict(self, d: dict[str, Any]): self._train_scheduler.load_state_dict(d["train_scheduler_state"]) if self._eval_scheduler is not None: self._eval_scheduler.load_state_dict(d["eval_scheduler_state"]) class StepInfo: """Stores the steps_per_checkpoint and total_steps used during the current training run. This class is needed by LambdaLR to allow us to update the steps on training init without resetting the entire LRScheduler from scratch (which would result in resetting the optimizer learning rate). """ def __init__(self, steps_per_checkpoint: int, total_steps: int, config: LRSchedulerConfig): self.config = config self.steps_per_checkpoint = steps_per_checkpoint self.num_training_steps = total_steps if self.config.warmup_fraction > 0 and self.config.warmup_evaluations > 0: logger.info( "Both `learning_rate_scheduler.warmup_fraction` and `learning_rate_scheduler.warmup_evaluations` " "provided. The larger of the two (as a function of the total training steps) will be used." ) num_warmup_steps = 0 if self.config.warmup_fraction > 0: num_warmup_steps = max(self.config.warmup_fraction * self.num_training_steps, num_warmup_steps) if self.config.warmup_evaluations > 0: num_warmup_steps = max(self.config.warmup_evaluations * self.steps_per_checkpoint, num_warmup_steps) self.num_warmup_steps = num_warmup_steps def get_schedule_with_warmup_and_decay( config: LRSchedulerConfig, optimizer: Optimizer, step_info: StepInfo, ) -> LambdaLR: """Creates a learning rate scheduler that updates each training step.""" schedulers = [] # Warmup scheduler. if step_info.num_warmup_steps > 0: warmup_scheduler = LambdaLR( optimizer, lambda current_step: float(current_step) / float(max(1, step_info.num_warmup_steps)), ) schedulers.append(warmup_scheduler) # Decay scheduler. decay = config.decay decay_scheduler = decay_registry[decay](config, optimizer, step_info) schedulers.append(decay_scheduler) if len(schedulers) == 1: # Only one scheduler, so no need to wrap in a SequentialLR. return schedulers[0] # Return a SequentialLR that applies the warmup and decay schedulers in order # with the warmup scheduler only applied for the first num_warmup_steps steps. return SequentialLR(optimizer, schedulers=schedulers, milestones=[step_info.num_warmup_steps]) def no_decay(current_step: int, num_training_steps: int, num_warmup_steps: int, config: LRSchedulerConfig): return 1.0 def linear_decay(current_step: int, num_training_steps: int, num_warmup_steps: int, config: LRSchedulerConfig): return max( 0.0, float(num_training_steps - num_warmup_steps - current_step) / float(max(1, num_training_steps - num_warmup_steps)), ) def exponential_decay(current_step: int, num_training_steps: int, num_warmup_steps: int, config: LRSchedulerConfig): decay_rate = float(config.decay_rate) decay_steps = float(config.decay_steps) step = float(current_step) exponent = 1 + step / decay_steps if config.staircase: exponent = math.ceil(exponent) return math.pow(decay_rate, exponent) def wrap_decay_fn(decay_fn: Callable) -> Callable: def init_fn(config: LRSchedulerConfig, optimizer: Optimizer, step_info: StepInfo) -> LambdaLR: return LambdaLR( optimizer, lambda current_step: decay_fn( current_step, step_info.num_training_steps, step_info.num_warmup_steps, config ), ) return init_fn def init_cosine_decay( config: LRSchedulerConfig, optimizer: Optimizer, step_info: StepInfo, ) -> CosineAnnealingWarmRestarts: t_0 = config.t_0 if not t_0: t_0 = step_info.steps_per_checkpoint if not t_0: # A scheduler may be initialized with dummy values like at the start of training. # Ensure that t_0 != 0, as this causes an error to be raised. t_0 = 1 return CosineAnnealingWarmRestarts( optimizer, T_0=t_0, T_mult=config.t_mult or 1, eta_min=config.eta_min or 0, ) decay_registry = { None: wrap_decay_fn(no_decay), "linear": wrap_decay_fn(linear_decay), "exponential": wrap_decay_fn(exponential_decay), "cosine": init_cosine_decay, } ================================================ FILE: ludwig/modules/metric_modules.py ================================================ # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import logging import sys from abc import ABC, abstractmethod from collections.abc import Callable, Generator from contextlib import contextmanager from typing import Any import torch from torch import Tensor, tensor from torchmetrics import MeanAbsoluteError, MeanAbsolutePercentageError from torchmetrics import MeanMetric as _MeanMetric from torchmetrics import MeanSquaredError, Metric from torchmetrics.classification import ( BinaryAccuracy, BinaryAUROC, BinaryPrecision, BinaryRecall, BinarySpecificity, MulticlassAccuracy, MulticlassAUROC, ) from torchmetrics.functional.regression.r2 import _r2_score_compute, _r2_score_update from torchmetrics.metric import jit_distributed_available from torchmetrics.text import BLEUScore, CharErrorRate, WordErrorRate from torchmetrics.text.perplexity import Perplexity from torchmetrics.text.rouge import ROUGEScore from ludwig.constants import ( # RESPONSE, ACCURACY, ACCURACY_MICRO, BINARY, BINARY_WEIGHTED_CROSS_ENTROPY, CATEGORY, CATEGORY_DISTRIBUTION, CORN, HITS_AT_K, HUBER, IGNORE_INDEX_TOKEN_ID, IMAGE, JACCARD, LOGITS, LOSS, MAXIMIZE, MEAN_ABSOLUTE_ERROR, MEAN_ABSOLUTE_PERCENTAGE_ERROR, MEAN_SQUARED_ERROR, MINIMIZE, NEXT_TOKEN_PERPLEXITY, NUMBER, PERPLEXITY, PRECISION, PREDICTIONS, PROBABILITIES, R2, RECALL, ROC_AUC, ROOT_MEAN_SQUARED_ERROR, ROOT_MEAN_SQUARED_PERCENTAGE_ERROR, SEQUENCE, SEQUENCE_ACCURACY, SET, SPECIFICITY, TEXT, TIMESERIES, TOKEN_ACCURACY, VECTOR, ) from ludwig.distributed import get_current_dist_strategy from ludwig.modules.loss_modules import ( BWCEWLoss, CORNLoss, HuberLoss, NextTokenSoftmaxCrossEntropyLoss, SequenceSoftmaxCrossEntropyLoss, SigmoidCrossEntropyLoss, SoftmaxCrossEntropyLoss, ) from ludwig.modules.metric_registry import get_metric_objective, get_metric_registry, register_metric from ludwig.schema.features.loss.loss import ( BWCEWLossConfig, CORNLossConfig, HuberLossConfig, SequenceSoftmaxCrossEntropyLossConfig, SigmoidCrossEntropyLossConfig, SoftmaxCrossEntropyLossConfig, ) from ludwig.utils.loss_utils import rmspe_loss from ludwig.utils.metric_utils import masked_correct_predictions from ludwig.utils.torch_utils import sequence_length_2D logger = logging.getLogger(__name__) class LudwigMetric(Metric, ABC): @classmethod def can_report(cls, feature: "OutputFeature") -> bool: # noqa: F821 return True @contextmanager def sync_context( self, dist_sync_fn: Callable | None = None, process_group: Any | None = None, should_sync: bool = True, should_unsync: bool = True, distributed_available: Callable | None = jit_distributed_available, ) -> Generator: """Override the behavior of this in the base class to support custom distributed strategies.""" dist_strategy = get_current_dist_strategy() self.sync( dist_sync_fn=dist_strategy.gather_all_tensors_fn(), process_group=process_group, should_sync=should_sync, distributed_available=dist_strategy.is_available, ) yield self.unsync(should_unsync=self._is_synced and should_unsync) @register_metric(ROOT_MEAN_SQUARED_ERROR, [NUMBER], MINIMIZE, PREDICTIONS) class RMSEMetric(MeanSquaredError, LudwigMetric): """Root mean squared error metric.""" def __init__(self, **kwargs): super().__init__(squared=False) @register_metric(PRECISION, [BINARY], MAXIMIZE, PROBABILITIES) class PrecisionMetric(BinaryPrecision, LudwigMetric): """Precision metric.""" def __init__(self, **kwargs): super().__init__() @register_metric(RECALL, [BINARY], MAXIMIZE, PROBABILITIES) class RecallMetric(BinaryRecall, LudwigMetric): """Recall metric.""" def __init__(self, **kwargs): super().__init__() @register_metric(ROC_AUC, [BINARY], MAXIMIZE, PROBABILITIES) class BinaryAUROCMetric(BinaryAUROC, LudwigMetric): """Area under the receiver operating curve.""" def __init__(self, **kwargs): super().__init__() def update(self, preds: Tensor, target: Tensor) -> None: super().update(preds, target.type(torch.int8)) @register_metric(ROC_AUC, [CATEGORY, CATEGORY_DISTRIBUTION], MAXIMIZE, PROBABILITIES) class CategoryAUROCMetric(MulticlassAUROC, LudwigMetric): """Area under the receiver operating curve.""" def __init__(self, num_classes: int, **kwargs): super().__init__(num_classes=num_classes) def update(self, preds: Tensor, target: Tensor) -> None: if len(target.shape) > 1: target = torch.argmax(target, dim=1) super().update(preds, target) @register_metric(SPECIFICITY, [BINARY], MAXIMIZE, PROBABILITIES) class SpecificityMetric(BinarySpecificity, LudwigMetric): """Specificity metric.""" def __init__(self, **kwargs): super().__init__() class MeanMetric(LudwigMetric): """Abstract class for computing mean of metrics.""" def __init__(self, **kwargs): super().__init__() self.avg = _MeanMetric() def update(self, preds: Tensor, target: Tensor) -> None: self.avg.update(self.get_current_value(preds, target)) def compute(self) -> Tensor: return self.avg.compute() def reset(self): super().reset() self.avg.reset() @abstractmethod def get_current_value(self, preds: Tensor, target: Tensor) -> Tensor: raise NotImplementedError() @register_metric(ROOT_MEAN_SQUARED_PERCENTAGE_ERROR, [NUMBER], MINIMIZE, PREDICTIONS) class RMSPEMetric(MeanMetric): def __init__(self, **kwargs): super().__init__() """ Root mean squared percentage error metric. """ def get_current_value(self, preds: Tensor, target: Tensor) -> Tensor: return rmspe_loss(target, preds) @register_metric(R2, [NUMBER, VECTOR, TIMESERIES], MAXIMIZE, PREDICTIONS) class R2Score(LudwigMetric): """Custom R-squared metric implementation that modifies torchmetrics R-squared implementation to return Nan when there is only sample. This is because R-squared is only defined for two or more samples. Custom implementation uses code from torchmetrics v0.9.2's implementation of R2: https://github.com/Lightning- AI/metrics/blob/master/src/torchmetrics/regression/r2.py """ def __init__( self, num_outputs: int = 1, adjusted: int = 0, multioutput: str = "uniform_average", **kwargs: Any ) -> None: super().__init__(**kwargs) self.num_outputs = num_outputs if adjusted < 0 or not isinstance(adjusted, int): raise ValueError("`adjusted` parameter should be an integer larger or equal to 0.") self.adjusted = adjusted allowed_multioutput = ("raw_values", "uniform_average", "variance_weighted") if multioutput not in allowed_multioutput: raise ValueError( f"Invalid input to argument `multioutput`. Choose one of the following: {allowed_multioutput}" ) self.multioutput = multioutput self.add_state("sum_squared_error", default=torch.zeros(self.num_outputs), dist_reduce_fx="sum") self.add_state("sum_error", default=torch.zeros(self.num_outputs), dist_reduce_fx="sum") self.add_state("residual", default=torch.zeros(self.num_outputs), dist_reduce_fx="sum") self.add_state("total", default=tensor(0), dist_reduce_fx="sum") def update(self, preds: Tensor, target: Tensor) -> None: """Update state with predictions and targets. Args: preds: Predictions from model target: Ground truth values """ sum_squared_error, sum_error, residual, n_obs = _r2_score_update(preds, target) self.sum_squared_error += sum_squared_error self.sum_error += sum_error self.residual += residual self.total += n_obs def compute(self) -> Tensor: """Computes r2 score over the metric states.""" # self.total maps to the number of observations in preds/target computed during update() if self.total <= 1: logger.warning( """R-squared (r2) is not defined for one sample. It needs at least two samples. Returning NaN.""" ) return torch.tensor(float("nan")) return _r2_score_compute( self.sum_squared_error, self.sum_error, self.residual, self.total, self.adjusted, self.multioutput ) @register_metric(LOSS, [], MINIMIZE, LOGITS) class LossMetric(MeanMetric, ABC): def __init__(self): super().__init__() @abstractmethod def get_current_value(self, preds: Tensor, target: Tensor) -> Tensor: raise NotImplementedError() @classmethod def can_report(cls, feature: "OutputFeature") -> bool: # noqa: F821 return False @register_metric(BINARY_WEIGHTED_CROSS_ENTROPY, [BINARY], MINIMIZE, LOGITS) class BWCEWLMetric(LossMetric): """Binary Weighted Cross Entropy Weighted Logits Score Metric.""" def __init__(self, config: BWCEWLossConfig, **kwargs): super().__init__() self.loss_function = BWCEWLoss(config) def get_current_value(self, preds: Tensor, target: Tensor) -> Tensor: return self.loss_function(preds, target) @register_metric("softmax_cross_entropy", [CATEGORY, CATEGORY_DISTRIBUTION, IMAGE], MINIMIZE, LOGITS) class SoftmaxCrossEntropyMetric(LossMetric): def __init__(self, config: SoftmaxCrossEntropyLossConfig, **kwargs): super().__init__() self.softmax_cross_entropy_function = SoftmaxCrossEntropyLoss(config) def get_current_value(self, preds: Tensor, target: Tensor): return self.softmax_cross_entropy_function(preds, target) @register_metric("sequence_softmax_cross_entropy", [SEQUENCE, TEXT], MINIMIZE, LOGITS) class SequenceSoftmaxCrossEntropyMetric(LossMetric): def __init__(self, config: SequenceSoftmaxCrossEntropyLossConfig, **kwargs): super().__init__() self.sequence_softmax_cross_entropy_function = SequenceSoftmaxCrossEntropyLoss(config) def get_current_value(self, preds: Tensor, target: Tensor): return self.sequence_softmax_cross_entropy_function(preds, target) @register_metric("next_token_softmax_cross_entropy", [SEQUENCE, TEXT], MINIMIZE, LOGITS) class NextTokenSoftmaxCrossEntropyMetric(LossMetric): def __init__(self, config: SequenceSoftmaxCrossEntropyLossConfig, **kwargs): super().__init__() self.next_token_softmax_cross_entropy_function = NextTokenSoftmaxCrossEntropyLoss(config) def get_current_value(self, preds: Tensor, target: Tensor): return self.next_token_softmax_cross_entropy_function(preds, target) @register_metric("sigmoid_cross_entropy", [SET], MINIMIZE, LOGITS) class SigmoidCrossEntropyMetric(LossMetric): def __init__(self, config: SigmoidCrossEntropyLossConfig, **kwargs): super().__init__() self.sigmoid_cross_entropy_function = SigmoidCrossEntropyLoss(config) def get_current_value(self, preds: Tensor, target: Tensor) -> Tensor: return self.sigmoid_cross_entropy_function(preds, target) @register_metric(TOKEN_ACCURACY, [SEQUENCE, TEXT], MAXIMIZE, PREDICTIONS) class TokenAccuracyMetric(MeanMetric): def __init__(self, **kwargs): super().__init__() def get_current_value(self, preds: Tensor, target: Tensor) -> Tensor: target = target.type(preds.dtype) target_sequence_length = sequence_length_2D(target) masked_correct_preds = masked_correct_predictions(target, preds, target_sequence_length) return torch.mean(masked_correct_preds) @register_metric(SEQUENCE_ACCURACY, [SEQUENCE, TEXT], MAXIMIZE, PREDICTIONS) class SequenceAccuracyMetric(MeanMetric): def __init__(self, **kwargs): super().__init__() def get_current_value(self, preds: Tensor, target: Tensor) -> Tensor: return torch.sum(torch.all(preds == target, dim=1)) / target.size()[0] @register_metric(PERPLEXITY, [SEQUENCE, TEXT], MINIMIZE, PROBABILITIES) class PerplexityMetric(Perplexity, LudwigMetric): def __init__(self, **kwargs): super().__init__(ignore_index=IGNORE_INDEX_TOKEN_ID) def update(self, preds: Tensor, target: Tensor) -> None: super().update(preds, target.type(torch.int64)) @register_metric(NEXT_TOKEN_PERPLEXITY, [SEQUENCE, TEXT], MINIMIZE, PROBABILITIES) class NextTokenPerplexityMetric(MeanMetric): def __init__(self, **kwargs): super().__init__() self.next_token_softmax_cross_entropy_function = NextTokenSoftmaxCrossEntropyLoss({}) def get_current_value(self, preds: Tensor, target: Tensor): # Perplexity can be represented as the exponential of the cross-entropy loss. # https://towardsdatascience.com/perplexity-in-language-models-87a196019a94 # We can't use torchmetrics perplexity because it calculates normal cross-entropy # loss as opposed to shifted cross entropy loss. shifted_loss = self.next_token_softmax_cross_entropy_function(preds, target) return torch.exp(shifted_loss) # @register_metric("bleu", [TEXT], MAXIMIZE, RESPONSE) # https://github.com/ludwig-ai/ludwig/issues/3953 class BLEUScoreMetric(BLEUScore, LudwigMetric): def __init__(self, **kwargs): super().__init__() # @register_metric("rouge", [TEXT], MAXIMIZE, RESPONSE) # https://github.com/ludwig-ai/ludwig/issues/3953 class ROUGEScoreMetric(ROUGEScore, LudwigMetric): def __init__(self, **kwargs): super().__init__() # @register_metric("word_error_rate", [TEXT], MINIMIZE, RESPONSE) # https://github.com/ludwig-ai/ludwig/issues/3953 class WordErrorRateMetric(WordErrorRate, LudwigMetric): def __init__(self, **kwargs): super().__init__() # @register_metric("char_error_rate", [TEXT], MINIMIZE, RESPONSE) # https://github.com/ludwig-ai/ludwig/issues/3953 class CharErrorRateMetric(CharErrorRate, LudwigMetric): def __init__(self, **kwargs): super().__init__() @register_metric(ACCURACY, [BINARY], MAXIMIZE, PREDICTIONS) class Accuracy(BinaryAccuracy, LudwigMetric): """R-squared metric.""" def __init__(self, **kwargs): super().__init__() @register_metric(ACCURACY, [CATEGORY, CATEGORY_DISTRIBUTION], MAXIMIZE, PREDICTIONS) class CategoryAccuracy(MulticlassAccuracy, LudwigMetric): def __init__(self, num_classes: int, **kwargs): super().__init__(num_classes=num_classes) def update(self, preds: Tensor, target: Tensor) -> None: if len(target.shape) > 1: target = torch.argmax(target, dim=1) super().update(preds, target.type(torch.long)) @register_metric(ACCURACY_MICRO, [CATEGORY, CATEGORY_DISTRIBUTION], MAXIMIZE, PREDICTIONS) class CategoryAccuracyMicro(MulticlassAccuracy, LudwigMetric): def __init__(self, num_classes: int, **kwargs): super().__init__(num_classes=num_classes, average="micro") def update(self, preds: Tensor, target: Tensor) -> None: if len(target.shape) > 1: target = torch.argmax(target, dim=1) super().update(preds, target.type(torch.long)) @register_metric(HITS_AT_K, [CATEGORY, CATEGORY_DISTRIBUTION], MAXIMIZE, LOGITS) class HitsAtKMetric(MulticlassAccuracy, LudwigMetric): def __init__(self, num_classes: int, top_k: int, **kwargs): super().__init__(num_classes=num_classes, top_k=top_k, **kwargs) def update(self, preds: Tensor, target: Tensor) -> None: if len(target.shape) > 1: target = torch.argmax(target, dim=1) super().update(preds, target.type(torch.long)) @classmethod def can_report(cls, feature: "OutputFeature") -> bool: # noqa: F821 return feature.num_classes > feature.top_k @register_metric(MEAN_ABSOLUTE_ERROR, [NUMBER, VECTOR, TIMESERIES], MINIMIZE, PREDICTIONS) class MAEMetric(MeanAbsoluteError, LudwigMetric): def __init__(self, **kwargs): super().__init__() def update(self, preds: Tensor, target: Tensor) -> None: super().update(preds.detach(), target) @register_metric(MEAN_SQUARED_ERROR, [NUMBER, VECTOR, TIMESERIES], MINIMIZE, PREDICTIONS) class MSEMetric(MeanSquaredError, LudwigMetric): def __init__(self, **kwargs): super().__init__() def update(self, preds: Tensor, target: Tensor) -> None: super().update(preds, target) @register_metric(MEAN_ABSOLUTE_PERCENTAGE_ERROR, [NUMBER, VECTOR, TIMESERIES], MINIMIZE, PREDICTIONS) class MAPEMetric(MeanAbsolutePercentageError, LudwigMetric): def __init__(self, **kwargs): super().__init__() def update(self, preds: Tensor, target: Tensor) -> None: super().update(preds, target) @register_metric(JACCARD, [SET], MAXIMIZE, PROBABILITIES) class JaccardMetric(MeanMetric): def __init__(self, threshold: float = 0.5, **kwargs): super().__init__() self.threshold = threshold def get_current_value(self, preds: Tensor, target: Tensor) -> Tensor: # notation: b is batch size and nc is number of unique elements in the set # preds: shape [b, nc] probabilities for each class # target: shape [b, nc] bit-mapped set representation preds = torch.greater_equal(preds, self.threshold) # now bit-mapped set target = target.type(torch.bool) intersection = torch.sum(torch.logical_and(target, preds).type(torch.float32), dim=-1) union = torch.sum(torch.logical_or(target, preds).type(torch.float32), dim=-1) return intersection / union # shape [b] @register_metric(HUBER, [NUMBER, VECTOR, TIMESERIES], MINIMIZE, PREDICTIONS) class HuberMetric(LossMetric): def __init__( self, config: HuberLossConfig, **kwargs, ): super().__init__() self.loss_function = HuberLoss(config=config) def get_current_value(self, preds: Tensor, target: Tensor) -> Tensor: return self.loss_function(preds, target) @register_metric(CORN, [CATEGORY], MINIMIZE, PREDICTIONS) class CORNMetric(LossMetric): def __init__( self, config: CORNLossConfig, **kwargs, ): super().__init__() self.loss_function = CORNLoss(config=config) def get_current_value(self, preds: Tensor, target: Tensor) -> Tensor: return self.loss_function(preds, target) def get_metric_cls(metric_name: str) -> type[LudwigMetric]: return get_metric_registry()[metric_name] def get_improved_fn(metric: str) -> Callable: if get_metric_objective(metric) == MINIMIZE: return lambda x, y: x < y else: return lambda x, y: x > y def get_initial_validation_value(metric: str) -> float: # Use finite floats instead of inf/-inf so that training_progress.json # is valid JSON (RFC 8259). sys.float_info.max (~1.8e308) is larger than # any real metric value, so comparison semantics are identical. if get_metric_objective(metric) == MINIMIZE: return sys.float_info.max else: return -sys.float_info.max def get_best_function(metric: str) -> Callable: if get_metric_objective(metric) == MINIMIZE: return min else: return max ================================================ FILE: ludwig/modules/metric_registry.py ================================================ from typing import Literal, TYPE_CHECKING from ludwig.api_annotations import DeveloperAPI from ludwig.constants import LOGITS, MAXIMIZE, MINIMIZE, PREDICTIONS, PROBABILITIES, RESPONSE from ludwig.utils.registry import Registry if TYPE_CHECKING: from ludwig.modules.metric_modules import LudwigMetric metric_feature_type_registry = Registry() metric_registry = Registry() metric_objective_registry = Registry() metric_tensor_input_registry = Registry() def register_metric( name: str, feature_types: str | list[str], objective: Literal[MINIMIZE, MAXIMIZE], output_feature_tensor_name: Literal[PREDICTIONS, PROBABILITIES, LOGITS], ): """Registers a metric class. Args: name: The name of the metric. Used in metric reporting and in the config. feature_types: The feature types that this metric can be used with. objective: The objective of the metric. Either MINIMIZE or MAXIMIZE. output_feature_tensor_name: Name of the tensor from output_feature::predictions() that should be used as input. For example: PREDICTIONS would be used for accuracy metrics while LOGITS would be used for loss metrics. """ if isinstance(feature_types, str): feature_types = [feature_types] def wrap(cls): for feature_type in feature_types: feature_registry = metric_feature_type_registry.get(feature_type, {}) feature_registry[name] = cls metric_feature_type_registry[feature_type] = feature_registry metric_registry[name] = cls metric_objective_registry[name] = objective metric_tensor_input_registry[name] = output_feature_tensor_name return cls return wrap def get_metric_classes(feature_type: str) -> dict[str, "LudwigMetric"]: return metric_feature_type_registry[feature_type] def get_metric_cls(feature_type: str, name: str) -> "LudwigMetric": return metric_feature_type_registry[feature_type][name] @DeveloperAPI def get_metric_feature_type_registry() -> Registry: return metric_feature_type_registry @DeveloperAPI def get_metric_registry() -> Registry: return metric_registry @DeveloperAPI def get_metric(metric_name: str) -> "LudwigMetric": # noqa return get_metric_registry()[metric_name] @DeveloperAPI def get_metrics_for_type(feature_type: str) -> dict[str, "LudwigMetric"]: # noqa return get_metric_feature_type_registry()[feature_type] @DeveloperAPI def get_metric_names_for_type(feature_type: str) -> list[str]: return sorted(list(get_metric_feature_type_registry()[feature_type].keys())) @DeveloperAPI def get_metric_objective(metric_name: str) -> Literal[MINIMIZE, MAXIMIZE]: return metric_objective_registry[metric_name] @DeveloperAPI def get_metric_tensor_input(metric_name: str) -> Literal[PREDICTIONS, PROBABILITIES, LOGITS, RESPONSE]: return metric_tensor_input_registry[metric_name] ================================================ FILE: ludwig/modules/mlp_mixer_modules.py ================================================ # Copyright (c) 2021 Linux Foundation # # 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. # ============================================================================== import torch import torch.nn as nn from ludwig.utils.torch_utils import LudwigModule class MLP(LudwigModule): def __init__( self, in_features: int | tuple[int], hidden_size: int, out_features: int | tuple[int] = None, dropout: float = 0.0, ): super().__init__() out_features = out_features or in_features self._input_shape = in_features self._output_shape = out_features self.linear1 = nn.Linear(in_features=in_features, out_features=hidden_size) self.linear2 = nn.Linear(in_features=hidden_size, out_features=out_features) self.dropout1 = nn.Dropout(dropout) self.dropout2 = nn.Dropout(dropout) def forward(self, inputs, **kwargs): hidden = self.dropout1(nn.functional.gelu(self.linear1(inputs))) return self.dropout2(self.linear2(hidden)) @property def input_shape(self) -> torch.Size: return torch.Size([self._input_shape]) @property def output_shape(self) -> torch.Size: return torch.Size([self._output_shape]) class MixerBlock(LudwigModule): def __init__(self, embed_size: int, n_patches: int, token_dim: int, channel_dim: int, dropout: float = 0.0): super().__init__() self._input_shape = (n_patches, embed_size) self._output_shape = (n_patches, embed_size) self.mlp1 = MLP(in_features=n_patches, hidden_size=token_dim, dropout=dropout) self.mlp2 = MLP(in_features=embed_size, hidden_size=channel_dim, dropout=dropout) self.layernorm1 = nn.LayerNorm(normalized_shape=embed_size) self.layernorm2 = nn.LayerNorm(normalized_shape=embed_size) def forward(self, inputs: torch.Tensor, **kwargs): assert inputs.shape[1:] == self.input_shape hidden = inputs hidden = self.layernorm1(hidden).transpose(1, 2) hidden = self.mlp1(hidden).transpose(1, 2) mid = hidden + inputs hidden = self.layernorm2(mid) hidden = self.mlp2(hidden) output = hidden + mid assert output.shape[1:] == self.output_shape return output @property def input_shape(self) -> torch.Size: return torch.Size(self._input_shape) @property def output_shape(self) -> torch.Size: return torch.Size(self._output_shape) class MLPMixer(LudwigModule): """MLPMixer. Implements MLP-Mixer: An all-MLP Architecture for Vision https://arxiv.org/abs/2105.01601 """ def __init__( self, img_height: int, img_width: int, in_channels: int, patch_size: int = 16, embed_size: int = 512, token_size: int = 2048, channel_dim: int = 256, num_layers: int = 8, dropout: float = 0.0, avg_pool: bool = True, ): super().__init__() assert (img_height % patch_size == 0) and (img_width % patch_size == 0) self._input_shape = (in_channels, img_height, img_width) n_patches = int(img_height * img_width / (patch_size**2)) self.patch_conv = nn.Conv2d( in_channels=in_channels, out_channels=embed_size, kernel_size=patch_size, stride=patch_size ) self.mixer_blocks = nn.ModuleList( [ MixerBlock( embed_size=embed_size, n_patches=n_patches, token_dim=token_size, channel_dim=channel_dim, dropout=dropout, ) for _ in range(num_layers) ] ) self.layer_norm = nn.LayerNorm(normalized_shape=embed_size) self.avg_pool = avg_pool if self.avg_pool: self._output_shape = torch.Size((embed_size,)) else: self._output_shape = torch.Size((n_patches, embed_size)) def forward(self, inputs: torch.Tensor) -> torch.Tensor: assert inputs.shape[1:] == self.input_shape hidden = self.patch_conv(inputs) hidden = hidden.flatten(2).transpose(1, 2) for mixer_block in self.mixer_blocks: hidden = mixer_block(hidden) hidden = self.layer_norm(hidden) if self.avg_pool: hidden = torch.mean(hidden, dim=1) assert hidden.shape[1:] == self.output_shape return hidden @property def input_shape(self) -> torch.Size: return torch.Size(self._input_shape) @property def output_shape(self) -> torch.Size: return self._output_shape ================================================ FILE: ludwig/modules/normalization_modules.py ================================================ import logging import numpy as np import torch from torch.nn import BatchNorm1d, BatchNorm2d, LayerNorm, Module from ludwig.utils.torch_utils import LudwigModule logger = logging.getLogger(__name__) # implementation adapted from https://github.com/dreamquark-ai/tabnet class GhostBatchNormalization(LudwigModule): def __init__( self, num_features: int, momentum: float = 0.05, epsilon: float = 1e-3, virtual_batch_size: int | None = 128 ): super().__init__() self.num_features = num_features self.virtual_batch_size = virtual_batch_size self.bn = torch.nn.BatchNorm1d(num_features, momentum=momentum, eps=epsilon) def forward(self, inputs): batch_size = inputs.shape[0] if self.training and self.virtual_batch_size: splits = inputs.chunk(int(np.ceil(batch_size / self.virtual_batch_size)), 0) if batch_size % self.virtual_batch_size == 1: # Skip batch normalization for the last chunk if it is size 1. logger.warning( f"Virtual batch size `{self.virtual_batch_size}` is not a factor of the batch size `{batch_size}`, " "resulting in a chunk of size 1. Skipping batch normalization for the last chunk of size 1." ) if batch_size == 1: logger.warning( "Batch size is 1, but batch normalization requires batch size >= 2. Skipping batch normalization." "Make sure to set `batch_size` to a value greater than 1." ) # We temporarily set the batch_norm module to eval mode as we can't compute the running statistics # when the batch size is 1. self.bn.eval() splits_with_bn = [self.bn(x) if x.shape[0] >= 1 else x for x in splits] self.bn.train() else: splits_with_bn = [self.bn(x) if x.shape[0] > 1 else x for x in splits] return torch.cat(splits_with_bn, 0) if batch_size != 1 or not self.training: return self.bn(inputs) return inputs @property def moving_mean(self) -> torch.Tensor: return self.bn.running_mean @property def moving_variance(self) -> torch.Tensor: return self.bn.running_var @property def output_shape(self) -> torch.Size: return torch.Size([self.num_features]) @property def input_shape(self) -> torch.Size: return torch.Size([self.num_features]) class BatchNorm1dOrIdentity(BatchNorm1d): """BatchNorm1d or Identity layer if the batch_size is 1. Workaround for: https://github.com/pytorch/pytorch/issues/4534 """ def forward(self, input: torch.Tensor) -> torch.Tensor: if input.shape[0] == 1: logger.warning( "Batch size is 1, but batch normalization requires batch size >= 2. Skipping batch normalization." "Make sure to set `batch_size` to a value greater than 1." ) return input return super().forward(input) class BatchNorm2dOrIdentity(BatchNorm2d): """BatchNorm2d or Identity layer if the batch_size is 1. Workaround for: https://github.com/pytorch/pytorch/issues/4534 """ def forward(self, input: torch.Tensor) -> torch.Tensor: if input.shape[0] == 1: logger.warning( "Batch size is 1, but batch normalization requires batch size >= 2. Skipping batch normalization." "Make sure to set `batch_size` to a value greater than 1." ) return input return super().forward(input) norm_registry = { "batch_1d": BatchNorm1dOrIdentity, "batch_2d": BatchNorm2dOrIdentity, "layer": LayerNorm, "ghost": GhostBatchNormalization, } def create_norm_layer(norm: str, input_rank: int, num_features: int, **norm_params) -> Module: if norm == "batch": # We use a different batch norm depending on the input_rank. # TODO(travis): consider moving this behind a general BatchNorm interface to avoid this kludge. if input_rank not in {2, 3}: ValueError(f"`input_rank` parameter expected to be either 2 or 3, but found {input_rank}.") norm = f"{norm}_{input_rank - 1}d" norm_cls = norm_registry.get(norm) if norm_cls is None: raise ValueError( f"Unsupported value for `norm` param: {norm}. Supported values are: {list(norm_registry.keys())}" ) return norm_cls(num_features, **norm_params) ================================================ FILE: ludwig/modules/optimization_modules.py ================================================ # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import dataclasses from typing import Optional, TYPE_CHECKING import torch from ludwig.utils.misc_utils import get_from_registry from ludwig.utils.torch_utils import LudwigModule if TYPE_CHECKING: from ludwig.schema.optimizers import BaseOptimizerConfig, GradientClippingConfig def create_clipper(gradient_clipping_config: Optional["GradientClippingConfig"]): from ludwig.schema.optimizers import GradientClippingConfig """Utility function that will convert a None-type gradient clipping config to the correct form.""" if isinstance(gradient_clipping_config, GradientClippingConfig): return gradient_clipping_config # Return default config if provided value is None: return GradientClippingConfig() def get_optimizer_class_and_kwargs( optimizer_config: "BaseOptimizerConfig", learning_rate: float ) -> tuple[type[torch.optim.Optimizer], dict]: """Returns the optimizer class and kwargs for the optimizer. :return: Tuple of optimizer class and kwargs for the optimizer. """ from ludwig.schema.optimizers import optimizer_registry # Get the corresponding torch optimizer class for the given config: optimizer_cls = get_from_registry(optimizer_config.type.lower(), optimizer_registry)[0] # Create a dict of parameters to be passed to torch (i.e. everything except `type`): if dataclasses.is_dataclass(optimizer_config): config_dict = dataclasses.asdict(optimizer_config) elif hasattr(optimizer_config, "to_dict"): config_dict = optimizer_config.to_dict() else: config_dict = vars(optimizer_config) cls_kwargs = {field: value for field, value in config_dict.items() if field != "type"} cls_kwargs["lr"] = learning_rate return optimizer_cls, cls_kwargs def create_optimizer( model: LudwigModule, optimizer_config: "BaseOptimizerConfig", learning_rate: float, ) -> torch.optim.Optimizer: """Returns a ready-to-use torch optimizer instance based on the given optimizer config. :param model: Underlying Ludwig model :param learning_rate: Initial learning rate for the optimizer :param optimizer_config: Instance of `ludwig.modules.optimization_modules.BaseOptimizerConfig`. :return: Initialized instance of a torch optimizer. """ # Make sure the optimizer is compatible with the available resources: if (optimizer_config.is_paged or optimizer_config.is_8bit) and ( not torch.cuda.is_available() or torch.cuda.device_count() == 0 ): raise ValueError( "Cannot use a paged or 8-bit optimizer on a non-GPU machine. " "Please use a different optimizer or run on a machine with a GPU." ) optimizer_cls, optimizer_kwargs = get_optimizer_class_and_kwargs(optimizer_config, learning_rate) return optimizer_cls(model.parameters(), **optimizer_kwargs) ================================================ FILE: ludwig/modules/recurrent_modules.py ================================================ # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import logging import torch from torch.nn import GRU, LSTM, RNN from ludwig.utils.misc_utils import get_from_registry from ludwig.utils.torch_utils import LudwigModule logger = logging.getLogger(__name__) rnn_layers_registry = { "rnn": RNN, "gru": GRU, "lstm": LSTM, } class RecurrentStack(LudwigModule): def __init__( self, input_size: int = None, hidden_size: int = 256, cell_type: str = "rnn", max_sequence_length: int | None = None, num_layers: int = 1, bidirectional: bool = False, use_bias: bool = True, dropout: float = 0.0, **kwargs, ): super().__init__() self.supports_masking = True self.input_size = input_size # api doc: H_in self.hidden_size = hidden_size # api doc: H_out self.max_sequence_length = max_sequence_length # api doc: L (sequence length) rnn_layer_class = get_from_registry(cell_type, rnn_layers_registry) rnn_params = {"num_layers": num_layers, "bias": use_bias, "dropout": dropout, "bidirectional": bidirectional} # Delegate recurrent params to PyTorch's RNN/GRU/LSTM implementations. self.layers = rnn_layer_class(input_size, hidden_size, batch_first=True, **rnn_params) @property def input_shape(self) -> torch.Size: if self.max_sequence_length: return torch.Size([self.max_sequence_length, self.input_size]) return torch.Size([self.input_size]) @property def output_shape(self) -> torch.Size: hidden_size = self.hidden_size * (2 if self.layers.bidirectional else 1) if self.max_sequence_length: return torch.Size([self.max_sequence_length, hidden_size]) return torch.Size([hidden_size]) def forward(self, inputs: torch.Tensor, mask=None): hidden, final_state = self.layers(inputs) if isinstance(final_state, tuple): # lstm cell type final_state = final_state[0][-1], final_state[1][-1] else: # rnn or gru cell type final_state = final_state[-1] return hidden, final_state ================================================ FILE: ludwig/modules/reduction_modules.py ================================================ # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import logging import torch from ludwig.modules.attention_modules import FeedForwardAttentionReducer from ludwig.utils.misc_utils import get_from_registry from ludwig.utils.torch_utils import LudwigModule, sequence_length_3D logger = logging.getLogger(__name__) class SequenceReducer(LudwigModule): """Reduces the sequence dimension of an input tensor according to the specified reduce_mode. Any additional kwargs are passed on to the reduce mode's constructor. If using reduce_mode=="attention", the input_size kwarg must also be specified. A sequence is a tensor of 2 or more dimensions, where the shape is [batch size x sequence length x ...]. :param reduce_mode: The reduction mode, one of {"last", "sum", "mean", "max", "concat", "attention", "none"} :param max_sequence_length The maximum sequence length. Only used for computation of shapes - inputs passed at runtime may have a smaller sequence length. :param encoding_size The size of each sequence element/embedding vector, or None if input is a sequence of scalars. """ def __init__(self, reduce_mode: str = None, max_sequence_length: int = 256, encoding_size: int = None, **kwargs): super().__init__() # save as private variable for debugging self._reduce_mode = reduce_mode self._max_sequence_length = max_sequence_length self._encoding_size = encoding_size # If embedding size specified and mode is attention, use embedding size as attention module input size # unless the input_size kwarg is provided. if reduce_mode == "attention" and encoding_size and "input_size" not in kwargs: kwargs["input_size"] = encoding_size # use registry to find required reduction function self._reduce_obj = get_from_registry(reduce_mode, reduce_mode_registry)(**kwargs) def forward(self, inputs, mask=None): """Forward pass of reducer. :param inputs: A tensor of 2 or more dimensions, where the shape is [batch size x sequence length x ...]. :param mask: A mask tensor of 2 dimensions [batch size x sequence length]. Not yet implemented. :return: The input after applying the reduction operation to sequence dimension. """ return self._reduce_obj(inputs, mask=mask) @property def input_shape(self) -> torch.Size: """Returns size of the input tensor without the batch dimension.""" if self._encoding_size is None: return torch.Size([self._max_sequence_length]) else: return torch.Size([self._max_sequence_length, self._encoding_size]) @property def output_shape(self) -> torch.Size: """Returns size of the output tensor without the batch dimension.""" input_shape = self.input_shape if self._reduce_mode in {None, "none", "None"}: return input_shape elif self._reduce_mode == "concat": if len(input_shape) > 1: return input_shape[:-2] + (input_shape[-1] * input_shape[-2],) return input_shape else: return input_shape[1:] # Reduce sequence dimension. class ReduceLast(torch.nn.Module): def forward(self, inputs, mask=None): # inputs: [batch_size, seq_size, hidden_size] batch_size = inputs.shape[0] # gather the correct outputs from the the RNN outputs (the outputs after sequence_length are all 0s) # todo: review for generality sequence_length = sequence_length_3D(inputs) - 1 sequence_length[sequence_length < 0] = 0 gathered = inputs[torch.arange(batch_size), sequence_length.type(torch.int64)] return gathered class ReduceSum(torch.nn.Module): def forward(self, inputs, mask=None): return torch.sum(inputs, dim=1) class ReduceMean(torch.nn.Module): def forward(self, inputs, mask=None): return torch.mean(inputs, dim=1) class ReduceMax(torch.nn.Module): def forward(self, inputs, mask=None): return torch.amax(inputs, dim=1) class ReduceConcat(torch.nn.Module): def forward(self, inputs, mask=None): if inputs.dim() > 2: return inputs.reshape(-1, inputs.shape[-1] * inputs.shape[-2]) return inputs class ReduceNone(torch.nn.Module): def forward(self, inputs, mask=None): return inputs reduce_mode_registry = { "last": ReduceLast, "sum": ReduceSum, "mean": ReduceMean, "avg": ReduceMean, "max": ReduceMax, "concat": ReduceConcat, "attention": FeedForwardAttentionReducer, # TODO: Simplify this. "none": ReduceNone, "None": ReduceNone, None: ReduceNone, } ================================================ FILE: ludwig/modules/tabnet_modules.py ================================================ import torch import torch.nn as nn from ludwig.modules.normalization_modules import GhostBatchNormalization from ludwig.utils.entmax import Entmax15, EntmaxBisect, Sparsemax from ludwig.utils.torch_utils import LudwigModule class TabNet(LudwigModule): def __init__( self, input_size: int, size: int, output_size: int, num_steps: int = 1, num_total_blocks: int = 4, num_shared_blocks: int = 2, relaxation_factor: float = 1.5, bn_momentum: float = 0.3, bn_epsilon: float = 1e-3, bn_virtual_bs: int | None = None, sparsity: float = 1e-5, entmax_mode: str = "sparsemax", entmax_alpha: float = 1.5, ): """TabNet Will output a vector of size output_dim. Args: input_size: concatenated size of input feature encoder outputs size: Embedding feature dimension output_size: Output dimension for TabNet num_steps: Total number of steps. num_total_blocks: Total number of feature transformer blocks. num_shared_blocks: Number of shared feature transformer blocks. relaxation_factor: >1 will allow features to be used more than once. bn_momentum: Batch normalization, momentum. bn_epsilon: Batch normalization, epsilon. bn_virtual_bs: Virtual batch ize for ghost batch norm. entmax_mode: Entmax is a sparse family of probability mapping which generalizes softmax and sparsemax. entmax_mode controls the sparsity. One of {"sparsemax", "entmax15", "constant", "adaptive"}. entmax_alpha: Must be a number between 1.0 and 2.0. If entmax_mode is "adaptive", entmax_alpha is used as the initial value for the learnable parameter. """ super().__init__() self.input_size = input_size self.size = size self.output_size = output_size self.num_steps = num_steps self.bn_virtual_bs = bn_virtual_bs self.relaxation_factor = relaxation_factor self.sparsity = torch.tensor(sparsity) self.batch_norm = nn.BatchNorm1d(input_size, momentum=bn_momentum, eps=bn_epsilon) kargs = { "num_total_blocks": num_total_blocks, "num_shared_blocks": num_shared_blocks, "bn_momentum": bn_momentum, "bn_epsilon": bn_epsilon, "bn_virtual_bs": bn_virtual_bs, } # first feature transformer block is built first # to get the shared blocks self.feature_transforms = nn.ModuleList([FeatureTransformer(input_size, size + output_size, **kargs)]) self.attentive_transforms = nn.ModuleList([None]) for i in range(num_steps): self.feature_transforms.append( FeatureTransformer( input_size, size + output_size, **kargs, shared_fc_layers=self.feature_transforms[0].shared_fc_layers, ) ) # attentive transformers are initialized in build # because their outputs size depends on the number # of features that we determine by looking at the # last dimension of the input tensor self.attentive_transforms.append( AttentiveTransformer( size, input_size, bn_momentum, bn_epsilon, bn_virtual_bs, entmax_mode, entmax_alpha ) ) self.final_projection = nn.Linear(output_size, output_size) # Register tensors to be used in forward pass. This is needed in order to move these tensors # to the correct device (GPU/CPU) during the forward pass. self.register_buffer("out_accumulator", torch.zeros(output_size)) self.register_buffer("aggregated_mask", torch.zeros(input_size)) self.register_buffer("prior_scales", torch.ones(input_size)) def forward(self, features: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, list[torch.Tensor]]: if features.dim() != 2: raise ValueError(f"Expecting incoming tensor to be dim 2, " f"instead dim={features.dim()}") # shape notation # i_s: input_size # s: size # o_s: output_size # b_s: batch_size batch_size = features.shape[0] # b_s # Tile out_accumulator, aggregated_mask, and prior_scales to add batch dimension. out_accumulator = torch.tile(self.out_accumulator, (batch_size, 1)) aggregated_mask = torch.tile(self.aggregated_mask, (batch_size, 1)) prior_scales = torch.tile(self.prior_scales, (batch_size, 1)) masks = [] total_entropy = 0.0 if batch_size != 1 or not self.training: # Skip batch normalization training if the batch size is 1. features = self.batch_norm(features) # [b_s, i_s] elif batch_size == 1: # We temporarily set the batch_norm module to eval mode as we can't compute the running statistics # when the batch size is 1. self.batch_norm.eval() features = self.batch_norm(features) # [b_s, i_s] self.batch_norm.train() masked_features = features x = self.feature_transforms[0](masked_features) # [b_s, s + o_s] for step_i in range(1, self.num_steps + 1): ######################### # Attentive Transformer # ######################### # x in following is shape [b_s, s] mask_values = self.attentive_transforms[step_i](x[:, self.output_size :], prior_scales) # [b_s, i_s] # relaxation factor 1 forces the feature to be only used once prior_scales = prior_scales * (self.relaxation_factor - mask_values) # [b_s, i_s] # entropy is used to penalize the amount of sparsity # in feature selection if self.sparsity.item() != 0.0: total_entropy += ( torch.mean(torch.sum(-mask_values * torch.log(mask_values + 0.00001), dim=1)) / self.num_steps ) masks.append(torch.unsqueeze(torch.unsqueeze(mask_values, 0), 3)) # [1, b_s, i_s, 1] ####################### # Feature Transformer # ####################### masked_features = torch.multiply(mask_values, features) x = self.feature_transforms[step_i](masked_features) # [b_s, s + o_s] # x in following is shape [b_s, o_s] out = nn.functional.relu(x[:, : self.output_size]) # [b_s, o_s] out_accumulator += out # Aggregated masks are used for visualization of the # feature importance attributes. scale = torch.sum(out, dim=1, keepdim=True) / self.num_steps aggregated_mask += mask_values * scale # [b_s, i_s] final_output = self.final_projection(out_accumulator) # [b_s, o_s] sparsity_loss = torch.multiply(self.sparsity, total_entropy) self.update_loss("sparsity_loss", sparsity_loss) return final_output, aggregated_mask, masks @property def input_shape(self) -> torch.Size: return torch.Size([self.input_size]) @property def output_shape(self) -> torch.Size: return torch.Size([self.output_size]) class FeatureBlock(LudwigModule): def __init__( self, input_size: int, size: int, apply_glu: bool = True, bn_momentum: float = 0.1, bn_epsilon: float = 1e-3, bn_virtual_bs: int = None, shared_fc_layer: LudwigModule = None, ): super().__init__() self.input_size = input_size self.apply_glu = apply_glu self.size = size units = size * 2 if apply_glu else size # Initialize fc_layer before assigning to shared layer for torchscript compatibilty self.fc_layer = nn.Linear(input_size, units, bias=False) if shared_fc_layer is not None: assert shared_fc_layer.weight.shape == self.fc_layer.weight.shape self.fc_layer = shared_fc_layer self.batch_norm = GhostBatchNormalization( units, virtual_batch_size=bn_virtual_bs, momentum=bn_momentum, epsilon=bn_epsilon ) def forward(self, inputs): # shape notation # i_s: input_size # s: size # u: units # b_s: batch_size # inputs shape [b_s, i_s] hidden = self.fc_layer(inputs) # [b_s, u] hidden = self.batch_norm(hidden) # [b_s, u] if self.apply_glu: hidden = nn.functional.glu(hidden, dim=-1) # [bs, s] return hidden # [b_s, 2*s] if apply_glu else [b_s, s] @property def input_shape(self) -> torch.Size: return torch.Size([self.input_size]) class AttentiveTransformer(LudwigModule): def __init__( self, input_size: int, size: int, bn_momentum: float = 0.1, bn_epsilon: float = 1e-3, bn_virtual_bs: int = None, entmax_mode: str = "sparsemax", entmax_alpha: float = 1.5, ): super().__init__() self.input_size = input_size self.size = size self.entmax_mode = entmax_mode if entmax_mode == "adaptive": self.register_buffer("trainable_alpha", torch.tensor(entmax_alpha, requires_grad=True)) else: self.trainable_alpha = entmax_alpha if self.entmax_mode == "sparsemax": self.entmax_module = Sparsemax() elif self.entmax_mode == "entmax15": self.entmax_module = Entmax15() else: self.entmax_module = EntmaxBisect(alpha=self.trainable_alpha) self.feature_block = FeatureBlock( input_size, size, bn_momentum=bn_momentum, bn_epsilon=bn_epsilon, bn_virtual_bs=bn_virtual_bs, apply_glu=False, ) def forward(self, inputs, prior_scales): # shape notation # i_s: input_size # s: size # b_s: batch_size # inputs shape [b_s, i_s], prior_scales shape [b_s, s] hidden = self.feature_block(inputs) # [b_s, s] hidden = hidden * prior_scales # [b_s, s] # removing the mean to try to avoid numerical instability # https://github.com/tensorflow/addons/issues/2314 # https://github.com/tensorflow/tensorflow/pull/21183/files # In (Arik and Pfister, 2019), they call the logits z. # The mean(logits) can be substracted from logits to make the algorithm # more numerically stable. the instability in this algorithm comes mostly # from the z_cumsum. Substacting the mean will cause z_cumsum to be close # to zero. # hidden = hidden - tf.math.reduce_mean(hidden, axis=1)[:, tf.newaxis] return self.entmax_module(hidden) @property def input_shape(self) -> torch.Size: return torch.Size([self.input_size]) @property def output_shape(self) -> torch.Size: return torch.Size([self.size]) # adapted and modified from: # https://github.com/ostamand/tensorflow-tabnet/blob/master/tabnet/models/transformers.py class FeatureTransformer(LudwigModule): def __init__( self, input_size: int, size: int, shared_fc_layers: list | None = None, num_total_blocks: int = 4, num_shared_blocks: int = 2, bn_momentum: float = 0.1, bn_epsilon: float = 1e-3, bn_virtual_bs: int = None, ): super().__init__() if shared_fc_layers is None: shared_fc_layers = [] self.input_size = input_size self.num_total_blocks = num_total_blocks self.num_shared_blocks = num_shared_blocks self.size = size kwargs = { "bn_momentum": bn_momentum, "bn_epsilon": bn_epsilon, "bn_virtual_bs": bn_virtual_bs, } # build blocks self.blocks = nn.ModuleList() for n in range(num_total_blocks): # Ensure the sizes fed into FeatureBlock are correct regardless of presence of shared_fc_layer if n == 0: in_features = input_size else: in_features = size if shared_fc_layers and n < len(shared_fc_layers): self.blocks.append(FeatureBlock(in_features, size, **kwargs, shared_fc_layer=shared_fc_layers[n])) else: self.blocks.append(FeatureBlock(in_features, size, **kwargs)) def forward(self, inputs: torch.Tensor) -> torch.Tensor: # shape notation # i_s: input_size # s: size # b_s: batch_size # inputs shape [b_s, i_s] hidden = self.blocks[0](inputs) # [b_s, s] for n in range(1, self.num_total_blocks): hidden = (self.blocks[n](hidden) + hidden) * (0.5**0.5) # [b_s, s] return hidden # [b_s, s] @property def shared_fc_layers(self): return [self.blocks[i].fc_layer for i in range(self.num_shared_blocks)] @property def input_shape(self) -> torch.Size: return torch.Size([self.input_size]) @property def output_shape(self) -> torch.Size: return torch.Size([self.size]) ================================================ FILE: ludwig/modules/training_hooks.py ================================================ import logging from abc import ABC, abstractmethod import torch logger = logging.getLogger(__name__) class TrainingHook(ABC): """A base class for training hooks in PyTorch. This class provides a template for implementing custom training hooks that can be activated, deactivated, and maintain a handle to the hook. Attributes: _hook_handle (Optional[torch.utils.hooks.RemovableHandle]): A handle to the registered forward hook, initially set to None. """ def __init__(self, *args, **kwargs) -> None: self._hook_handle = None @abstractmethod def hook_fn(self, module: torch.nn.Module, inputs: torch.tensor, outputs: torch.Tensor) -> torch.Tensor: """Abstract method to be implemented by subclasses. This is the method that defines the custom behavior of the training hook during a forward pass for the specified module. Args: module (nn.Module): The PyTorch module for which the hook is activated. inputs (torch.Tensor): The input to the module during the forward pass. outputs (torch.Tensor): The output from the module during the forward pass. Returns: torch.Tensor: The output tensor from the module. Raises: NotImplementedError: If the method is not implemented in a subclass. """ def activate_hook(self, module: torch.nn.Module) -> torch.nn.Module: """Activates the training hook for a given module. Args: module (nn.Module): The PyTorch module for which the hook is activated. Returns: nn.Module: The input module with the training hook activated. """ self._hook_handle = module.register_forward_hook(self.hook_fn) return module def deactivate_hook(self): """Deactivates and removes the training hook.""" if self._hook_handle is not None: self._hook_handle.remove() self._hook_handle = None class NEFTuneHook(TrainingHook): def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.neftune_noise_alpha = kwargs.get("neftune_noise_alpha") def hook_fn(self, module: torch.nn.Module, input: torch.Tensor, output: torch.Tensor) -> torch.Tensor: """Implements the NEFTune forward pass for the model using forward hooks. Note this works only for torch.nn. Embedding layers. This method is slightly adapted from the original source code that can be found here: https://github.com/neelsjain/NEFTune. The input tensor is ignored since the noise is added to the output of the embedding layer. Returns: torch.Tensor: The output tensor from the module. """ if module.training: dims = torch.tensor(output.size(1) * output.size(2)) mag_norm = module.neftune_noise_alpha / torch.sqrt(dims) output = output + torch.zeros_like(output).uniform_(-mag_norm, mag_norm) return output def activate_hook(self, module: torch.nn.Module) -> torch.nn.Module: """Activates the neftune as presented in this code and paper: Code: https://github.com/neelsjain/NEFTune Paper: https://arxiv.org/abs/2310.05914 Args: module (nn.Module): The PyTorch module for which the hook is activated. Returns: nn.Module: The input module with the training hook activated. """ from peft import PeftModel if isinstance(module, PeftModel): embeddings = module.base_model.model.get_input_embeddings() else: embeddings = module.get_input_embeddings() embeddings.neftune_noise_alpha = self.neftune_noise_alpha self._hook_handle = embeddings.register_forward_hook(self.hook_fn) return module ================================================ FILE: ludwig/predict.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import argparse import logging import sys from ast import literal_eval import pandas as pd from ludwig.api import LudwigModel from ludwig.backend import ALL_BACKENDS, Backend, initialize_backend from ludwig.callbacks import Callback from ludwig.constants import FULL, TEST, TRAINING, VALIDATION from ludwig.contrib import add_contrib_callback_args from ludwig.globals import LUDWIG_VERSION from ludwig.utils.print_utils import get_logging_level_registry, print_ludwig logger = logging.getLogger(__name__) def predict_cli( model_path: str, dataset: str | dict | pd.DataFrame = None, data_format: str = None, split: str = FULL, batch_size: int = 128, generation_config: str | None = None, skip_save_unprocessed_output: bool = False, skip_save_predictions: bool = False, output_directory: str = "results", gpus: str | int | list[int] = None, gpu_memory_limit: float | None = None, allow_parallel_threads: bool = True, callbacks: list[Callback] = None, backend: Backend | str = None, logging_level: int = logging.INFO, **kwargs, ) -> None: """Loads pre-trained model to make predictions on the provided data set. # Inputs :param model_path: (str) filepath to pre-trained model. :param dataset: (Union[str, dict, pandas.DataFrame], default: `None`) source containing the entire dataset to be used in the prediction. :param data_format: (str, default: `None`) format to interpret data sources. Will be inferred automatically if not specified. Valid formats are `'auto'`, `'csv'`, `'excel'`, `'feather'`, `'fwf'`, `'hdf5'` (cache file produced during previous training), `'html'` (file containing a single HTML `
`), `'json'`, `'jsonl'`, `'parquet'`, `'pickle'` (pickled Pandas DataFrame), `'sas'`, `'spss'`, `'stata'`, `'tsv'`. :param split: (str, default: `full`) split on which to perform predictions. Valid values are `'training'`, `'validation'`, `'test'` and `'full'`. :param batch_size: (int, default `128`) size of batches for processing. :param generation_config: (str, default: `None`) a string representing the parameters for generation required to perform predictions with an LLM. The string must be a JSON formatted dictionary with keys from https://huggingface.co/docs/transformers/main_classes/text_generation#transformers.GenerationConfig These will be merged with the generation parameters from the original model config. :param skip_save_unprocessed_output: (bool, default: `False`) by default predictions and their probabilities are saved in both raw unprocessed numpy files containing tensors and as postprocessed CSV files (one for each output feature). If this parameter is True, only the CSV ones are saved and the numpy ones are skipped. :param skip_save_predictions: (bool, default: `False`) skips saving test predictions CSV files :param output_directory: (str, default: `'results'`) the directory that will contain the training statistics, TensorBoard logs, the saved model and the training progress files. :param gpus: (list, default: `None`) list of GPUs that are available for training. :param gpu_memory_limit: (float: default: `None`) maximum memory fraction [0, 1] allowed to allocate per GPU device. :param allow_parallel_threads: (bool, default: `True`) allow PyTorch to use multithreading parallelism to improve performance at the cost of determinism. :param callbacks: (list, default: `None`) a list of `ludwig.callbacks.Callback` objects that provide hooks into the Ludwig pipeline. :param backend: (Union[Backend, str]) `Backend` or string name of backend to use to execute preprocessing / training steps. :param logging_level: (int) Log level that will be sent to stderr. # Returns :return: ('None') """ model = LudwigModel.load( model_path, logging_level=logging_level, backend=backend, gpus=gpus, gpu_memory_limit=gpu_memory_limit, allow_parallel_threads=allow_parallel_threads, callbacks=callbacks, ) model.predict( dataset=dataset, data_format=data_format, split=split, batch_size=batch_size, generation_config=literal_eval(generation_config) if generation_config else None, skip_save_unprocessed_output=skip_save_unprocessed_output, skip_save_predictions=skip_save_predictions, output_directory=output_directory, return_type="dict", ) def cli(sys_argv): parser = argparse.ArgumentParser( description="This script loads a pretrained model " "and uses it to predict", prog="ludwig predict", usage="%(prog)s [options]", ) # --------------- # Data parameters # --------------- parser.add_argument("--dataset", help="input data file path", required=True) parser.add_argument( "--data_format", help="format of the input data", default="auto", choices=[ "auto", "csv", "excel", "feather", "fwf", "hdf5", "html", "tables", "json", "jsonl", "parquet", "pickle", "sas", "spss", "stata", "tsv", ], ) parser.add_argument( "-s", "--split", default=FULL, choices=[TRAINING, VALIDATION, TEST, FULL], help="the split to test the model on" ) # ---------------- # Model parameters # ---------------- parser.add_argument("-m", "--model_path", help="model to load", required=True) parser.add_argument("-gc", "--generation_config", help="generation config (LLMs only)", default=None) # ------------------------- # Output results parameters # ------------------------- parser.add_argument( "-od", "--output_directory", type=str, default="results", help="directory that contains the results" ) parser.add_argument( "-ssuo", "--skip_save_unprocessed_output", help="skips saving intermediate NPY output files", action="store_true", default=False, ) parser.add_argument( "-sstp", "--skip_save_predictions", help="skips saving predictions CSV files", action="store_true", default=False, ) # ------------------ # Generic parameters # ------------------ parser.add_argument("-bs", "--batch_size", type=int, default=128, help="size of batches") # ------------------ # Runtime parameters # ------------------ parser.add_argument("-g", "--gpus", type=int, default=0, help="list of gpu to use") parser.add_argument( "-gml", "--gpu_memory_limit", type=float, default=None, help="maximum memory fraction [0, 1] allowed to allocate per GPU device", ) parser.add_argument( "-dpt", "--disable_parallel_threads", action="store_false", dest="allow_parallel_threads", help="disable PyTorch from using multithreading for reproducibility", ) parser.add_argument( "-b", "--backend", help="specifies backend to use for parallel / distributed execution, " "defaults to local execution", choices=ALL_BACKENDS, ) parser.add_argument( "-l", "--logging_level", default="info", help="the level of logging to use", choices=["critical", "error", "warning", "info", "debug", "notset"], ) add_contrib_callback_args(parser) args = parser.parse_args(sys_argv) args.callbacks = args.callbacks or [] for callback in args.callbacks: callback.on_cmdline("predict", *sys_argv) args.logging_level = get_logging_level_registry()[args.logging_level] logging.getLogger("ludwig").setLevel(args.logging_level) global logger logger = logging.getLogger("ludwig.predict") args.backend = initialize_backend(args.backend) if args.backend.is_coordinator(): print_ludwig("Predict", LUDWIG_VERSION) logger.info(f"Dataset path: {args.dataset}") logger.info(f"Model path: {args.model_path}") logger.info("") predict_cli(**vars(args)) if __name__ == "__main__": cli(sys.argv[1:]) ================================================ FILE: ludwig/preprocess.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import argparse import logging import sys import pandas as pd import yaml from ludwig.api import LudwigModel from ludwig.backend import ALL_BACKENDS, Backend, initialize_backend from ludwig.callbacks import Callback from ludwig.contrib import add_contrib_callback_args from ludwig.globals import LUDWIG_VERSION from ludwig.utils.data_utils import load_yaml from ludwig.utils.defaults import default_random_seed from ludwig.utils.print_utils import get_logging_level_registry, print_ludwig logger = logging.getLogger(__name__) def preprocess_cli( preprocessing_config: str | dict = None, dataset: str | dict | pd.DataFrame = None, training_set: str | dict | pd.DataFrame = None, validation_set: str | dict | pd.DataFrame = None, test_set: str | dict | pd.DataFrame = None, training_set_metadata: str | dict = None, data_format: str = None, random_seed: int = default_random_seed, logging_level: int = logging.INFO, callbacks: list[Callback] = None, backend: Backend | str = None, **kwargs ) -> None: """*train* defines the entire training procedure used by Ludwig's internals. Requires most of the parameters that are taken into the model. Builds a full ludwig model and performs the training. :param preprocessing_config: (Union[str, dict]) in-memory representation of config or string path to a YAML config file. :param dataset: (Union[str, dict, pandas.DataFrame], default: `None`) source containing the entire dataset to be used for training. If it has a split column, it will be used for splitting (0 for train, 1 for validation, 2 for test), otherwise the dataset will be randomly split. :param training_set: (Union[str, dict, pandas.DataFrame], default: `None`) source containing training data. :param validation_set: (Union[str, dict, pandas.DataFrame], default: `None`) source containing validation data. :param test_set: (Union[str, dict, pandas.DataFrame], default: `None`) source containing test data. :param training_set_metadata: (Union[str, dict], default: `None`) metadata JSON file or loaded metadata. Intermediate preprocessed structure containing the mappings of the input dataset created the first time an input file is used in the same directory with the same name and a '.meta.json' extension. :param data_format: (str, default: `None`) format to interpret data sources. Will be inferred automatically if not specified. Valid formats are `'auto'`, `'csv'`, `'excel'`, `'feather'`, `'fwf'`, `'hdf5'` (cache file produced during previous training), `'html'` (file containing a single HTML `
`), `'json'`, `'jsonl'`, `'parquet'`, `'pickle'` (pickled Pandas DataFrame), `'sas'`, `'spss'`, `'stata'`, `'tsv'`. :param experiment_name: (str, default: `'experiment'`) name for the experiment. :param model_name: (str, default: `'run'`) name of the model that is being used. :param model_load_path: (str, default: `None`) if this is specified the loaded model will be used as initialization (useful for transfer learning). :param model_resume_path: (str, default: `None`) resumes training of the model from the path specified. The config is restored. In addition to config, training statistics, loss for each epoch and the state of the optimizer are restored such that training can be effectively continued from a previously interrupted training process. :param skip_save_training_description: (bool, default: `False`) disables saving the description JSON file. :param skip_save_training_statistics: (bool, default: `False`) disables saving training statistics JSON file. :param skip_save_model: (bool, default: `False`) disables saving model weights and hyperparameters each time the model improves. By default Ludwig saves model weights after each epoch the validation metric improves, but if the model is really big that can be time consuming. If you do not want to keep the weights and just find out what performance a model can get with a set of hyperparameters, use this parameter to skip it, but the model will not be loadable later on and the returned model will have the weights obtained at the end of training, instead of the weights of the epoch with the best validation performance. :param skip_save_progress: (bool, default: `False`) disables saving progress each epoch. By default Ludwig saves weights and stats after each epoch for enabling resuming of training, but if the model is really big that can be time consuming and will uses twice as much space, use this parameter to skip it, but training cannot be resumed later on. :param skip_save_log: (bool, default: `False`) disables saving TensorBoard logs. By default Ludwig saves logs for the TensorBoard, but if it is not needed turning it off can slightly increase the overall speed. :param skip_save_processed_input: (bool, default: `False`) if input dataset is provided it is preprocessed and cached by saving an HDF5 and JSON files to avoid running the preprocessing again. If this parameter is `False`, the HDF5 and JSON file are not saved. :param output_directory: (str, default: `'results'`) the directory that will contain the training statistics, TensorBoard logs, the saved model and the training progress files. :param gpus: (list, default: `None`) list of GPUs that are available for training. :param gpu_memory_limit: (float: default: `None`) maximum memory fraction [0, 1] allowed to allocate per GPU device. :param allow_parallel_threads: (bool, default: `True`) allow PyTorch to use multithreading parallelism to improve performance at the cost of determinism. :param callbacks: (list, default: `None`) a list of `ludwig.callbacks.Callback` objects that provide hooks into the Ludwig pipeline. :param backend: (Union[Backend, str]) `Backend` or string name of backend to use to execute preprocessing / training steps. :param random_seed: (int: default: 42) random seed used for weights initialization, splits and any other random function. :param logging_level: (int) Log level that will be sent to stderr. # Return :return: (`None`) """ model = LudwigModel( config=preprocessing_config, logging_level=logging_level, callbacks=callbacks, backend=backend, ) model.preprocess( dataset=dataset, training_set=training_set, validation_set=validation_set, test_set=test_set, training_set_metadata=training_set_metadata, data_format=data_format, skip_save_processed_input=False, random_seed=random_seed, ) def cli(sys_argv): parser = argparse.ArgumentParser( description="This script preprocess a dataset", prog="ludwig preprocess", usage="%(prog)s [options]" ) # --------------- # Data parameters # --------------- parser.add_argument( "--dataset", help="input data file path. " "If it has a split column, it will be used for splitting " "(0: train, 1: validation, 2: test), " "otherwise the dataset will be randomly split", ) parser.add_argument("--training_set", help="input train data file path") parser.add_argument("--validation_set", help="input validation data file path") parser.add_argument("--test_set", help="input test data file path") parser.add_argument( "--training_set_metadata", help="input metadata JSON file path. An intermediate preprocessed file " "containing the mappings of the input file created " "the first time a file is used, in the same directory " "with the same name and a .json extension", ) parser.add_argument( "--data_format", help="format of the input data", default="auto", choices=[ "auto", "csv", "excel", "feather", "fwf", "hdf5", "html" "tables", "json", "jsonl", "parquet", "pickle", "sas", "spss", "stata", "tsv", ], ) # ---------------- # Model parameters # ---------------- preprocessing_def = parser.add_mutually_exclusive_group(required=True) preprocessing_def.add_argument( "-pc", "--preprocessing_config", dest="preprocessing_config", type=load_yaml, help="YAML file describing the preprocessing. " "Ignores --preprocessing_config." "Uses the same format of config, " "but ignores encoder specific parameters, " "decoder specific parameters, combiner and training parameters", ) preprocessing_def.add_argument( "-pcs", "--preprocessing_config_str", type=yaml.safe_load, help="preproceesing config. " "Uses the same format of config, " "but ignores encoder specific parameters, " "decoder specific parameters, combiner and training parameters", ) # ------------------ # Runtime parameters # ------------------ parser.add_argument( "-rs", "--random_seed", type=int, default=42, help="a random seed that is going to be used anywhere there is a call " "to a random number generator: data splitting, parameter " "initialization and training set shuffling", ) parser.add_argument( "-b", "--backend", help="specifies backend to use for parallel / distributed execution, " "defaults to local execution", choices=ALL_BACKENDS, ) parser.add_argument( "-l", "--logging_level", default="info", help="the level of logging to use", choices=["critical", "error", "warning", "info", "debug", "notset"], ) add_contrib_callback_args(parser) args = parser.parse_args(sys_argv) args.callbacks = args.callbacks or [] for callback in args.callbacks: callback.on_cmdline("preprocess", *sys_argv) args.logging_level = get_logging_level_registry()[args.logging_level] logging.getLogger("ludwig").setLevel(args.logging_level) global logger logger = logging.getLogger("ludwig.preprocess") args.backend = initialize_backend(args.backend) if args.backend.is_coordinator(): print_ludwig("Preprocess", LUDWIG_VERSION) preprocess_cli(**vars(args)) if __name__ == "__main__": cli(sys.argv[1:]) ================================================ FILE: ludwig/progress_bar.py ================================================ import uuid import tqdm try: import ray.train as rt except ImportError: rt = None class LudwigProgressBarActions: CREATE = "create" UPDATE = "update" CLOSE = "close" class LudwigProgressBar: """Class for progress bars that supports distributed progress bars in ray. # Inputs :param report_to_ray: (bool) use the ray.train.report method to report progress to the ray driver. If false then this behaves as a normal tqdm progress bar :param config: (dict) the tqdm configs used for the progress bar. See https://github.com/tqdm/tqdm#parameters for list of parameters :param is_coordinator: (bool) whether the calling process is the coordinator process. # Example usage: ```python from ludwig.progress_bar import LudwigProgressBar config = {"total": 20, "desc": "Sample progress bar"} pbar = LudwigProgressBar(report_to_ray=False, config=config, is_coordinator=True) for i in range(20): pbar.update(1) pbar.close() ``` """ def __init__( self, report_to_ray: bool, config: dict, is_coordinator: bool, ) -> None: """Constructor for the LudwigProgressBar class. # Inputs :param report_to_ray: (bool) use the ray.train.report method to report progress to the ray driver. If false then this behaves as a normal tqdm progress bar :param config: (dict) the tqdm configs used for the progress bar. See https://github.com/tqdm/tqdm#parameters for list of parameters :param is_coordinator: (bool) whether the calling process is the coordinator process. # Return :return: (None) `None` """ if report_to_ray and rt is None: raise ValueError("Set report_to_ray=True but ray is not installed. Run `pip install ray`") self.id = str(uuid.uuid4())[-8:] self.report_to_ray = report_to_ray self.is_coordinator = is_coordinator self.config = config self.total_steps = 0 self.progress_bar = None if not self.report_to_ray: if self.is_coordinator: self.progress_bar = tqdm.tqdm(**config) else: if "file" in self.config: self.config.pop("file") # All processes need to call ray.train.report since ray has a lock that blocks # a process when calling report if there are processes that haven't called it. Similar # to a distributed checkpoint. Therefore we pass the flag to the driver. # In Ray 2.x, rt.report() only accepts metrics and checkpoint kwargs, # so we pass progress_bar data inside the metrics dict. rt.report( metrics={ "progress_bar": { "id": self.id, "config": self.config, "action": LudwigProgressBarActions.CREATE, "is_coordinator": self.is_coordinator, } } ) def set_postfix(self, ordered_dict: dict = None, **kwargs) -> None: """Sets the postfix (additional stats) for the progress bar.""" if self.progress_bar: self.progress_bar.set_postfix(ordered_dict, **kwargs) def update(self, steps: int) -> None: """Updates the progress bar. # Inputs :param steps: (int) number of steps to update the progress bar by # Return :return: (None) `None` """ self.total_steps += steps if self.progress_bar: self.progress_bar.update(steps) elif self.report_to_ray: rt.report( metrics={ "progress_bar": { "id": self.id, "update_by": steps, "is_coordinator": self.is_coordinator, "action": LudwigProgressBarActions.UPDATE, } } ) def close(self) -> None: """Closes the progress bar. # Return :return: (None) `None` """ if self.progress_bar: self.progress_bar.close() elif self.report_to_ray: rt.report( metrics={ "progress_bar": { "id": self.id, "is_coordinator": self.is_coordinator, "action": LudwigProgressBarActions.CLOSE, } } ) ================================================ FILE: ludwig/schema/__init__.py ================================================ # TODO(travis): figure out why we need these imports to avoid circular import error from ludwig.schema.combiners.utils import get_combiner_jsonschema # noqa from ludwig.schema.features.utils import get_input_feature_jsonschema, get_output_feature_jsonschema # noqa from ludwig.schema.hyperopt import get_hyperopt_jsonschema # noqa from ludwig.schema.trainer import get_model_type_jsonschema, get_trainer_jsonschema # noqa ================================================ FILE: ludwig/schema/combiners/__init__.py ================================================ import ludwig.schema.combiners.comparator # noqa: F401 import ludwig.schema.combiners.concat # noqa: F401 import ludwig.schema.combiners.project_aggregate # noqa: F401 import ludwig.schema.combiners.sequence # noqa: F401 import ludwig.schema.combiners.sequence_concat # noqa: F401 import ludwig.schema.combiners.tab_transformer # noqa: F401 import ludwig.schema.combiners.tabnet # noqa: F401 import ludwig.schema.combiners.transformer # noqa: F401 ================================================ FILE: ludwig/schema/combiners/base.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.schema import utils as schema_utils from ludwig.schema.utils import ludwig_dataclass @DeveloperAPI @ludwig_dataclass class BaseCombinerConfig(schema_utils.BaseMarshmallowConfig): """Base combiner config class.""" type: str ================================================ FILE: ludwig/schema/combiners/common_transformer_options.py ================================================ from typing import Any from ludwig.api_annotations import DeveloperAPI from ludwig.schema import common_fields from ludwig.schema import utils as schema_utils from ludwig.schema.metadata import COMBINER_METADATA from ludwig.schema.utils import ludwig_dataclass @DeveloperAPI @ludwig_dataclass class CommonTransformerConfig: """Common transformer parameter values.""" dropout: float = schema_utils.FloatRange( default=0.1, min=0, max=1, description="Dropout rate for the transformer block.", parameter_metadata=COMBINER_METADATA["transformer"]["dropout"], ) transformer_output_size: int = schema_utils.NonNegativeInteger( default=256, description="Size of the fully connected layer after self attention in the transformer block. This is usually " "the same as `hidden_size` and `embedding_size`.", parameter_metadata=COMBINER_METADATA["transformer"]["transformer_output_size"], ) hidden_size: int = schema_utils.NonNegativeInteger( default=256, description="The number of hidden units of the TransformerStack as well as the dimension that each incoming " "input feature is projected to before feeding to the TransformerStack.", parameter_metadata=COMBINER_METADATA["transformer"]["hidden_size"], ) num_layers: int = schema_utils.PositiveInteger( default=1, description="The number of transformer layers.", parameter_metadata=COMBINER_METADATA["transformer"]["num_layers"], ) num_heads: int = schema_utils.NonNegativeInteger( default=8, description="Number of heads of the self attention in the transformer block.", parameter_metadata=COMBINER_METADATA["transformer"]["num_heads"], ) use_bias: bool = schema_utils.Boolean( default=True, description="Whether the layer uses a bias vector.", parameter_metadata=COMBINER_METADATA["transformer"]["use_bias"], ) bias_initializer: str | dict = common_fields.BiasInitializerField() weights_initializer: str | dict = common_fields.WeightsInitializerField() # TODO(#1673): Add conditional logic for fields like this one: num_fc_layers: int = schema_utils.NonNegativeInteger( default=0, description="The number of stacked fully connected layers (only applies if `reduce_output` is not null).", parameter_metadata=COMBINER_METADATA["transformer"]["num_fc_layers"], ) output_size: int = schema_utils.PositiveInteger( default=256, description="Output size of a fully connected layer.", parameter_metadata=COMBINER_METADATA["transformer"]["output_size"], ) norm: str | None = common_fields.NormField() norm_params: dict | None = common_fields.NormParamsField() fc_layers: list[dict[str, Any]] | None = common_fields.FCLayersField() fc_dropout: float = common_fields.DropoutField() fc_activation: str = schema_utils.ActivationOptions( default="relu", parameter_metadata=COMBINER_METADATA["transformer"]["fc_activation"], ) fc_residual: bool = common_fields.ResidualField() ================================================ FILE: ludwig/schema/combiners/comparator.py ================================================ from typing import Any from ludwig.api_annotations import DeveloperAPI from ludwig.error import ConfigValidationError from ludwig.schema import common_fields from ludwig.schema import utils as schema_utils from ludwig.schema.combiners.base import BaseCombinerConfig from ludwig.schema.combiners.utils import register_combiner_config from ludwig.schema.metadata import COMBINER_METADATA from ludwig.schema.utils import ludwig_dataclass @DeveloperAPI @register_combiner_config("comparator") @ludwig_dataclass class ComparatorCombinerConfig(BaseCombinerConfig): """Parameters for comparator combiner.""" def __post_init__(self): if self.num_fc_layers == 0 and self.fc_layers is None: raise ConfigValidationError( "`combiner.type=comparator` requires at least one fully connected layer. " "Set `num_fc_layers > 0` or `fc_layers`." ) if not self.entity_1: raise ConfigValidationError( "`combiner.entity_1` is required and must contain as least one input feature name." ) if not self.entity_2: raise ConfigValidationError( "`combiner.entity_2` is required and must contain as least one input feature name." ) type: str = schema_utils.ProtectedString( "comparator", description=COMBINER_METADATA["comparator"]["type"].long_description, ) entity_1: list[str] = schema_utils.List( default=None, description=( "The list of input feature names `[feature_1, feature_2, ...]` constituting the first entity to compare. " "*Required*." ), parameter_metadata=COMBINER_METADATA["comparator"]["entity_1"], ) entity_2: list[str] = schema_utils.List( default=None, description=( "The list of input feature names `[feature_1, feature_2, ...]` constituting the second entity to compare. " "*Required*." ), parameter_metadata=COMBINER_METADATA["comparator"]["entity_2"], ) dropout: float = common_fields.DropoutField() activation: str = schema_utils.ActivationOptions(default="relu") use_bias: bool = schema_utils.Boolean( default=True, description="Whether the layer uses a bias vector.", parameter_metadata=COMBINER_METADATA["comparator"]["use_bias"], ) bias_initializer: str | dict = common_fields.BiasInitializerField() weights_initializer: str | dict = common_fields.WeightsInitializerField() num_fc_layers: int = common_fields.NumFCLayersField(default=1) output_size: int = schema_utils.PositiveInteger( default=256, description="Output size of a fully connected layer.", parameter_metadata=COMBINER_METADATA["comparator"]["output_size"], ) norm: str | None = common_fields.NormField() norm_params: dict | None = common_fields.NormParamsField() fc_layers: list[dict[str, Any]] | None = common_fields.FCLayersField() ================================================ FILE: ludwig/schema/combiners/concat.py ================================================ from typing import Any from ludwig.api_annotations import DeveloperAPI from ludwig.schema import common_fields from ludwig.schema import utils as schema_utils from ludwig.schema.combiners.base import BaseCombinerConfig from ludwig.schema.combiners.utils import register_combiner_config from ludwig.schema.metadata import COMBINER_METADATA from ludwig.schema.utils import ludwig_dataclass @DeveloperAPI @register_combiner_config("concat") @ludwig_dataclass class ConcatCombinerConfig(BaseCombinerConfig): """Parameters for concat combiner.""" type: str = schema_utils.ProtectedString( "concat", description=COMBINER_METADATA["concat"]["type"].long_description, ) dropout: float = common_fields.DropoutField() activation: str = schema_utils.ActivationOptions(default="relu") flatten_inputs: bool = schema_utils.Boolean( default=False, description="Whether to flatten input tensors to a vector.", parameter_metadata=COMBINER_METADATA["concat"]["flatten_inputs"], ) residual: bool = common_fields.ResidualField() use_bias: bool = schema_utils.Boolean( default=True, description="Whether the layer uses a bias vector.", parameter_metadata=COMBINER_METADATA["concat"]["use_bias"], ) bias_initializer: str | dict = common_fields.BiasInitializerField() weights_initializer: str | dict = common_fields.WeightsInitializerField() num_fc_layers: int = common_fields.NumFCLayersField() output_size: int = schema_utils.PositiveInteger( default=256, description="Output size of a fully connected layer.", parameter_metadata=COMBINER_METADATA["concat"]["output_size"], ) norm: str | None = common_fields.NormField() norm_params: dict | None = common_fields.NormParamsField() fc_layers: list[dict[str, Any]] | None = common_fields.FCLayersField() ================================================ FILE: ludwig/schema/combiners/project_aggregate.py ================================================ from typing import Any from ludwig.api_annotations import DeveloperAPI from ludwig.schema import utils as schema_utils from ludwig.schema.combiners.base import BaseCombinerConfig from ludwig.schema.combiners.utils import register_combiner_config from ludwig.schema.metadata import COMBINER_METADATA from ludwig.schema.utils import ludwig_dataclass @DeveloperAPI @register_combiner_config("project_aggregate") @ludwig_dataclass class ProjectAggregateCombinerConfig(BaseCombinerConfig): type: str = schema_utils.ProtectedString( "project_aggregate", description=COMBINER_METADATA["project_aggregate"]["type"].long_description, ) projection_size: int = schema_utils.PositiveInteger( default=128, description="All combiner inputs are projected to this size before being aggregated.", parameter_metadata=COMBINER_METADATA["project_aggregate"]["projection_size"], ) residual: bool = schema_utils.Boolean( default=True, description="Whether to add residual skip connection between the fully connected layers in the stack.", parameter_metadata=COMBINER_METADATA["project_aggregate"]["residual"], ) dropout: float = schema_utils.FloatRange( default=0.0, min=0, max=1, description="Dropout rate to apply to each fully connected layer.", parameter_metadata=COMBINER_METADATA["project_aggregate"]["dropout"], ) activation: str = schema_utils.ActivationOptions( default="relu", description="Activation to apply to each fully connected layer.", parameter_metadata=COMBINER_METADATA["project_aggregate"]["activation"], ) num_fc_layers: int = schema_utils.NonNegativeInteger( default=2, description="Number of fully connected layers after aggregation.", parameter_metadata=COMBINER_METADATA["project_aggregate"]["num_fc_layers"], ) output_size: int = schema_utils.PositiveInteger( default=128, description="Output size of each layer of the stack of fully connected layers.", parameter_metadata=COMBINER_METADATA["project_aggregate"]["output_size"], ) norm: str | None = schema_utils.StringOptions( ["batch", "layer"], default="layer", description="Normalization to apply to each projection and fully connected layer.", parameter_metadata=COMBINER_METADATA["project_aggregate"]["norm"], ) norm_params: dict | None = schema_utils.Dict( description="Parameters of the normalization to apply to each projection and fully connected layer.", parameter_metadata=COMBINER_METADATA["project_aggregate"]["norm_params"], ) fc_layers: list[dict[str, Any]] | None = schema_utils.DictList( description="Full specification of the fully connected layers after the aggregation. It should be a list of " "dict, each dict representing one layer of the fully connected layer stack. ", parameter_metadata=COMBINER_METADATA["project_aggregate"]["fc_layers"], ) use_bias: bool = schema_utils.Boolean( default=True, description="Whether the layers use a bias vector.", parameter_metadata=COMBINER_METADATA["project_aggregate"]["use_bias"], ) bias_initializer: str | dict = schema_utils.InitializerOrDict( default="zeros", description="Initializer to use for the bias of the projection and for the fully connected layers.", parameter_metadata=COMBINER_METADATA["project_aggregate"]["bias_initializer"], ) weights_initializer: str | dict = schema_utils.InitializerOrDict( default="xavier_uniform", description="Initializer to use for the weights of the projection and for the fully connected layers.", parameter_metadata=COMBINER_METADATA["project_aggregate"]["weights_initializer"], ) ================================================ FILE: ludwig/schema/combiners/sequence.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.constants import MODEL_ECD, SEQUENCE from ludwig.schema import utils as schema_utils from ludwig.schema.combiners.base import BaseCombinerConfig from ludwig.schema.combiners.sequence_concat import MAIN_SEQUENCE_FEATURE_DESCRIPTION from ludwig.schema.combiners.utils import register_combiner_config from ludwig.schema.encoders.base import BaseEncoderConfig from ludwig.schema.encoders.utils import EncoderDataclassField from ludwig.schema.metadata import COMBINER_METADATA from ludwig.schema.utils import ludwig_dataclass """ SEQUENCE encoders that always return 2D [batch_size, hidden_size] tensors, regardless of how they are parameterized. These should never be used with modules that expect 3D tensors, such as the SequenceCombiner. """ _2D_SEQUENCE_ENCODERS = ["embed"] @DeveloperAPI @register_combiner_config("sequence") @ludwig_dataclass class SequenceCombinerConfig(BaseCombinerConfig): """Parameters for sequence combiner.""" type: str = schema_utils.ProtectedString( "sequence", description=COMBINER_METADATA["sequence"]["type"].long_description, ) main_sequence_feature: str | None = schema_utils.String( default=None, allow_none=True, description=MAIN_SEQUENCE_FEATURE_DESCRIPTION, parameter_metadata=COMBINER_METADATA["sequence"]["main_sequence_feature"], ) encoder: BaseEncoderConfig = EncoderDataclassField( MODEL_ECD, feature_type=SEQUENCE, default="parallel_cnn", description="Encoder to apply to `main_sequence_feature`. The encoder must produce" " a tensor of size [batch_size, sequence_length, hidden_size]", blocklist=_2D_SEQUENCE_ENCODERS, ) reduce_output: str | None = schema_utils.ReductionOptions( default=None, description="Strategy to use to aggregate the embeddings of the items of the set.", parameter_metadata=COMBINER_METADATA["sequence"]["reduce_output"], ) ================================================ FILE: ludwig/schema/combiners/sequence_concat.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.schema import utils as schema_utils from ludwig.schema.combiners.base import BaseCombinerConfig from ludwig.schema.combiners.utils import register_combiner_config from ludwig.schema.metadata import COMBINER_METADATA from ludwig.schema.utils import ludwig_dataclass MAIN_SEQUENCE_FEATURE_DESCRIPTION = """ Name of a sequence, text, or time series feature to concatenate the outputs of the other features to. If no `main_sequence_feature` is specified, the combiner will look through all the features in the order they are defined in the configuration and will look for a feature with a rank 3 tensor output (sequence, text or time series). If it cannot find one it will raise an exception, otherwise the output of that feature will be used for concatenating the other features along the sequence `s` dimension. If there are other input features with a rank 3 output tensor, the combiner will concatenate them alongside the `s` dimension. All sequence-like input features must have identical `s` dimension, otherwise an error will be thrown. """ @DeveloperAPI @register_combiner_config("sequence_concat") @ludwig_dataclass class SequenceConcatCombinerConfig(BaseCombinerConfig): """Parameters for sequence concat combiner.""" @staticmethod def module_name(): return "sequence_concat" type: str = schema_utils.ProtectedString( "sequence_concat", description=COMBINER_METADATA["sequence_concat"]["type"].long_description, ) main_sequence_feature: str | None = schema_utils.String( default=None, allow_none=True, description=MAIN_SEQUENCE_FEATURE_DESCRIPTION, parameter_metadata=COMBINER_METADATA["sequence_concat"]["main_sequence_feature"], ) reduce_output: str | None = schema_utils.ReductionOptions( default=None, description="Strategy to use to aggregate the embeddings of the items of the set.", parameter_metadata=COMBINER_METADATA["sequence_concat"]["reduce_output"], ) ================================================ FILE: ludwig/schema/combiners/tab_transformer.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.schema import utils as schema_utils from ludwig.schema.combiners.base import BaseCombinerConfig from ludwig.schema.combiners.common_transformer_options import CommonTransformerConfig from ludwig.schema.combiners.utils import register_combiner_config from ludwig.schema.metadata import COMBINER_METADATA from ludwig.schema.utils import ludwig_dataclass @DeveloperAPI @register_combiner_config("tabtransformer") @ludwig_dataclass class TabTransformerCombinerConfig(BaseCombinerConfig, CommonTransformerConfig): """Parameters for tab transformer combiner.""" type: str = schema_utils.ProtectedString( "tabtransformer", description=COMBINER_METADATA["tabtransformer"]["type"].long_description, ) embed_input_feature_name: str | int | None = schema_utils.Embed( description="This value controls the size of the embeddings. Valid values are `add` which uses the " "`hidden_size` value or an integer that is set to a specific value. In the case of an integer " "value, it must be smaller than hidden_size.", parameter_metadata=COMBINER_METADATA["tabtransformer"]["embed_input_feature_name"], ) reduce_output: str = schema_utils.ReductionOptions( default="concat", description="Strategy to use to aggregate the output of the transformer.", parameter_metadata=COMBINER_METADATA["tabtransformer"]["reduce_output"], ) ================================================ FILE: ludwig/schema/combiners/tabnet.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.schema import utils as schema_utils from ludwig.schema.combiners.base import BaseCombinerConfig from ludwig.schema.combiners.utils import register_combiner_config from ludwig.schema.metadata import COMBINER_METADATA from ludwig.schema.utils import ludwig_dataclass @DeveloperAPI @register_combiner_config("tabnet") @ludwig_dataclass class TabNetCombinerConfig(BaseCombinerConfig): """Parameters for tabnet combiner.""" type: str = schema_utils.ProtectedString( "tabnet", description=COMBINER_METADATA["tabnet"]["type"].long_description, ) size: int = schema_utils.PositiveInteger( default=32, description="Size of the hidden layers. `N_a` in (Arik and Pfister, 2019).", parameter_metadata=COMBINER_METADATA["tabnet"]["size"], ) dropout: float = schema_utils.FloatRange( default=0.05, min=0, max=1, description="Dropout rate for the transformer block.", parameter_metadata=COMBINER_METADATA["tabnet"]["dropout"], ) output_size: int = schema_utils.PositiveInteger( default=128, description="Output size of a fully connected layer. `N_d` in (Arik and Pfister, 2019).", parameter_metadata=COMBINER_METADATA["tabnet"]["output_size"], ) num_steps: int = schema_utils.NonNegativeInteger( default=3, description="Number of steps / repetitions of the the attentive transformer and feature transformer " "computations. `N_steps` in (Arik and Pfister, 2019).", parameter_metadata=COMBINER_METADATA["tabnet"]["num_steps"], ) num_total_blocks: int = schema_utils.NonNegativeInteger( default=4, description="Total number of feature transformer blocks at each step.", parameter_metadata=COMBINER_METADATA["tabnet"]["num_total_blocks"], ) num_shared_blocks: int = schema_utils.NonNegativeInteger( default=2, description="Number of shared feature transformer blocks across the steps.", parameter_metadata=COMBINER_METADATA["tabnet"]["num_shared_blocks"], ) relaxation_factor: float = schema_utils.FloatRange( default=1.5, description="Factor that influences how many times a feature should be used across the steps of computation. " "a value of 1 implies it each feature should be use once, a higher value allows for multiple " "usages. `gamma` in (Arik and Pfister, 2019).", parameter_metadata=COMBINER_METADATA["tabnet"]["relaxation_factor"], ) bn_epsilon: float = schema_utils.FloatRange( default=1e-3, description="Epsilon to be added to the batch norm denominator.", parameter_metadata=COMBINER_METADATA["tabnet"]["bn_epsilon"], ) bn_momentum: float = schema_utils.FloatRange( default=0.05, description="Momentum of the batch norm. 1 - `m_B` from the TabNet paper.", parameter_metadata=COMBINER_METADATA["tabnet"]["bn_momentum"], ) bn_virtual_bs: int | None = schema_utils.PositiveInteger( default=1024, allow_none=True, description="Size of the virtual batch size used by ghost batch norm. If null, regular batch norm is used " "instead. `B_v` from the TabNet paper.", parameter_metadata=COMBINER_METADATA["tabnet"]["bn_virtual_bs"], ) sparsity: float = schema_utils.FloatRange( default=1e-4, description="Multiplier of the sparsity inducing loss. `lambda_sparse` in (Arik and Pfister, 2019).", parameter_metadata=COMBINER_METADATA["tabnet"]["sparsity"], ) entmax_mode: str = schema_utils.StringOptions( ["entmax15", "sparsemax", "constant", "adaptive"], default="sparsemax", description=( "Entmax is a sparse family of probability mapping which generalizes softmax and sparsemax. " "`entmax_mode` controls the sparsity" ), parameter_metadata=COMBINER_METADATA["tabnet"]["entmax_mode"], ) entmax_alpha: float = schema_utils.FloatRange( default=1.5, min=1, max=2, description=( "Must be a number between 1.0 and 2.0. If entmax_mode is `adaptive`, " "`entmax_alpha` is used as the initial value for the learnable parameter. " "1 corresponds to softmax, 2 is sparsemax." ), parameter_metadata=COMBINER_METADATA["tabnet"]["entmax_alpha"], ) ================================================ FILE: ludwig/schema/combiners/transformer.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.schema import utils as schema_utils from ludwig.schema.combiners.base import BaseCombinerConfig from ludwig.schema.combiners.common_transformer_options import CommonTransformerConfig from ludwig.schema.combiners.utils import register_combiner_config from ludwig.schema.metadata import COMBINER_METADATA from ludwig.schema.utils import ludwig_dataclass @DeveloperAPI @register_combiner_config("transformer") @ludwig_dataclass class TransformerCombinerConfig(BaseCombinerConfig, CommonTransformerConfig): """Parameters for transformer combiner.""" type: str = schema_utils.ProtectedString( "transformer", description=COMBINER_METADATA["transformer"]["type"].long_description, ) reduce_output: str | None = schema_utils.ReductionOptions( default="mean", description="Strategy to use to aggregate the output of the transformer.", parameter_metadata=COMBINER_METADATA["transformer"]["reduce_output"], ) ================================================ FILE: ludwig/schema/combiners/utils.py ================================================ from typing import Any from ludwig.api_annotations import DeveloperAPI from ludwig.constants import TYPE from ludwig.schema import utils as schema_utils from ludwig.schema.combiners.base import BaseCombinerConfig from ludwig.schema.metadata import COMBINER_METADATA from ludwig.schema.metadata.parameter_metadata import convert_metadata_to_json, ParameterMetadata from ludwig.utils.registry import Registry DEFAULT_VALUE = "concat" DESCRIPTION = "Select the combiner type." combiner_config_registry = Registry[type[BaseCombinerConfig]]() @DeveloperAPI def register_combiner_config(name: str): def wrap(cls: type[BaseCombinerConfig]): combiner_config_registry[name] = cls return cls return wrap @DeveloperAPI def get_combiner_registry(): return combiner_config_registry @DeveloperAPI def get_combiner_jsonschema(): """Returns a JSON schema structured to only require a `type` key and then conditionally apply a corresponding combiner's field constraints.""" combiner_types = sorted(list(combiner_config_registry.keys())) parameter_metadata = convert_metadata_to_json( ParameterMetadata.from_dict( { "commonly_used": True, "expected_impact": 3, "ui_display_name": "Combiner Type", } ) ) return { "type": "object", "properties": { "type": { "type": "string", "enum": combiner_types, "enumDescriptions": get_combiner_descriptions(), "default": DEFAULT_VALUE, "title": "combiner_options", "description": DESCRIPTION, "parameter_metadata": parameter_metadata, }, }, "allOf": get_combiner_conds(), "required": ["type"], } @DeveloperAPI def get_combiner_descriptions(): """This function returns a dictionary of combiner descriptions available at the type selection. The process works as follows - 1) Get a dictionary of valid combiners from the combiner config registry, but inverse the key/value pairs since we need to index `valid_combiners` later with an altered version of the combiner config class name. 2) Loop through Combiner Metadata entries, if a metadata entry has a combiner name that matches a valid combiner, add the description metadata to the output dictionary. Returns: dict: A dictionary of combiner descriptions. """ return {k: convert_metadata_to_json(v[TYPE]) for k, v in COMBINER_METADATA.items() if k in combiner_config_registry} @DeveloperAPI def get_combiner_conds() -> list[dict[str, Any]]: """Returns a list of if-then JSON clauses for each combiner type in `combiner_registry` and its properties' constraints.""" combiner_types = sorted(list(combiner_config_registry.keys())) conds = [] for combiner_type in combiner_types: combiner_cls = combiner_config_registry[combiner_type] schema_cls = combiner_cls combiner_schema = schema_utils.unload_jsonschema_from_marshmallow_class(schema_cls) combiner_props = combiner_schema["properties"] schema_utils.remove_duplicate_fields(combiner_props) combiner_cond = schema_utils.create_cond({"type": combiner_type}, combiner_props) conds.append(combiner_cond) return conds class CombinerSelection(schema_utils.TypeSelection): def __init__(self): # For registration of all combiners import ludwig.combiners.combiners # noqa super().__init__(registry=combiner_config_registry, default_value=DEFAULT_VALUE, description=DESCRIPTION) def get_schema_from_registry(self, key: str) -> type[schema_utils.BaseMarshmallowConfig]: return self.registry[key] def _jsonschema_type_mapping(self): return get_combiner_jsonschema() ================================================ FILE: ludwig/schema/common_fields.py ================================================ from dataclasses import Field from ludwig.schema import utils as schema_utils from ludwig.schema.metadata import COMMON_METADATA from ludwig.schema.metadata.parameter_metadata import ParameterMetadata from ludwig.utils.torch_utils import initializer_registry def DropoutField(default: float = 0.0, description: str = None, parameter_metadata: ParameterMetadata = None) -> Field: description = description or "Default dropout rate applied to fully connected layers." full_description = description + ( " Increasing dropout is a common form of regularization to combat overfitting. " "The dropout is expressed as the probability of an element to be zeroed out (0.0 means no dropout)." ) parameter_metadata = parameter_metadata or COMMON_METADATA["dropout"] return schema_utils.FloatRange( default=default, min=0, max=1, description=full_description, parameter_metadata=parameter_metadata, ) def ResidualField( default: bool = False, description: str = None, parameter_metadata: ParameterMetadata = None ) -> Field: description = description or ( "Whether to add a residual connection to each fully connected layer block. " "Requires all fully connected layers to have the same `output_size`." ) parameter_metadata = parameter_metadata or COMMON_METADATA["residual"] return schema_utils.Boolean( default=False, description=description, parameter_metadata=parameter_metadata, ) def NumFCLayersField( default: int = 0, description: str = None, parameter_metadata: ParameterMetadata = None, non_zero=False ) -> Field: assert (not non_zero) or (default > 0 and non_zero) description = description or "Number of stacked fully connected layers to apply." full_description = description + ( " Increasing layers adds capacity to the model, enabling it to learn more complex feature interactions." ) parameter_metadata = parameter_metadata or COMMON_METADATA["num_fc_layers"] # When using a dense encoder, the number of fully connected layers must be strictly greater than 0. if non_zero: return schema_utils.PositiveInteger( default=default, allow_none=False, description=full_description, parameter_metadata=parameter_metadata ) return schema_utils.NonNegativeInteger( default=default, allow_none=False, description=full_description, parameter_metadata=parameter_metadata, ) def NormField( default: str | None = None, description: str = None, parameter_metadata: ParameterMetadata = None ) -> Field: description = description or "Default normalization applied at the beginnging of fully connected layers." parameter_metadata = parameter_metadata or COMMON_METADATA["norm"] return schema_utils.StringOptions( ["batch", "layer", "ghost"], default=default, allow_none=True, description=description, parameter_metadata=parameter_metadata, ) def NormParamsField(description: str = None, parameter_metadata: ParameterMetadata = None) -> Field: description = description or "Default parameters passed to the `norm` module." parameter_metadata = parameter_metadata or COMMON_METADATA["norm_params"] return schema_utils.Dict( description=description, parameter_metadata=parameter_metadata, ) def FCLayersField(description: str = None, parameter_metadata: ParameterMetadata = None) -> Field: description = description or ( "List of dictionaries containing the parameters of all the fully connected layers. " "The length of the list determines the number of stacked fully connected layers " "and the content of each dictionary determines the parameters for a specific layer. " "The available parameters for each layer are: `activation`, `dropout`, `norm`, `norm_params`, " "`output_size`, `use_bias`, `bias_initializer` and `weights_initializer`. If any of those values " "is missing from the dictionary, the default one provided as a standalone parameter will be used instead." ) parameter_metadata = parameter_metadata or COMMON_METADATA["fc_layers"] return schema_utils.DictList( description=description, parameter_metadata=parameter_metadata, ) INITIALIZER_SUFFIX = """ Alternatively it is possible to specify a dictionary with a key `type` that identifies the type of initializer and other keys for its parameters, e.g. `{type: normal, mean: 0, stddev: 0}`. For a description of the parameters of each initializer, see [torch.nn.init](https://pytorch.org/docs/stable/nn.init.html). """ def BiasInitializerField( default: str = "zeros", description: str = None, parameter_metadata: ParameterMetadata = None ) -> Field: initializers_str = ", ".join([f"`{i}`" for i in initializer_registry.keys()]) description = description or "Initializer for the bias vector." full_description = f"{description} Options: {initializers_str}. {INITIALIZER_SUFFIX}" parameter_metadata = parameter_metadata or COMMON_METADATA["bias_initializer"] return schema_utils.InitializerOrDict( default=default, description=full_description, parameter_metadata=parameter_metadata, ) def WeightsInitializerField( default: str = "xavier_uniform", description: str = None, parameter_metadata: ParameterMetadata = None ) -> Field: initializers_str = ", ".join([f"`{i}`" for i in initializer_registry.keys()]) description = description or "Initializer for the weight matrix." full_description = f"{description} Options: {initializers_str}. {INITIALIZER_SUFFIX}" parameter_metadata = parameter_metadata or COMMON_METADATA["weights_initializer"] return schema_utils.InitializerOrDict( default=default, description=full_description, parameter_metadata=parameter_metadata, ) def EmbeddingInitializerField( default: str | None = None, description: str = None, parameter_metadata: ParameterMetadata = None ) -> Field: description = description or "Initializer for the embedding matrix." parameter_metadata = parameter_metadata or COMMON_METADATA["embedding_initializer"] return schema_utils.StringOptions( list(initializer_registry.keys()), default=default, allow_none=True, description=description, parameter_metadata=parameter_metadata, ) def EmbeddingSizeField( default: int = 256, description: str = None, parameter_metadata: ParameterMetadata = None ) -> Field: description = description or ( "The maximum embedding size. The actual size will be `min(vocabulary_size, embedding_size)` for " "`dense` representations and exactly `vocabulary_size` for the `sparse` encoding, where `vocabulary_size` " "is the number of unique strings appearing in the training set input column plus the number of " "special tokens (``, ``, ``, ``)." ) parameter_metadata = parameter_metadata or COMMON_METADATA["embedding_size"] return schema_utils.PositiveInteger( default=default, description=description, parameter_metadata=parameter_metadata, ) def EmbeddingsOnCPUField( default: bool = False, description: str = None, parameter_metadata: ParameterMetadata = None ) -> Field: description = description or ( "Whether to force the placement of the embedding matrix in regular memory and have the CPU resolve them. " "By default embedding matrices are stored on GPU memory if a GPU is used, as it allows for faster access, " "but in some cases the embedding matrix may be too large. This parameter forces the placement of the " "embedding matrix in regular memory and the CPU is used for embedding lookup, slightly slowing down the " "process as a result of data transfer between CPU and GPU memory." ) parameter_metadata = parameter_metadata or COMMON_METADATA["embeddings_on_cpu"] return schema_utils.Boolean( default=default, description=description, parameter_metadata=parameter_metadata, ) def EmbeddingsTrainableField( default: bool = True, description: str = None, parameter_metadata: ParameterMetadata = None ) -> Field: description = description or ( "If `true` embeddings are trained during the training process, if `false` embeddings are fixed. " "It may be useful when loading pretrained embeddings for avoiding finetuning them. This parameter " "has effect only when `representation` is `dense`; `sparse` one-hot encodings are not trainable." ) parameter_metadata = parameter_metadata or COMMON_METADATA["embeddings_trainable"] return schema_utils.Boolean( default=default, description=description, parameter_metadata=parameter_metadata, ) def PretrainedEmbeddingsField( default: str | None = None, description: str = None, parameter_metadata: ParameterMetadata = None ) -> Field: description = description or ( "Path to a file containing pretrained embeddings. By default `dense` embeddings are initialized " "randomly, but this parameter allows to specify a path to a file containing embeddings in the " "[GloVe format](https://nlp.stanford.edu/projects/glove/). When the file containing the embeddings is " "loaded, only the embeddings with labels present in the vocabulary are kept, the others are discarded. " "If the vocabulary contains strings that have no match in the embeddings file, their embeddings are " "initialized with the average of all other embedding plus some random noise to make them different " "from each other. This parameter has effect only if `representation` is `dense`." ) parameter_metadata = parameter_metadata or COMMON_METADATA["pretrained_embeddings"] return schema_utils.String( default=default, allow_none=True, description=description, parameter_metadata=parameter_metadata, ) def MaxSequenceLengthField( default: int | None = None, description: str = None, parameter_metadata: ParameterMetadata = None ) -> Field: description = description or "[internal] Maximum sequence length from preprocessing." parameter_metadata = parameter_metadata or COMMON_METADATA["max_sequence_length"] return schema_utils.PositiveInteger( default=default, allow_none=True, description=description, parameter_metadata=parameter_metadata, ) def VocabField( default: list | None = None, description: str = None, parameter_metadata: ParameterMetadata = None ) -> Field: description = description or "[internal] Vocabulary for the encoder from preprocessing." parameter_metadata = parameter_metadata or COMMON_METADATA["vocab"] return schema_utils.List( default=default, description=description, parameter_metadata=parameter_metadata, ) def VocabSizeField( default: list | None = None, description: str = None, parameter_metadata: ParameterMetadata = None ) -> Field: description = description or "[internal] Size of the vocabulary from preprocessing." parameter_metadata = parameter_metadata or COMMON_METADATA["vocab_size"] return schema_utils.PositiveInteger( default=default, allow_none=True, description=description, parameter_metadata=parameter_metadata, ) def RepresentationField( default: str = "dense", description: str = None, parameter_metadata: ParameterMetadata = None ) -> Field: description = description or ( "Representation of the embedding. `dense` means the embeddings are initialized randomly, " "`sparse` means they are initialized to be one-hot encodings." ) parameter_metadata = parameter_metadata or COMMON_METADATA["representation"] return schema_utils.StringOptions( ["dense", "sparse"], default=default, description=description, parameter_metadata=parameter_metadata, ) def ReduceOutputField( default: str | None = "sum", description: str = None, parameter_metadata: ParameterMetadata = None ) -> Field: description = description or ( "How to reduce the output tensor along the `s` sequence length dimension if the rank of the " "tensor is greater than 2." ) parameter_metadata = parameter_metadata or COMMON_METADATA["reduce_output"] return schema_utils.ReductionOptions( default=default, description=description, parameter_metadata=parameter_metadata, ) ================================================ FILE: ludwig/schema/decoders/__init__.py ================================================ # Register all decoders import ludwig.schema.decoders.base import ludwig.schema.decoders.image_decoders # noqa import ludwig.schema.decoders.llm_decoders # noqa import ludwig.schema.decoders.sequence_decoders # noqa ================================================ FILE: ludwig/schema/decoders/base.py ================================================ from abc import ABC from ludwig.api_annotations import DeveloperAPI from ludwig.constants import BINARY, CATEGORY, MODEL_ECD, MODEL_LLM, NUMBER, SET, TIMESERIES, VECTOR from ludwig.schema import common_fields from ludwig.schema import utils as schema_utils from ludwig.schema.decoders.utils import register_decoder_config from ludwig.schema.metadata import DECODER_METADATA from ludwig.schema.utils import ludwig_dataclass @DeveloperAPI @ludwig_dataclass class BaseDecoderConfig(schema_utils.BaseMarshmallowConfig, ABC): """Base class for decoders.""" type: str = schema_utils.StringOptions( ["regressor", "classifier", "projector", "generator", "tagger"], default=None, allow_none=True, description="The type of decoder to use.", parameter_metadata=DECODER_METADATA["BaseDecoder"]["type"], ) fc_layers: list[dict] = common_fields.FCLayersField() num_fc_layers: int = common_fields.NumFCLayersField( description="Number of fully-connected layers if `fc_layers` not specified." ) fc_output_size: int = schema_utils.PositiveInteger( default=256, description="Output size of fully connected stack.", parameter_metadata=DECODER_METADATA["BaseDecoder"]["fc_output_size"], ) fc_use_bias: bool = schema_utils.Boolean( default=True, description="Whether the layer uses a bias vector in the fc_stack.", parameter_metadata=DECODER_METADATA["BaseDecoder"]["fc_use_bias"], ) fc_weights_initializer: str | dict = schema_utils.OneOfOptionsField( default="xavier_uniform", allow_none=True, description="The weights initializer to use for the layers in the fc_stack", field_options=[ schema_utils.InitializerOptions( description="Preconfigured initializer to use for the layers in the fc_stack.", parameter_metadata=DECODER_METADATA["BaseDecoder"]["fc_weights_initializer"], ), schema_utils.Dict( description="Custom initializer to use for the layers in the fc_stack.", parameter_metadata=DECODER_METADATA["BaseDecoder"]["fc_weights_initializer"], ), ], parameter_metadata=DECODER_METADATA["BaseDecoder"]["fc_weights_initializer"], ) fc_bias_initializer: str | dict = schema_utils.OneOfOptionsField( default="zeros", allow_none=True, description="The bias initializer to use for the layers in the fc_stack", field_options=[ schema_utils.InitializerOptions( description="Preconfigured bias initializer to use for the layers in the fc_stack.", parameter_metadata=DECODER_METADATA["BaseDecoder"]["fc_bias_initializer"], ), schema_utils.Dict( description="Custom bias initializer to use for the layers in the fc_stack.", parameter_metadata=DECODER_METADATA["BaseDecoder"]["fc_bias_initializer"], ), ], parameter_metadata=DECODER_METADATA["BaseDecoder"]["fc_bias_initializer"], ) fc_norm: str = common_fields.NormField() fc_norm_params: dict = common_fields.NormParamsField() fc_activation: str = schema_utils.ActivationOptions(default="relu") fc_dropout: float = common_fields.DropoutField() @DeveloperAPI @ludwig_dataclass class PassthroughDecoderConfig(BaseDecoderConfig): """PassthroughDecoderConfig is a dataclass that configures the parameters used for a passthrough decoder.""" @classmethod def module_name(cls): return "PassthroughDecoder" type: str = schema_utils.ProtectedString( "passthrough", description="The passthrough decoder simply returns the raw numerical values coming from the combiner as " "outputs", parameter_metadata=DECODER_METADATA["PassthroughDecoder"]["type"], ) input_size: int = schema_utils.PositiveInteger( default=1, description="Size of the input to the decoder.", parameter_metadata=DECODER_METADATA["PassthroughDecoder"]["input_size"], ) @DeveloperAPI @register_decoder_config("regressor", [BINARY, NUMBER], model_types=[MODEL_ECD]) @ludwig_dataclass class RegressorConfig(BaseDecoderConfig): """RegressorConfig is a dataclass that configures the parameters used for a regressor decoder.""" @classmethod def module_name(cls): return "Regressor" type: str = schema_utils.ProtectedString( "regressor", description=DECODER_METADATA["Regressor"]["type"].long_description, ) input_size: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Size of the input to the decoder.", parameter_metadata=DECODER_METADATA["Regressor"]["input_size"], ) use_bias: bool = schema_utils.Boolean( default=True, description="Whether the layer uses a bias vector.", parameter_metadata=DECODER_METADATA["Regressor"]["use_bias"], ) weights_initializer: str = schema_utils.InitializerOptions( description="Initializer for the weight matrix.", parameter_metadata=DECODER_METADATA["Regressor"]["weights_initializer"], ) bias_initializer: str = schema_utils.InitializerOptions( default="zeros", description="Initializer for the bias vector.", parameter_metadata=DECODER_METADATA["Regressor"]["bias_initializer"], ) @DeveloperAPI @register_decoder_config("projector", [VECTOR, TIMESERIES], model_types=[MODEL_ECD]) @ludwig_dataclass class ProjectorConfig(BaseDecoderConfig): """ProjectorConfig is a dataclass that configures the parameters used for a projector decoder.""" @classmethod def module_name(cls): return "Projector" type: str = schema_utils.ProtectedString( "projector", description=DECODER_METADATA["Projector"]["type"].long_description, ) input_size: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Size of the input to the decoder.", parameter_metadata=DECODER_METADATA["Projector"]["input_size"], ) output_size: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Size of the output of the decoder.", parameter_metadata=DECODER_METADATA["Projector"]["output_size"], ) use_bias: bool = schema_utils.Boolean( default=True, description="Whether the layer uses a bias vector.", parameter_metadata=DECODER_METADATA["Projector"]["use_bias"], ) weights_initializer: str = schema_utils.InitializerOptions( description="Initializer for the weight matrix.", parameter_metadata=DECODER_METADATA["Projector"]["weights_initializer"], ) bias_initializer: str = schema_utils.InitializerOptions( default="zeros", description="Initializer for the bias vector.", parameter_metadata=DECODER_METADATA["Projector"]["bias_initializer"], ) activation: str = schema_utils.ActivationOptions( default=None, description=" Indicates the activation function applied to the output.", parameter_metadata=DECODER_METADATA["Projector"]["activation"], ) multiplier: float = schema_utils.FloatRange( default=1.0, min=0, min_inclusive=False, description=( "Multiplier to scale the activated outputs by. Useful when setting `activation` to something " "that outputs a value between [-1, 1] like tanh to re-scale values back to order of magnitude of " "the data you're trying to predict. A good rule of thumb in such cases is to pick a value like " "`x * (max - min)` where x is a scalar in the range [1, 2]. For example, if you're trying to predict " "something like temperature, it might make sense to pick a multiplier on the order of `100`." ), ) clip: list[int] | tuple[int] = schema_utils.FloatRangeTupleDataclassField( n=2, default=None, allow_none=True, min=0, max=999999999, description="Clip the output of the decoder to be within the given range.", parameter_metadata=DECODER_METADATA["Projector"]["clip"], ) @DeveloperAPI @register_decoder_config("classifier", [CATEGORY, SET], model_types=[MODEL_ECD, MODEL_LLM]) @ludwig_dataclass class ClassifierConfig(BaseDecoderConfig): @classmethod def module_name(cls): return "Classifier" type: str = schema_utils.ProtectedString( "classifier", description=DECODER_METADATA["Classifier"]["type"].long_description, ) input_size: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Size of the input to the decoder.", parameter_metadata=DECODER_METADATA["Classifier"]["input_size"], ) num_classes: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Number of classes to predict.", parameter_metadata=DECODER_METADATA["Classifier"]["num_classes"], ) use_bias: bool = schema_utils.Boolean( default=True, description="Whether the layer uses a bias vector.", parameter_metadata=DECODER_METADATA["Classifier"]["use_bias"], ) weights_initializer: str = schema_utils.InitializerOptions( description="Initializer for the weight matrix.", parameter_metadata=DECODER_METADATA["Classifier"]["weights_initializer"], ) bias_initializer: str = schema_utils.InitializerOptions( default="zeros", description="Initializer for the bias vector.", parameter_metadata=DECODER_METADATA["Classifier"]["bias_initializer"], ) ================================================ FILE: ludwig/schema/decoders/image_decoders.py ================================================ from typing import TYPE_CHECKING from ludwig.api_annotations import DeveloperAPI from ludwig.constants import IMAGE, MODEL_ECD from ludwig.schema import utils as schema_utils from ludwig.schema.decoders.base import BaseDecoderConfig from ludwig.schema.decoders.utils import register_decoder_config from ludwig.schema.metadata import DECODER_METADATA from ludwig.schema.utils import ludwig_dataclass if TYPE_CHECKING: from ludwig.schema.features.preprocessing.image import ImagePreprocessingConfig class ImageDecoderConfig(BaseDecoderConfig): def set_fixed_preprocessing_params(self, model_type: str, preprocessing: "ImagePreprocessingConfig"): preprocessing.requires_equal_dimensions = False preprocessing.height = None preprocessing.width = None @DeveloperAPI @register_decoder_config("unet", [IMAGE], model_types=[MODEL_ECD]) @ludwig_dataclass class UNetDecoderConfig(ImageDecoderConfig): @staticmethod def module_name(): return "UNetDecoder" type: str = schema_utils.ProtectedString( "unet", description=DECODER_METADATA["UNetDecoder"]["type"].long_description, ) input_size: int = schema_utils.PositiveInteger( default=1024, description="Size of the input to the decoder.", parameter_metadata=DECODER_METADATA["UNetDecoder"]["input_size"], ) height: int = schema_utils.NonNegativeInteger( default=None, allow_none=True, description="Height of the output image.", parameter_metadata=DECODER_METADATA["UNetDecoder"]["height"], ) width: int = schema_utils.NonNegativeInteger( default=None, allow_none=True, description="Width of the output image.", parameter_metadata=DECODER_METADATA["UNetDecoder"]["width"], ) num_channels: int | None = schema_utils.NonNegativeInteger( default=None, allow_none=True, description="Number of channels in the output image. ", parameter_metadata=DECODER_METADATA["UNetDecoder"]["num_channels"], ) conv_norm: str | None = schema_utils.StringOptions( ["batch"], default="batch", allow_none=True, description="This is the default norm that will be used for each double conv layer." "It can be null or batch.", parameter_metadata=DECODER_METADATA["UNetDecoder"]["conv_norm"], ) num_classes: int | None = schema_utils.NonNegativeInteger( default=None, allow_none=True, description="Number of classes to predict in the output. ", parameter_metadata=DECODER_METADATA["UNetDecoder"]["num_classes"], ) ================================================ FILE: ludwig/schema/decoders/llm_decoders.py ================================================ from typing import Any from ludwig.api_annotations import DeveloperAPI from ludwig.constants import CATEGORY, MODEL_LLM, TEXT from ludwig.schema import utils as schema_utils from ludwig.schema.decoders.base import BaseDecoderConfig from ludwig.schema.decoders.utils import register_decoder_config from ludwig.schema.utils import BaseMarshmallowConfig, ludwig_dataclass @DeveloperAPI @ludwig_dataclass class BaseExtractorDecoderConfig(BaseMarshmallowConfig): tokenizer: str = "hf_tokenizer" input_size: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Size of the input to the decoder.", ) pretrained_model_name_or_path: str = schema_utils.String( default="", allow_none=True, description="Path to the pretrained model or model identifier from huggingface.co/models.", ) vocab_file: str = schema_utils.String( default="", allow_none=True, description="Path to the vocabulary file.", ) max_new_tokens: int = schema_utils.Integer( default=None, allow_none=True, description="Maximum number of new tokens that will be generated.", ) @DeveloperAPI @register_decoder_config("text_extractor", [TEXT], model_types=[MODEL_LLM]) @ludwig_dataclass class TextExtractorDecoderConfig(BaseExtractorDecoderConfig, BaseDecoderConfig): @classmethod def module_name(cls): return "TextExtractorDecoder" type: str = schema_utils.ProtectedString("text_extractor") @DeveloperAPI @register_decoder_config("category_extractor", [CATEGORY], model_types=[MODEL_LLM]) @ludwig_dataclass class CategoryExtractorDecoderConfig(BaseExtractorDecoderConfig, BaseDecoderConfig): @classmethod def module_name(cls): return "CategoryExtractorDecoder" type: str = schema_utils.ProtectedString("category_extractor") # Match is a dict of label class match: dict[str, dict[str, Any]] = schema_utils.Dict( default=None, allow_none=False, description="A dictionary of label classes and their corresponding " "match patterns definitions that will be used to parse the output " "of the LLM.", ) str2idx: dict[str, int] = schema_utils.Dict( default=None, allow_none=True, description="A dictionary of label classes and their corresponding " "indices that will be used to parse the output of the LLM.", ) fallback_label: str = schema_utils.String( default="", allow_none=True, description="The label to use if the parser fails to parse the input.", ) ================================================ FILE: ludwig/schema/decoders/sequence_decoders.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.constants import MODEL_ECD, SEQUENCE, TEXT from ludwig.schema import common_fields from ludwig.schema import utils as schema_utils from ludwig.schema.decoders.base import BaseDecoderConfig from ludwig.schema.decoders.utils import register_decoder_config from ludwig.schema.metadata import DECODER_METADATA from ludwig.schema.utils import ludwig_dataclass @DeveloperAPI @register_decoder_config("generator", [SEQUENCE, TEXT], model_types=[MODEL_ECD]) @ludwig_dataclass class SequenceGeneratorDecoderConfig(BaseDecoderConfig): @staticmethod def module_name(): return "SequenceGeneratorDecoder" type: str = schema_utils.ProtectedString( "generator", description=DECODER_METADATA["SequenceGeneratorDecoder"]["type"].long_description, ) vocab_size: int = common_fields.VocabSizeField() max_sequence_length: int = common_fields.MaxSequenceLengthField() cell_type: str = schema_utils.StringOptions( ["rnn", "lstm", "gru"], default="gru", description="Type of recurrent cell to use.", parameter_metadata=DECODER_METADATA["SequenceGeneratorDecoder"]["cell_type"], ) input_size: int = schema_utils.PositiveInteger( default=256, description="Size of the input to the decoder.", parameter_metadata=DECODER_METADATA["SequenceGeneratorDecoder"]["input_size"], ) reduce_input: str = schema_utils.StringOptions( ["sum", "mean", "avg", "max", "concat", "last"], default="sum", description="How to reduce an input that is not a vector, but a matrix or a higher order tensor, on the first " "dimension (second if you count the batch dimension)", parameter_metadata=DECODER_METADATA["SequenceGeneratorDecoder"]["reduce_input"], ) num_layers: int = schema_utils.PositiveInteger( default=1, description="The number of stacked recurrent layers.", parameter_metadata=DECODER_METADATA["SequenceGeneratorDecoder"]["num_layers"], ) @DeveloperAPI @register_decoder_config("tagger", [SEQUENCE, TEXT], model_types=[MODEL_ECD]) @ludwig_dataclass class SequenceTaggerDecoderConfig(BaseDecoderConfig): @classmethod def module_name(cls): return "SequenceTaggerDecoder" type: str = schema_utils.ProtectedString( "tagger", description=DECODER_METADATA["SequenceTaggerDecoder"]["type"].long_description, ) input_size: int = schema_utils.PositiveInteger( default=256, description="Size of the input to the decoder.", parameter_metadata=DECODER_METADATA["SequenceTaggerDecoder"]["input_size"], ) vocab_size: int = common_fields.VocabSizeField() max_sequence_length: int = common_fields.MaxSequenceLengthField() use_attention: bool = schema_utils.Boolean( default=False, description="Whether to apply a multi-head self attention layer before prediction.", parameter_metadata=DECODER_METADATA["SequenceTaggerDecoder"]["use_attention"], ) use_bias: bool = schema_utils.Boolean( default=True, description="Whether the layer uses a bias vector.", parameter_metadata=DECODER_METADATA["SequenceTaggerDecoder"]["use_bias"], ) attention_embedding_size: int = schema_utils.PositiveInteger( default=256, description="The embedding size of the multi-head self attention layer.", parameter_metadata=DECODER_METADATA["SequenceTaggerDecoder"]["attention_embedding_size"], ) attention_num_heads: int = schema_utils.PositiveInteger( default=8, description="The number of attention heads in the multi-head self attention layer.", parameter_metadata=DECODER_METADATA["SequenceTaggerDecoder"]["attention_num_heads"], ) ================================================ FILE: ludwig/schema/decoders/utils.py ================================================ from dataclasses import Field from typing import Any, TYPE_CHECKING from ludwig.api_annotations import DeveloperAPI from ludwig.constants import MODEL_ECD, TYPE from ludwig.schema import utils as schema_utils from ludwig.schema.metadata import DECODER_METADATA from ludwig.schema.metadata.parameter_metadata import convert_metadata_to_json from ludwig.utils.registry import Registry if TYPE_CHECKING: from ludwig.schema.decoders.base import BaseDecoderConfig decoder_config_registry = Registry() @DeveloperAPI def register_decoder_config(name: str, features: str | list[str], model_types: list[str] | None = None): if model_types is None: model_types = [MODEL_ECD] if isinstance(features, str): features = [features] def wrap(cls): for model_type in model_types: for feature in features: key = (model_type, feature) feature_registry = decoder_config_registry.get(key, {}) feature_registry[name] = cls decoder_config_registry[key] = feature_registry return cls return wrap @DeveloperAPI def get_decoder_cls(model_type: str, feature: str, name: str): return decoder_config_registry[(model_type, feature)][name] @DeveloperAPI def get_decoder_classes(model_type: str, feature: str) -> dict[str, type["BaseDecoderConfig"]]: return decoder_config_registry[(model_type, feature)] @DeveloperAPI def get_decoder_descriptions(model_type: str, feature_type: str): """This function returns a dictionary of decoder descriptions available at the type selection. The process works as follows - 1) Get a dictionary of valid decoders from the decoder config registry, but inverse the key/value pairs since we need to index `valid_decoders` later with an altered version of the decoder config class name. 2) Loop through Decoder Metadata entries, if a metadata entry has a decoder name that matches a valid decoder, add the description metadata to the output dictionary. Args: model_type (str): The model type to get decoder descriptions for feature_type (str): The feature type to get decoder descriptions for Returns: dict: A dictionary of decoder descriptions """ output = {} valid_decoders = { cls.module_name() if hasattr(cls, "module_name") else None: registered_name for registered_name, cls in get_decoder_classes(model_type, feature_type).items() } for k, v in DECODER_METADATA.items(): if k in valid_decoders.keys(): output[valid_decoders[k]] = convert_metadata_to_json(v[TYPE]) return output @DeveloperAPI def get_decoder_conds(decoder_classes: dict[str, type["BaseDecoderConfig"]]) -> list[dict[str, Any]]: """Returns a JSON schema of conditionals to validate against decoder types for specific feature types.""" conds = [] for decoder_type, decoder_cls in decoder_classes.items(): other_props = schema_utils.unload_jsonschema_from_marshmallow_class(decoder_cls)["properties"] schema_utils.remove_duplicate_fields(other_props) decoder_cond = schema_utils.create_cond( {"type": decoder_type}, other_props, ) conds.append(decoder_cond) return conds @DeveloperAPI def DecoderDataclassField(model_type: str, feature_type: str, default: str) -> Field: """Custom dataclass field that when used inside a dataclass will allow the user to specify a decoder config. Returns: Initialized dataclass field that converts an untyped dict with params to a decoder config. """ decoder_registry = get_decoder_classes(model_type, feature_type) class DecoderSelection(schema_utils.TypeSelection): def __init__(self): super().__init__(registry=decoder_registry, default_value=default, allow_str_value=True) def get_schema_from_registry(self, key: str) -> type[schema_utils.BaseMarshmallowConfig]: return decoder_registry[key] def _jsonschema_type_mapping(self): return { "type": "object", "properties": { "type": { "type": "string", "enum": list(decoder_registry.keys()), "enumDescriptions": get_decoder_descriptions(model_type, feature_type), "default": default, }, }, "title": "decoder_options", "allOf": get_decoder_conds(decoder_registry), } return DecoderSelection().get_default_field() ================================================ FILE: ludwig/schema/defaults/__init__.py ================================================ ================================================ FILE: ludwig/schema/defaults/base.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.schema import utils as schema_utils from ludwig.schema.utils import ludwig_dataclass @DeveloperAPI @ludwig_dataclass class BaseDefaultsConfig(schema_utils.BaseMarshmallowConfig): """Base defaults config class.""" ================================================ FILE: ludwig/schema/defaults/ecd.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.constants import ( AUDIO, BAG, BINARY, CATEGORY, DATE, H3, IMAGE, NUMBER, SEQUENCE, SET, TEXT, TIMESERIES, VECTOR, ) from ludwig.schema import utils as schema_utils from ludwig.schema.defaults.base import BaseDefaultsConfig from ludwig.schema.defaults.utils import DefaultsDataclassField from ludwig.schema.features.base import BaseFeatureConfig from ludwig.schema.utils import ludwig_dataclass @DeveloperAPI @ludwig_dataclass class ECDDefaultsConfig(BaseDefaultsConfig): audio: BaseFeatureConfig = DefaultsDataclassField(feature_type=AUDIO) bag: BaseFeatureConfig = DefaultsDataclassField(feature_type=BAG) binary: BaseFeatureConfig = DefaultsDataclassField(feature_type=BINARY) category: BaseFeatureConfig = DefaultsDataclassField(feature_type=CATEGORY) date: BaseFeatureConfig = DefaultsDataclassField(feature_type=DATE) h3: BaseFeatureConfig = DefaultsDataclassField(feature_type=H3) image: BaseFeatureConfig = DefaultsDataclassField(feature_type=IMAGE) number: BaseFeatureConfig = DefaultsDataclassField(feature_type=NUMBER) sequence: BaseFeatureConfig = DefaultsDataclassField(feature_type=SEQUENCE) set: BaseFeatureConfig = DefaultsDataclassField(feature_type=SET) text: BaseFeatureConfig = DefaultsDataclassField(feature_type=TEXT) timeseries: BaseFeatureConfig = DefaultsDataclassField(feature_type=TIMESERIES) vector: BaseFeatureConfig = DefaultsDataclassField(feature_type=VECTOR) @DeveloperAPI class ECDDefaultsField(schema_utils.DictMarshmallowField): def __init__(self): super().__init__(ECDDefaultsConfig) ================================================ FILE: ludwig/schema/defaults/llm.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.constants import TEXT from ludwig.schema import utils as schema_utils from ludwig.schema.defaults.base import BaseDefaultsConfig from ludwig.schema.defaults.utils import DefaultsDataclassField from ludwig.schema.features.base import BaseFeatureConfig from ludwig.schema.features.utils import llm_defaults_config_registry from ludwig.schema.utils import ludwig_dataclass @DeveloperAPI @ludwig_dataclass class LLMDefaultsConfig(BaseDefaultsConfig): text: BaseFeatureConfig = DefaultsDataclassField(feature_type=TEXT, defaults_registry=llm_defaults_config_registry) @DeveloperAPI class LLMDefaultsField(schema_utils.DictMarshmallowField): def __init__(self): super().__init__(LLMDefaultsConfig) ================================================ FILE: ludwig/schema/defaults/utils.py ================================================ from dataclasses import field import ludwig.schema.utils as schema_utils from ludwig.api_annotations import DeveloperAPI from ludwig.error import ConfigValidationError from ludwig.schema.features.utils import ecd_defaults_config_registry from ludwig.utils.registry import Registry @DeveloperAPI def DefaultsDataclassField(feature_type: str, defaults_registry: Registry = ecd_defaults_config_registry): """Custom dataclass field that when used inside a dataclass will allow the user to specify a nested default config for a specific feature type. Returns: Initialized dataclass field that converts an untyped dict with params to a defaults config. """ class DefaultMarshmallowField(schema_utils.LudwigSchemaField): """Custom field that deserializes a dict for a valid defaults config from the feature_registry and creates a corresponding JSON schema for external usage.""" def _deserialize(self, value, attr, data, **kwargs): if value is None: return None if isinstance(value, dict): defaults_class = defaults_registry[feature_type] try: return defaults_class.Schema().load(value) except (TypeError, ConfigValidationError) as error: raise ConfigValidationError(f"Invalid params: {value}, see `{attr}` definition. Error: {error}") raise ConfigValidationError(f"Invalid params: {value}") def _jsonschema_type_mapping(self): defaults_cls = defaults_registry[feature_type] props = schema_utils.unload_jsonschema_from_marshmallow_class(defaults_cls)["properties"] return { "type": "object", "properties": props, "additionalProperties": False, "title": "defaults_options", } try: defaults_cls = defaults_registry[feature_type] dump_default = defaults_cls.Schema().dump({}) load_default = lambda: defaults_cls.Schema().load({}) return field( metadata={ "marshmallow_field": DefaultMarshmallowField( allow_none=False, dump_default=dump_default, load_default=load_default, ) }, default_factory=load_default, ) except Exception as e: raise ConfigValidationError( f"Unsupported feature type: {feature_type}. Allowed: {defaults_registry.keys()}. " f"Details: {e}" ) ================================================ FILE: ludwig/schema/encoders/__init__.py ================================================ # Register all encoder schemas import ludwig.schema.encoders.bag_encoders import ludwig.schema.encoders.category_encoders import ludwig.schema.encoders.date_encoders import ludwig.schema.encoders.h3_encoders import ludwig.schema.encoders.image import ludwig.schema.encoders.sequence_encoders import ludwig.schema.encoders.set_encoders import ludwig.schema.encoders.text_encoders # noqa ================================================ FILE: ludwig/schema/encoders/bag_encoders.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.constants import BAG from ludwig.schema import utils as schema_utils from ludwig.schema.encoders.base import BaseEncoderConfig from ludwig.schema.encoders.utils import register_encoder_config from ludwig.schema.metadata import ENCODER_METADATA from ludwig.schema.utils import ludwig_dataclass @DeveloperAPI @register_encoder_config("embed", BAG) @ludwig_dataclass class BagEmbedWeightedConfig(BaseEncoderConfig): @staticmethod def module_name(): return "BagEmbedWeighted" type: str = schema_utils.ProtectedString( "embed", description=ENCODER_METADATA["BagEmbedWeighted"]["type"].long_description, ) dropout: float = schema_utils.FloatRange( default=0.0, min=0, max=1, description="Dropout probability for the embedding.", parameter_metadata=ENCODER_METADATA["BagEmbedWeighted"]["dropout"], ) activation: str = schema_utils.ActivationOptions( description="The default activation function that will be used for each layer.", parameter_metadata=ENCODER_METADATA["BagEmbedWeighted"]["activation"], ) vocab: list[str] = schema_utils.List( default=None, description="Vocabulary of the encoder", parameter_metadata=ENCODER_METADATA["BagEmbedWeighted"]["vocab"], ) representation: str = schema_utils.StringOptions( ["dense", "sparse"], default="dense", description="The representation of the embedding. Either dense or sparse.", parameter_metadata=ENCODER_METADATA["BagEmbedWeighted"]["representation"], ) embedding_size: int = schema_utils.PositiveInteger( default=50, description="The maximum embedding size, the actual size will be min(vocabulary_size, embedding_size) for " "dense representations and exactly vocabulary_size for the sparse encoding, where vocabulary_size " "is the number of different strings appearing in the training set in the input column (plus 1 for " "the unknown token placeholder ).", parameter_metadata=ENCODER_METADATA["BagEmbedWeighted"]["embedding_size"], ) force_embedding_size: bool = schema_utils.Boolean( default=False, description="Force the embedding size to be equal to the vocabulary size. This parameter has effect only if " "representation is dense.", parameter_metadata=ENCODER_METADATA["BagEmbedWeighted"]["force_embedding_size"], ) embeddings_on_cpu: bool = schema_utils.Boolean( default=False, description="By default embedding matrices are stored on GPU memory if a GPU is used, as it allows for faster " "access, but in some cases the embedding matrix may be too large. This parameter forces the " "placement of the embedding matrix in regular memory and the CPU is used for embedding lookup, " "slightly slowing down the process as a result of data transfer between CPU and GPU memory.", parameter_metadata=ENCODER_METADATA["BagEmbedWeighted"]["embeddings_on_cpu"], ) embeddings_trainable: bool = schema_utils.Boolean( default=True, description="If true embeddings are trained during the training process, if false embeddings are fixed. It " "may be useful when loading pretrained embeddings for avoiding fine tuning them. This parameter " "has effect only when representation is dense as sparse one-hot encodings are not trainable.", parameter_metadata=ENCODER_METADATA["BagEmbedWeighted"]["embeddings_trainable"], ) pretrained_embeddings: str = schema_utils.String( default=None, allow_none=True, description="By default dense embeddings are initialized randomly, but this parameter allows to specify a " "path to a file containing embeddings in the GloVe format. When the file containing the " "embeddings is loaded, only the embeddings with labels present in the vocabulary are kept, " "the others are discarded. If the vocabulary contains strings that have no match in the " "embeddings file, their embeddings are initialized with the average of all other embedding plus " "some random noise to make them different from each other. This parameter has effect only if " "representation is dense.", parameter_metadata=ENCODER_METADATA["BagEmbedWeighted"]["pretrained_embeddings"], ) use_bias: bool = schema_utils.Boolean( default=True, description="Whether the layer uses a bias vector.", parameter_metadata=ENCODER_METADATA["BagEmbedWeighted"]["use_bias"], ) bias_initializer: str = schema_utils.InitializerOptions( default="zeros", description="Initializer to use for the bias vector.", parameter_metadata=ENCODER_METADATA["BagEmbedWeighted"]["bias_initializer"], ) weights_initializer: str = schema_utils.InitializerOptions( description="Initializer to use for the weights matrix.", parameter_metadata=ENCODER_METADATA["BagEmbedWeighted"]["weights_initializer"], ) output_size: int = schema_utils.PositiveInteger( default=10, description="If output_size is not already specified in fc_layers this is the default output_size that will " "be used for each layer. It indicates the size of the output of a fully connected layer.", parameter_metadata=ENCODER_METADATA["BagEmbedWeighted"]["output_size"], ) norm: str = schema_utils.StringOptions( ["batch", "layer"], default=None, allow_none=True, description="The default norm that will be used for each layer.", parameter_metadata=ENCODER_METADATA["BagEmbedWeighted"]["norm"], ) norm_params: dict = schema_utils.Dict( default=None, description="Parameters used if norm is either `batch` or `layer`.", parameter_metadata=ENCODER_METADATA["BagEmbedWeighted"]["norm_params"], ) num_fc_layers: int = schema_utils.NonNegativeInteger( default=0, description="This is the number of stacked fully connected layers that the input to the feature passes " "through. Their output is projected in the feature's output space.", parameter_metadata=ENCODER_METADATA["BagEmbedWeighted"]["num_fc_layers"], ) fc_layers: list[dict] = schema_utils.DictList( # TODO (Connor): Add nesting logic for fc_layers default=None, description="List of dictionaries containing the parameters for each fully connected layer.", parameter_metadata=ENCODER_METADATA["BagEmbedWeighted"]["fc_layers"], ) ================================================ FILE: ludwig/schema/encoders/base.py ================================================ from abc import ABC from typing import TYPE_CHECKING from ludwig.api_annotations import DeveloperAPI from ludwig.constants import BINARY, MODEL_ECD, MODEL_LLM, NUMBER, TEXT, TIMESERIES, VECTOR from ludwig.schema import common_fields from ludwig.schema import utils as schema_utils from ludwig.schema.encoders.utils import register_encoder_config from ludwig.schema.metadata import ENCODER_METADATA from ludwig.schema.utils import ludwig_dataclass if TYPE_CHECKING: from ludwig.schema.features.preprocessing.base import BasePreprocessingConfig @DeveloperAPI @ludwig_dataclass class BaseEncoderConfig(schema_utils.BaseMarshmallowConfig, ABC): """Base class for encoders.""" type: str skip: bool = schema_utils.Boolean( False, "[internal] Whether to skip encoder and use input as output.", parameter_metadata=ENCODER_METADATA["BaseEncoder"]["skip"], ) def set_fixed_preprocessing_params(self, model_type: str, preprocessing: "BasePreprocessingConfig"): pass def is_pretrained(self) -> bool: return False def can_cache_embeddings(self) -> bool: return False @DeveloperAPI @register_encoder_config("passthrough", [TEXT], model_types=[MODEL_LLM]) @register_encoder_config("passthrough", [BINARY, NUMBER, VECTOR], model_types=[MODEL_ECD]) @ludwig_dataclass class PassthroughEncoderConfig(BaseEncoderConfig): """PassthroughEncoderConfig is a dataclass that configures the parameters used for a passthrough encoder.""" @staticmethod def module_name(): return "PassthroughEncoder" type: str = schema_utils.ProtectedString( "passthrough", description=ENCODER_METADATA["PassthroughEncoder"]["type"].long_description, ) @DeveloperAPI @register_encoder_config("dense", [BINARY, NUMBER, VECTOR, TIMESERIES]) @ludwig_dataclass class DenseEncoderConfig(BaseEncoderConfig): """DenseEncoderConfig is a dataclass that configures the parameters used for a dense encoder.""" @staticmethod def module_name(): return "DenseEncoder" type: str = schema_utils.ProtectedString( "dense", description=ENCODER_METADATA["DenseEncoder"]["type"].long_description, ) dropout: float = common_fields.DropoutField() activation: str = schema_utils.ActivationOptions() input_size: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Size of the input to the dense encoder.", parameter_metadata=ENCODER_METADATA["DenseEncoder"]["input_size"], ) output_size: int = schema_utils.PositiveInteger( default=256, description="Size of the output of the feature.", parameter_metadata=ENCODER_METADATA["DenseEncoder"]["output_size"], ) use_bias: bool = schema_utils.Boolean( default=True, description="Whether the layer uses a bias vector.", parameter_metadata=ENCODER_METADATA["DenseEncoder"]["use_bias"], ) bias_initializer: str | dict = common_fields.BiasInitializerField() weights_initializer: str | dict = common_fields.WeightsInitializerField() norm: str = common_fields.NormField() norm_params: dict = common_fields.NormParamsField() num_layers: int = common_fields.NumFCLayersField(default=1, non_zero=True) fc_layers: list[dict] = common_fields.FCLayersField() ================================================ FILE: ludwig/schema/encoders/category_encoders.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.constants import CATEGORY, MODEL_ECD from ludwig.schema import common_fields from ludwig.schema import utils as schema_utils from ludwig.schema.encoders.base import BaseEncoderConfig from ludwig.schema.encoders.utils import register_encoder_config from ludwig.schema.metadata import ENCODER_METADATA from ludwig.schema.utils import ludwig_dataclass @DeveloperAPI @register_encoder_config("passthrough", CATEGORY, model_types=[MODEL_ECD]) @ludwig_dataclass class CategoricalPassthroughEncoderConfig(BaseEncoderConfig): """CategoricalPassthroughEncoderConfig is a dataclass that configures the parameters used for a categorical passthrough encoder.""" @staticmethod def module_name(): return "CategoricalPassthroughEncoder" type: str = schema_utils.ProtectedString( "passthrough", description=ENCODER_METADATA["PassthroughEncoder"]["type"].long_description, ) @DeveloperAPI @register_encoder_config("dense", CATEGORY) @ludwig_dataclass class CategoricalEmbedConfig(BaseEncoderConfig): @staticmethod def module_name(): return "CategoricalEmbed" type: str = schema_utils.ProtectedString( "dense", description=ENCODER_METADATA["CategoricalEmbed"]["type"].long_description, ) dropout: float = common_fields.DropoutField() vocab: list[str] = common_fields.VocabField() embedding_initializer: str = common_fields.EmbeddingInitializerField() embedding_size: int = common_fields.EmbeddingSizeField( default=50, description=( "The maximum embedding size, the actual size will be min(vocabulary_size, embedding_size) for " "dense representations and exactly vocabulary_size for the sparse encoding, where vocabulary_size " "is the number of different strings appearing in the training set in the column the feature is " "named after (plus 1 for )." ), ) embeddings_on_cpu: bool = common_fields.EmbeddingsOnCPUField() embeddings_trainable: bool = common_fields.EmbeddingsTrainableField() pretrained_embeddings: str = common_fields.PretrainedEmbeddingsField() @DeveloperAPI @register_encoder_config("sparse", CATEGORY) @ludwig_dataclass class CategoricalSparseConfig(BaseEncoderConfig): @staticmethod def module_name(): return "CategorySparse" type: str = schema_utils.ProtectedString( "sparse", description=ENCODER_METADATA["CategoricalSparse"]["type"].long_description, ) dropout: float = common_fields.DropoutField() vocab: list[str] = common_fields.VocabField() embedding_initializer: str = common_fields.EmbeddingInitializerField() embeddings_on_cpu: bool = common_fields.EmbeddingsOnCPUField() # TODO(travis): seems like this is not really a valid user option. We should probably just remove these # params entirely and update the encoder implementation. embeddings_trainable: bool = common_fields.EmbeddingsTrainableField(default=False) pretrained_embeddings: str = common_fields.PretrainedEmbeddingsField() @DeveloperAPI @register_encoder_config("onehot", CATEGORY, model_types=[MODEL_ECD]) @ludwig_dataclass class CategoricalOneHotEncoderConfig(BaseEncoderConfig): """CategoricalOneHotEncoderConfig is a dataclass that configures the parameters used for a categorical onehot encoder.""" type: str = schema_utils.ProtectedString( "onehot", description="Type of encoder.", ) vocab: list[str] = common_fields.VocabField() def can_cache_embeddings(self) -> bool: return True ================================================ FILE: ludwig/schema/encoders/date_encoders.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.constants import DATE from ludwig.schema import utils as schema_utils from ludwig.schema.encoders.base import BaseEncoderConfig from ludwig.schema.encoders.utils import register_encoder_config from ludwig.schema.metadata import ENCODER_METADATA from ludwig.schema.utils import ludwig_dataclass @DeveloperAPI @register_encoder_config("embed", DATE) @ludwig_dataclass class DateEmbedConfig(BaseEncoderConfig): @staticmethod def module_name(): return "DateEmbed" type: str = schema_utils.ProtectedString( "embed", description=ENCODER_METADATA["DateEmbed"]["type"].long_description, ) dropout: float = schema_utils.FloatRange( default=0.0, min=0, max=1, description="Dropout probability for the embedding.", parameter_metadata=ENCODER_METADATA["DateEmbed"]["dropout"], ) activation: str = schema_utils.ActivationOptions( description="The default activation function that will be used for each layer.", parameter_metadata=ENCODER_METADATA["DateEmbed"]["activation"], ) use_bias: bool = schema_utils.Boolean( default=True, description="Whether the layer uses a bias vector.", parameter_metadata=ENCODER_METADATA["DateEmbed"]["use_bias"], ) bias_initializer: str = schema_utils.InitializerOptions( default="zeros", description="Initializer to use for the bias vector.", parameter_metadata=ENCODER_METADATA["DateEmbed"]["bias_initializer"], ) weights_initializer: str = schema_utils.InitializerOptions( description="Initializer to use for the weights matrix.", parameter_metadata=ENCODER_METADATA["DateEmbed"]["weights_initializer"], ) embedding_size: int = schema_utils.PositiveInteger( default=10, description="The maximum embedding size adopted.", parameter_metadata=ENCODER_METADATA["DateEmbed"]["embedding_size"], ) embeddings_on_cpu: bool = schema_utils.Boolean( default=False, description="Whether to force the placement of the embedding matrix in regular memory and have the CPU " "resolve them.", parameter_metadata=ENCODER_METADATA["DateEmbed"]["embeddings_on_cpu"], ) output_size: int = schema_utils.PositiveInteger( default=10, description="If an output_size is not already specified in fc_layers this is the default output_size that " "will be used for each layer. It indicates the size of the output of a fully connected layer.", parameter_metadata=ENCODER_METADATA["DateEmbed"]["output_size"], ) norm: str = schema_utils.StringOptions( ["batch", "layer"], default=None, allow_none=True, description="The default norm that will be used for each layer.", parameter_metadata=ENCODER_METADATA["DateEmbed"]["norm"], ) norm_params: dict = schema_utils.Dict( default=None, description="Parameters used if norm is either `batch` or `layer`.", parameter_metadata=ENCODER_METADATA["DateEmbed"]["norm_params"], ) num_fc_layers: int = schema_utils.NonNegativeInteger( default=0, description="The number of stacked fully connected layers.", parameter_metadata=ENCODER_METADATA["DateEmbed"]["num_fc_layers"], ) # TODO (Connor): Add nesting logic for fc_layers, see fully_connected_module.py fc_layers: list[dict] = schema_utils.DictList( default=None, description="List of dictionaries containing the parameters for each fully connected layer.", parameter_metadata=ENCODER_METADATA["DateEmbed"]["fc_layers"], ) @DeveloperAPI @register_encoder_config("wave", DATE) @ludwig_dataclass class DateWaveConfig(BaseEncoderConfig): @staticmethod def module_name(): return "DateWave" type: str = schema_utils.ProtectedString( "wave", description=ENCODER_METADATA["DateWave"]["type"].long_description, ) dropout: float = schema_utils.FloatRange( default=0.0, min=0, max=1, description="Dropout probability for the embedding.", parameter_metadata=ENCODER_METADATA["DateWave"]["dropout"], ) activation: str = schema_utils.ActivationOptions( description="The default activation function that will be used for each layer.", parameter_metadata=ENCODER_METADATA["DateWave"]["activation"], ) use_bias: bool = schema_utils.Boolean( default=True, description="Whether the layer uses a bias vector.", parameter_metadata=ENCODER_METADATA["DateWave"]["use_bias"], ) bias_initializer: str = schema_utils.InitializerOptions( default="zeros", description="Initializer to use for the bias vector.", parameter_metadata=ENCODER_METADATA["DateWave"]["bias_initializer"], ) weights_initializer: str = schema_utils.InitializerOptions( description="Initializer to use for the weights matrix.", parameter_metadata=ENCODER_METADATA["DateWave"]["weights_initializer"], ) output_size: int = schema_utils.PositiveInteger( default=10, description="If an output_size is not already specified in fc_layers this is the default output_size that " "will be used for each layer. It indicates the size of the output of a fully connected layer.", parameter_metadata=ENCODER_METADATA["DateWave"]["output_size"], ) norm: str = schema_utils.StringOptions( ["batch", "layer"], default=None, allow_none=True, description="The default norm that will be used for each layer.", parameter_metadata=ENCODER_METADATA["DateWave"]["norm"], ) norm_params: dict = schema_utils.Dict( default=None, description="Parameters used if norm is either `batch` or `layer`.", parameter_metadata=ENCODER_METADATA["DateWave"]["norm_params"], ) num_fc_layers: int = schema_utils.PositiveInteger( default=1, description="The number of stacked fully connected layers.", parameter_metadata=ENCODER_METADATA["DateWave"]["num_fc_layers"], ) # TODO (Connor): Add nesting logic for fc_layers, see fully_connected_module.py fc_layers: list[dict] = schema_utils.DictList( default=None, description="List of dictionaries containing the parameters for each fully connected layer.", parameter_metadata=ENCODER_METADATA["DateWave"]["fc_layers"], ) ================================================ FILE: ludwig/schema/encoders/h3_encoders.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.constants import H3 from ludwig.schema import utils as schema_utils from ludwig.schema.encoders.base import BaseEncoderConfig from ludwig.schema.encoders.utils import register_encoder_config from ludwig.schema.metadata import ENCODER_METADATA from ludwig.schema.utils import ludwig_dataclass @DeveloperAPI @register_encoder_config("embed", H3) @ludwig_dataclass class H3EmbedConfig(BaseEncoderConfig): @staticmethod def module_name(): return "H3Embed" type: str = schema_utils.ProtectedString( "embed", description=ENCODER_METADATA["H3Embed"]["type"].long_description, ) dropout: float = schema_utils.FloatRange( default=0.0, min=0, max=1, description="Dropout probability for the embedding.", parameter_metadata=ENCODER_METADATA["H3Embed"]["dropout"], ) activation: str = schema_utils.ActivationOptions( description="The default activation function that will be used for each layer.", parameter_metadata=ENCODER_METADATA["H3Embed"]["activation"], ) use_bias: bool = schema_utils.Boolean( default=True, description="Whether the layer uses a bias vector.", parameter_metadata=ENCODER_METADATA["H3Embed"]["use_bias"], ) bias_initializer: str = schema_utils.InitializerOptions( default="zeros", description="Initializer to use for the bias vector.", parameter_metadata=ENCODER_METADATA["H3Embed"]["bias_initializer"], ) weights_initializer: str = schema_utils.InitializerOptions( description="Initializer to use for the weights matrix.", parameter_metadata=ENCODER_METADATA["H3Embed"]["weights_initializer"], ) embedding_size: int = schema_utils.PositiveInteger( default=10, description="The maximum embedding size adopted.", parameter_metadata=ENCODER_METADATA["H3Embed"]["embedding_size"], ) embeddings_on_cpu: bool = schema_utils.Boolean( default=False, description="Whether to force the placement of the embedding matrix in regular memory and have the CPU " "resolve them.", parameter_metadata=ENCODER_METADATA["H3Embed"]["embeddings_on_cpu"], ) reduce_output: str = schema_utils.ReductionOptions( default="sum", description="How to reduce the output tensor along the `s` sequence length dimension if the rank of the " "tensor is greater than 2.", parameter_metadata=ENCODER_METADATA["H3Embed"]["reduce_output"], ) output_size: int = schema_utils.PositiveInteger( default=10, description="If an output_size is not already specified in fc_layers this is the default output_size that " "will be used for each layer. It indicates the size of the output of a fully connected layer.", parameter_metadata=ENCODER_METADATA["H3Embed"]["output_size"], ) norm: str = schema_utils.StringOptions( ["batch", "layer"], default=None, allow_none=True, description="The default norm that will be used for each layer.", parameter_metadata=ENCODER_METADATA["H3Embed"]["norm"], ) norm_params: dict = schema_utils.Dict( default=None, description="Parameters used if norm is either `batch` or `layer`.", parameter_metadata=ENCODER_METADATA["H3Embed"]["norm_params"], ) num_fc_layers: int = schema_utils.NonNegativeInteger( default=0, description="The number of stacked fully connected layers.", parameter_metadata=ENCODER_METADATA["H3Embed"]["num_fc_layers"], ) fc_layers: list[dict] = schema_utils.DictList( # TODO (Connor): Add nesting logic for fc_layers default=None, description="List of dictionaries containing the parameters for each fully connected layer.", parameter_metadata=ENCODER_METADATA["H3Embed"]["fc_layers"], ) @DeveloperAPI @register_encoder_config("weighted_sum", H3) @ludwig_dataclass class H3WeightedSumConfig(BaseEncoderConfig): @staticmethod def module_name(): return "H3WeightedSum" type: str = schema_utils.ProtectedString( "weighted_sum", description=ENCODER_METADATA["H3WeightedSum"]["type"].long_description, ) dropout: float = schema_utils.FloatRange( default=0.0, min=0, max=1, description="Dropout probability for the embedding.", parameter_metadata=ENCODER_METADATA["H3WeightedSum"]["dropout"], ) activation: str = schema_utils.ActivationOptions( description="The default activation function that will be used for each layer.", parameter_metadata=ENCODER_METADATA["H3WeightedSum"]["activation"], ) use_bias: bool = schema_utils.Boolean( default=True, description="Whether the layer uses a bias vector.", parameter_metadata=ENCODER_METADATA["H3WeightedSum"]["use_bias"], ) bias_initializer: str = schema_utils.InitializerOptions( default="zeros", description="Initializer to use for the bias vector.", parameter_metadata=ENCODER_METADATA["H3WeightedSum"]["bias_initializer"], ) weights_initializer: str = schema_utils.InitializerOptions( description="Initializer to use for the weights matrix.", parameter_metadata=ENCODER_METADATA["H3WeightedSum"]["weights_initializer"], ) embedding_size: int = schema_utils.PositiveInteger( default=10, description="The maximum embedding size adopted.", parameter_metadata=ENCODER_METADATA["H3WeightedSum"]["embedding_size"], ) embeddings_on_cpu: bool = schema_utils.Boolean( default=False, description="Whether to force the placement of the embedding matrix in regular memory and have the CPU " "resolve them.", parameter_metadata=ENCODER_METADATA["H3WeightedSum"]["embeddings_on_cpu"], ) should_softmax: bool = schema_utils.Boolean( default=False, description="Determines if the weights of the weighted sum should be passed though a softmax layer before " "being used.", parameter_metadata=ENCODER_METADATA["H3WeightedSum"]["should_softmax"], ) output_size: int = schema_utils.PositiveInteger( default=10, description="If an output_size is not already specified in fc_layers this is the default output_size that " "will be used for each layer. It indicates the size of the output of a fully connected layer.", parameter_metadata=ENCODER_METADATA["H3WeightedSum"]["output_size"], ) norm: str = schema_utils.StringOptions( ["batch", "layer"], default=None, allow_none=True, description="The default norm that will be used for each layer.", parameter_metadata=ENCODER_METADATA["H3WeightedSum"]["norm"], ) norm_params: dict = schema_utils.Dict( default=None, description="Parameters used if norm is either `batch` or `layer`.", parameter_metadata=ENCODER_METADATA["H3WeightedSum"]["norm_params"], ) num_fc_layers: int = schema_utils.NonNegativeInteger( default=0, description="The number of stacked fully connected layers.", parameter_metadata=ENCODER_METADATA["H3WeightedSum"]["num_fc_layers"], ) fc_layers: list[dict] = schema_utils.DictList( # TODO (Connor): Add nesting logic for fc_layers default=None, description="List of dictionaries containing the parameters for each fully connected layer.", parameter_metadata=ENCODER_METADATA["H3WeightedSum"]["fc_layers"], ) @DeveloperAPI @register_encoder_config("rnn", H3) @ludwig_dataclass class H3RNNConfig(BaseEncoderConfig): @staticmethod def module_name(): return "H3RNN" type: str = schema_utils.ProtectedString( "rnn", description=ENCODER_METADATA["H3RNN"]["type"].long_description, ) dropout: float = schema_utils.FloatRange( default=0.0, min=0, max=1, description="The dropout rate", parameter_metadata=ENCODER_METADATA["H3RNN"]["dropout"], ) recurrent_dropout: float = schema_utils.FloatRange( default=0.0, min=0, max=1, description="The dropout rate for the recurrent state", parameter_metadata=ENCODER_METADATA["H3RNN"]["recurrent_dropout"], ) activation: str = schema_utils.ActivationOptions( default="tanh", description="The activation function to use", parameter_metadata=ENCODER_METADATA["H3RNN"]["activation"], ) recurrent_activation: str = schema_utils.ActivationOptions( default="sigmoid", description="The activation function to use in the recurrent step", parameter_metadata=ENCODER_METADATA["H3RNN"]["recurrent_activation"], ) cell_type: str = schema_utils.StringOptions( ["rnn", "lstm", "lstm_block", "ln", "lstm_cudnn", "gru", "gru_block", "gru_cudnn"], default="rnn", description="The type of recurrent cell to use. Available values are: `rnn`, `lstm`, `lstm_block`, `lstm`, " "`ln`, `lstm_cudnn`, `gru`, `gru_block`, `gru_cudnn`. For reference about the differences between " "the cells please refer to PyTorch's documentation. We suggest to use the `block` variants on " "CPU and the `cudnn` variants on GPU because of their increased speed. ", parameter_metadata=ENCODER_METADATA["H3RNN"]["cell_type"], ) num_layers: int = schema_utils.PositiveInteger( default=1, description="The number of stacked recurrent layers.", parameter_metadata=ENCODER_METADATA["H3RNN"]["num_layers"], ) hidden_size: int = schema_utils.PositiveInteger( default=10, description="The size of the hidden representation within the transformer block. It is usually the same as " "the embedding_size, but if the two values are different, a projection layer will be added before " "the first transformer block.", parameter_metadata=ENCODER_METADATA["H3RNN"]["hidden_size"], ) use_bias: bool = schema_utils.Boolean( default=True, description="Whether to use a bias vector.", parameter_metadata=ENCODER_METADATA["H3RNN"]["use_bias"], ) unit_forget_bias: bool = schema_utils.Boolean( default=True, description="If true, add 1 to the bias of the forget gate at initialization", parameter_metadata=ENCODER_METADATA["H3RNN"]["unit_forget_bias"], ) bias_initializer: str = schema_utils.InitializerOptions( default="zeros", description="Initializer to use for the bias vector.", parameter_metadata=ENCODER_METADATA["H3RNN"]["bias_initializer"], ) weights_initializer: str = schema_utils.InitializerOptions( description="Initializer to use for the weights matrix.", parameter_metadata=ENCODER_METADATA["H3RNN"]["weights_initializer"], ) recurrent_initializer: str = schema_utils.InitializerOptions( default="orthogonal", description="The initializer for recurrent matrix weights", parameter_metadata=ENCODER_METADATA["H3RNN"]["recurrent_initializer"], ) reduce_output: str = schema_utils.ReductionOptions( default="last", description="How to reduce the output tensor along the `s` sequence length dimension if the rank of the " "tensor is greater than 2.", parameter_metadata=ENCODER_METADATA["H3RNN"]["reduce_output"], ) embedding_size: int = schema_utils.PositiveInteger( default=10, description="The maximum embedding size adopted.", parameter_metadata=ENCODER_METADATA["H3RNN"]["embedding_size"], ) embeddings_on_cpu: bool = schema_utils.Boolean( default=False, description="Whether to force the placement of the embedding matrix in regular memory and have the CPU " "resolve them.", parameter_metadata=ENCODER_METADATA["H3RNN"]["embeddings_on_cpu"], ) bidirectional: bool = schema_utils.Boolean( default=False, description="If true, two recurrent networks will perform encoding in the forward and backward direction and " "their outputs will be concatenated.", parameter_metadata=ENCODER_METADATA["H3RNN"]["bidirectional"], ) ================================================ FILE: ludwig/schema/encoders/image/__init__.py ================================================ import ludwig.schema.encoders.image.base import ludwig.schema.encoders.image.timm # noqa import ludwig.schema.encoders.image.torchvision # noqa ================================================ FILE: ludwig/schema/encoders/image/base.py ================================================ from typing import Any, TYPE_CHECKING from ludwig.api_annotations import DeveloperAPI from ludwig.constants import IMAGE from ludwig.schema import utils as schema_utils from ludwig.schema.encoders.base import BaseEncoderConfig from ludwig.schema.encoders.utils import register_encoder_config from ludwig.schema.metadata import ENCODER_METADATA from ludwig.schema.utils import ludwig_dataclass from ludwig.utils.torch_utils import initializer_registry if TYPE_CHECKING: from ludwig.schema.features.preprocessing.image import ImagePreprocessingConfig class ImageEncoderConfig(BaseEncoderConfig): def set_fixed_preprocessing_params(self, model_type: str, preprocessing: "ImagePreprocessingConfig"): preprocessing.requires_equal_dimensions = False preprocessing.height = None preprocessing.width = None @DeveloperAPI @register_encoder_config("stacked_cnn", IMAGE) @ludwig_dataclass class Stacked2DCNNConfig(ImageEncoderConfig): @staticmethod def module_name(): return "Stacked2DCNN" type: str = schema_utils.ProtectedString( "stacked_cnn", description=ENCODER_METADATA["Stacked2DCNN"]["type"].long_description, ) conv_dropout: int | None = schema_utils.FloatRange( default=0.0, min=0, max=1, description="Dropout rate", parameter_metadata=ENCODER_METADATA["Stacked2DCNN"]["conv_dropout"], ) conv_activation: str = schema_utils.ActivationOptions( description="If an activation is not already specified in conv_layers this is the default activation that " "will be used for each layer. It indicates the activation function applied to the output.", parameter_metadata=ENCODER_METADATA["Stacked2DCNN"]["conv_activation"], ) height: int = schema_utils.NonNegativeInteger( default=None, allow_none=True, description="Height of the input image.", parameter_metadata=ENCODER_METADATA["Stacked2DCNN"]["height"], ) width: int = schema_utils.NonNegativeInteger( default=None, allow_none=True, description="Width of the input image.", parameter_metadata=ENCODER_METADATA["Stacked2DCNN"]["width"], ) num_channels: int | None = schema_utils.NonNegativeInteger( default=None, allow_none=True, description="Number of channels to use in the encoder. ", parameter_metadata=ENCODER_METADATA["Stacked2DCNN"]["num_channels"], ) out_channels: int | None = schema_utils.NonNegativeInteger( default=32, description="Indicates the number of filters, and by consequence the output channels of the 2d convolution. " "If out_channels is not already specified in conv_layers this is the default out_channels that " "will be used for each layer. ", parameter_metadata=ENCODER_METADATA["Stacked2DCNN"]["out_channels"], ) kernel_size: int | tuple[int] | None = schema_utils.OneOfOptionsField( default=3, description="An integer or pair of integers specifying the kernel size. A single integer specifies a square " "kernel, while a pair of integers specifies the height and width of the kernel in that order (h, " "w). If a kernel_size is not specified in conv_layers this kernel_size that will be used for " "each layer.", field_options=[ schema_utils.PositiveInteger(allow_none=False, description="", default=3), schema_utils.List(list_type=int, allow_none=False), ], parameter_metadata=ENCODER_METADATA["Stacked2DCNN"]["kernel_size"], ) stride: int | tuple[int] | None = schema_utils.OneOfOptionsField( default=1, description="An integer or pair of integers specifying the stride of the convolution along the height and " "width. If a stride is not already specified in conv_layers, specifies the default stride of the " "2D convolutional kernel that will be used for each layer.", field_options=[ schema_utils.PositiveInteger(allow_none=False, description="", default=1), schema_utils.List(list_type=int, allow_none=False), ], parameter_metadata=ENCODER_METADATA["Stacked2DCNN"]["stride"], ) padding_mode: str | None = schema_utils.StringOptions( options=["zeros", "reflect", "replicate", "circular"], default="zeros", description="If padding_mode is not already specified in conv_layers, specifies the default padding_mode of " "the 2D convolutional kernel that will be used for each layer.", parameter_metadata=ENCODER_METADATA["Stacked2DCNN"]["padding_mode"], ) padding: int | tuple[int] | str | None = schema_utils.OneOfOptionsField( default="valid", allow_none=True, description="An int, pair of ints (h, w), or one of ['valid', 'same'] specifying the padding used for" "convolution kernels.", field_options=[ schema_utils.NonNegativeInteger(allow_none=True, description="", default=None), schema_utils.List(list_type=int, allow_none=False), schema_utils.StringOptions(options=["valid", "same"], default="valid", allow_none=False), ], parameter_metadata=ENCODER_METADATA["Stacked2DCNN"]["padding"], ) dilation: int | tuple[int] | None = schema_utils.OneOfOptionsField( default=1, allow_none=True, description="An int or pair of ints specifying the dilation rate to use for dilated convolution. If dilation " "is not already specified in conv_layers, specifies the default dilation of the 2D convolutional " "kernel that will be used for each layer.", field_options=[ schema_utils.PositiveInteger(allow_none=True, description="", default=None), schema_utils.List(list_type=int, allow_none=False), ], parameter_metadata=ENCODER_METADATA["Stacked2DCNN"]["dilation"], ) groups: int | None = schema_utils.PositiveInteger( default=1, description="Groups controls the connectivity between convolution inputs and outputs. When groups = 1, each " "output channel depends on every input channel. When groups > 1, input and output channels are " "divided into groups separate groups, where each output channel depends only on the inputs in its " "respective input channel group. in_channels and out_channels must both be divisible by groups.", parameter_metadata=ENCODER_METADATA["Stacked2DCNN"]["groups"], ) pool_function: str | None = schema_utils.StringOptions( ["max", "average", "avg", "mean"], default="max", description="Pooling function to use.", parameter_metadata=ENCODER_METADATA["conv_params"]["pool_function"], ) pool_kernel_size: int | tuple[int] | None = schema_utils.OneOfOptionsField( default=2, allow_none=True, description="An integer or pair of integers specifying the pooling size. If pool_kernel_size is not specified " "in conv_layers this is the default value that will be used for each layer.", field_options=[ schema_utils.PositiveInteger(allow_none=True, description="", default=None), schema_utils.List(list_type=int, allow_none=False), ], parameter_metadata=ENCODER_METADATA["Stacked2DCNN"]["pool_kernel_size"], ) pool_stride: int | tuple[int] | None = schema_utils.OneOfOptionsField( default=None, allow_none=True, description="An integer or pair of integers specifying the pooling stride, which is the factor by which the " "pooling layer downsamples the feature map. Defaults to pool_kernel_size.", field_options=[ schema_utils.PositiveInteger(allow_none=True, description="", default=None), schema_utils.List(list_type=int, allow_none=False), ], parameter_metadata=ENCODER_METADATA["Stacked2DCNN"]["pool_stride"], ) pool_padding: int | tuple[int] | None = schema_utils.OneOfOptionsField( default=0, allow_none=True, description="An integer or pair of ints specifying pooling padding (h, w).", field_options=[ schema_utils.NonNegativeInteger(allow_none=True, description="", default=None), schema_utils.List(list_type=int, allow_none=False), ], parameter_metadata=ENCODER_METADATA["Stacked2DCNN"]["pool_padding"], ) pool_dilation: int | tuple[int] | None = schema_utils.OneOfOptionsField( default=1, allow_none=True, description="An integer or pair of ints specifying pooling dilation rate (h, w).", field_options=[ schema_utils.PositiveInteger(default=None, allow_none=True, description=""), schema_utils.List(list_type=int, allow_none=False), ], parameter_metadata=ENCODER_METADATA["Stacked2DCNN"]["pool_dilation"], ) output_size: int | None = schema_utils.PositiveInteger( default=128, description="If output_size is not already specified in fc_layers this is the default output_size that will " "be used for each layer. It indicates the size of the output of a fully connected layer. ", parameter_metadata=ENCODER_METADATA["Stacked2DCNN"]["output_size"], ) conv_use_bias: bool | None = schema_utils.Boolean( default=True, description="If bias not already specified in conv_layers, specifies if the 2D convolutional kernel should " "have a bias term.", ) conv_norm: str | None = schema_utils.StringOptions( ["batch", "layer"], default=None, allow_none=True, description="If a norm is not already specified in conv_layers this is the default norm that will be used for " "each layer. It indicates the normalization applied to the activations and can be null, " "batch or layer.", parameter_metadata=ENCODER_METADATA["Stacked2DCNN"]["conv_norm"], ) conv_norm_params: dict[str, Any] | None = schema_utils.Dict( default=None, description="Parameters used if conv_norm is either batch or layer. ", parameter_metadata=ENCODER_METADATA["Stacked2DCNN"]["conv_norm_params"], ) num_conv_layers: int | None = schema_utils.NonNegativeInteger( default=None, allow_none=True, description="Number of convolutional layers to use in the encoder. ", parameter_metadata=ENCODER_METADATA["conv_params"]["num_conv_layers"], ) conv_layers: list[dict] | None = schema_utils.DictList( default=None, description="List of convolutional layers to use in the encoder. ", parameter_metadata=ENCODER_METADATA["conv_params"]["conv_layers"], ) fc_dropout: float | None = schema_utils.FloatRange( default=0.0, min=0, max=1, description="Dropout rate", parameter_metadata=ENCODER_METADATA["Stacked2DCNN"]["fc_dropout"], ) fc_activation: str | None = schema_utils.ActivationOptions( description="If an activation is not already specified in fc_layers this is the default activation that will " "be used for each layer. It indicates the activation function applied to the output.", parameter_metadata=ENCODER_METADATA["Stacked2DCNN"]["fc_activation"], ) fc_use_bias: bool | None = schema_utils.Boolean( default=True, description="Whether the layer uses a bias vector.", parameter_metadata=ENCODER_METADATA["Stacked2DCNN"]["fc_use_bias"], ) fc_bias_initializer: str | None = schema_utils.StringOptions( sorted(list(initializer_registry.keys())), default="zeros", description="Initializer for the bias vector.", parameter_metadata=ENCODER_METADATA["Stacked2DCNN"]["fc_bias_initializer"], ) fc_weights_initializer: str | None = schema_utils.StringOptions( sorted(list(initializer_registry.keys())), default="xavier_uniform", description="Initializer for the weights matrix.", parameter_metadata=ENCODER_METADATA["Stacked2DCNN"]["fc_weights_initializer"], ) fc_norm: str | None = schema_utils.StringOptions( ["batch", "layer"], default=None, allow_none=True, description="If a norm is not already specified in fc_layers this is the default norm that will be used for " "each layer. It indicates the norm of the output and can be null, batch or layer.", parameter_metadata=ENCODER_METADATA["Stacked2DCNN"]["fc_norm"], ) fc_norm_params: dict[str, Any] | None = schema_utils.Dict( default=None, description="Parameters used if norm is either batch or layer. For information on parameters used with batch " "see Torch's documentation on batch normalization or for layer see Torch's documentation on layer " "normalization.", parameter_metadata=ENCODER_METADATA["Stacked2DCNN"]["fc_norm_params"], ) num_fc_layers: int | None | None = schema_utils.PositiveInteger( default=1, description="The number of stacked fully connected layers.", parameter_metadata=ENCODER_METADATA["Stacked2DCNN"]["num_fc_layers"], ) fc_layers: list[dict] | None | None = schema_utils.DictList( default=None, description="A list of dictionaries containing the parameters of all the fully connected layers. The length " "of the list determines the number of stacked fully connected layers and the content of each " "dictionary determines the parameters for a specific layer. The available parameters for each " "layer are: activation, dropout, norm, norm_params, output_size, use_bias, bias_initializer and " "weights_initializer. If any of those values is missing from the dictionary, the default one " "specified as a parameter of the encoder will be used instead. ", parameter_metadata=ENCODER_METADATA["Stacked2DCNN"]["fc_layers"], ) @DeveloperAPI @register_encoder_config("_resnet_legacy", IMAGE) @ludwig_dataclass class ResNetConfig(ImageEncoderConfig): @staticmethod def module_name(): return "ResNet" type: str = schema_utils.ProtectedString( "_resnet_legacy", description=ENCODER_METADATA["ResNet"]["type"].long_description, ) dropout: float | None = schema_utils.FloatRange( default=0.0, min=0, max=1, description="Dropout rate", parameter_metadata=ENCODER_METADATA["ResNet"]["dropout"], ) activation: str | None = schema_utils.ActivationOptions( description="if an activation is not already specified in fc_layers this is the default activation that will " "be used for each layer. It indicates the activation function applied to the output.", parameter_metadata=ENCODER_METADATA["ResNet"]["activation"], ) height: int = schema_utils.NonNegativeInteger( default=None, allow_none=True, description="Height of the input image.", parameter_metadata=ENCODER_METADATA["ResNet"]["height"], ) width: int = schema_utils.NonNegativeInteger( default=None, allow_none=True, description="Width of the input image.", parameter_metadata=ENCODER_METADATA["ResNet"]["width"], ) resnet_size: int | None = schema_utils.PositiveInteger( default=50, description="The size of the ResNet model to use.", parameter_metadata=ENCODER_METADATA["ResNet"]["resnet_size"], ) num_channels: int | None = schema_utils.NonNegativeInteger( default=None, allow_none=True, description="Number of channels to use in the encoder. ", parameter_metadata=ENCODER_METADATA["ResNet"]["num_channels"], ) out_channels: int | None = schema_utils.NonNegativeInteger( default=32, description="Indicates the number of filters, and by consequence the output channels of the 2d convolution. " "If out_channels is not already specified in conv_layers this is the default out_channels that " "will be used for each layer. ", parameter_metadata=ENCODER_METADATA["ResNet"]["out_channels"], ) kernel_size: int | tuple[int] | None = schema_utils.OneOfOptionsField( default=3, allow_none=True, description="An integer or pair of integers specifying the kernel size. A single integer specifies a square " "kernel, while a pair of integers specifies the height and width of the kernel in that order (h, " "w). If a kernel_size is not specified in conv_layers this kernel_size that will be used for " "each layer.", field_options=[ schema_utils.PositiveInteger(allow_none=True, description="", default=None), schema_utils.List(list_type=int, allow_none=False), ], parameter_metadata=ENCODER_METADATA["ResNet"]["kernel_size"], ) conv_stride: int | tuple[int] = schema_utils.OneOfOptionsField( default=1, allow_none=True, description="An integer or pair of integers specifying the stride of the initial convolutional layer.", field_options=[ schema_utils.PositiveInteger(allow_none=True, description="", default=None), schema_utils.List(list_type=int, allow_none=False), ], parameter_metadata=ENCODER_METADATA["ResNet"]["conv_stride"], ) first_pool_kernel_size: int | tuple[int] = schema_utils.OneOfOptionsField( default=None, allow_none=True, description="Pool size to be used for the first pooling layer. If none, the first pooling layer is skipped.", field_options=[ schema_utils.PositiveInteger(allow_none=True, description="", default=None), schema_utils.List(list_type=int, allow_none=False), ], parameter_metadata=ENCODER_METADATA["ResNet"]["first_pool_kernel_size"], ) first_pool_stride: int | tuple[int] = schema_utils.OneOfOptionsField( default=None, allow_none=True, description="Stride for first pooling layer. If null, defaults to first_pool_kernel_size.", field_options=[ schema_utils.PositiveInteger(allow_none=True, description="", default=None), schema_utils.List(list_type=int, allow_none=False), ], parameter_metadata=ENCODER_METADATA["ResNet"]["first_pool_stride"], ) batch_norm_momentum: float = schema_utils.NonNegativeFloat( default=0.9, description="Momentum of the batch norm running statistics.", parameter_metadata=ENCODER_METADATA["ResNet"]["batch_norm_momentum"], ) batch_norm_epsilon: float = schema_utils.NonNegativeFloat( default=0.001, description="Epsilon of the batch norm.", parameter_metadata=ENCODER_METADATA["ResNet"]["batch_norm_epsilon"], ) use_bias: bool | None = schema_utils.Boolean( default=True, description="Whether the layer uses a bias vector.", parameter_metadata=ENCODER_METADATA["ResNet"]["use_bias"], ) bias_initializer: str | None = schema_utils.StringOptions( sorted(list(initializer_registry.keys())), default="zeros", description="initializer for the bias vector.", parameter_metadata=ENCODER_METADATA["ResNet"]["bias_initializer"], ) weights_initializer: str | None = schema_utils.StringOptions( sorted(list(initializer_registry.keys())), default="xavier_uniform", description="Initializer for the weights matrix.", parameter_metadata=ENCODER_METADATA["ResNet"]["weights_initializer"], ) output_size: int | None = schema_utils.PositiveInteger( default=128, description="if output_size is not already specified in fc_layers this is the default output_size that will " "be used for each layer. It indicates the size of the output of a fully connected layer. ", parameter_metadata=ENCODER_METADATA["ResNet"]["output_size"], ) norm: str | None = schema_utils.StringOptions( ["batch", "layer"], default=None, allow_none=True, description="if a norm is not already specified in fc_layers this is the default norm that will be used for " "each layer. It indicates the norm of the output and can be null, batch or layer.", parameter_metadata=ENCODER_METADATA["ResNet"]["norm"], ) norm_params: dict[str, Any] | None = schema_utils.Dict( default=None, description="parameters used if norm is either batch or layer. For information on parameters used with batch " "see Torch's documentation on batch normalization or for layer see Torch's documentation on layer " "normalization.", parameter_metadata=ENCODER_METADATA["ResNet"]["norm_params"], ) num_fc_layers: int | None | None = schema_utils.PositiveInteger( default=1, description="The number of stacked fully connected layers.", parameter_metadata=ENCODER_METADATA["ResNet"]["num_fc_layers"], ) fc_layers: list[dict] | None | None = schema_utils.DictList( default=None, description="A list of dictionaries containing the parameters of all the fully connected layers. The length " "of the list determines the number of stacked fully connected layers and the content of each " "dictionary determines the parameters for a specific layer. The available parameters for each " "layer are: activation, dropout, norm, norm_params, output_size, use_bias, bias_initializer and " "weights_initializer. If any of those values is missing from the dictionary, the default one " "specified as a parameter of the encoder will be used instead. ", parameter_metadata=ENCODER_METADATA["ResNet"]["fc_layers"], ) @DeveloperAPI @register_encoder_config("mlp_mixer", IMAGE) @ludwig_dataclass class MLPMixerConfig(ImageEncoderConfig): @staticmethod def module_name(): return "MLPMixer" type: str = schema_utils.ProtectedString( "mlp_mixer", description=ENCODER_METADATA["MLPMixer"]["type"].long_description, ) dropout: float = schema_utils.FloatRange( default=0.0, min=0, max=1, description="Dropout rate.", parameter_metadata=ENCODER_METADATA["MLPMixer"]["dropout"], ) height: int = schema_utils.NonNegativeInteger( default=None, allow_none=True, description="Height of the input image.", parameter_metadata=ENCODER_METADATA["MLPMixer"]["height"], ) width: int = schema_utils.NonNegativeInteger( default=None, allow_none=True, description="Width of the input image.", parameter_metadata=ENCODER_METADATA["MLPMixer"]["width"], ) num_channels: int = schema_utils.NonNegativeInteger( default=None, allow_none=True, description="Number of channels to use in the encoder. ", parameter_metadata=ENCODER_METADATA["MLPMixer"]["num_channels"], ) patch_size: int = schema_utils.PositiveInteger( default=16, description="The image patch size. Each patch is patch_size² pixels. Must evenly divide the image width and " "height.", parameter_metadata=ENCODER_METADATA["MLPMixer"]["patch_size"], ) embed_size: int = schema_utils.PositiveInteger( default=512, description="The patch embedding size, the output size of the mixer if avg_pool is true.", parameter_metadata=ENCODER_METADATA["MLPMixer"]["embed_size"], ) token_size: int = schema_utils.PositiveInteger( default=2048, description="The per-patch embedding size.", parameter_metadata=ENCODER_METADATA["MLPMixer"]["token_size"], ) channel_dim: int = schema_utils.PositiveInteger( default=256, description="Number of channels in hidden layer.", parameter_metadata=ENCODER_METADATA["MLPMixer"]["channel_dim"], ) num_layers: int = schema_utils.PositiveInteger( default=8, description="The depth of the network (the number of Mixer blocks).", parameter_metadata=ENCODER_METADATA["MLPMixer"]["num_layers"], ) avg_pool: bool = schema_utils.Boolean( default=True, description="If true, pools output over patch dimension, outputs a vector of shape (embed_size). If false, " "the output tensor is of shape (n_patches, embed_size), where n_patches is img_height x img_width " "/ patch_size².", parameter_metadata=ENCODER_METADATA["MLPMixer"]["avg_pool"], ) @DeveloperAPI @register_encoder_config("_vit_legacy", IMAGE) @ludwig_dataclass class ViTConfig(ImageEncoderConfig): @staticmethod def module_name(): return "ViT" type: str = schema_utils.ProtectedString( "_vit_legacy", description=ENCODER_METADATA["ViT"]["type"].long_description, ) height: int = schema_utils.NonNegativeInteger( default=None, allow_none=True, description="Height of the input image.", parameter_metadata=ENCODER_METADATA["ViT"]["height"], ) width: int = schema_utils.NonNegativeInteger( default=None, allow_none=True, description="Width of the input image.", parameter_metadata=ENCODER_METADATA["ViT"]["width"], ) num_hidden_layers: int = schema_utils.PositiveInteger( default=12, description="Number of hidden layers in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["ViT"]["num_hidden_layers"], ) hidden_size: int = schema_utils.PositiveInteger( default=768, description="Dimensionality of the encoder layers and the pooling layer.", parameter_metadata=ENCODER_METADATA["ViT"]["hidden_size"], ) hidden_act: str = schema_utils.StringOptions( ["relu", "gelu", "selu", "gelu_new"], default="gelu", description="Hidden layer activation, one of gelu, relu, selu or gelu_new.", parameter_metadata=ENCODER_METADATA["ViT"]["hidden_act"], ) hidden_dropout_prob: float = schema_utils.NonNegativeFloat( default=0.1, description="The dropout rate for all fully connected layers in the embeddings, encoder, and pooling.", parameter_metadata=ENCODER_METADATA["ViT"]["hidden_dropout_prob"], ) num_attention_heads: int = schema_utils.PositiveInteger( default=12, description="Number of attention heads in each attention layer.", parameter_metadata=ENCODER_METADATA["ViT"]["num_attention_heads"], ) attention_probs_dropout_prob: float = schema_utils.NonNegativeFloat( default=0.1, description="The dropout rate for the attention probabilities.", parameter_metadata=ENCODER_METADATA["ViT"]["attention_probs_dropout_prob"], ) intermediate_size: int = schema_utils.PositiveInteger( default=3072, description="Dimensionality of the intermediate (i.e., feed-forward) layer in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["ViT"]["intermediate_size"], ) initializer_range: float = schema_utils.NonNegativeFloat( default=0.02, description="The standard deviation of the truncated_normal_initializer for initializing all weight matrices.", parameter_metadata=ENCODER_METADATA["ViT"]["initializer_range"], ) layer_norm_eps: float = schema_utils.NonNegativeFloat( default=1e-12, description="The epsilon used by the layer normalization layers.", parameter_metadata=ENCODER_METADATA["ViT"]["layer_norm_eps"], ) gradient_checkpointing: bool = schema_utils.Boolean( default=False, description="", parameter_metadata=ENCODER_METADATA["ViT"]["gradient_checkpointing"], ) patch_size: int = schema_utils.PositiveInteger( default=16, description="The image patch size. Each patch is patch_size² pixels. Must evenly divide the image width and " "height.", parameter_metadata=ENCODER_METADATA["ViT"]["patch_size"], ) saved_weights_in_checkpoint: bool = schema_utils.Boolean( default=False, description="Are the pretrained encoder weights saved in this model's checkpoint? Automatically set to" "True for trained models to prevent loading pretrained encoder weights from model hub.", parameter_metadata=ENCODER_METADATA["ViT"]["saved_weights_in_checkpoint"], ) trainable: bool = schema_utils.Boolean( default=True, description="Is the encoder trainable.", parameter_metadata=ENCODER_METADATA["ViT"]["trainable"], ) use_pretrained: bool = schema_utils.Boolean( default=True, description="Use pre-trained model weights from Hugging Face.", parameter_metadata=ENCODER_METADATA["ViT"]["use_pretrained"], ) pretrained_model: str = schema_utils.String( default="google/vit-base-patch16-224", description="The name of the pre-trained model to use.", parameter_metadata=ENCODER_METADATA["ViT"]["pretrained_model"], ) def set_fixed_preprocessing_params(self, model_type: str, preprocessing: "ImagePreprocessingConfig"): """If the encoder is not in trainable mode, override the image width and height to be compatible with the pretrained encoder image dimension requirements.""" if self.requires_equal_dimensions() and self.required_width() != self.required_height(): raise ValueError("Invalid definition. `required_width` and `required_height` are not equal") preprocessing.requires_equal_dimensions = self.requires_equal_dimensions() if not self.trainable or self.use_pretrained: preprocessing.height = self.required_height() preprocessing.width = self.required_width() @classmethod def requires_equal_dimensions(cls) -> bool: return True @classmethod def required_width(cls) -> int | None: return 224 @classmethod def required_height(cls) -> int | None: return 224 def is_pretrained(self) -> bool: return self.use_pretrained @DeveloperAPI @register_encoder_config("unet", IMAGE) @ludwig_dataclass class UNetEncoderConfig(ImageEncoderConfig): @staticmethod def module_name(): return "UNetEncoder" type: str = schema_utils.ProtectedString( "unet", description=ENCODER_METADATA["UNetEncoder"]["type"].long_description, ) height: int = schema_utils.NonNegativeInteger( default=None, allow_none=True, description="Height of the input image.", parameter_metadata=ENCODER_METADATA["UNetEncoder"]["height"], ) width: int = schema_utils.NonNegativeInteger( default=None, allow_none=True, description="Width of the input image.", parameter_metadata=ENCODER_METADATA["UNetEncoder"]["width"], ) num_channels: int | None = schema_utils.NonNegativeInteger( default=None, allow_none=True, description="Number of channels in the input image. ", parameter_metadata=ENCODER_METADATA["UNetEncoder"]["num_channels"], ) conv_norm: str | None = schema_utils.StringOptions( ["batch"], default="batch", allow_none=True, description="This is the default norm that will be used for each double conv layer." "It can be null or batch.", parameter_metadata=ENCODER_METADATA["UNetEncoder"]["conv_norm"], ) ================================================ FILE: ludwig/schema/encoders/image/timm.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.constants import IMAGE from ludwig.schema import utils as schema_utils from ludwig.schema.encoders.base import BaseEncoderConfig from ludwig.schema.encoders.utils import register_encoder_config from ludwig.schema.metadata import ENCODER_METADATA from ludwig.schema.utils import ludwig_dataclass @ludwig_dataclass class TimmBaseConfig(BaseEncoderConfig): use_pretrained: bool = schema_utils.Boolean( default=True, description="Download model weights from pretrained model.", parameter_metadata=ENCODER_METADATA["TimmEncoder"]["use_pretrained"], ) saved_weights_in_checkpoint: bool = schema_utils.Boolean( default=False, description="Whether to use weights saved in the Ludwig checkpoint instead of pretrained weights.", parameter_metadata=ENCODER_METADATA["TimmEncoder"]["saved_weights_in_checkpoint"], ) trainable: bool = schema_utils.Boolean( default=True, description="Whether the encoder parameters are trainable.", parameter_metadata=ENCODER_METADATA["TimmEncoder"]["trainable"], ) def is_pretrained(self) -> bool: return self.use_pretrained @DeveloperAPI @register_encoder_config("timm", IMAGE) @ludwig_dataclass class TimmEncoderConfig(TimmBaseConfig): type: str = schema_utils.ProtectedString("timm", description="Type of encoder.") model_name: str = schema_utils.String( default="caformer_s18", description=( "Name of the timm model to use. Any model from the timm library is supported. " "See https://huggingface.co/docs/timm for available models." ), parameter_metadata=ENCODER_METADATA["TimmEncoder"]["model_name"], ) # Convenience aliases for MetaFormer variants with curated model_name options CAFORMER_MODELS = [ "caformer_s18", "caformer_s36", "caformer_m36", "caformer_b36", "caformer_s18.sail_in22k_ft_in1k", "caformer_s18.sail_in22k_ft_in1k_384", "caformer_s36.sail_in22k_ft_in1k", "caformer_s36.sail_in22k_ft_in1k_384", "caformer_m36.sail_in22k_ft_in1k", "caformer_m36.sail_in22k_ft_in1k_384", "caformer_b36.sail_in22k_ft_in1k", "caformer_b36.sail_in22k_ft_in1k_384", ] CONVFORMER_MODELS = [ "convformer_s18", "convformer_s36", "convformer_m36", "convformer_b36", "convformer_s18.sail_in22k_ft_in1k", "convformer_s18.sail_in22k_ft_in1k_384", "convformer_s36.sail_in22k_ft_in1k", "convformer_s36.sail_in22k_ft_in1k_384", "convformer_m36.sail_in22k_ft_in1k", "convformer_m36.sail_in22k_ft_in1k_384", "convformer_b36.sail_in22k_ft_in1k", "convformer_b36.sail_in22k_ft_in1k_384", ] POOLFORMER_MODELS = [ "poolformerv2_s12", "poolformerv2_s24", "poolformerv2_s36", "poolformerv2_m36", "poolformerv2_m48", "poolformer_s12", "poolformer_s24", "poolformer_s36", "poolformer_m36", "poolformer_m48", ] @DeveloperAPI @register_encoder_config("caformer", IMAGE) @ludwig_dataclass class TimmCAFormerEncoderConfig(TimmBaseConfig): type: str = schema_utils.ProtectedString("caformer", description="Type of encoder.") model_name: str = schema_utils.StringOptions( CAFORMER_MODELS, default="caformer_s18", allow_none=False, description=( "CAFormer model variant. Hybrid Conv+Attention MetaFormer achieving SOTA accuracy. " "Variants with '.sail_in22k_ft_in1k' are pretrained on ImageNet-21K and finetuned on ImageNet-1K. " "Variants with '_384' use 384x384 input resolution." ), parameter_metadata=ENCODER_METADATA["TimmCAFormerEncoder"]["model_name"], ) @DeveloperAPI @register_encoder_config("convformer", IMAGE) @ludwig_dataclass class TimmConvFormerEncoderConfig(TimmBaseConfig): type: str = schema_utils.ProtectedString("convformer", description="Type of encoder.") model_name: str = schema_utils.StringOptions( CONVFORMER_MODELS, default="convformer_s18", allow_none=False, description=( "ConvFormer model variant. Pure CNN MetaFormer that outperforms ConvNeXt. " "Variants with '.sail_in22k_ft_in1k' are pretrained on ImageNet-21K and finetuned on ImageNet-1K." ), parameter_metadata=ENCODER_METADATA["TimmConvFormerEncoder"]["model_name"], ) @DeveloperAPI @register_encoder_config("poolformer", IMAGE) @ludwig_dataclass class TimmPoolFormerEncoderConfig(TimmBaseConfig): type: str = schema_utils.ProtectedString("poolformer", description="Type of encoder.") model_name: str = schema_utils.StringOptions( POOLFORMER_MODELS, default="poolformerv2_s12", allow_none=False, description=( "PoolFormer model variant. MetaFormer using simple average pooling as token mixer. " "V2 variants use StarReLU activation and improved training recipe." ), parameter_metadata=ENCODER_METADATA["TimmPoolFormerEncoder"]["model_name"], ) ================================================ FILE: ludwig/schema/encoders/image/torchvision.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.constants import IMAGE from ludwig.schema import utils as schema_utils from ludwig.schema.encoders.base import BaseEncoderConfig from ludwig.schema.encoders.utils import register_encoder_config from ludwig.schema.metadata import ENCODER_METADATA from ludwig.schema.utils import ludwig_dataclass @ludwig_dataclass class TVBaseEncoderConfig(BaseEncoderConfig): use_pretrained: bool = schema_utils.Boolean( default=True, description="Download model weights from pre-trained model.", parameter_metadata=ENCODER_METADATA["TVBaseEncoder"]["use_pretrained"], ) model_cache_dir: str | None = schema_utils.String( default=None, allow_none=True, description="Directory path to cache pretrained model weights.", parameter_metadata=ENCODER_METADATA["TVBaseEncoder"]["model_cache_dir"], ) saved_weights_in_checkpoint: bool = schema_utils.Boolean( default=False, description="Whether to save the weights in the checkpoint.", parameter_metadata=ENCODER_METADATA["TVBaseEncoder"]["saved_weights_in_checkpoint"], ) trainable: bool = schema_utils.Boolean( default=True, description="Is the encoder trainable.", parameter_metadata=ENCODER_METADATA["TVBaseEncoder"]["trainable"], ) def is_pretrained(self) -> bool: return self.use_pretrained @DeveloperAPI @register_encoder_config("alexnet", IMAGE) @ludwig_dataclass class TVAlexNetEncoderConfig(TVBaseEncoderConfig): type: str = schema_utils.ProtectedString("alexnet", description="Type of encoder.") model_variant: str = schema_utils.StringOptions( ["base"], default="base", allow_none=False, description="Pretrained model variant to use.", parameter_metadata=ENCODER_METADATA["TVAlexNetEncoder"]["model_variant"], ) @DeveloperAPI @register_encoder_config("convnext", IMAGE) @ludwig_dataclass class TVConvNeXtEncoderConfig(TVBaseEncoderConfig): type: str = schema_utils.ProtectedString("convnext", description="Type of encoder.") model_variant: str = schema_utils.StringOptions( ["tiny", "small", "base", "large"], default="base", allow_none=False, description="Pretrained model variant to use.", parameter_metadata=ENCODER_METADATA["TVConvNeXtEncoder"]["model_variant"], ) @DeveloperAPI @register_encoder_config("densenet", IMAGE) @ludwig_dataclass class TVDenseNetEncoderConfig(TVBaseEncoderConfig): type: str = schema_utils.ProtectedString("densenet", description="Type of encoder.") model_variant: int = schema_utils.IntegerOptions( [121, 161, 169, 201], default=121, allow_none=False, description="Pretrained model variant to use.", parameter_metadata=ENCODER_METADATA["TVDenseNetEncoder"]["model_variant"], ) @DeveloperAPI @register_encoder_config("efficientnet", IMAGE) @ludwig_dataclass class TVEfficientNetEncoderConfig(TVBaseEncoderConfig): type: str = schema_utils.ProtectedString("efficientnet", description="Type of encoder.") model_variant: str = schema_utils.StringOptions( [ "b0", "b1", "b2", "b3", "b4", "b5", "b6", "b7", "v2_s", "v2_m", "v2_l", ], default="b0", allow_none=False, description="Pretrained model variant to use.", parameter_metadata=ENCODER_METADATA["TVEfficientNetEncoder"]["model_variant"], ) @DeveloperAPI @register_encoder_config("googlenet", IMAGE) @ludwig_dataclass class TVGoogLeNetEncoderConfig(TVBaseEncoderConfig): type: str = schema_utils.ProtectedString("googlenet", description="Type of encoder.") model_variant: str = schema_utils.StringOptions( ["base"], default="base", allow_none=False, description="Pretrained model variant to use.", parameter_metadata=ENCODER_METADATA["TVGoogLeNetEncoder"]["model_variant"], ) @DeveloperAPI @register_encoder_config("inceptionv3", IMAGE) @ludwig_dataclass class TVInceptionV3EncoderConfig(TVBaseEncoderConfig): type: str = schema_utils.ProtectedString("inceptionv3", description="Type of encoder.") model_variant: str = schema_utils.StringOptions( ["base"], default="base", allow_none=False, description="Pretrained model variant to use.", parameter_metadata=ENCODER_METADATA["TVGoogLeNetEncoder"]["model_variant"], ) @DeveloperAPI @register_encoder_config("maxvit", IMAGE) @ludwig_dataclass class TVMaxVitEncoderConfig(TVBaseEncoderConfig): type: str = schema_utils.ProtectedString("maxvit", description="Type of encoder.") model_variant: str = schema_utils.StringOptions( ["t"], default="t", allow_none=False, description="Pretrained model variant to use.", parameter_metadata=ENCODER_METADATA["TVMNASNetEncoder"]["model_variant"], ) @DeveloperAPI @register_encoder_config("mnasnet", IMAGE) @ludwig_dataclass class TVMNASNetEncoderConfig(TVBaseEncoderConfig): type: str = schema_utils.ProtectedString("mnasnet", description="Type of encoder.") model_variant: str = schema_utils.StringOptions( ["0_5", "0_75", "1_0", "1_3"], default="0_5", allow_none=False, description="Pretrained model variant to use.", parameter_metadata=ENCODER_METADATA["TVMNASNetEncoder"]["model_variant"], ) @DeveloperAPI @register_encoder_config("mobilenetv2", IMAGE) @ludwig_dataclass class TVMobileNetV2EncoderConfig(TVBaseEncoderConfig): type: str = schema_utils.ProtectedString("mobilenetv2", description="Type of encoder.") model_variant: str = schema_utils.StringOptions( ["base"], default="base", allow_none=False, description="Pretrained model variant to use.", parameter_metadata=ENCODER_METADATA["TVMobileNetV2Encoder"]["model_variant"], ) @DeveloperAPI @register_encoder_config("mobilenetv3", IMAGE) @ludwig_dataclass class TVMobileNetV3EncoderConfig(TVBaseEncoderConfig): type: str = schema_utils.ProtectedString("mobilenetv3", description="Type of encoder.") model_variant: str = schema_utils.StringOptions( [ "small", "large", ], default="small", allow_none=False, description="Pretrained model variant to use.", parameter_metadata=ENCODER_METADATA["TVMobileNetV3Encoder"]["model_variant"], ) @DeveloperAPI @register_encoder_config("regnet", IMAGE) @ludwig_dataclass class TVRegNetEncoderConfig(TVBaseEncoderConfig): type: str = schema_utils.ProtectedString("regnet", description="Type of encoder.") model_variant: str = schema_utils.StringOptions( [ "x_1_6gf", "x_16gf", "x_32gf", "x_3_2gf", "x_400mf", "x_800mf", "x_8gf", "y_128gf", "y_16gf", "y_1_6gf", "y_32gf", "y_3_2gf", "y_400mf", "y_800mf", "y_8gf", ], default="x_1_6gf", allow_none=False, description="Pretrained model variant to use.", parameter_metadata=ENCODER_METADATA["TVRegNetEncoder"]["model_variant"], ) @DeveloperAPI @register_encoder_config("resnet", IMAGE) @ludwig_dataclass class TVResNetEncoderConfig(TVBaseEncoderConfig): type: str = schema_utils.ProtectedString("resnet", description="Type of encoder.") model_variant: int = schema_utils.IntegerOptions( [18, 34, 50, 101, 152], default=50, allow_none=False, description="Pretrained model variant to use.", parameter_metadata=ENCODER_METADATA["TVResNetEncoder"]["model_variant"], ) @DeveloperAPI @register_encoder_config("resnext", IMAGE) @ludwig_dataclass class TVResNeXtEncoderConfig(TVBaseEncoderConfig): type: str = schema_utils.ProtectedString("resnext", description="Type of encoder.") model_variant: str = schema_utils.StringOptions( ["50_32x4d", "101_32x8d", "101_64x4d"], default="50_32x4d", allow_none=False, description="Pretrained model variant to use.", parameter_metadata=ENCODER_METADATA["TVResNeXtEncoder"]["model_variant"], ) @DeveloperAPI @register_encoder_config("shufflenet_v2", IMAGE) @ludwig_dataclass class TVShuffleNetV2EncoderConfig(TVBaseEncoderConfig): type: str = schema_utils.ProtectedString("shufflenet_v2", description="Type of encoder.") model_variant: str = schema_utils.StringOptions( [ "x0_5", "x1_0", "x1_5", "x2_0", ], default="x0_5", allow_none=False, description="Pretrained model variant to use.", parameter_metadata=ENCODER_METADATA["TVShuffleNetV2Encoder"]["model_variant"], ) @DeveloperAPI @register_encoder_config("squeezenet", IMAGE) @ludwig_dataclass class TVSqueezeNetEncoderConfig(TVBaseEncoderConfig): type: str = schema_utils.ProtectedString("squeezenet", description="Type of encoder.") model_variant: str = schema_utils.StringOptions( [ "1_0", "1_1", ], default="1_0", allow_none=False, description="Pretrained model variant to use.", parameter_metadata=ENCODER_METADATA["TVSqueezeNetEncoder"]["model_variant"], ) @DeveloperAPI @register_encoder_config("swin_transformer", IMAGE) @ludwig_dataclass class TVSwinTransformerEncoderConfig(TVBaseEncoderConfig): type: str = schema_utils.ProtectedString("swin_transformer", description="Type of encoder.") model_variant: str = schema_utils.StringOptions( [ "t", "s", "b", ], default="t", allow_none=False, description="Pretrained model variant to use.", parameter_metadata=ENCODER_METADATA["TVSwinTransformerEncoder"]["model_variant"], ) @DeveloperAPI @register_encoder_config("vit", IMAGE) @ludwig_dataclass class TVViTEncoderConfig(TVBaseEncoderConfig): type: str = schema_utils.ProtectedString("vit", description="Type of encoder.") model_variant: str = schema_utils.StringOptions( [ "b_16", "b_32", "l_16", "l_32", "h_14", ], default="b_16", allow_none=False, description="Pretrained model variant to use.", parameter_metadata=ENCODER_METADATA["TVViTEncoder"]["model_variant"], ) @DeveloperAPI @register_encoder_config("vgg", IMAGE) @ludwig_dataclass class TVVGGEncoderConfig(TVBaseEncoderConfig): type: str = schema_utils.ProtectedString("vgg", description="Type of encoder.") model_variant: int | str = schema_utils.OneOfOptionsField( default=11, description="Pretrained model variant to use.", field_options=[ schema_utils.IntegerOptions( [ 11, 13, 16, 19, ], default=11, allow_none=False, ), schema_utils.StringOptions( [ "11_bn", "13_bn", "16_bn", "19_bn", ], default="11_bn", allow_none=False, ), ], allow_none=False, parameter_metadata=ENCODER_METADATA["TVVGGEncoder"]["model_variant"], ) @DeveloperAPI @register_encoder_config("wide_resnet", IMAGE) @ludwig_dataclass class TVWideResNetEncoderConfig(TVBaseEncoderConfig): type: str = schema_utils.ProtectedString("wide_resnet", description="Type of encoder.") model_variant: str = schema_utils.StringOptions( [ "50_2", "101_2", ], default="50_2", allow_none=False, description="Pretrained model variant to use.", parameter_metadata=ENCODER_METADATA["TVViTEncoder"]["model_variant"], ) ================================================ FILE: ludwig/schema/encoders/sequence_encoders.py ================================================ from dataclasses import Field from typing import TYPE_CHECKING from ludwig.api_annotations import DeveloperAPI from ludwig.constants import AUDIO, SEQUENCE, TEXT, TIMESERIES from ludwig.schema import common_fields from ludwig.schema import utils as schema_utils from ludwig.schema.encoders.base import BaseEncoderConfig from ludwig.schema.encoders.utils import register_encoder_config from ludwig.schema.metadata import ENCODER_METADATA from ludwig.schema.utils import ludwig_dataclass if TYPE_CHECKING: from ludwig.schema.features.preprocessing.sequence import SequencePreprocessingConfig CONV_LAYERS_DESCRIPTION = """ A list of dictionaries containing the parameters of all the convolutional layers. The length of the list determines the number of stacked convolutional layers and the content of each dictionary determines the parameters for a specific layer. The available parameters for each layer are: `activation`, `dropout`, `norm`, `norm_params`, `num_filters`, `filter_size`, `strides`, `padding`, `dilation_rate`, `use_bias`, `pool_function`, `pool_padding`, `pool_size`, `pool_strides`, `bias_initializer`, `weights_initializer`. If any of those values is missing from the dictionary, the default one specified as a parameter of the encoder will be used instead. If both `conv_layers` and `num_conv_layers` are `null`, a default list will be assigned to `conv_layers` with the value `[{filter_size: 7, pool_size: 3}, {filter_size: 7, pool_size: 3}, {filter_size: 3, pool_size: null}, {filter_size: 3, pool_size: null}, {filter_size: 3, pool_size: null}, {filter_size: 3, pool_size: 3}]`. """ NUM_CONV_LAYERS_DESCRIPTION = "The number of stacked convolutional layers when `conv_layers` is `null`." def NumFiltersField(default: int = 256) -> Field: return schema_utils.PositiveInteger( default=default, description="Number of filters, and by consequence number of output channels of the 1d convolution.", parameter_metadata=ENCODER_METADATA["conv_params"]["num_filters"], ) def FilterSizeField(default: int = 3) -> Field: return schema_utils.PositiveInteger( default=default, description="Size of the 1d convolutional filter. It indicates how wide the 1d convolutional filter is.", parameter_metadata=ENCODER_METADATA["conv_params"]["filter_size"], ) def PoolFunctionField(default: str = "max") -> Field: return schema_utils.ReductionOptions( default=default, description=( "Pooling function to use. `max` will select the maximum value. Any of `average`, `avg`, or " "`mean` will compute the mean value" ), parameter_metadata=ENCODER_METADATA["conv_params"]["pool_function"], ) def PoolSizeField(default: int | None = None) -> Field: return schema_utils.PositiveInteger( default=None, allow_none=True, description=( "The default pool_size that will be used for each layer. If a pool_size is not already specified " "in conv_layers this is the default pool_size that will be used for each layer. It indicates the size of " "the max pooling that will be performed along the `s` sequence dimension after the convolution operation." ), parameter_metadata=ENCODER_METADATA["conv_params"]["pool_size"], ) @DeveloperAPI @ludwig_dataclass class SequenceEncoderConfig(BaseEncoderConfig): """Base class for sequence encoders.""" def set_fixed_preprocessing_params(self, model_type: str, preprocessing: "SequencePreprocessingConfig"): if isinstance(preprocessing, dict): preprocessing["cache_encoder_embeddings"] = False else: preprocessing.cache_encoder_embeddings = False @DeveloperAPI @register_encoder_config("passthrough", [TIMESERIES]) @ludwig_dataclass class SequencePassthroughConfig(SequenceEncoderConfig): @staticmethod def module_name(): return "SequencePassthrough" type: str = schema_utils.ProtectedString( "passthrough", description=ENCODER_METADATA["SequencePassthrough"]["type"].long_description, ) max_sequence_length: int = common_fields.MaxSequenceLengthField() encoding_size: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="The size of the encoding vector, or None if sequence elements are scalars.", parameter_metadata=ENCODER_METADATA["SequencePassthrough"]["encoding_size"], ) reduce_output: str = common_fields.ReduceOutputField(default=None) @DeveloperAPI @register_encoder_config("embed", [SEQUENCE, TEXT]) @ludwig_dataclass class SequenceEmbedConfig(SequenceEncoderConfig): @staticmethod def module_name(): return "SequenceEmbed" type: str = schema_utils.ProtectedString( "embed", description=ENCODER_METADATA["SequenceEmbed"]["type"].long_description, ) dropout: float = common_fields.DropoutField(description="Dropout rate applied to the embedding.") max_sequence_length: int = common_fields.MaxSequenceLengthField() representation: str = common_fields.RepresentationField() vocab: list = common_fields.VocabField() weights_initializer: str = common_fields.WeightsInitializerField(default="uniform") reduce_output: str = common_fields.ReduceOutputField() embedding_size: int = common_fields.EmbeddingSizeField() embeddings_on_cpu: bool = common_fields.EmbeddingsOnCPUField() embeddings_trainable: bool = common_fields.EmbeddingsTrainableField() pretrained_embeddings: str = common_fields.PretrainedEmbeddingsField() @DeveloperAPI @register_encoder_config("parallel_cnn", [AUDIO, SEQUENCE, TEXT, TIMESERIES]) @ludwig_dataclass class ParallelCNNConfig(SequenceEncoderConfig): @staticmethod def module_name(): return "ParallelCNN" type: str = schema_utils.ProtectedString( "parallel_cnn", description=ENCODER_METADATA["ParallelCNN"]["type"].long_description, ) dropout: float = common_fields.DropoutField(description="Dropout rate applied to the embedding.") activation: str = schema_utils.ActivationOptions( description="The default activation function that will be used for each layer." ) max_sequence_length: int = common_fields.MaxSequenceLengthField() representation: str = common_fields.RepresentationField() vocab: list = common_fields.VocabField() use_bias: bool = schema_utils.Boolean( default=True, description="Whether to use a bias vector.", parameter_metadata=ENCODER_METADATA["ParallelCNN"]["use_bias"], ) bias_initializer: str = common_fields.BiasInitializerField() weights_initializer: str = common_fields.WeightsInitializerField() should_embed: bool = schema_utils.Boolean( default=True, description="Whether to embed the input sequence.", parameter_metadata=ENCODER_METADATA["ParallelCNN"]["should_embed"], ) embedding_size: int = common_fields.EmbeddingSizeField() embeddings_on_cpu: bool = common_fields.EmbeddingsOnCPUField() embeddings_trainable: bool = common_fields.EmbeddingsTrainableField() pretrained_embeddings: str = common_fields.PretrainedEmbeddingsField() reduce_output: str = common_fields.ReduceOutputField() num_conv_layers: int = schema_utils.PositiveInteger( default=None, allow_none=True, description=NUM_CONV_LAYERS_DESCRIPTION, parameter_metadata=ENCODER_METADATA["conv_params"]["num_conv_layers"], ) conv_layers: list[dict] = schema_utils.DictList( # TODO (Connor): Add nesting logic for conv_layers default=None, description=CONV_LAYERS_DESCRIPTION, parameter_metadata=ENCODER_METADATA["conv_params"]["conv_layers"], ) num_filters: int = NumFiltersField() filter_size: int = FilterSizeField() pool_function: str = PoolFunctionField() pool_size: int = PoolSizeField() output_size: int = schema_utils.PositiveInteger( default=256, description="The default output_size that will be used for each layer.", parameter_metadata=ENCODER_METADATA["ParallelCNN"]["output_size"], ) norm: str = schema_utils.StringOptions( ["batch", "layer"], default=None, allow_none=True, description="The default norm that will be used for each layer.", parameter_metadata=ENCODER_METADATA["ParallelCNN"]["norm"], ) norm_params: dict = schema_utils.Dict( default=None, description="Parameters used if norm is either `batch` or `layer`.", parameter_metadata=ENCODER_METADATA["ParallelCNN"]["norm_params"], ) num_fc_layers: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Number of parallel fully connected layers to use.", parameter_metadata=ENCODER_METADATA["ParallelCNN"]["num_fc_layers"], ) fc_layers: list[dict] = schema_utils.DictList( # TODO (Connor): Add nesting logic for fc_layers default=None, description="List of dictionaries containing the parameters for each fully connected layer.", parameter_metadata=ENCODER_METADATA["ParallelCNN"]["fc_layers"], ) @DeveloperAPI @register_encoder_config("stacked_cnn", [AUDIO, SEQUENCE, TEXT, TIMESERIES]) @ludwig_dataclass class StackedCNNConfig(SequenceEncoderConfig): @staticmethod def module_name(): return "StackedCNN" type: str = schema_utils.ProtectedString( "stacked_cnn", description=ENCODER_METADATA["StackedCNN"]["type"].long_description, ) dropout: float = common_fields.DropoutField(description="Dropout rate applied to the embedding.") activation: str = schema_utils.ActivationOptions( description="The default activation function that will be used for each layer." ) max_sequence_length: int = common_fields.MaxSequenceLengthField() representation: str = common_fields.RepresentationField() vocab: list = common_fields.VocabField() num_conv_layers: int = schema_utils.PositiveInteger( default=None, allow_none=True, description=NUM_CONV_LAYERS_DESCRIPTION, parameter_metadata=ENCODER_METADATA["conv_params"]["num_conv_layers"], ) conv_layers: list[dict] = schema_utils.DictList( # TODO (Connor): Add nesting logic for conv_layers default=None, description=CONV_LAYERS_DESCRIPTION, parameter_metadata=ENCODER_METADATA["conv_params"]["conv_layers"], ) num_filters: int = NumFiltersField() filter_size: int = FilterSizeField() pool_function: str = PoolFunctionField() pool_size: int = PoolSizeField() strides: int = schema_utils.PositiveInteger( default=1, description="Stride length of the convolution.", parameter_metadata=ENCODER_METADATA["StackedCNN"]["strides"], ) padding: str = schema_utils.StringOptions( ["valid", "same"], default="same", description="Padding to use.", parameter_metadata=ENCODER_METADATA["StackedCNN"]["padding"], ) dilation_rate: int = schema_utils.PositiveInteger( default=1, description="Dilation rate to use for dilated convolution.", parameter_metadata=ENCODER_METADATA["StackedCNN"]["dilation_rate"], ) pool_strides: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Factor to scale down.", parameter_metadata=ENCODER_METADATA["StackedCNN"]["pool_strides"], ) pool_padding: str = schema_utils.StringOptions( ["valid", "same"], default="same", description="Padding to use.", parameter_metadata=ENCODER_METADATA["StackedCNN"]["pool_padding"], ) use_bias: bool = schema_utils.Boolean( default=True, description="Whether to use a bias vector.", parameter_metadata=ENCODER_METADATA["StackedCNN"]["use_bias"], ) bias_initializer: str = common_fields.BiasInitializerField() weights_initializer: str = common_fields.WeightsInitializerField() should_embed: bool = schema_utils.Boolean( default=True, description="Whether to embed the input sequence.", parameter_metadata=ENCODER_METADATA["StackedCNN"]["should_embed"], ) embedding_size: int = common_fields.EmbeddingSizeField() embeddings_on_cpu: bool = common_fields.EmbeddingsOnCPUField() embeddings_trainable: bool = common_fields.EmbeddingsTrainableField() pretrained_embeddings: str = common_fields.PretrainedEmbeddingsField() reduce_output: str = common_fields.ReduceOutputField() output_size: int = schema_utils.PositiveInteger( default=256, description="The default output_size that will be used for each layer.", parameter_metadata=ENCODER_METADATA["StackedCNN"]["output_size"], ) norm: str = schema_utils.StringOptions( ["batch", "layer"], default=None, allow_none=True, description="The default norm that will be used for each layer.", parameter_metadata=ENCODER_METADATA["StackedCNN"]["norm"], ) norm_params: dict = schema_utils.Dict( default=None, description="Parameters used if norm is either `batch` or `layer`.", parameter_metadata=ENCODER_METADATA["StackedCNN"]["norm_params"], ) num_fc_layers: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Number of parallel fully connected layers to use.", parameter_metadata=ENCODER_METADATA["StackedCNN"]["num_fc_layers"], ) fc_layers: list[dict] = schema_utils.DictList( # TODO (Connor): Add nesting logic for fc_layers default=None, description="List of dictionaries containing the parameters for each fully connected layer.", parameter_metadata=ENCODER_METADATA["StackedCNN"]["fc_layers"], ) @DeveloperAPI @register_encoder_config("stacked_parallel_cnn", [AUDIO, SEQUENCE, TEXT, TIMESERIES]) @ludwig_dataclass class StackedParallelCNNConfig(SequenceEncoderConfig): @staticmethod def module_name(): return "StackedParallelCNN" type: str = schema_utils.ProtectedString( "stacked_parallel_cnn", description=ENCODER_METADATA["StackedParallelCNN"]["type"].long_description, ) dropout: float = common_fields.DropoutField(description="Dropout rate applied to the embedding.") activation: str = schema_utils.ActivationOptions( description="The default activation function that will be used for each layer." ) max_sequence_length: int = common_fields.MaxSequenceLengthField() representation: str = common_fields.RepresentationField() vocab: list = common_fields.VocabField() num_stacked_layers: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="If stacked_layers is null, this is the number of elements in the stack of parallel convolutional " "layers. ", parameter_metadata=ENCODER_METADATA["StackedParallelCNN"]["num_stacked_layers"], ) stacked_layers: list[dict] = schema_utils.DictList( default=None, description="a nested list of lists of dictionaries containing the parameters of the stack of parallel " "convolutional layers. The length of the list determines the number of stacked parallel " "convolutional layers, length of the sub-lists determines the number of parallel conv layers and " "the content of each dictionary determines the parameters for a specific layer. ", parameter_metadata=ENCODER_METADATA["StackedParallelCNN"]["stacked_layers"], ) num_filters: int = NumFiltersField() filter_size: int = FilterSizeField() pool_function: str = PoolFunctionField() pool_size: int = PoolSizeField() use_bias: bool = schema_utils.Boolean( default=True, description="Whether to use a bias vector.", parameter_metadata=ENCODER_METADATA["StackedParallelCNN"]["use_bias"], ) bias_initializer: str = common_fields.BiasInitializerField() weights_initializer: str = common_fields.WeightsInitializerField() should_embed: bool = schema_utils.Boolean( default=True, description="If True the input sequence is expected to be made of integers and will be mapped into embeddings", parameter_metadata=ENCODER_METADATA["StackedParallelCNN"]["should_embed"], ) embedding_size: int = common_fields.EmbeddingSizeField() embeddings_on_cpu: bool = common_fields.EmbeddingsOnCPUField() embeddings_trainable: bool = common_fields.EmbeddingsTrainableField() pretrained_embeddings: str = common_fields.PretrainedEmbeddingsField() reduce_output: str = common_fields.ReduceOutputField() output_size: int = schema_utils.PositiveInteger( default=256, description="The default output_size that will be used for each layer.", parameter_metadata=ENCODER_METADATA["StackedParallelCNN"]["output_size"], ) norm: str = schema_utils.StringOptions( ["batch", "layer"], default=None, allow_none=True, description="The default norm that will be used for each layer.", parameter_metadata=ENCODER_METADATA["StackedParallelCNN"]["norm"], ) norm_params: dict = schema_utils.Dict( default=None, description="Parameters used if norm is either `batch` or `layer`.", parameter_metadata=ENCODER_METADATA["StackedParallelCNN"]["norm_params"], ) num_fc_layers: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Number of parallel fully connected layers to use.", parameter_metadata=ENCODER_METADATA["StackedParallelCNN"]["num_fc_layers"], ) fc_layers: list[dict] = schema_utils.DictList( # TODO (Connor): Add nesting logic for fc_layers default=None, description="List of dictionaries containing the parameters for each fully connected layer.", parameter_metadata=ENCODER_METADATA["StackedParallelCNN"]["fc_layers"], ) @DeveloperAPI @register_encoder_config("rnn", [AUDIO, SEQUENCE, TEXT, TIMESERIES]) @ludwig_dataclass class StackedRNNConfig(SequenceEncoderConfig): @staticmethod def module_name(): return "StackedRNN" type: str = schema_utils.ProtectedString( "rnn", description=ENCODER_METADATA["StackedRNN"]["type"].long_description, ) dropout: float = common_fields.DropoutField(description="Dropout rate.") recurrent_dropout: float = schema_utils.FloatRange( default=0.0, min=0, max=1, description="The dropout rate for the recurrent state", parameter_metadata=ENCODER_METADATA["StackedRNN"]["recurrent_dropout"], ) activation: str = schema_utils.ActivationOptions(default="tanh", description="The default activation function.") recurrent_activation: str = schema_utils.ActivationOptions( default="sigmoid", description="The activation function to use in the recurrent step", parameter_metadata=ENCODER_METADATA["StackedRNN"]["recurrent_activation"], ) max_sequence_length: int = common_fields.MaxSequenceLengthField() representation: str = common_fields.RepresentationField() vocab: list = common_fields.VocabField() cell_type: str = schema_utils.StringOptions( ["rnn", "lstm", "gru"], default="rnn", description="The type of recurrent cell to use. Available values are: `rnn`, `lstm`, `gru`. For reference " "about the differences between the cells please refer to " "[torch.nn Recurrent Layers](https://pytorch.org/docs/stable/nn.html#recurrent-layers).", parameter_metadata=ENCODER_METADATA["StackedRNN"]["cell_type"], ) num_layers: int = schema_utils.PositiveInteger( default=1, description="The number of stacked recurrent layers.", parameter_metadata=ENCODER_METADATA["StackedRNN"]["num_layers"], ) state_size: int = schema_utils.PositiveInteger( default=256, description="The size of the state of the rnn.", parameter_metadata=ENCODER_METADATA["StackedRNN"]["state_size"], ) bidirectional: bool = schema_utils.Boolean( default=False, description="If true, two recurrent networks will perform encoding in the forward and backward direction and " "their outputs will be concatenated.", parameter_metadata=ENCODER_METADATA["StackedRNN"]["bidirectional"], ) unit_forget_bias: bool = schema_utils.Boolean( default=True, description="If true, add 1 to the bias of the forget gate at initialization", parameter_metadata=ENCODER_METADATA["StackedRNN"]["unit_forget_bias"], ) recurrent_initializer: str = schema_utils.InitializerOptions( default="orthogonal", description="The initializer for recurrent matrix weights", parameter_metadata=ENCODER_METADATA["StackedRNN"]["recurrent_initializer"], ) use_bias: bool = schema_utils.Boolean( default=True, description="Whether to use a bias vector.", parameter_metadata=ENCODER_METADATA["StackedRNN"]["use_bias"], ) bias_initializer: str = common_fields.BiasInitializerField() weights_initializer: str = common_fields.WeightsInitializerField() should_embed: bool = schema_utils.Boolean( default=True, description="If True the input sequence is expected to be made of integers and will be mapped into embeddings", parameter_metadata=ENCODER_METADATA["StackedRNN"]["should_embed"], ) embedding_size: int = common_fields.EmbeddingSizeField() embeddings_on_cpu: bool = common_fields.EmbeddingsOnCPUField() embeddings_trainable: bool = common_fields.EmbeddingsTrainableField() pretrained_embeddings: str = common_fields.PretrainedEmbeddingsField() reduce_output: str = common_fields.ReduceOutputField(default="last") output_size: int = schema_utils.PositiveInteger( default=256, description="The default output_size that will be used for each layer.", parameter_metadata=ENCODER_METADATA["StackedRNN"]["output_size"], ) norm: str = common_fields.NormField(description="The default norm that will be used for each layer.") norm_params: dict = common_fields.NormParamsField() num_fc_layers: int = common_fields.NumFCLayersField(description="Number of parallel fully connected layers to use.") fc_activation: str = schema_utils.ActivationOptions() fc_dropout: float = common_fields.DropoutField() fc_layers: list[dict] = common_fields.FCLayersField() @DeveloperAPI @register_encoder_config("cnnrnn", [AUDIO, SEQUENCE, TEXT, TIMESERIES]) @ludwig_dataclass class StackedCNNRNNConfig(SequenceEncoderConfig): @staticmethod def module_name(): return "StackedCNNRNN" type: str = schema_utils.ProtectedString( "cnnrnn", description=ENCODER_METADATA["StackedCNNRNN"]["type"].long_description, ) dropout: float = common_fields.DropoutField(description="Dropout rate.") recurrent_dropout: float = schema_utils.FloatRange( default=0.0, min=0, max=1, description="The dropout rate for the recurrent state", parameter_metadata=ENCODER_METADATA["StackedCNNRNN"]["recurrent_dropout"], ) conv_dropout: float = schema_utils.FloatRange( default=0.0, min=0, max=1, description="The dropout rate for the convolutional layers", parameter_metadata=ENCODER_METADATA["StackedCNNRNN"]["conv_dropout"], ) activation: str = schema_utils.ActivationOptions( default="tanh", description="The default activation function to use." ) recurrent_activation: str = schema_utils.ActivationOptions( default="sigmoid", description="The activation function to use in the recurrent step", parameter_metadata=ENCODER_METADATA["StackedCNNRNN"]["recurrent_activation"], ) conv_activation: str = schema_utils.ActivationOptions( description="The default activation function that will be used for each convolutional layer.", parameter_metadata=ENCODER_METADATA["StackedCNNRNN"]["conv_activation"], ) max_sequence_length: int = common_fields.MaxSequenceLengthField() representation: str = common_fields.RepresentationField() vocab: list = common_fields.VocabField() cell_type: str = schema_utils.StringOptions( ["rnn", "lstm", "gru"], default="rnn", description="The type of recurrent cell to use. Available values are: `rnn`, `lstm`, `gru`. For reference " "about the differences between the cells please refer to " "[torch.nn Recurrent Layers](https://pytorch.org/docs/stable/nn.html#recurrent-layers).", parameter_metadata=ENCODER_METADATA["StackedCNNRNN"]["cell_type"], ) num_conv_layers: int = schema_utils.PositiveInteger( default=None, allow_none=True, description=NUM_CONV_LAYERS_DESCRIPTION, parameter_metadata=ENCODER_METADATA["conv_params"]["num_conv_layers"], ) conv_layers: list[dict] = schema_utils.DictList( # TODO (Connor): Add nesting logic for conv_layers default=None, description=CONV_LAYERS_DESCRIPTION, parameter_metadata=ENCODER_METADATA["conv_params"]["conv_layers"], ) num_filters: int = NumFiltersField() filter_size: int = FilterSizeField(default=5) pool_function: str = PoolFunctionField() pool_size: int = PoolSizeField(default=2) strides: int = schema_utils.PositiveInteger( default=1, description="Stride length of the convolution.", parameter_metadata=ENCODER_METADATA["StackedCNNRNN"]["strides"], ) padding: str = schema_utils.StringOptions( ["valid", "same"], default="same", description="Padding to use.", parameter_metadata=ENCODER_METADATA["StackedCNNRNN"]["padding"], ) dilation_rate: int = schema_utils.PositiveInteger( default=1, description="Dilation rate to use for dilated convolution.", parameter_metadata=ENCODER_METADATA["StackedCNNRNN"]["dilation_rate"], ) pool_strides: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Factor to scale down.", parameter_metadata=ENCODER_METADATA["StackedCNNRNN"]["pool_strides"], ) pool_padding: str = schema_utils.StringOptions( ["valid", "same"], default="same", description="Padding to use.", parameter_metadata=ENCODER_METADATA["StackedCNNRNN"]["pool_padding"], ) num_rec_layers: int = schema_utils.PositiveInteger( default=1, description="The number of stacked recurrent layers.", parameter_metadata=ENCODER_METADATA["StackedCNNRNN"]["num_rec_layers"], ) state_size: int = schema_utils.PositiveInteger( default=256, description="The size of the state of the rnn.", parameter_metadata=ENCODER_METADATA["StackedCNNRNN"]["state_size"], ) bidirectional: bool = schema_utils.Boolean( default=False, description="If true, two recurrent networks will perform encoding in the forward and backward direction and " "their outputs will be concatenated.", parameter_metadata=ENCODER_METADATA["StackedCNNRNN"]["bidirectional"], ) unit_forget_bias: bool = schema_utils.Boolean( default=True, description="If true, add 1 to the bias of the forget gate at initialization", parameter_metadata=ENCODER_METADATA["StackedCNNRNN"]["unit_forget_bias"], ) recurrent_initializer: str = schema_utils.InitializerOptions( default="orthogonal", description="The initializer for recurrent matrix weights", parameter_metadata=ENCODER_METADATA["StackedCNNRNN"]["recurrent_initializer"], ) use_bias: bool = schema_utils.Boolean( default=True, description="Whether to use a bias vector.", parameter_metadata=ENCODER_METADATA["StackedCNNRNN"]["use_bias"], ) bias_initializer: str = common_fields.BiasInitializerField() weights_initializer: str = common_fields.WeightsInitializerField() should_embed: bool = schema_utils.Boolean( default=True, description="If True the input sequence is expected to be made of integers and will be mapped into embeddings", parameter_metadata=ENCODER_METADATA["StackedCNNRNN"]["should_embed"], ) embedding_size: int = common_fields.EmbeddingSizeField() embeddings_on_cpu: bool = common_fields.EmbeddingsOnCPUField() embeddings_trainable: bool = common_fields.EmbeddingsTrainableField() pretrained_embeddings: str = common_fields.PretrainedEmbeddingsField() reduce_output: str = common_fields.ReduceOutputField(default="last") output_size: int = schema_utils.PositiveInteger( default=256, description="The default output_size that will be used for each layer.", parameter_metadata=ENCODER_METADATA["StackedCNNRNN"]["output_size"], ) norm: str = common_fields.NormField(description="The default norm that will be used for each layer.") norm_params: dict = common_fields.NormParamsField() num_fc_layers: int = common_fields.NumFCLayersField(description="Number of parallel fully connected layers to use.") fc_activation: str = schema_utils.ActivationOptions() fc_dropout: float = common_fields.DropoutField() fc_layers: list[dict] = common_fields.FCLayersField() @DeveloperAPI @register_encoder_config("transformer", [SEQUENCE, TEXT, TIMESERIES]) @ludwig_dataclass class StackedTransformerConfig(SequenceEncoderConfig): @staticmethod def module_name(): return "StackedTransformer" type: str = schema_utils.ProtectedString( "transformer", description=ENCODER_METADATA["StackedTransformer"]["type"].long_description, ) dropout: float = common_fields.DropoutField(default=0.1, description="The dropout rate for the transformer block.") max_sequence_length: int = common_fields.MaxSequenceLengthField() representation: str = common_fields.RepresentationField() vocab: list = common_fields.VocabField() num_layers: int = schema_utils.PositiveInteger( default=1, description="The number of transformer layers.", parameter_metadata=ENCODER_METADATA["StackedTransformer"]["num_layers"], ) hidden_size: int = schema_utils.PositiveInteger( default=256, description="The size of the hidden representation within the transformer block. It is usually the same as " "the embedding_size, but if the two values are different, a projection layer will be added before " "the first transformer block.", parameter_metadata=ENCODER_METADATA["StackedTransformer"]["hidden_size"], ) num_heads: int = schema_utils.PositiveInteger( default=8, description="Number of attention heads in each transformer block.", parameter_metadata=ENCODER_METADATA["StackedTransformer"]["num_heads"], ) transformer_output_size: int = schema_utils.PositiveInteger( default=256, description="Size of the fully connected layer after self attention in the transformer block. This is usually " "the same as hidden_size and embedding_size.", parameter_metadata=ENCODER_METADATA["StackedTransformer"]["transformer_output_size"], ) use_bias: bool = schema_utils.Boolean( default=True, description="Whether to use a bias vector.", parameter_metadata=ENCODER_METADATA["StackedTransformer"]["use_bias"], ) bias_initializer: str = common_fields.BiasInitializerField() weights_initializer: str = common_fields.WeightsInitializerField() should_embed: bool = schema_utils.Boolean( default=True, description="If True the input sequence is expected to be made of integers and will be mapped into embeddings", parameter_metadata=ENCODER_METADATA["StackedTransformer"]["should_embed"], ) embedding_size: int = common_fields.EmbeddingSizeField() embeddings_on_cpu: bool = common_fields.EmbeddingsOnCPUField() embeddings_trainable: bool = common_fields.EmbeddingsTrainableField() pretrained_embeddings: str = common_fields.PretrainedEmbeddingsField() reduce_output: str = common_fields.ReduceOutputField(default="last") output_size: int = schema_utils.PositiveInteger( default=256, description="The default output_size that will be used for each layer.", parameter_metadata=ENCODER_METADATA["StackedTransformer"]["output_size"], ) norm: str = common_fields.NormField(description="The default norm that will be used for each layer.") norm_params: dict = common_fields.NormParamsField() num_fc_layers: int = common_fields.NumFCLayersField(description="Number of parallel fully connected layers to use.") fc_activation: str = schema_utils.ActivationOptions() fc_dropout: float = common_fields.DropoutField() fc_layers: list[dict] = common_fields.FCLayersField() ================================================ FILE: ludwig/schema/encoders/set_encoders.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.constants import SET from ludwig.schema import utils as schema_utils from ludwig.schema.encoders.base import BaseEncoderConfig from ludwig.schema.encoders.utils import register_encoder_config from ludwig.schema.metadata import ENCODER_METADATA from ludwig.schema.utils import ludwig_dataclass @DeveloperAPI @register_encoder_config("embed", SET) @ludwig_dataclass class SetSparseEncoderConfig(BaseEncoderConfig): @staticmethod def module_name(): return "SetSparseEncoder" type: str = schema_utils.ProtectedString( "embed", description=ENCODER_METADATA["SetSparseEncoder"]["type"].long_description, ) dropout: float = schema_utils.FloatRange( default=0.0, min=0, max=1, description="Dropout probability for the embedding.", parameter_metadata=ENCODER_METADATA["SetSparseEncoder"]["dropout"], ) activation: str = schema_utils.ActivationOptions( description="The default activation function that will be used for each layer.", parameter_metadata=ENCODER_METADATA["SetSparseEncoder"]["activation"], ) representation: str = schema_utils.StringOptions( ["dense", "sparse"], default="dense", description="The representation of the embedding. Either dense or sparse.", parameter_metadata=ENCODER_METADATA["SetSparseEncoder"]["representation"], ) vocab: list[str] = schema_utils.List( default=None, description="Vocabulary of the encoder", parameter_metadata=ENCODER_METADATA["SetSparseEncoder"]["vocab"], ) use_bias: bool = schema_utils.Boolean( default=True, description="Whether the layer uses a bias vector.", parameter_metadata=ENCODER_METADATA["SetSparseEncoder"]["use_bias"], ) bias_initializer: str = schema_utils.InitializerOptions( default="zeros", description="Initializer to use for the bias vector.", parameter_metadata=ENCODER_METADATA["SetSparseEncoder"]["bias_initializer"], ) weights_initializer: str = schema_utils.InitializerOptions( description="Initializer to use for the weights matrix.", parameter_metadata=ENCODER_METADATA["SetSparseEncoder"]["weights_initializer"], ) embedding_size: int = schema_utils.PositiveInteger( default=50, description="The maximum embedding size, the actual size will be min(vocabulary_size, embedding_size) for " "dense representations and exactly vocabulary_size for the sparse encoding, where vocabulary_size " "is the number of different strings appearing in the training set in the input column (plus 1 for " "the unknown token placeholder ).", parameter_metadata=ENCODER_METADATA["SetSparseEncoder"]["embedding_size"], ) embeddings_on_cpu: bool = schema_utils.Boolean( default=False, description="By default embedding matrices are stored on GPU memory if a GPU is used, as it allows for faster " "access, but in some cases the embedding matrix may be too large. This parameter forces the " "placement of the embedding matrix in regular memory and the CPU is used for embedding lookup, " "slightly slowing down the process as a result of data transfer between CPU and GPU memory.", parameter_metadata=ENCODER_METADATA["SetSparseEncoder"]["embeddings_on_cpu"], ) embeddings_trainable: bool = schema_utils.Boolean( default=True, description="If true embeddings are trained during the training process, if false embeddings are fixed. It " "may be useful when loading pretrained embeddings for avoiding finetuning them. This parameter " "has effect only when representation is dense as sparse one-hot encodings are not trainable.", parameter_metadata=ENCODER_METADATA["SetSparseEncoder"]["embeddings_trainable"], ) pretrained_embeddings: str = schema_utils.String( default=None, allow_none=True, description="By default dense embeddings are initialized randomly, but this parameter allows to specify a " "path to a file containing embeddings in the GloVe format. When the file containing the " "embeddings is loaded, only the embeddings with labels present in the vocabulary are kept, " "the others are discarded. If the vocabulary contains strings that have no match in the " "embeddings file, their embeddings are initialized with the average of all other embedding plus " "some random noise to make them different from each other. This parameter has effect only if " "representation is dense.", parameter_metadata=ENCODER_METADATA["SetSparseEncoder"]["pretrained_embeddings"], ) output_size: int = schema_utils.PositiveInteger( default=10, description="If output_size is not already specified in fc_layers this is the default output_size that will " "be used for each layer. It indicates the size of the output of a fully connected layer.", parameter_metadata=ENCODER_METADATA["SetSparseEncoder"]["output_size"], ) norm: str = schema_utils.StringOptions( ["batch", "layer"], default=None, allow_none=True, description="The default norm that will be used for each layer.", parameter_metadata=ENCODER_METADATA["SetSparseEncoder"]["norm"], ) norm_params: dict = schema_utils.Dict( default=None, description="Parameters used if norm is either `batch` or `layer`.", parameter_metadata=ENCODER_METADATA["SetSparseEncoder"]["norm_params"], ) num_fc_layers: int = schema_utils.NonNegativeInteger( default=0, description="This is the number of stacked fully connected layers that the input to the feature passes " "through. Their output is projected in the feature's output space.", parameter_metadata=ENCODER_METADATA["SetSparseEncoder"]["num_fc_layers"], ) fc_layers: list[dict] = schema_utils.DictList( # TODO (Connor): Add nesting logic for fc_layers default=None, description="List of dictionaries containing the parameters for each fully connected layer.", parameter_metadata=ENCODER_METADATA["SetSparseEncoder"]["fc_layers"], ) ================================================ FILE: ludwig/schema/encoders/text/__init__.py ================================================ ================================================ FILE: ludwig/schema/encoders/text/encoders.py ================================================ from collections.abc import Callable from typing import TYPE_CHECKING from ludwig.api_annotations import DeveloperAPI from ludwig.constants import MODEL_ECD, TEXT from ludwig.error import ConfigValidationError from ludwig.schema import utils as schema_utils from ludwig.schema.encoders.sequence_encoders import SequenceEncoderConfig from ludwig.schema.encoders.text.hf_model_params import DebertaModelParams from ludwig.schema.encoders.utils import register_encoder_config from ludwig.schema.llms.base_model import BaseModelDataclassField from ludwig.schema.llms.model_parameters import ModelParametersConfig, ModelParametersConfigField from ludwig.schema.llms.peft import AdapterDataclassField, BaseAdapterConfig from ludwig.schema.llms.quantization import QuantizationConfig, QuantizationConfigField from ludwig.schema.metadata import ENCODER_METADATA from ludwig.schema.metadata.parameter_metadata import INTERNAL_ONLY, ParameterMetadata from ludwig.schema.utils import ludwig_dataclass if TYPE_CHECKING: from ludwig.schema.features.preprocessing.text import TextPreprocessingConfig class HFEncoderConfig(SequenceEncoderConfig): trainable: bool use_pretrained: bool pretrained_model_name_or_path: str reduce_output: str def set_fixed_preprocessing_params(self, model_type: str, preprocessing: "TextPreprocessingConfig"): model_name = self.pretrained_model_name_or_path if model_name is None and self.use_pretrained: # no default model name, so model name is required by the subclass raise ValueError( f"Missing required parameter for `{self.type}` encoder: `pretrained_model_name_or_path` when " "`use_pretrained` is True." ) preprocessing.tokenizer = "hf_tokenizer" preprocessing.pretrained_model_name_or_path = model_name if not self.can_cache_embeddings(): preprocessing.cache_encoder_embeddings = False def is_pretrained(self) -> bool: return self.use_pretrained def can_cache_embeddings(self) -> bool: """Returns true if the encoder's output embeddings will not change during training.""" return not self.trainable and self.reduce_output != "attention" @DeveloperAPI @ludwig_dataclass class HFEncoderImplConfig(HFEncoderConfig): """This dataclass configures the base HF encoder implmenetation.""" use_pretrained: bool = schema_utils.Boolean( default=True, description="Whether to use the pretrained weights for the model. If false, the model will train from " "scratch which is very computationally expensive.", parameter_metadata=ENCODER_METADATA["HFEncoder"]["use_pretrained"], ) trainable: bool = schema_utils.Boolean( default=False, description="Whether to finetune the model on your dataset.", parameter_metadata=ENCODER_METADATA["HFEncoder"]["trainable"], ) adapter: BaseAdapterConfig | None = AdapterDataclassField() pretrained_kwargs: dict = schema_utils.Dict( default=None, description="Additional kwargs to pass to the pretrained model.", ) # Internal params set based on preprocessing metadata max_sequence_length: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="", parameter_metadata=INTERNAL_ONLY, ) vocab_size: int = schema_utils.PositiveInteger( default=None, description="", parameter_metadata=INTERNAL_ONLY, ) saved_weights_in_checkpoint: bool = schema_utils.Boolean( default=False, description=( "Are the pretrained encoder weights saved in this model's checkpoint? Automatically set to" "True for trained models to prevent loading pretrained encoder weights from model hub." ), parameter_metadata=INTERNAL_ONLY, ) @DeveloperAPI @register_encoder_config("albert", TEXT) @ludwig_dataclass class ALBERTConfig(HFEncoderConfig): """This dataclass configures the schema used for an ALBERT encoder.""" @staticmethod def module_name(): return "ALBERT" type: str = schema_utils.ProtectedString( "albert", description=ENCODER_METADATA["ALBERT"]["type"].long_description, ) max_sequence_length: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Maximum length of the input sequence.", parameter_metadata=ENCODER_METADATA["ALBERT"]["max_sequence_length"], ) use_pretrained: bool = schema_utils.Boolean( default=True, description="Whether to use the pretrained weights for the model. If false, the model will train from " "scratch which is very computationally expensive.", parameter_metadata=ENCODER_METADATA["ALBERT"]["use_pretrained"], ) pretrained_model_name_or_path: str = schema_utils.String( default="albert-base-v2", description="Name or path of the pretrained model.", parameter_metadata=ENCODER_METADATA["ALBERT"]["pretrained_model_name_or_path"], ) saved_weights_in_checkpoint: bool = schema_utils.Boolean( default=False, description="Are the pretrained encoder weights saved in this model's checkpoint? Automatically set to" "True for trained models to prevent loading pretrained encoder weights from model hub.", parameter_metadata=ENCODER_METADATA["ALBERT"]["saved_weights_in_checkpoint"], ) trainable: bool = schema_utils.Boolean( default=False, description="Whether to finetune the model on your dataset.", parameter_metadata=ENCODER_METADATA["ALBERT"]["trainable"], ) adapter: BaseAdapterConfig | None = AdapterDataclassField() reduce_output: str = schema_utils.String( default="cls_pooled", description="The method used to reduce a sequence of tensors down to a single tensor.", parameter_metadata=ENCODER_METADATA["ALBERT"]["reduce_output"], ) vocab: list = schema_utils.List( default=None, description="Vocabulary for the encoder", parameter_metadata=ENCODER_METADATA["ALBERT"]["vocab"], ) vocab_size: int = schema_utils.PositiveInteger( default=30000, description="Vocabulary size of the ALBERT model. Defines the number of different tokens that can be " "represented by the inputs_ids passed.", parameter_metadata=ENCODER_METADATA["ALBERT"]["vocab_size"], ) embedding_size: int = schema_utils.PositiveInteger( default=128, description="Dimensionality of vocabulary embeddings.", parameter_metadata=ENCODER_METADATA["ALBERT"]["embedding_size"], ) hidden_size: int = schema_utils.PositiveInteger( default=768, description="Dimensionality of the encoder layers and the pooler layer.", parameter_metadata=ENCODER_METADATA["ALBERT"]["hidden_size"], ) num_hidden_layers: int = schema_utils.PositiveInteger( default=12, description="Number of hidden layers in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["ALBERT"]["num_hidden_layers"], ) num_hidden_groups: int = schema_utils.PositiveInteger( default=1, description="Number of groups for the hidden layers, parameters in the same group are shared.", parameter_metadata=ENCODER_METADATA["ALBERT"]["num_hidden_groups"], ) num_attention_heads: int = schema_utils.PositiveInteger( default=12, description="Number of attention heads for each attention layer in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["ALBERT"]["num_attention_heads"], ) intermediate_size: int = schema_utils.PositiveInteger( default=3072, description="The dimensionality of the “intermediate” (often named feed-forward) layer in the Transformer " "encoder.", parameter_metadata=ENCODER_METADATA["ALBERT"]["intermediate_size"], ) inner_group_num: int = schema_utils.PositiveInteger( default=1, description="The number of inner repetition of attention and ffn.", parameter_metadata=ENCODER_METADATA["ALBERT"]["inner_group_num"], ) hidden_act: str = schema_utils.StringOptions( ["gelu", "relu", "silu", "gelu_new"], default="gelu_new", description="The non-linear activation function (function or string) in the encoder and pooler.", parameter_metadata=ENCODER_METADATA["ALBERT"]["hidden_act"], ) hidden_dropout_prob: float = schema_utils.FloatRange( default=0.0, min=0, max=1, description="The dropout probability for all fully connected layers in the embeddings, encoder, and pooler.", parameter_metadata=ENCODER_METADATA["ALBERT"]["hidden_dropout_prob"], ) attention_probs_dropout_prob: float = schema_utils.FloatRange( default=0.0, min=0, max=1, description="The dropout ratio for the attention probabilities.", parameter_metadata=ENCODER_METADATA["ALBERT"]["attention_probs_dropout_prob"], ) max_position_embeddings: int = schema_utils.PositiveInteger( default=512, description="The maximum sequence length that this model might ever be used with. Typically set this to " "something large (e.g., 512 or 1024 or 2048).", parameter_metadata=ENCODER_METADATA["ALBERT"]["max_position_embeddings"], ) type_vocab_size: int = schema_utils.PositiveInteger( default=2, description="The vocabulary size of the token_type_ids passed when calling AlbertModel or TFAlbertModel.", parameter_metadata=ENCODER_METADATA["ALBERT"]["type_vocab_size"], ) initializer_range: float = schema_utils.NonNegativeFloat( default=0.02, description="The standard deviation of the truncated_normal_initializer for initializing all weight matrices.", parameter_metadata=ENCODER_METADATA["ALBERT"]["initializer_range"], ) layer_norm_eps: float = schema_utils.NonNegativeFloat( default=1e-12, description="The epsilon used by the layer normalization layers.", parameter_metadata=ENCODER_METADATA["ALBERT"]["layer_norm_eps"], ) classifier_dropout_prob: float = schema_utils.FloatRange( default=0.1, min=0, max=1, description="The dropout ratio for attached classifiers.", parameter_metadata=ENCODER_METADATA["ALBERT"]["classifier_dropout_prob"], ) position_embedding_type: str = schema_utils.StringOptions( ["absolute", "relative_key", "relative_key_query"], default="absolute", description="", parameter_metadata=ENCODER_METADATA["ALBERT"]["position_embedding_type"], ) pad_token_id: int = schema_utils.Integer( default=0, description="The ID of the token to use as padding.", parameter_metadata=ENCODER_METADATA["ALBERT"]["pad_token_id"], ) bos_token_id: int = schema_utils.Integer( default=2, description="The beginning of sequence token ID.", parameter_metadata=ENCODER_METADATA["ALBERT"]["bos_token_id"], ) eos_token_id: int = schema_utils.Integer( default=3, description="The end of sequence token ID.", parameter_metadata=ENCODER_METADATA["ALBERT"]["eos_token_id"], ) pretrained_kwargs: dict = schema_utils.Dict( default=None, description="Additional kwargs to pass to the pretrained model.", parameter_metadata=ENCODER_METADATA["ALBERT"]["pretrained_kwargs"], ) # TODO: uncomment when sentencepiece doesn't cause segfaults: https://github.com/ludwig-ai/ludwig/issues/2983 @DeveloperAPI # @register_encoder_config("mt5", TEXT) @ludwig_dataclass class MT5Config(HFEncoderConfig): """This dataclass configures the schema used for an MT5 encoder.""" @staticmethod def module_name(): return "MT5" type: str = schema_utils.ProtectedString( "mt5", description=ENCODER_METADATA["MT5"]["type"].long_description, ) max_sequence_length: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Maximum length of the input sequence.", parameter_metadata=ENCODER_METADATA["MT5"]["max_sequence_length"], ) use_pretrained: bool = schema_utils.Boolean( default=True, description="Whether to use the pretrained weights for the model. If false, the model will train from " "scratch which is very computationally expensive.", parameter_metadata=ENCODER_METADATA["MT5"]["use_pretrained"], ) pretrained_model_name_or_path: str = schema_utils.String( default="google/mt5-base", description="Name or path of the pretrained model.", parameter_metadata=ENCODER_METADATA["MT5"]["pretrained_model_name_or_path"], ) saved_weights_in_checkpoint: bool = schema_utils.Boolean( default=False, description="Are the pretrained encoder weights saved in this model's checkpoint? Automatically set to" "True for trained models to prevent loading pretrained encoder weights from model hub.", parameter_metadata=ENCODER_METADATA["MT5"]["saved_weights_in_checkpoint"], ) trainable: bool = schema_utils.Boolean( default=False, description="Whether to finetune the model on your dataset.", parameter_metadata=ENCODER_METADATA["MT5"]["trainable"], ) adapter: BaseAdapterConfig | None = AdapterDataclassField() reduce_output: str = schema_utils.String( default="sum", description="The method used to reduce a sequence of tensors down to a single tensor.", parameter_metadata=ENCODER_METADATA["MT5"]["reduce_output"], ) vocab: list = schema_utils.List( default=None, description="Vocabulary for the encoder", parameter_metadata=ENCODER_METADATA["MT5"]["vocab"], ) vocab_size: int = schema_utils.PositiveInteger( default=250112, description="Vocabulary size of the T5 model. Defines the number of different tokens that can be represented " "by the inputs_ids passed when calling T5Model or TFT5Model.", parameter_metadata=ENCODER_METADATA["MT5"]["vocab_size"], ) d_model: int = schema_utils.PositiveInteger( default=512, description="Size of the encoder layers and the pooler layer.", parameter_metadata=ENCODER_METADATA["MT5"]["d_model"], ) d_kv: int = schema_utils.PositiveInteger( default=64, description="Size of the key, query, value projections per attention head. d_kv has to be equal to d_model // " "num_heads.", parameter_metadata=ENCODER_METADATA["MT5"]["d_kv"], ) d_ff: int = schema_utils.PositiveInteger( default=1024, description="Size of the intermediate feed forward layer in each T5Block.", parameter_metadata=ENCODER_METADATA["MT5"]["d_ff"], ) num_layers: int = schema_utils.PositiveInteger( default=8, description="Number of hidden layers in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["MT5"]["num_layers"], ) num_decoder_layers: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Number of hidden layers in the Transformer decoder. Will use the same value as num_layers if not " "set.", parameter_metadata=ENCODER_METADATA["MT5"]["num_decoder_layers"], ) num_heads: int = schema_utils.PositiveInteger( default=6, description="Number of attention heads for each attention layer in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["MT5"]["num_heads"], ) relative_attention_num_buckets: int = schema_utils.PositiveInteger( default=32, description="The number of buckets to use for each attention layer.", parameter_metadata=ENCODER_METADATA["MT5"]["relative_attention_num_buckets"], ) dropout_rate: float = schema_utils.FloatRange( default=0.1, min=0, max=1, description="The ratio for all dropout layers.", parameter_metadata=ENCODER_METADATA["MT5"]["dropout_rate"], ) layer_norm_epsilon: float = schema_utils.NonNegativeFloat( default=1e-06, description="The epsilon used by the layer normalization layers.", parameter_metadata=ENCODER_METADATA["MT5"]["layer_norm_epsilon"], ) initializer_factor: float = schema_utils.NonNegativeFloat( default=1.0, description="A factor for initializing all weight matrices (should be kept to 1, used internally for " "initialization testing)", parameter_metadata=ENCODER_METADATA["MT5"]["initializer_factor"], ) feed_forward_proj: str = schema_utils.StringOptions( ["relu", "gated-gelu"], default="gated-gelu", description="Type of feed forward layer to be used. ", parameter_metadata=ENCODER_METADATA["MT5"]["feed_forward_proj"], ) is_encoder_decoder: bool = schema_utils.Boolean( default=True, description="", parameter_metadata=ENCODER_METADATA["MT5"]["is_encoder_decoder"], ) use_cache: bool = schema_utils.Boolean( default=True, description="", parameter_metadata=ENCODER_METADATA["MT5"]["use_cache"], ) tokenizer_class: str = schema_utils.String( default="T5Tokenizer", description="", parameter_metadata=ENCODER_METADATA["MT5"]["tokenizer_class"], ) tie_word_embeddings: bool = schema_utils.Boolean( default=False, description="Whether the model's input and output word embeddings should be tied.", parameter_metadata=ENCODER_METADATA["MT5"]["tie_word_embeddings"], ) pad_token_id: int = schema_utils.Integer( default=0, description="The ID of the token to use as padding.", parameter_metadata=ENCODER_METADATA["MT5"]["pad_token_id"], ) eos_token_id: int = schema_utils.Integer( default=1, description="The end of sequence token ID.", parameter_metadata=ENCODER_METADATA["MT5"]["eos_token_id"], ) decoder_start_token_id: int = schema_utils.Integer( default=0, description="If an encoder-decoder model starts decoding with a different token than _bos_, the id of that " "token.", parameter_metadata=ENCODER_METADATA["MT5"]["decoder_start_token_id"], ) pretrained_kwargs: dict = schema_utils.Dict( default=None, description="Additional kwargs to pass to the pretrained model.", parameter_metadata=ENCODER_METADATA["MT5"]["pretrained_kwargs"], ) @DeveloperAPI @register_encoder_config("xlmroberta", TEXT) @ludwig_dataclass class XLMRoBERTaConfig(HFEncoderConfig): """This dataclass configures the schema used for an XLMRoBERTa encoder.""" @staticmethod def module_name(): return "XLMRoBERTa" type: str = schema_utils.ProtectedString( "xlmroberta", description=ENCODER_METADATA["XLMRoBERTa"]["type"].long_description, ) max_sequence_length: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Maximum length of the input sequence.", parameter_metadata=ENCODER_METADATA["XLMRoBERTa"]["max_sequence_length"], ) use_pretrained: bool = schema_utils.Boolean( default=True, description="Whether to use the pretrained weights for the model. If false, the model will train from " "scratch which is very computationally expensive.", parameter_metadata=ENCODER_METADATA["XLMRoBERTa"]["use_pretrained"], ) pretrained_model_name_or_path: str = schema_utils.String( default="xlm-roberta-base", description="Name or path of the pretrained model.", parameter_metadata=ENCODER_METADATA["XLMRoBERTa"]["pretrained_model_name_or_path"], ) saved_weights_in_checkpoint: bool = schema_utils.Boolean( default=False, description="Are the pretrained encoder weights saved in this model's checkpoint? Automatically set to" "True for trained models to prevent loading pretrained encoder weights from model hub.", parameter_metadata=ENCODER_METADATA["XLMRoBERTa"]["saved_weights_in_checkpoint"], ) reduce_output: str = schema_utils.String( default="cls_pooled", description="The method used to reduce a sequence of tensors down to a single tensor.", parameter_metadata=ENCODER_METADATA["XLMRoBERTa"]["reduce_output"], ) trainable: bool = schema_utils.Boolean( default=False, description="Whether to finetune the model on your dataset.", parameter_metadata=ENCODER_METADATA["XLMRoBERTa"]["trainable"], ) adapter: BaseAdapterConfig | None = AdapterDataclassField() vocab: list = schema_utils.List( default=None, description="Vocabulary for the encoder", parameter_metadata=ENCODER_METADATA["XLMRoBERTa"]["vocab"], ) vocab_size: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Vocabulary size of the XLMRoBERTa model.", parameter_metadata=ENCODER_METADATA["XLMRoBERTa"]["vocab_size"], ) pad_token_id: int = schema_utils.Integer( default=1, description="The ID of the token to use as padding.", parameter_metadata=ENCODER_METADATA["XLMRoBERTa"]["pad_token_id"], ) bos_token_id: int = schema_utils.Integer( default=0, description="The beginning of sequence token ID.", parameter_metadata=ENCODER_METADATA["XLMRoBERTa"]["bos_token_id"], ) eos_token_id: int = schema_utils.Integer( default=2, description="The end of sequence token ID.", parameter_metadata=ENCODER_METADATA["XLMRoBERTa"]["eos_token_id"], ) max_position_embeddings: int = schema_utils.PositiveInteger( default=514, description="The maximum sequence length that this model might ever be used with. Typically set this to " "something large just in case (e.g., 512 or 1024 or 2048).", parameter_metadata=ENCODER_METADATA["XLMRoBERTa"]["max_position_embeddings"], ) type_vocab_size: int = schema_utils.PositiveInteger( default=1, description="The vocabulary size of the token_type_ids passed in.", parameter_metadata=ENCODER_METADATA["XLMRoBERTa"]["type_vocab_size"], ) add_pooling_layer: bool = schema_utils.Boolean( default=True, description="Whether to add a pooling layer to the encoder.", parameter_metadata=ENCODER_METADATA["XLMRoBERTa"]["add_pooling_layer"], ) pretrained_kwargs: dict = schema_utils.Dict( default=None, description="Additional kwargs to pass to the pretrained model.", parameter_metadata=ENCODER_METADATA["XLMRoBERTa"]["pretrained_kwargs"], ) @DeveloperAPI @register_encoder_config("bert", TEXT) @ludwig_dataclass class BERTConfig(HFEncoderConfig): """This dataclass configures the schema used for an BERT encoder.""" @staticmethod def module_name(): return "BERT" type: str = schema_utils.ProtectedString( "bert", description=ENCODER_METADATA["BERT"]["type"].long_description, ) max_sequence_length: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Maximum length of the input sequence.", parameter_metadata=ENCODER_METADATA["BERT"]["max_sequence_length"], ) use_pretrained: bool = schema_utils.Boolean( default=True, description="Whether to use the pretrained weights for the model. If false, the model will train from " "scratch which is very computationally expensive.", parameter_metadata=ENCODER_METADATA["BERT"]["use_pretrained"], ) pretrained_model_name_or_path: str = schema_utils.String( default="bert-base-uncased", description="Name or path of the pretrained model.", parameter_metadata=ENCODER_METADATA["BERT"]["pretrained_model_name_or_path"], ) saved_weights_in_checkpoint: bool = schema_utils.Boolean( default=False, description="Are the pretrained encoder weights saved in this model's checkpoint? Automatically set to" "True for trained models to prevent loading pretrained encoder weights from model hub.", parameter_metadata=ENCODER_METADATA["BERT"]["saved_weights_in_checkpoint"], ) trainable: bool = schema_utils.Boolean( default=False, description="Whether to finetune the model on your dataset.", parameter_metadata=ENCODER_METADATA["BERT"]["trainable"], ) adapter: BaseAdapterConfig | None = AdapterDataclassField() reduce_output: str = schema_utils.String( default="cls_pooled", description="The method used to reduce a sequence of tensors down to a single tensor.", parameter_metadata=ENCODER_METADATA["BERT"]["reduce_output"], ) vocab: list = schema_utils.List( default=None, description="Vocabulary for the encoder", parameter_metadata=ENCODER_METADATA["BERT"]["vocab"], ) vocab_size: int = schema_utils.PositiveInteger( default=30522, description="Vocabulary size of the BERT model. Defines the number of different tokens that can be " "represented by the inputs_ids passed when calling BertModel or TFBertModel.", parameter_metadata=ENCODER_METADATA["BERT"]["vocab_size"], ) hidden_size: int = schema_utils.PositiveInteger( default=768, description="Dimensionality of the encoder layers and the pooler layer.", parameter_metadata=ENCODER_METADATA["BERT"]["hidden_size"], ) num_hidden_layers: int = schema_utils.PositiveInteger( default=12, description="Number of hidden layers in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["BERT"]["num_hidden_layers"], ) num_attention_heads: int = schema_utils.PositiveInteger( default=12, description="Number of attention heads for each attention layer in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["BERT"]["num_attention_heads"], ) intermediate_size: int = schema_utils.PositiveInteger( default=3072, description="Dimensionality of the “intermediate” (often named feed-forward) layer in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["BERT"]["intermediate_size"], ) hidden_act: str | Callable = schema_utils.StringOptions( # TODO: add support for callable ["gelu", "relu", "silu", "gelu_new"], default="gelu", description="The non-linear activation function (function or string) in the encoder and pooler.", parameter_metadata=ENCODER_METADATA["BERT"]["hidden_act"], ) hidden_dropout_prob: float = schema_utils.FloatRange( default=0.1, min=0, max=1, description="The dropout probability for all fully connected layers in the embeddings, encoder, and pooler.", parameter_metadata=ENCODER_METADATA["BERT"]["hidden_dropout_prob"], ) attention_probs_dropout_prob: float = schema_utils.FloatRange( default=0.1, min=0, max=1, description="The dropout ratio for the attention probabilities.", parameter_metadata=ENCODER_METADATA["BERT"]["attention_probs_dropout_prob"], ) max_position_embeddings: int = schema_utils.PositiveInteger( default=512, description="The maximum sequence length that this model might ever be used with. Typically set this to " "something large just in case (e.g., 512 or 1024 or 2048).", parameter_metadata=ENCODER_METADATA["BERT"]["max_position_embeddings"], ) type_vocab_size: int = schema_utils.PositiveInteger( default=2, description="The vocabulary size of the token_type_ids passed when calling BertModel or TFBertModel.", parameter_metadata=ENCODER_METADATA["BERT"]["type_vocab_size"], ) initializer_range: float = schema_utils.NonNegativeFloat( default=0.02, description="The standard deviation of the truncated_normal_initializer for initializing all weight matrices.", parameter_metadata=ENCODER_METADATA["BERT"]["initializer_range"], ) layer_norm_eps: float = schema_utils.NonNegativeFloat( default=1e-12, description="The epsilon used by the layer normalization layers.", parameter_metadata=ENCODER_METADATA["BERT"]["layer_norm_eps"], ) pad_token_id: int = schema_utils.Integer( default=0, description="The ID of the token to use as padding.", parameter_metadata=ENCODER_METADATA["BERT"]["pad_token_id"], ) gradient_checkpointing: bool = schema_utils.Boolean( default=False, description="Whether to use gradient checkpointing.", parameter_metadata=ENCODER_METADATA["BERT"]["gradient_checkpointing"], ) position_embedding_type: str = schema_utils.StringOptions( ["absolute", "relative_key", "relative_key_query"], default="absolute", description="Type of position embedding.", parameter_metadata=ENCODER_METADATA["BERT"]["position_embedding_type"], ) classifier_dropout: float = schema_utils.FloatRange( default=None, allow_none=True, min=0, max=1, description="The dropout ratio for the classification head.", parameter_metadata=ENCODER_METADATA["BERT"]["classifier_dropout"], ) pretrained_kwargs: dict = schema_utils.Dict( default=None, description="Additional kwargs to pass to the pretrained model.", parameter_metadata=ENCODER_METADATA["BERT"]["pretrained_kwargs"], ) @DeveloperAPI @register_encoder_config("deberta", TEXT) @ludwig_dataclass class DebertaV2Config(HFEncoderImplConfig, DebertaModelParams): """This dataclass configures the schema used for a DeBERTa-v2 / v3 encoder.""" @staticmethod def module_name(): return "DeBERTa" type: str = schema_utils.ProtectedString( "deberta", description=ENCODER_METADATA["DeBERTa"]["type"].long_description, ) pretrained_model_name_or_path: str = schema_utils.String( default="sileod/deberta-v3-base-tasksource-nli", description="Name or path of the pretrained model.", parameter_metadata=ENCODER_METADATA["DeBERTa"]["pretrained_model_name_or_path"], ) reduce_output: str = schema_utils.StringOptions( ["cls_pooled", "last", "sum", "mean", "max", "concat", "attention"], default="sum", allow_none=True, description="The method used to reduce a sequence of tensors down to a single tensor.", ) # TODO: uncomment once we figure out host memory issue: https://github.com/ludwig-ai/ludwig/issues/3107 @DeveloperAPI # @register_encoder_config("xlm", TEXT) @ludwig_dataclass class XLMConfig(HFEncoderConfig): """This dataclass configures the schema used for an XLM encoder.""" @staticmethod def module_name(): return "XLM" type: str = schema_utils.ProtectedString( "xlm", description=ENCODER_METADATA["XLM"]["type"].long_description, ) max_sequence_length: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Maximum length of the input sequence.", parameter_metadata=ENCODER_METADATA["XLM"]["max_sequence_length"], ) use_pretrained: bool = schema_utils.Boolean( default=True, description="Whether to use the pretrained weights for the model. If false, the model will train from " "scratch which is very computationally expensive.", parameter_metadata=ENCODER_METADATA["XLM"]["use_pretrained"], ) pretrained_model_name_or_path: str = schema_utils.String( default="xlm-mlm-en-2048", description="Name or path of the pretrained model.", parameter_metadata=ENCODER_METADATA["XLM"]["pretrained_model_name_or_path"], ) saved_weights_in_checkpoint: bool = schema_utils.Boolean( default=False, description="Are the pretrained encoder weights saved in this model's checkpoint? Automatically set to" "True for trained models to prevent loading pretrained encoder weights from model hub.", parameter_metadata=ENCODER_METADATA["XLM"]["saved_weights_in_checkpoint"], ) trainable: bool = schema_utils.Boolean( default=False, description="Whether to finetune the model on your dataset.", parameter_metadata=ENCODER_METADATA["XLM"]["trainable"], ) adapter: BaseAdapterConfig | None = AdapterDataclassField() reduce_output: str = schema_utils.String( default="sum", description="The method used to reduce a sequence of tensors down to a single tensor.", parameter_metadata=ENCODER_METADATA["XLM"]["reduce_output"], ) vocab: list = schema_utils.List( default=None, description="Vocabulary for the encoder", parameter_metadata=ENCODER_METADATA["XLM"]["vocab"], ) vocab_size: int = schema_utils.PositiveInteger( default=30145, description="Vocabulary size of the BERT model. Defines the number of different tokens that can be " "represented by the inputs_ids passed when calling XLMModel or TFXLMModel.", parameter_metadata=ENCODER_METADATA["XLM"]["vocab_size"], ) emb_dim: int = schema_utils.PositiveInteger( default=2048, description="Dimensionality of the encoder layers and the pooler layer.", parameter_metadata=ENCODER_METADATA["XLM"]["emb_dim"], ) n_layers: int = schema_utils.PositiveInteger( default=12, description="Number of hidden layers in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["XLM"]["n_layers"], ) n_heads: int = schema_utils.PositiveInteger( default=16, description="Number of attention heads for each attention layer in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["XLM"]["n_heads"], ) dropout: float = schema_utils.FloatRange( default=0.1, min=0, max=1, description="The dropout probability for all fully connected layers in the embeddings, encoder, and pooler.", parameter_metadata=ENCODER_METADATA["XLM"]["dropout"], ) attention_dropout: float = schema_utils.FloatRange( default=0.1, min=0, max=1, description="The dropout probability for the attention mechanism.", parameter_metadata=ENCODER_METADATA["XLM"]["attention_dropout"], ) gelu_activation: bool = schema_utils.Boolean( default=True, description="Whether or not to use gelu for the activations instead of relu.", parameter_metadata=ENCODER_METADATA["XLM"]["gelu_activation"], ) sinusoidal_embeddings: bool = schema_utils.Boolean( default=False, description="Whether or not to use sinusoidal positional embeddings instead of absolute positional embeddings.", parameter_metadata=ENCODER_METADATA["XLM"]["sinusoidal_embeddings"], ) causal: bool = schema_utils.Boolean( default=False, description="Whether or not the model should behave in a causal manner. Causal models use a triangular " "attention mask in order to only attend to the left-side context instead if a bidirectional " "context.", parameter_metadata=ENCODER_METADATA["XLM"]["causal"], ) asm: bool = schema_utils.Boolean( default=False, description="Whether or not to use an adaptive log softmax projection layer instead of a linear layer for the " "prediction layer.", parameter_metadata=ENCODER_METADATA["XLM"]["asm"], ) n_langs: int = schema_utils.PositiveInteger( default=1, description="The number of languages the model handles. Set to 1 for monolingual models.", parameter_metadata=ENCODER_METADATA["XLM"]["n_langs"], ) use_lang_emb: bool = schema_utils.Boolean( default=True, description="Whether to use language embeddings. Some models use additional language embeddings, " "see the multilingual models page for information on how to use them.", parameter_metadata=ENCODER_METADATA["XLM"]["use_lang_emb"], ) max_position_embeddings: int = schema_utils.PositiveInteger( default=512, description="The maximum sequence length that this model might ever be used with. Typically set this to " "something large just in case (e.g., 512 or 1024 or 2048).", parameter_metadata=ENCODER_METADATA["XLM"]["max_position_embeddings"], ) embed_init_std: float = schema_utils.NonNegativeFloat( default=2048**-0.5, description="The standard deviation of the truncated_normal_initializer for initializing the embedding " "matrices.", parameter_metadata=ENCODER_METADATA["XLM"]["embed_init_std"], ) layer_norm_eps: float = schema_utils.NonNegativeFloat( default=1e-12, description="The epsilon used by the layer normalization layers.", parameter_metadata=ENCODER_METADATA["XLM"]["layer_norm_eps"], ) init_std: float = schema_utils.NonNegativeFloat( default=0.02, description="The standard deviation of the truncated_normal_initializer for initializing all weight matrices " "except the embedding matrices.", parameter_metadata=ENCODER_METADATA["XLM"]["init_std"], ) bos_index: int = schema_utils.NonNegativeInteger( default=0, description="The index of the beginning of sentence token in the vocabulary.", parameter_metadata=ENCODER_METADATA["XLM"]["bos_index"], ) eos_index: int = schema_utils.NonNegativeInteger( default=1, description="The index of the end of sentence token in the vocabulary.", parameter_metadata=ENCODER_METADATA["XLM"]["eos_index"], ) pad_index: int = schema_utils.NonNegativeInteger( default=2, description="The index of the padding token in the vocabulary.", parameter_metadata=ENCODER_METADATA["XLM"]["pad_index"], ) unk_index: int = schema_utils.NonNegativeInteger( default=3, description="The index of the unknown token in the vocabulary.", parameter_metadata=ENCODER_METADATA["XLM"]["unk_index"], ) mask_index: int = schema_utils.NonNegativeInteger( default=5, description="The index of the masking token in the vocabulary.", parameter_metadata=ENCODER_METADATA["XLM"]["mask_index"], ) is_encoder: bool = schema_utils.Boolean( default=True, description="Whether or not the initialized model should be a transformer encoder or decoder as seen in " "Vaswani et al.", parameter_metadata=ENCODER_METADATA["XLM"]["is_encoder"], ) start_n_top: int = schema_utils.PositiveInteger( default=5, description="Used in the SQuAD evaluation script.", parameter_metadata=ENCODER_METADATA["XLM"]["start_n_top"], ) end_n_top: int = schema_utils.PositiveInteger( default=5, description="Used in the SQuAD evaluation script.", parameter_metadata=ENCODER_METADATA["XLM"]["end_n_top"], ) mask_token_id: int = schema_utils.Integer( default=0, description="Model agnostic parameter to identify masked tokens when generating text in an MLM context.", parameter_metadata=ENCODER_METADATA["XLM"]["mask_token_id"], ) lang_id: int = schema_utils.Integer( default=0, description="The ID of the language used by the model. This parameter is used when generating text in a given " "language.", parameter_metadata=ENCODER_METADATA["XLM"]["lang_id"], ) pad_token_id: int = schema_utils.Integer( default=2, description="The ID of the token to use as padding.", parameter_metadata=ENCODER_METADATA["XLM"]["pad_token_id"], ) bos_token_id: int = schema_utils.Integer( default=0, description="The beginning of sequence token ID.", parameter_metadata=ENCODER_METADATA["XLM"]["bos_token_id"], ) pretrained_kwargs: dict = schema_utils.Dict( default=None, description="Additional kwargs to pass to the pretrained model.", parameter_metadata=ENCODER_METADATA["XLM"]["pretrained_kwargs"], ) @DeveloperAPI @register_encoder_config("gpt", TEXT) @ludwig_dataclass class GPTConfig(HFEncoderConfig): """This dataclass configures the schema used for an GPT encoder.""" @staticmethod def module_name(): return "GPT" type: str = schema_utils.ProtectedString( "gpt", description=ENCODER_METADATA["GPT"]["type"].long_description, ) max_sequence_length: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Maximum length of the input sequence.", parameter_metadata=ENCODER_METADATA["GPT"]["max_sequence_length"], ) reduce_output: str = schema_utils.String( default="sum", description="The method used to reduce a sequence of tensors down to a single tensor.", parameter_metadata=ENCODER_METADATA["GPT"]["reduce_output"], ) use_pretrained: bool = schema_utils.Boolean( default=True, description="Whether to use the pretrained weights for the model. If false, the model will train from " "scratch which is very computationally expensive.", parameter_metadata=ENCODER_METADATA["GPT"]["use_pretrained"], ) pretrained_model_name_or_path: str = schema_utils.String( default="openai-gpt", description="Name or path of the pretrained model.", parameter_metadata=ENCODER_METADATA["GPT"]["pretrained_model_name_or_path"], ) saved_weights_in_checkpoint: bool = schema_utils.Boolean( default=False, description="Are the pretrained encoder weights saved in this model's checkpoint? Automatically set to" "True for trained models to prevent loading pretrained encoder weights from model hub.", parameter_metadata=ENCODER_METADATA["GPT"]["saved_weights_in_checkpoint"], ) trainable: bool = schema_utils.Boolean( default=False, description="Whether to finetune the model on your dataset.", parameter_metadata=ENCODER_METADATA["GPT"]["trainable"], ) adapter: BaseAdapterConfig | None = AdapterDataclassField() vocab: list = schema_utils.List( default=None, description="Vocabulary for the encoder", parameter_metadata=ENCODER_METADATA["GPT"]["vocab"], ) vocab_size: int = schema_utils.PositiveInteger( default=30522, description="Vocabulary size of the GPT model. Defines the number of different tokens that can be " "represented by the inputs_ids passed when calling OpenAIGPTModel or TFOpenAIGPTModel.", parameter_metadata=ENCODER_METADATA["GPT"]["vocab_size"], ) n_positions: int = schema_utils.PositiveInteger( default=40478, description="The maximum sequence length that this model might ever be used with. Typically set this to " "something large just in case (e.g., 512 or 1024 or 2048).", parameter_metadata=ENCODER_METADATA["GPT"]["n_positions"], ) n_ctx: int = schema_utils.PositiveInteger( default=512, description="Dimensionality of the causal mask (usually same as n_positions)", parameter_metadata=ENCODER_METADATA["GPT"]["n_ctx"], ) n_embd: int = schema_utils.PositiveInteger( default=768, description="Dimensionality of the embeddings and hidden states.", parameter_metadata=ENCODER_METADATA["GPT"]["n_embd"], ) n_layer: int = schema_utils.PositiveInteger( default=12, description="Number of hidden layers in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["GPT"]["n_layer"], ) n_head: int = schema_utils.PositiveInteger( default=12, description="Number of attention heads for each attention layer in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["GPT"]["n_head"], ) afn: str = schema_utils.StringOptions( ["gelu", "relu", "silu"], # gelu_new results in a KeyError. default="gelu", description="The non-linear activation function (function or string) in the encoder and pooler.", parameter_metadata=ENCODER_METADATA["GPT"]["afn"], ) resid_pdrop: float = schema_utils.FloatRange( default=0.1, description="The dropout probability for all fully connected layers in the embeddings, encoder, and pooler.", parameter_metadata=ENCODER_METADATA["GPT"]["resid_pdrop"], ) embd_pdrop: float = schema_utils.FloatRange( default=0.1, description="The dropout ratio for the embeddings.", parameter_metadata=ENCODER_METADATA["GPT"]["embd_pdrop"], ) attn_pdrop: float = schema_utils.FloatRange( default=0.1, description="The dropout ratio for the attention.", parameter_metadata=ENCODER_METADATA["GPT"]["attn_pdrop"], ) layer_norm_epsilon: float = schema_utils.NonNegativeFloat( default=1e-5, description="The epsilon to use in the layer normalization layers", parameter_metadata=ENCODER_METADATA["GPT"]["layer_norm_epsilon"], ) initializer_range: float = schema_utils.NonNegativeFloat( default=0.02, description="The standard deviation of the truncated_normal_initializer for initializing all weight matrices.", parameter_metadata=ENCODER_METADATA["GPT"]["initializer_range"], ) pretrained_kwargs: dict = schema_utils.Dict( default=None, description="Additional kwargs to pass to the pretrained model.", parameter_metadata=ENCODER_METADATA["GPT"]["pretrained_kwargs"], ) @DeveloperAPI @register_encoder_config("gpt2", TEXT) @ludwig_dataclass class GPT2Config(HFEncoderConfig): """This dataclass configures the schema used for an GPT2 encoder.""" @staticmethod def module_name(): return "GPT2" type: str = schema_utils.ProtectedString( "gpt2", description=ENCODER_METADATA["GPT2"]["type"].long_description, ) max_sequence_length: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Maximum length of the input sequence.", parameter_metadata=ENCODER_METADATA["GPT2"]["max_sequence_length"], ) use_pretrained: bool = schema_utils.Boolean( default=True, description="Whether to use the pretrained weights for the model. If false, the model will train from " "scratch which is very computationally expensive.", parameter_metadata=ENCODER_METADATA["GPT2"]["use_pretrained"], ) pretrained_model_name_or_path: str = schema_utils.String( default="gpt2", description="Name or path of the pretrained model.", parameter_metadata=ENCODER_METADATA["GPT2"]["pretrained_model_name_or_path"], ) reduce_output: str = schema_utils.String( default="sum", description="The method used to reduce a sequence of tensors down to a single tensor.", parameter_metadata=ENCODER_METADATA["GPT2"]["reduce_output"], ) trainable: bool = schema_utils.Boolean( default=False, description="Whether to finetune the model on your dataset.", parameter_metadata=ENCODER_METADATA["GPT2"]["trainable"], ) adapter: BaseAdapterConfig | None = AdapterDataclassField() vocab: list = schema_utils.List( default=None, description="Vocabulary for the encoder", parameter_metadata=ENCODER_METADATA["GPT2"]["vocab"], ) vocab_size: int = schema_utils.PositiveInteger( default=50257, description="Vocabulary size of the GPT-2 model. Defines the number of different tokens that can be " "represented by the inputs_ids passed when calling GPT2Model or TFGPT2Model.", parameter_metadata=ENCODER_METADATA["GPT2"]["vocab_size"], ) n_positions: int = schema_utils.PositiveInteger( default=1024, description="The maximum sequence length that this model might ever be used with. Typically set this to " "something large just in case (e.g., 512 or 1024 or 2048).", parameter_metadata=ENCODER_METADATA["GPT2"]["n_positions"], ) n_ctx: int = schema_utils.PositiveInteger( default=1024, description="Dimensionality of the causal mask (usually same as n_positions)", parameter_metadata=ENCODER_METADATA["GPT2"]["n_ctx"], ) n_embd: int = schema_utils.PositiveInteger( default=768, description="Dimensionality of the embeddings and hidden states.", parameter_metadata=ENCODER_METADATA["GPT2"]["n_embd"], ) n_layer: int = schema_utils.PositiveInteger( default=12, description="Number of hidden layers in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["GPT2"]["n_layer"], ) n_head: int = schema_utils.PositiveInteger( default=12, description="Number of attention heads for each attention layer in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["GPT2"]["n_head"], ) n_inner: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Dimensionality of the inner feed-forward layers. None will set it to 4 times n_embd", parameter_metadata=ENCODER_METADATA["GPT2"]["n_inner"], ) activation_function: str = schema_utils.StringOptions( ["relu", "silu", "gelu", "tanh", "gelu_new"], default="gelu_new", description="Activation function, to be selected in the list ['relu', 'silu', 'gelu', 'tanh', 'gelu_new'].", parameter_metadata=ENCODER_METADATA["GPT2"]["activation_function"], ) resid_pdrop: float = schema_utils.FloatRange( default=0.1, min=0, max=1, description="The dropout probability for all fully connected layers in the embeddings, encoder, and pooler.", parameter_metadata=ENCODER_METADATA["GPT2"]["resid_pdrop"], ) embd_pdrop: float = schema_utils.FloatRange( default=0.1, min=0, max=1, description="The dropout ratio for the embeddings.", parameter_metadata=ENCODER_METADATA["GPT2"]["embd_pdrop"], ) attn_pdrop: float = schema_utils.FloatRange( default=0.1, min=0, max=1, description="The dropout ratio for the attention.", parameter_metadata=ENCODER_METADATA["GPT2"]["attn_pdrop"], ) layer_norm_epsilon: float = schema_utils.NonNegativeFloat( default=1e-5, description="The epsilon to use in the layer normalization layers.", parameter_metadata=ENCODER_METADATA["GPT2"]["layer_norm_epsilon"], ) initializer_range: float = schema_utils.NonNegativeFloat( default=0.02, description="The standard deviation of the truncated_normal_initializer for initializing all weight matrices.", parameter_metadata=ENCODER_METADATA["GPT2"]["initializer_range"], ) scale_attn_weights: bool = schema_utils.Boolean( default=True, description="Scale attention weights by dividing by sqrt(hidden_size).", parameter_metadata=ENCODER_METADATA["GPT2"]["scale_attn_weights"], ) pretrained_kwargs: dict = schema_utils.Dict( default=None, description="Additional kwargs to pass to the pretrained model.", parameter_metadata=ENCODER_METADATA["GPT2"]["pretrained_kwargs"], ) @DeveloperAPI @register_encoder_config("roberta", TEXT) @ludwig_dataclass class RoBERTaConfig(HFEncoderConfig): """This dataclass configures the schema used for an RoBERTa encoder.""" @staticmethod def module_name(): return "RoBERTa" type: str = schema_utils.ProtectedString( "roberta", description=ENCODER_METADATA["RoBERTa"]["type"].long_description, ) max_sequence_length: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Maximum length of the input sequence.", parameter_metadata=ENCODER_METADATA["RoBERTa"]["max_sequence_length"], ) use_pretrained: bool = schema_utils.Boolean( default=True, description="Whether to use the pretrained weights for the model. If false, the model will train from " "scratch which is very computationally expensive.", parameter_metadata=ENCODER_METADATA["RoBERTa"]["use_pretrained"], ) pretrained_model_name_or_path: str = schema_utils.String( default="roberta-base", description="Name or path of the pretrained model.", parameter_metadata=ENCODER_METADATA["RoBERTa"]["pretrained_model_name_or_path"], ) saved_weights_in_checkpoint: bool = schema_utils.Boolean( default=False, description="Are the pretrained encoder weights saved in this model's checkpoint? Automatically set to" "True for trained models to prevent loading pretrained encoder weights from model hub.", parameter_metadata=ENCODER_METADATA["RoBERTa"]["saved_weights_in_checkpoint"], ) reduce_output: str = schema_utils.String( default="cls_pooled", description="The method used to reduce a sequence of tensors down to a single tensor.", parameter_metadata=ENCODER_METADATA["RoBERTa"]["reduce_output"], ) trainable: bool = schema_utils.Boolean( default=False, description="Whether to finetune the model on your dataset.", parameter_metadata=ENCODER_METADATA["RoBERTa"]["trainable"], ) adapter: BaseAdapterConfig | None = AdapterDataclassField() vocab: list = schema_utils.List( default=None, description="Vocabulary for the encoder", parameter_metadata=ENCODER_METADATA["RoBERTa"]["vocab"], ) vocab_size: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Vocabulary size of the RoBERTa model.", parameter_metadata=ENCODER_METADATA["RoBERTa"]["vocab_size"], ) pad_token_id: int = schema_utils.Integer( default=1, description="The ID of the token to use as padding.", parameter_metadata=ENCODER_METADATA["RoBERTa"]["pad_token_id"], ) bos_token_id: int = schema_utils.Integer( default=0, description="The beginning of sequence token ID.", parameter_metadata=ENCODER_METADATA["RoBERTa"]["bos_token_id"], ) eos_token_id: int = schema_utils.Integer( default=2, description="The end of sequence token ID.", parameter_metadata=ENCODER_METADATA["RoBERTa"]["eos_token_id"], ) pretrained_kwargs: dict = schema_utils.Dict( default=None, description="Additional kwargs to pass to the pretrained model.", parameter_metadata=ENCODER_METADATA["RoBERTa"]["pretrained_kwargs"], ) @DeveloperAPI @register_encoder_config("transformer_xl", TEXT) @ludwig_dataclass class TransformerXLConfig(HFEncoderConfig): """This dataclass configures the schema used for an TransformerXL encoder.""" @staticmethod def module_name(): return "TransformerXL" type: str = schema_utils.ProtectedString( "transformer_xl", description=ENCODER_METADATA["TransformerXL"]["type"].long_description, ) max_sequence_length: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Maximum length of the input sequence.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["max_sequence_length"], ) use_pretrained: bool = schema_utils.Boolean( default=True, description="Whether to use the pretrained weights for the model. If false, the model will train from " "scratch which is very computationally expensive.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["use_pretrained"], ) pretrained_model_name_or_path: str = schema_utils.String( default="transfo-xl-wt103", description="Name or path of the pretrained model.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["pretrained_model_name_or_path"], ) saved_weights_in_checkpoint: bool = schema_utils.Boolean( default=False, description="Are the pretrained encoder weights saved in this model's checkpoint? Automatically set to" "True for trained models to prevent loading pretrained encoder weights from model hub.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["saved_weights_in_checkpoint"], ) reduce_output: str = schema_utils.String( default="sum", description="The method used to reduce a sequence of tensors down to a single tensor.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["reduce_output"], ) trainable: bool = schema_utils.Boolean( default=False, description="Whether to finetune the model on your dataset.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["trainable"], ) adapter: BaseAdapterConfig | None = AdapterDataclassField() vocab: list = schema_utils.List( default=None, description="Vocabulary for the encoder", parameter_metadata=ENCODER_METADATA["TransformerXL"]["vocab"], ) vocab_size: int = schema_utils.PositiveInteger( default=267735, description="Vocabulary size of the TransfoXL model. Defines the number of different tokens that can be " "represented by the inputs_ids passed when calling TransfoXLModel or TFTransfoXLModel.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["vocab_size"], ) cutoffs: list[int] = schema_utils.List( int, default=[20000, 40000, 200000], description="Cutoffs for the adaptive softmax.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["cutoffs"], ) d_model: int = schema_utils.PositiveInteger( default=1024, description="Dimensionality of the model’s hidden states.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["d_model"], ) d_embed: int = schema_utils.PositiveInteger( default=1024, description="Dimensionality of the embeddings", parameter_metadata=ENCODER_METADATA["TransformerXL"]["d_embed"], ) n_head: int = schema_utils.PositiveInteger( default=16, description="Number of attention heads for each attention layer in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["n_head"], ) d_head: int = schema_utils.PositiveInteger( default=64, description="Dimensionality of the model’s heads.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["d_head"], ) d_inner: int = schema_utils.PositiveInteger( default=4096, description=" Inner dimension in FF", parameter_metadata=ENCODER_METADATA["TransformerXL"]["d_inner"], ) div_val: int = schema_utils.PositiveInteger( default=4, description="Divident value for adapative input and softmax.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["div_val"], ) pre_lnorm: bool = schema_utils.Boolean( default=False, description="Whether or not to apply LayerNorm to the input instead of the output in the blocks.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["pre_lnorm"], ) n_layer: int = schema_utils.PositiveInteger( default=18, description="Number of hidden layers in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["n_layer"], ) mem_len: int = schema_utils.PositiveInteger( default=1600, description="Length of the retained previous heads.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["mem_len"], ) clamp_len: int = schema_utils.PositiveInteger( default=1000, description="Use the same pos embeddings after clamp_len.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["clamp_len"], ) same_length: bool = schema_utils.Boolean( default=True, description="Whether or not to use the same attn length for all tokens", parameter_metadata=ENCODER_METADATA["TransformerXL"]["same_length"], ) proj_share_all_but_first: bool = schema_utils.Boolean( default=True, description="True to share all but first projs, False not to share.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["proj_share_all_but_first"], ) attn_type: int = schema_utils.IntegerRange( default=0, min=0, max=3, description="Attention type. 0 for Transformer-XL, 1 for Shaw et al, 2 for Vaswani et al, 3 for Al Rfou et al.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["attn_type"], ) sample_softmax: int = schema_utils.Integer( default=-1, description="Number of samples in the sampled softmax.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["sample_softmax"], ) adaptive: bool = schema_utils.Boolean( default=True, description="Whether or not to use adaptive softmax.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["adaptive"], ) dropout: float = schema_utils.FloatRange( default=0.1, min=0, max=1, description="The dropout probability for all fully connected layers in the embeddings, encoder, and pooler.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["dropout"], ) dropatt: float = schema_utils.NonNegativeFloat( default=0.0, description="The dropout ratio for the attention probabilities.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["dropatt"], ) untie_r: bool = schema_utils.Boolean( default=True, description="Whether ot not to untie relative position biases.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["untie_r"], ) init: str = schema_utils.String( default="normal", description="Parameter initializer to use.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["init"], ) init_range: float = schema_utils.NonNegativeFloat( default=0.01, description="Parameters initialized by U(-init_range, init_range).", parameter_metadata=ENCODER_METADATA["TransformerXL"]["init_range"], ) proj_init_std: float = schema_utils.NonNegativeFloat( default=0.01, description="Parameters initialized by N(0, init_std)", parameter_metadata=ENCODER_METADATA["TransformerXL"]["proj_init_std"], ) init_std: float = schema_utils.NonNegativeFloat( default=0.02, description="Parameters initialized by N(0, init_std)", parameter_metadata=ENCODER_METADATA["TransformerXL"]["init_std"], ) layer_norm_epsilon: float = schema_utils.NonNegativeFloat( default=1e-5, description="The epsilon to use in the layer normalization layers", parameter_metadata=ENCODER_METADATA["TransformerXL"]["layer_norm_epsilon"], ) eos_token_id: int = schema_utils.Integer( default=0, description="The end of sequence token ID.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["eos_token_id"], ) pretrained_kwargs: dict = schema_utils.Dict( default=None, description="Additional kwargs to pass to the pretrained model.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["pretrained_kwargs"], ) @DeveloperAPI @register_encoder_config("xlnet", TEXT) @ludwig_dataclass class XLNetConfig(HFEncoderConfig): """This dataclass configures the schema used for an XLNet encoder.""" @staticmethod def module_name(): return "XLNet" type: str = schema_utils.ProtectedString( "xlnet", description=ENCODER_METADATA["XLNet"]["type"].long_description, ) max_sequence_length: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Maximum length of the input sequence.", parameter_metadata=ENCODER_METADATA["XLNet"]["max_sequence_length"], ) use_pretrained: bool = schema_utils.Boolean( default=True, description="Whether to use the pretrained weights for the model. If false, the model will train from " "scratch which is very computationally expensive.", parameter_metadata=ENCODER_METADATA["XLNet"]["use_pretrained"], ) pretrained_model_name_or_path: str = schema_utils.String( default="xlnet-base-cased", description="Name or path of the pretrained model.", parameter_metadata=ENCODER_METADATA["XLNet"]["pretrained_model_name_or_path"], ) saved_weights_in_checkpoint: bool = schema_utils.Boolean( default=False, description="Are the pretrained encoder weights saved in this model's checkpoint? Automatically set to" "True for trained models to prevent loading pretrained encoder weights from model hub.", parameter_metadata=ENCODER_METADATA["XLNet"]["saved_weights_in_checkpoint"], ) reduce_output: str = schema_utils.String( default="sum", description="The method used to reduce a sequence of tensors down to a single tensor.", parameter_metadata=ENCODER_METADATA["XLNet"]["reduce_output"], ) trainable: bool = schema_utils.Boolean( default=False, description="Whether to finetune the model on your dataset.", parameter_metadata=ENCODER_METADATA["XLNet"]["trainable"], ) adapter: BaseAdapterConfig | None = AdapterDataclassField() vocab: list = schema_utils.List( default=None, description="Vocabulary for the encoder", parameter_metadata=ENCODER_METADATA["XLNet"]["vocab"], ) vocab_size: int = schema_utils.PositiveInteger( default=32000, description="Vocabulary size of the XLNet model. Defines the number of different tokens that can be " "represented by the inputs_ids passed when calling XLNetModel or TFXLNetModel.", parameter_metadata=ENCODER_METADATA["XLNet"]["vocab_size"], ) d_model: int = schema_utils.PositiveInteger( default=768, description="Dimensionality of the encoder layers and the pooler layer.", parameter_metadata=ENCODER_METADATA["XLNet"]["d_model"], ) n_layer: int = schema_utils.PositiveInteger( default=12, description="Number of hidden layers in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["XLNet"]["n_layer"], ) n_head: int = schema_utils.PositiveInteger( default=12, description="Number of attention heads for each attention layer in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["XLNet"]["n_head"], ) d_inner: int = schema_utils.PositiveInteger( default=3072, description="Dimensionality of the “intermediate” (often named feed-forward) layer in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["XLNet"]["d_inner"], ) ff_activation: str = schema_utils.StringOptions( ["gelu", "relu", "silu", "gelu_new"], default="gelu", description="The non-linear activation function (function or string) in the encoder and pooler. If string, " "'gelu', 'relu', 'silu' and 'gelu_new' are supported.", parameter_metadata=ENCODER_METADATA["XLNet"]["ff_activation"], ) untie_r: bool = schema_utils.Boolean( default=True, description="Whether or not to untie relative position biases", parameter_metadata=ENCODER_METADATA["XLNet"]["untie_r"], ) attn_type: str = schema_utils.StringOptions( ["bi"], default="bi", description="The attention type used by the model. Currently only 'bi' is supported.", parameter_metadata=ENCODER_METADATA["XLNet"]["attn_type"], ) initializer_range: float = schema_utils.NonNegativeFloat( default=0.02, description="The standard deviation of the truncated_normal_initializer for initializing all weight matrices.", parameter_metadata=ENCODER_METADATA["XLNet"]["initializer_range"], ) layer_norm_eps: float = schema_utils.NonNegativeFloat( default=1e-12, description="The epsilon used by the layer normalization layers.", parameter_metadata=ENCODER_METADATA["XLNet"]["layer_norm_eps"], ) dropout: float = schema_utils.FloatRange( default=0.1, description="The dropout probability for all fully connected layers in the embeddings, encoder, and pooler.", parameter_metadata=ENCODER_METADATA["XLNet"]["dropout"], ) mem_len: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="The number of tokens to cache. The key/value pairs that have already been pre-computed in a " "previous forward pass won’t be re-computed. ", parameter_metadata=ENCODER_METADATA["XLNet"]["mem_len"], ) reuse_len: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="The number of tokens in the current batch to be cached and reused in the future.", parameter_metadata=ENCODER_METADATA["XLNet"]["reuse_len"], ) use_mems_eval: bool = schema_utils.Boolean( default=True, description="Whether or not the model should make use of the recurrent memory mechanism in evaluation mode.", parameter_metadata=ENCODER_METADATA["XLNet"]["use_mems_eval"], ) use_mems_train: bool = schema_utils.Boolean( default=False, description="Whether or not the model should make use of the recurrent memory mechanism in train mode.", parameter_metadata=ENCODER_METADATA["XLNet"]["use_mems_train"], ) bi_data: bool = schema_utils.Boolean( default=False, description="Whether or not to use bidirectional input pipeline. Usually set to True during pretraining and " "False during finetuning.", parameter_metadata=ENCODER_METADATA["XLNet"]["bi_data"], ) clamp_len: int = schema_utils.Integer( default=-1, description="Clamp all relative distances larger than clamp_len. Setting this attribute to -1 means no " "clamping.", parameter_metadata=ENCODER_METADATA["XLNet"]["clamp_len"], ) same_length: bool = schema_utils.Boolean( default=False, description="Whether or not to use the same attention length for each token.", parameter_metadata=ENCODER_METADATA["XLNet"]["same_length"], ) summary_type: str = schema_utils.StringOptions( ["last", "first", "mean", "cls_index", "attn"], default="last", description="Argument used when doing sequence summary. Used in the sequence classification and multiple " "choice models.", parameter_metadata=ENCODER_METADATA["XLNet"]["summary_type"], ) summary_use_proj: bool = schema_utils.Boolean( default=True, description="", parameter_metadata=ENCODER_METADATA["XLNet"]["summary_use_proj"], ) summary_activation: str = schema_utils.String( default="tanh", description="Argument used when doing sequence summary. Used in the sequence classification and multiple " "choice models.", parameter_metadata=ENCODER_METADATA["XLNet"]["summary_activation"], ) summary_last_dropout: float = schema_utils.FloatRange( default=0.1, description="Used in the sequence classification and multiple choice models.", parameter_metadata=ENCODER_METADATA["XLNet"]["summary_last_dropout"], ) start_n_top: int = schema_utils.PositiveInteger( default=5, description="Used in the SQuAD evaluation script.", parameter_metadata=ENCODER_METADATA["XLNet"]["start_n_top"], ) end_n_top: int = schema_utils.PositiveInteger( default=5, description=" Used in the SQuAD evaluation script.", parameter_metadata=ENCODER_METADATA["XLNet"]["end_n_top"], ) pad_token_id: int = schema_utils.Integer( default=5, description="The ID of the token to use as padding.", parameter_metadata=ENCODER_METADATA["XLNet"]["pad_token_id"], ) bos_token_id: int = schema_utils.Integer( default=1, description="The beginning of sequence token ID.", parameter_metadata=ENCODER_METADATA["XLNet"]["bos_token_id"], ) eos_token_id: int = schema_utils.Integer( default=2, description="The end of sequence token ID.", parameter_metadata=ENCODER_METADATA["XLNet"]["eos_token_id"], ) pretrained_kwargs: dict = schema_utils.Dict( default=None, description="Additional kwargs to pass to the pretrained model.", parameter_metadata=ENCODER_METADATA["XLNet"]["pretrained_kwargs"], ) @DeveloperAPI @register_encoder_config("distilbert", TEXT) @ludwig_dataclass class DistilBERTConfig(HFEncoderConfig): """This dataclass configures the schema used for an DistilBERT encoder.""" @staticmethod def module_name(): return "DistilBERT" type: str = schema_utils.ProtectedString( "distilbert", description=ENCODER_METADATA["DistilBERT"]["type"].long_description, ) max_sequence_length: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Maximum length of the input sequence.", parameter_metadata=ENCODER_METADATA["DistilBERT"]["max_sequence_length"], ) use_pretrained: bool = schema_utils.Boolean( default=True, description="Whether to use the pretrained weights for the model. If false, the model will train from " "scratch which is very computationally expensive.", parameter_metadata=ENCODER_METADATA["DistilBERT"]["use_pretrained"], ) pretrained_model_name_or_path: str = schema_utils.String( default="distilbert-base-uncased", description="Name or path of the pretrained model.", parameter_metadata=ENCODER_METADATA["DistilBERT"]["pretrained_model_name_or_path"], ) saved_weights_in_checkpoint: bool = schema_utils.Boolean( default=False, description="Are the pretrained encoder weights saved in this model's checkpoint? Automatically set to" "True for trained models to prevent loading pretrained encoder weights from model hub.", parameter_metadata=ENCODER_METADATA["DistilBERT"]["saved_weights_in_checkpoint"], ) reduce_output: str = schema_utils.String( default="sum", description="The method used to reduce a sequence of tensors down to a single tensor.", parameter_metadata=ENCODER_METADATA["DistilBERT"]["reduce_output"], ) trainable: bool = schema_utils.Boolean( default=False, description="Whether to finetune the model on your dataset.", parameter_metadata=ENCODER_METADATA["DistilBERT"]["trainable"], ) adapter: BaseAdapterConfig | None = AdapterDataclassField() vocab: list = schema_utils.List( default=None, description="Vocabulary for the encoder", parameter_metadata=ENCODER_METADATA["DistilBERT"]["vocab"], ) vocab_size: int = schema_utils.PositiveInteger( default=30522, description="Vocabulary size of the DistilBERT model. Defines the number of different tokens that can be " "represented by the inputs_ids passed when calling DistilBertModel or TFDistilBertModel.", parameter_metadata=ENCODER_METADATA["DistilBERT"]["vocab_size"], ) dropout: float = schema_utils.FloatRange( default=0.1, min=0, max=1, description="The dropout probability for all fully connected layers in the embeddings, encoder, and pooler.", parameter_metadata=ENCODER_METADATA["DistilBERT"]["dropout"], ) max_position_embeddings: int = schema_utils.PositiveInteger( default=512, description="The maximum sequence length that this model might ever be used with. Typically set this to " "something large just in case (e.g., 512 or 1024 or 2048).", parameter_metadata=ENCODER_METADATA["DistilBERT"]["max_position_embeddings"], ) sinusoidal_pos_embds: bool = schema_utils.Boolean( default=False, description="Whether to use sinusoidal positional embeddings.", parameter_metadata=ENCODER_METADATA["DistilBERT"]["sinusoidal_pos_embds"], ) n_layers: int = schema_utils.PositiveInteger( default=6, description="Number of hidden layers in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["DistilBERT"]["n_layers"], ) n_heads: int = schema_utils.PositiveInteger( default=12, description="Number of hidden layers in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["DistilBERT"]["n_heads"], ) dim: int = schema_utils.PositiveInteger( default=768, description=" Dimensionality of the encoder layers and the pooler layer.", parameter_metadata=ENCODER_METADATA["DistilBERT"]["dim"], ) hidden_dim: int = schema_utils.PositiveInteger( default=3072, description="The size of the “intermediate” (often named feed-forward) layer in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["DistilBERT"]["hidden_dim"], ) attention_dropout: float = schema_utils.NonNegativeFloat( default=0.1, description="The dropout ratio for the attention probabilities.", parameter_metadata=ENCODER_METADATA["DistilBERT"]["attention_dropout"], ) activation: str | Callable = schema_utils.StringOptions( # TODO: Add support for callable ["gelu", "relu", "silu", "gelu_new"], default="gelu", description="The non-linear activation function (function or string) in the encoder and pooler. If string, " "'gelu', 'relu', 'silu' and 'gelu_new' are supported.", parameter_metadata=ENCODER_METADATA["DistilBERT"]["activation"], ) initializer_range: float = schema_utils.NonNegativeFloat( default=0.02, description="The standard deviation of the truncated_normal_initializer for initializing all weight matrices.", parameter_metadata=ENCODER_METADATA["DistilBERT"]["initializer_range"], ) qa_dropout: float = schema_utils.FloatRange( default=0.1, min=0, max=1, description="The dropout probabilities used in the question answering model DistilBertForQuestionAnswering.", parameter_metadata=ENCODER_METADATA["DistilBERT"]["qa_dropout"], ) seq_classif_dropout: float = schema_utils.FloatRange( default=0.2, min=0, max=1, description="The dropout probabilities used in the sequence classification and the multiple choice model " "DistilBertForSequenceClassification.", parameter_metadata=ENCODER_METADATA["DistilBERT"]["seq_classif_dropout"], ) pretrained_kwargs: dict = schema_utils.Dict( default=None, description="Additional kwargs to pass to the pretrained model.", parameter_metadata=ENCODER_METADATA["DistilBERT"]["pretrained_kwargs"], ) # TODO: uncomment when CTRL bug (https://github.com/ludwig-ai/ludwig/issues/2977) has been fixed to add back in @DeveloperAPI # @register_encoder_config("ctrl", TEXT) @ludwig_dataclass class CTRLConfig(HFEncoderConfig): """This dataclass configures the schema used for an CTRL encoder.""" @staticmethod def module_name(): return "CTRL" type: str = schema_utils.ProtectedString( "ctrl", description=ENCODER_METADATA["CTRL"]["type"].long_description, ) max_sequence_length: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Maximum length of the input sequence.", parameter_metadata=ENCODER_METADATA["CTRL"]["max_sequence_length"], ) use_pretrained: bool = schema_utils.Boolean( default=True, description="Whether to use the pretrained weights for the model. If false, the model will train from " "scratch which is very computationally expensive.", parameter_metadata=ENCODER_METADATA["CTRL"]["use_pretrained"], ) pretrained_model_name_or_path: str = schema_utils.String( default="ctrl", description="Name or path of the pretrained model.", parameter_metadata=ENCODER_METADATA["CTRL"]["pretrained_model_name_or_path"], ) saved_weights_in_checkpoint: bool = schema_utils.Boolean( default=False, description="Are the pretrained encoder weights saved in this model's checkpoint? Automatically set to" "True for trained models to prevent loading pretrained encoder weights from model hub.", parameter_metadata=ENCODER_METADATA["CTRL"]["saved_weights_in_checkpoint"], ) reduce_output: str = schema_utils.String( default="sum", description="The method used to reduce a sequence of tensors down to a single tensor.", parameter_metadata=ENCODER_METADATA["CTRL"]["reduce_output"], ) trainable: bool = schema_utils.Boolean( default=False, description="Whether to finetune the model on your dataset.", parameter_metadata=ENCODER_METADATA["CTRL"]["trainable"], ) adapter: BaseAdapterConfig | None = AdapterDataclassField() vocab: list = schema_utils.List( default=None, description="Vocabulary for the encoder", parameter_metadata=ENCODER_METADATA["CTRL"]["vocab"], ) vocab_size: int = schema_utils.PositiveInteger( default=246534, description="Vocabulary size of the CTRL model. Defines the number of different tokens that can be " "represented by the inputs_ids passed when calling CTRLModel or TFCTRLModel.", parameter_metadata=ENCODER_METADATA["CTRL"]["vocab_size"], ) n_positions: int = schema_utils.PositiveInteger( default=256, description="The maximum sequence length that this model might ever be used with. Typically set this to " "something large just in case (e.g., 512 or 1024 or 2048).", parameter_metadata=ENCODER_METADATA["CTRL"]["n_positions"], ) n_ctx: int = schema_utils.PositiveInteger( default=256, description="Dimensionality of the causal mask (usually same as n_positions)", parameter_metadata=ENCODER_METADATA["CTRL"]["n_ctx"], ) n_embd: int = schema_utils.PositiveInteger( default=1280, description="Dimensionality of the embeddings and hidden states.", parameter_metadata=ENCODER_METADATA["CTRL"]["n_embd"], ) dff: int = schema_utils.PositiveInteger( default=8192, description="Dimensionality of the inner dimension of the feed forward networks (FFN).", parameter_metadata=ENCODER_METADATA["CTRL"]["dff"], ) n_layer: int = schema_utils.PositiveInteger( default=48, description="Number of hidden layers in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["CTRL"]["n_layer"], ) n_head: int = schema_utils.PositiveInteger( default=16, description="Number of attention heads for each attention layer in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["CTRL"]["n_head"], ) resid_pdrop: float = schema_utils.FloatRange( default=0.1, min=0, max=1, description=" The dropout probability for all fully connected layers in the embeddings, encoder, and pooler.", parameter_metadata=ENCODER_METADATA["CTRL"]["resid_pdrop"], ) embd_pdrop: float = schema_utils.NonNegativeFloat( default=0.1, description="The dropout ratio for the embeddings.", parameter_metadata=ENCODER_METADATA["CTRL"]["embd_pdrop"], ) attn_pdrop: float = schema_utils.NonNegativeFloat( default=0.1, description="The dropout ratio for the attention.", parameter_metadata=ENCODER_METADATA["CTRL"]["attn_pdrop"], ) layer_norm_epsilon: float = schema_utils.NonNegativeFloat( default=1e-6, description="The epsilon to use in the layer normalization layers", parameter_metadata=ENCODER_METADATA["CTRL"]["layer_norm_epsilon"], ) initializer_range: float = schema_utils.NonNegativeFloat( default=0.02, description="The standard deviation of the truncated_normal_initializer for initializing all weight matrices.", parameter_metadata=ENCODER_METADATA["CTRL"]["initializer_range"], ) pretrained_kwargs: dict = schema_utils.Dict( default=None, description="Additional kwargs to pass to the pretrained model.", parameter_metadata=ENCODER_METADATA["CTRL"]["pretrained_kwargs"], ) @DeveloperAPI @register_encoder_config("camembert", TEXT) @ludwig_dataclass class CamemBERTConfig(HFEncoderConfig): """This dataclass configures the schema used for an CamemBERT encoder.""" @staticmethod def module_name(): return "CamemBERT" type: str = schema_utils.ProtectedString( "camembert", description=ENCODER_METADATA["CamemBERT"]["type"].long_description, ) max_sequence_length: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Maximum length of the input sequence.", parameter_metadata=ENCODER_METADATA["CamemBERT"]["max_sequence_length"], ) use_pretrained: bool = schema_utils.Boolean( default=True, description="Whether to use the pretrained weights for the model. If false, the model will train from " "scratch which is very computationally expensive.", parameter_metadata=ENCODER_METADATA["CamemBERT"]["use_pretrained"], ) saved_weights_in_checkpoint: bool = schema_utils.Boolean( default=False, description="Are the pretrained encoder weights saved in this model's checkpoint? Automatically set to" "True for trained models to prevent loading pretrained encoder weights from model hub.", parameter_metadata=ENCODER_METADATA["CamemBERT"]["saved_weights_in_checkpoint"], ) pretrained_model_name_or_path: str = schema_utils.String( default="camembert-base", description="Name or path of the pretrained model.", parameter_metadata=ENCODER_METADATA["CamemBERT"]["pretrained_model_name_or_path"], ) reduce_output: str = schema_utils.String( default="sum", description="The method used to reduce a sequence of tensors down to a single tensor.", parameter_metadata=ENCODER_METADATA["CamemBERT"]["reduce_output"], ) trainable: bool = schema_utils.Boolean( default=False, description="Whether to finetune the model on your dataset.", parameter_metadata=ENCODER_METADATA["CamemBERT"]["trainable"], ) adapter: BaseAdapterConfig | None = AdapterDataclassField() vocab: list = schema_utils.List( default=None, description="Vocabulary for the encoder", parameter_metadata=ENCODER_METADATA["CamemBERT"]["vocab"], ) vocab_size: int = schema_utils.PositiveInteger( default=32005, description="Vocabulary size of the CamemBERT model.", parameter_metadata=ENCODER_METADATA["CamemBERT"]["vocab_size"], ) hidden_size: int = schema_utils.PositiveInteger( default=768, description="Dimensionality of the encoder layers and the pooler layer.", parameter_metadata=ENCODER_METADATA["CamemBERT"]["hidden_size"], ) num_hidden_layers: int = schema_utils.PositiveInteger( default=12, description="Number of hidden layers in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["CamemBERT"]["num_hidden_layers"], ) num_attention_heads: int = schema_utils.PositiveInteger( default=12, description="Number of attention heads for each attention layer in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["CamemBERT"]["num_attention_heads"], ) intermediate_size: int = schema_utils.PositiveInteger( default=3072, description="Dimensionality of the “intermediate” (often named feed-forward) layer in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["CamemBERT"]["intermediate_size"], ) hidden_act: str | Callable = schema_utils.StringOptions( # TODO: add support for callable ["gelu", "relu", "silu", "gelu_new"], default="gelu", description="The non-linear activation function (function or string) in the encoder and pooler.", parameter_metadata=ENCODER_METADATA["CamemBERT"]["hidden_act"], ) hidden_dropout_prob: float = schema_utils.FloatRange( default=0.1, min=0, max=1, description="The dropout probability for all fully connected layers in the embeddings, encoder, and pooler.", parameter_metadata=ENCODER_METADATA["CamemBERT"]["hidden_dropout_prob"], ) attention_probs_dropout_prob: float = schema_utils.FloatRange( default=0.1, min=0, max=1, description="The dropout ratio for the attention probabilities.", parameter_metadata=ENCODER_METADATA["CamemBERT"]["attention_probs_dropout_prob"], ) max_position_embeddings: int = schema_utils.PositiveInteger( default=514, description="The maximum sequence length that this model might ever be used with. Typically set this to " "something large just in case (e.g., 512 or 1024 or 2048).", parameter_metadata=ENCODER_METADATA["CamemBERT"]["max_position_embeddings"], ) type_vocab_size: int = schema_utils.PositiveInteger( default=1, description="The vocabulary size of the token_type_ids passed when calling BertModel or TFBertModel.", parameter_metadata=ENCODER_METADATA["CamemBERT"]["type_vocab_size"], ) initializer_range: float = schema_utils.NonNegativeFloat( default=0.02, description="The standard deviation of the truncated_normal_initializer for initializing all weight matrices.", parameter_metadata=ENCODER_METADATA["CamemBERT"]["initializer_range"], ) layer_norm_eps: float = schema_utils.NonNegativeFloat( default=1e-05, description="The epsilon used by the layer normalization layers.", parameter_metadata=ENCODER_METADATA["CamemBERT"]["layer_norm_eps"], ) pad_token_id: int = schema_utils.Integer( default=1, description="The ID of the token to use as padding.", parameter_metadata=ENCODER_METADATA["CamemBERT"]["pad_token_id"], ) gradient_checkpointing: bool = schema_utils.Boolean( default=False, description="Whether to use gradient checkpointing.", parameter_metadata=ENCODER_METADATA["CamemBERT"]["gradient_checkpointing"], ) position_embedding_type: str = schema_utils.StringOptions( ["absolute", "relative_key", "relative_key_query"], default="absolute", description="Type of position embedding.", parameter_metadata=ENCODER_METADATA["CamemBERT"]["position_embedding_type"], ) classifier_dropout: float = schema_utils.FloatRange( default=None, allow_none=True, min=0, max=1, description="The dropout ratio for the classification head.", parameter_metadata=ENCODER_METADATA["CamemBERT"]["classifier_dropout"], ) pretrained_kwargs: dict = schema_utils.Dict( default=None, description="Additional kwargs to pass to the pretrained model.", parameter_metadata=ENCODER_METADATA["CamemBERT"]["pretrained_kwargs"], ) @DeveloperAPI @register_encoder_config("t5", TEXT) @ludwig_dataclass class T5Config(HFEncoderConfig): """This dataclass configures the schema used for an T5 encoder.""" @staticmethod def module_name(): return "T5" type: str = schema_utils.ProtectedString( "t5", description=ENCODER_METADATA["T5"]["type"].long_description, ) max_sequence_length: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Maximum length of the input sequence.", parameter_metadata=ENCODER_METADATA["T5"]["max_sequence_length"], ) use_pretrained: bool = schema_utils.Boolean( default=True, description="Whether to use the pretrained weights for the model. If false, the model will train from " "scratch which is very computationally expensive.", parameter_metadata=ENCODER_METADATA["T5"]["use_pretrained"], ) pretrained_model_name_or_path: str = schema_utils.String( default="t5-small", description="Name or path of the pretrained model.", parameter_metadata=ENCODER_METADATA["T5"]["pretrained_model_name_or_path"], ) saved_weights_in_checkpoint: bool = schema_utils.Boolean( default=False, description="Are the pretrained encoder weights saved in this model's checkpoint? Automatically set to" "True for trained models to prevent loading pretrained encoder weights from model hub.", parameter_metadata=ENCODER_METADATA["T5"]["saved_weights_in_checkpoint"], ) reduce_output: str = schema_utils.String( default="sum", description="The method used to reduce a sequence of tensors down to a single tensor.", parameter_metadata=ENCODER_METADATA["T5"]["reduce_output"], ) trainable: bool = schema_utils.Boolean( default=False, description="Whether to finetune the model on your dataset.", parameter_metadata=ENCODER_METADATA["T5"]["trainable"], ) adapter: BaseAdapterConfig | None = AdapterDataclassField() vocab: list = schema_utils.List( default=None, description="Vocabulary for the encoder", parameter_metadata=ENCODER_METADATA["T5"]["vocab"], ) vocab_size: int = schema_utils.PositiveInteger( default=32128, description="Vocabulary size of the T5 model. Defines the number of different tokens that can be represented " "by the inputs_ids passed when calling T5Model or TFT5Model.", parameter_metadata=ENCODER_METADATA["T5"]["vocab_size"], ) d_model: int = schema_utils.PositiveInteger( default=512, description="Size of the encoder layers and the pooler layer.", parameter_metadata=ENCODER_METADATA["T5"]["d_model"], ) d_kv: int = schema_utils.PositiveInteger( default=64, description="Size of the key, query, value projections per attention head. d_kv has to be equal to d_model // " "num_heads.", parameter_metadata=ENCODER_METADATA["T5"]["d_kv"], ) d_ff: int = schema_utils.PositiveInteger( default=2048, description="Size of the intermediate feed forward layer in each T5Block.", parameter_metadata=ENCODER_METADATA["T5"]["d_ff"], ) num_layers: int = schema_utils.PositiveInteger( default=6, description="Number of hidden layers in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["T5"]["num_layers"], ) num_decoder_layers: int = schema_utils.PositiveInteger( default=6, description="Number of hidden layers in the Transformer decoder. Will use the same value as num_layers if not " "set.", parameter_metadata=ENCODER_METADATA["T5"]["num_decoder_layers"], ) num_heads: int = schema_utils.PositiveInteger( default=8, description="Number of attention heads for each attention layer in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["T5"]["num_heads"], ) relative_attention_num_buckets: int = schema_utils.PositiveInteger( default=32, description="The number of buckets to use for each attention layer.", parameter_metadata=ENCODER_METADATA["T5"]["relative_attention_num_buckets"], ) dropout_rate: float = schema_utils.FloatRange( default=0.1, min=0, max=1, description="The ratio for all dropout layers.", parameter_metadata=ENCODER_METADATA["T5"]["dropout_rate"], ) layer_norm_eps: float = schema_utils.NonNegativeFloat( default=1e-6, description="The epsilon used by the layer normalization layers.", parameter_metadata=ENCODER_METADATA["T5"]["layer_norm_eps"], ) initializer_factor: float = schema_utils.NonNegativeFloat( default=1, description="A factor for initializing all weight matrices (should be kept to 1, used internally for " "initialization testing).", parameter_metadata=ENCODER_METADATA["T5"]["initializer_factor"], ) feed_forward_proj: str = schema_utils.StringOptions( ["relu", "gated-gelu"], default="relu", description="Type of feed forward layer to be used. Should be one of 'relu' or 'gated-gelu'. T5v1.1 uses the " "'gated-gelu' feed forward projection. Original T5 uses 'relu'.", parameter_metadata=ENCODER_METADATA["T5"]["feed_forward_proj"], ) pretrained_kwargs: dict = schema_utils.Dict( default=None, description="Additional kwargs to pass to the pretrained model.", parameter_metadata=ENCODER_METADATA["T5"]["pretrained_kwargs"], ) @DeveloperAPI @register_encoder_config("flaubert", TEXT) @ludwig_dataclass class FlauBERTConfig(HFEncoderConfig): """This dataclass configures the schema used for an FlauBERT encoder.""" @staticmethod def module_name(): return "FlauBERT" type: str = schema_utils.ProtectedString( "flaubert", description=ENCODER_METADATA["FlauBERT"]["type"].long_description, ) max_sequence_length: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Maximum length of the input sequence.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["max_sequence_length"], ) use_pretrained: bool = schema_utils.Boolean( default=True, description="Whether to use the pretrained weights for the model. If false, the model will train from " "scratch which is very computationally expensive.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["use_pretrained"], ) pretrained_model_name_or_path: str = schema_utils.String( default="flaubert/flaubert_small_cased", description="Name of path of the pretrained model.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["pretrained_model_name_or_path"], ) saved_weights_in_checkpoint: bool = schema_utils.Boolean( default=False, description="Are the pretrained encoder weights saved in this model's checkpoint? Automatically set to" "True for trained models to prevent loading pretrained encoder weights from model hub.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["saved_weights_in_checkpoint"], ) reduce_output: str = schema_utils.String( default="sum", description="The method used to reduce a sequence of tensors down to a single tensor.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["reduce_output"], ) trainable: bool = schema_utils.Boolean( default=False, description="Whether to finetune the model on your dataset.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["trainable"], ) adapter: BaseAdapterConfig | None = AdapterDataclassField() vocab: list = schema_utils.List( default=None, description="Vocabulary for the encoder", parameter_metadata=ENCODER_METADATA["FlauBERT"]["vocab"], ) vocab_size: int = schema_utils.PositiveInteger( default=30145, description="Vocabulary size of the FlauBERT model. Defines the number of different tokens that can be " "represented by the inputs_ids passed when calling FlaubertModel or TFFlaubertModel.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["vocab_size"], ) pre_norm: bool = schema_utils.Boolean( default=True, description="Whether to apply the layer normalization before or after the feed forward layer following the " "attention in each layer (Vaswani et al., Tensor2Tensor for Neural Machine Translation. 2018)", parameter_metadata=ENCODER_METADATA["FlauBERT"]["pre_norm"], ) layerdrop: float = schema_utils.FloatRange( default=0.2, min=0, max=1, description="Probability to drop layers during training (Fan et al., Reducing Transformer Depth on Demand " "with Structured Dropout. ICLR 2020)", parameter_metadata=ENCODER_METADATA["FlauBERT"]["layerdrop"], ) emb_dim: int = schema_utils.PositiveInteger( default=512, description="Dimensionality of the encoder layers and the pooler layer.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["emb_dim"], ) n_layers: int = schema_utils.PositiveInteger( default=6, description="Number of hidden layers in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["n_layers"], ) n_heads: int = schema_utils.PositiveInteger( default=8, description="Number of attention heads for each attention layer in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["n_heads"], ) dropout: float = schema_utils.FloatRange( default=0.1, min=0, max=1, description="The dropout probability for all fully connected layers in the embeddings, encoder, and pooler.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["dropout"], ) attention_dropout: float = schema_utils.FloatRange( default=0.1, min=0, max=1, description="The dropout probability for the attention mechanism", parameter_metadata=ENCODER_METADATA["FlauBERT"]["attention_dropout"], ) gelu_activation: bool = schema_utils.Boolean( default=True, description="Whether or not to use a gelu activation instead of relu.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["gelu_activation"], ) sinusoidal_embeddings: bool = schema_utils.Boolean( default=False, description="Whether or not to use sinusoidal positional embeddings instead of absolute positional embeddings.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["sinusoidal_embeddings"], ) causal: bool = schema_utils.Boolean( default=False, description="Whether or not the model should behave in a causal manner. Causal models use a triangular " "attention mask in order to only attend to the left-side context instead if a bidirectional " "context.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["causal"], ) asm: bool = schema_utils.Boolean( default=False, description="Whether or not to use an adaptive log softmax projection layer instead of a linear layer for the " "prediction layer.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["asm"], ) n_langs: int = schema_utils.PositiveInteger( default=1, description="The number of languages the model handles. Set to 1 for monolingual models.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["n_langs"], ) use_lang_emb: bool = schema_utils.Boolean( default=True, description="Whether to use language embeddings. Some models use additional language embeddings, " "see the multilingual models page for information on how to use them.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["use_lang_emb"], ) max_position_embeddings: int = schema_utils.PositiveInteger( default=512, description="The maximum sequence length that this model might ever be used with. Typically set this to " "something large just in case (e.g., 512 or 1024 or 2048).", parameter_metadata=ENCODER_METADATA["FlauBERT"]["max_position_embeddings"], ) embed_init_std: float = schema_utils.NonNegativeFloat( default=2048**-0.5, description="The standard deviation of the truncated_normal_initializer for initializing the embedding " "matrices.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["embed_init_std"], ) init_std: int = schema_utils.NonNegativeFloat( default=0.02, description="The standard deviation of the truncated_normal_initializer for initializing all weight matrices " "except the embedding matrices.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["init_std"], ) layer_norm_eps: float = schema_utils.NonNegativeFloat( default=1e-06, description="The epsilon used by the layer normalization layers.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["layer_norm_eps"], ) bos_index: int = schema_utils.NonNegativeInteger( default=0, description="The index of the beginning of sentence token in the vocabulary.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["bos_index"], ) eos_index: int = schema_utils.NonNegativeInteger( default=1, description="The index of the end of sentence token in the vocabulary.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["eos_index"], ) pad_index: int = schema_utils.NonNegativeInteger( default=2, description="The index of the padding token in the vocabulary.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["pad_index"], ) unk_index: int = schema_utils.NonNegativeInteger( default=3, description="The index of the unknown token in the vocabulary.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["unk_index"], ) mask_index: int = schema_utils.NonNegativeInteger( default=5, description="The index of the masking token in the vocabulary.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["mask_index"], ) is_encoder: bool = schema_utils.Boolean( default=True, description="Whether or not the initialized model should be a transformer encoder or decoder as seen in " "Vaswani et al.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["is_encoder"], ) mask_token_id: int = schema_utils.Integer( default=0, description="Model agnostic parameter to identify masked tokens when generating text in an MLM context.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["mask_token_id"], ) lang_id: int = schema_utils.Integer( default=0, description="The ID of the language used by the model. This parameter is used when generating text in a given " "language.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["lang_id"], ) pretrained_kwargs: dict = schema_utils.Dict( default=None, description="Additional kwargs to pass to the pretrained model.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["pretrained_kwargs"], ) @DeveloperAPI @register_encoder_config("electra", TEXT) @ludwig_dataclass class ELECTRAConfig(HFEncoderConfig): """This dataclass configures the schema used for an ELECTRA encoder.""" @staticmethod def module_name(): return "ELECTRA" type: str = schema_utils.ProtectedString( "electra", description=ENCODER_METADATA["ELECTRA"]["type"].long_description, ) max_sequence_length: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Maximum length of the input sequence.", parameter_metadata=ENCODER_METADATA["ELECTRA"]["max_sequence_length"], ) use_pretrained: bool = schema_utils.Boolean( default=True, description="Whether to use the pretrained weights for the model. If false, the model will train from " "scratch which is very computationally expensive.", parameter_metadata=ENCODER_METADATA["ELECTRA"]["use_pretrained"], ) pretrained_model_name_or_path: str = schema_utils.String( default="google/electra-small-discriminator", description="Name or path of the pretrained model.", parameter_metadata=ENCODER_METADATA["ELECTRA"]["pretrained_model_name_or_path"], ) saved_weights_in_checkpoint: bool = schema_utils.Boolean( default=False, description="Are the pretrained encoder weights saved in this model's checkpoint? Automatically set to" "True for trained models to prevent loading pretrained encoder weights from model hub.", parameter_metadata=ENCODER_METADATA["ELECTRA"]["saved_weights_in_checkpoint"], ) reduce_output: str = schema_utils.String( default="sum", description="The method used to reduce a sequence of tensors down to a single tensor.", parameter_metadata=ENCODER_METADATA["ELECTRA"]["reduce_output"], ) trainable: bool = schema_utils.Boolean( default=False, description="Whether to finetune the model on your dataset.", parameter_metadata=ENCODER_METADATA["ELECTRA"]["trainable"], ) adapter: BaseAdapterConfig | None = AdapterDataclassField() vocab: list = schema_utils.List( default=None, description="Vocabulary for the encoder", parameter_metadata=ENCODER_METADATA["ELECTRA"]["vocab"], ) vocab_size: int = schema_utils.PositiveInteger( default=30522, description="Vocabulary size of the ELECTRA model. Defines the number of different tokens that can be " "represented by the inputs_ids passed when calling ElectraModel or TFElectraModel.", parameter_metadata=ENCODER_METADATA["ELECTRA"]["vocab_size"], ) embedding_size: int = schema_utils.PositiveInteger( default=128, description="Dimensionality of the encoder layers and the pooler layer.", parameter_metadata=ENCODER_METADATA["ELECTRA"]["embedding_size"], ) hidden_size: int = schema_utils.PositiveInteger( default=256, description="Dimensionality of the encoder layers and the pooler layer.", parameter_metadata=ENCODER_METADATA["ELECTRA"]["hidden_size"], ) num_hidden_layers: int = schema_utils.PositiveInteger( default=12, description="Number of hidden layers in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["ELECTRA"]["num_hidden_layers"], ) num_attention_heads: int = schema_utils.PositiveInteger( default=4, description="Number of attention heads for each attention layer in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["ELECTRA"]["num_attention_heads"], ) intermediate_size: int = schema_utils.PositiveInteger( default=1024, description="Dimensionality of the “intermediate” (i.e., feed-forward) layer in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["ELECTRA"]["intermediate_size"], ) hidden_act: str | Callable = schema_utils.StringOptions( # TODO: add support for callable ["gelu", "relu", "silu", "gelu_new"], default="gelu", description="The non-linear activation function (function or string) in the encoder and pooler.", parameter_metadata=ENCODER_METADATA["ELECTRA"]["hidden_act"], ) hidden_dropout_prob: float = schema_utils.FloatRange( default=0.1, min=0, max=1, description="The dropout probability for all fully connected layers in the embeddings, encoder, and pooler.", parameter_metadata=ENCODER_METADATA["ELECTRA"]["hidden_dropout_prob"], ) attention_probs_dropout_prob: float = schema_utils.FloatRange( default=0.1, min=0, max=1, description="The dropout ratio for the attention probabilities.", parameter_metadata=ENCODER_METADATA["ELECTRA"]["attention_probs_dropout_prob"], ) max_position_embeddings: int = schema_utils.PositiveInteger( default=512, description="The maximum sequence length that this model might ever be used with. Typically set this to " "something large just in case (e.g., 512 or 1024 or 2048).", parameter_metadata=ENCODER_METADATA["ELECTRA"]["max_position_embeddings"], ) type_vocab_size: int = schema_utils.PositiveInteger( default=2, description="The vocabulary size of the token_type_ids passed when calling ElectraModel or TFElectraModel.", parameter_metadata=ENCODER_METADATA["ELECTRA"]["type_vocab_size"], ) initializer_range: float = schema_utils.NonNegativeFloat( default=0.02, description="The standard deviation of the truncated_normal_initializer for initializing all weight matrices.", parameter_metadata=ENCODER_METADATA["ELECTRA"]["initializer_range"], ) layer_norm_eps: float = schema_utils.NonNegativeFloat( default=1e-12, description="The epsilon used by the layer normalization layers.", parameter_metadata=ENCODER_METADATA["ELECTRA"]["layer_norm_eps"], ) position_embedding_type: str = schema_utils.StringOptions( ["absolute", "relative_key", "relative_key_query"], default="absolute", description="Type of position embedding.", parameter_metadata=ENCODER_METADATA["ELECTRA"]["position_embedding_type"], ) classifier_dropout: float = schema_utils.FloatRange( default=None, allow_none=True, min=0, max=1, description="The dropout ratio for the classification head.", parameter_metadata=ENCODER_METADATA["ELECTRA"]["classifier_dropout"], ) pretrained_kwargs: dict = schema_utils.Dict( default=None, description="Additional kwargs to pass to the pretrained model.", parameter_metadata=ENCODER_METADATA["ELECTRA"]["pretrained_kwargs"], ) @DeveloperAPI @register_encoder_config("longformer", TEXT) @ludwig_dataclass class LongformerConfig(HFEncoderConfig): """This dataclass configures the schema used for a Longformer encoder.""" @staticmethod def module_name(): return "Longformer" type: str = schema_utils.ProtectedString( "longformer", description=ENCODER_METADATA["Longformer"]["type"].long_description, ) max_sequence_length: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Maximum length of the input sequence.", parameter_metadata=ENCODER_METADATA["Longformer"]["max_sequence_length"], ) use_pretrained: bool = schema_utils.Boolean( default=True, description="Whether to use the pretrained weights for the model. If false, the model will train from " "scratch which is very computationally expensive.", parameter_metadata=ENCODER_METADATA["Longformer"]["use_pretrained"], ) attention_window: list[int] | int = schema_utils.OneOfOptionsField( default=512, allow_none=False, description="Size of an attention window around each token. If an int, use the same size for all layers. To " "specify a different window size for each layer, use a List[int] where len(attention_window) == " "num_hidden_layers.", field_options=[ schema_utils.PositiveInteger(allow_none=False, description="", default=512), schema_utils.List(list_type=int, allow_none=False), ], parameter_metadata=ENCODER_METADATA["Longformer"]["attention_window"], ) sep_token_id: int = schema_utils.Integer( default=2, description="ID of the separator token, which is used when building a sequence from multiple sequences", parameter_metadata=ENCODER_METADATA["Longformer"]["sep_token_id"], ) pretrained_model_name_or_path: str = schema_utils.String( default="allenai/longformer-base-4096", description="Name or path of the pretrained model.", parameter_metadata=ENCODER_METADATA["Longformer"]["pretrained_model_name_or_path"], ) saved_weights_in_checkpoint: bool = schema_utils.Boolean( default=False, description="Are the pretrained encoder weights saved in this model's checkpoint? Automatically set to" "True for trained models to prevent loading pretrained encoder weights from model hub.", parameter_metadata=ParameterMetadata(internal_only=True), ) reduce_output: str = schema_utils.String( default="cls_pooled", description="The method used to reduce a sequence of tensors down to a single tensor.", parameter_metadata=ENCODER_METADATA["Longformer"]["reduce_output"], ) trainable: bool = schema_utils.Boolean( default=False, description="Whether to finetune the model on your dataset.", parameter_metadata=ENCODER_METADATA["Longformer"]["trainable"], ) adapter: BaseAdapterConfig | None = AdapterDataclassField() vocab: list = schema_utils.List( default=None, description="Vocabulary for the encoder", parameter_metadata=ENCODER_METADATA["Longformer"]["vocab"], ) vocab_size: int = schema_utils.PositiveInteger( default=50265, description="Vocabulary size of the Longformer model.", parameter_metadata=ENCODER_METADATA["Longformer"]["vocab_size"], ) max_position_embeddings: int = schema_utils.PositiveInteger( default=4098, description="The maximum sequence length that this model might ever be used with. Typically set this to " "something large just in case (e.g., 512 or 1024 or 2048).", parameter_metadata=ENCODER_METADATA["Longformer"]["max_position_embeddings"], ) type_vocab_size: int = schema_utils.PositiveInteger( default=1, description="The vocabulary size of the token_type_ids passed when calling LongformerEncoder", parameter_metadata=ENCODER_METADATA["Longformer"]["type_vocab_size"], ) pretrained_kwargs: dict = schema_utils.Dict( default=None, description="Additional kwargs to pass to the pretrained model.", parameter_metadata=ENCODER_METADATA["Longformer"]["pretrained_kwargs"], ) @DeveloperAPI @register_encoder_config("auto_transformer", TEXT) @ludwig_dataclass class AutoTransformerConfig(HFEncoderConfig): """This dataclass configures the schema used for an AutoTransformer encoder.""" def __post_init__(self): if self.pretrained_model_name_or_path is None: raise ConfigValidationError( "`pretrained_model_name_or_path` must be specified for encoder: `auto_transformer`." ) @staticmethod def module_name(): return "AutoTransformer" @property def use_pretrained(self) -> bool: # Always set this to True since we always want to use the pretrained weights # We don't currently support training from scratch for AutoTransformers return True type: str = schema_utils.ProtectedString( "auto_transformer", description=ENCODER_METADATA["AutoTransformer"]["type"].long_description, ) pretrained_model_name_or_path: str = schema_utils.String( default=None, allow_none=True, description="Name or path of the pretrained model.", parameter_metadata=ENCODER_METADATA["AutoTransformer"]["pretrained_model_name_or_path"], ) max_sequence_length: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Maximum length of the input sequence.", parameter_metadata=ENCODER_METADATA["AutoTransformer"]["max_sequence_length"], ) reduce_output: str = schema_utils.ReductionOptions( default="sum", description="The method used to reduce a sequence of tensors down to a single tensor.", parameter_metadata=ENCODER_METADATA["AutoTransformer"]["reduce_output"], ) trainable: bool = schema_utils.Boolean( default=False, description="Whether to finetune the model on your dataset.", parameter_metadata=ENCODER_METADATA["AutoTransformer"]["trainable"], ) adapter: BaseAdapterConfig | None = AdapterDataclassField() vocab: list = schema_utils.List( default=None, description="Vocabulary for the encoder", parameter_metadata=ENCODER_METADATA["AutoTransformer"]["vocab"], ) vocab_size: int = schema_utils.PositiveInteger( default=None, allow_none=True, description=( "Vocabulary size of the AutoTransformer model. If None, the vocab size will be inferred " "from the given pretrained model" ), parameter_metadata=ENCODER_METADATA["AutoTransformer"]["vocab_size"], ) pretrained_kwargs: dict = schema_utils.Dict( default=None, description="Additional kwargs to pass to the pretrained model.", parameter_metadata=ENCODER_METADATA["AutoTransformer"]["pretrained_kwargs"], ) @DeveloperAPI @register_encoder_config("tf_idf", TEXT, model_types=[MODEL_ECD]) @ludwig_dataclass class TfIdfEncoderConfig(SequenceEncoderConfig): type: str = schema_utils.ProtectedString("tf_idf") max_sequence_length: int = schema_utils.Integer(default=None, allow_none=True, parameter_metadata=INTERNAL_ONLY) str2idf: dict[str, int] = schema_utils.Dict(parameter_metadata=INTERNAL_ONLY) vocab: list = schema_utils.List(default=None, parameter_metadata=INTERNAL_ONLY) vocab_size: int = schema_utils.Integer(default=None, allow_none=True, parameter_metadata=INTERNAL_ONLY) def set_fixed_preprocessing_params(self, model_type: str, preprocessing: "TextPreprocessingConfig"): preprocessing.compute_idf = True def can_cache_embeddings(self) -> bool: return True @DeveloperAPI @register_encoder_config("llm", TEXT, model_types=[MODEL_ECD]) @ludwig_dataclass class LLMEncoderConfig(SequenceEncoderConfig): type: str = schema_utils.ProtectedString("llm") base_model: str = BaseModelDataclassField() max_sequence_length: int = schema_utils.Integer(default=None, allow_none=True, parameter_metadata=INTERNAL_ONLY) adapter: BaseAdapterConfig | None = AdapterDataclassField() quantization: QuantizationConfig | None = QuantizationConfigField().get_default_field() model_parameters: ModelParametersConfig | None = ModelParametersConfigField().get_default_field() ================================================ FILE: ludwig/schema/encoders/text/hf_model_params.py ================================================ from ludwig.schema import utils as schema_utils from ludwig.schema.metadata.parameter_metadata import INTERNAL_ONLY from ludwig.schema.utils import ludwig_dataclass """ NOTE TO DEVELOPERS: the implementation of the schema classes below must match the parameters of the HF PretrainedConfig class exactly. This is because we convert this object into the matching HF PretrainedConfig object before passing it to the model. Additionally, for loading and saving pretrained models, we take the config from the existing model and load it into this config before saving. As such, if any params needed by the pretrained model are missing, we will not be able to load checkpoints correctly. A common mistake is to look at the PretrainedConfig __init__ method params and ignore any additional **kwargs. In some cases, these kwargs are used to set additional params on the config object. For example, the DebertaConfig class has `position_buckets` as a kwarg param, but it nonetheless requires this to construct the model architecture. To debug issues with missing parameters, try printing out the `model.config` of the pretrained transformer and check for any params it includes that are not present in your schema config. """ @ludwig_dataclass class DebertaModelParams(schema_utils.BaseMarshmallowConfig): @classmethod def get_hf_config_param_names(cls) -> set[str]: return DebertaModelParams.get_valid_field_names() # Model architecture params for training from scratch # TODO(travis): conditionally disable setting these when `use_pretrained=True`. vocab_size: int = schema_utils.PositiveInteger( default=None, description="", parameter_metadata=INTERNAL_ONLY, ) hidden_size: int = schema_utils.PositiveInteger( default=1536, description="Dimensionality of the encoder layers and the pooler layer.", ) num_hidden_layers: int = schema_utils.PositiveInteger( default=24, description="Number of hidden layers in the Transformer encoder.", ) num_attention_heads: int = schema_utils.PositiveInteger( default=24, description="Number of attention heads for each attention layer in the Transformer encoder.", ) intermediate_size: int = schema_utils.PositiveInteger( default=6144, description="Dimensionality of the 'intermediate' (often named feed-forward) layer in the Transformer encoder.", ) hidden_act: str = schema_utils.StringOptions( options=["gelu", "relu", "silu", "tanh", "gelu_fast", "mish", "linear", "sigmoid", "gelu_new"], default="gelu", description="The non-linear activation function (function or string) in the encoder and pooler.", ) hidden_dropout_prob: float = schema_utils.NonNegativeFloat( default=0.1, description="The dropout probability for all fully connected layers in the embeddings, encoder, and pooler.", ) attention_probs_dropout_prob: float = schema_utils.NonNegativeFloat( default=0.1, description="The dropout ratio for the attention probabilities.", ) max_position_embeddings: int = schema_utils.PositiveInteger( default=512, description=( "The maximum sequence length that this model might ever be used with. Typically set this to something " "large just in case (e.g., 512 or 1024 or 2048)." ), ) type_vocab_size: int = schema_utils.NonNegativeInteger( default=0, description=("The vocabulary size of the `token_type_ids`."), ) initializer_range: float = schema_utils.NonNegativeFloat( default=0.02, description=( "The standard deviation of the truncated_normal_initializer for initializing all weight matrices." ), ) layer_norm_eps: float = schema_utils.NonNegativeFloat( default=1e-7, description="The epsilon used by the layer normalization layers.", ) relative_attention: bool = schema_utils.Boolean( default=True, description="Whether use relative position encoding.", ) max_relative_positions: int = schema_utils.Integer( default=-1, description=( "The range of relative positions `[-max_position_embeddings, max_position_embeddings]`. Use the same " "value as `max_position_embeddings`." ), ) pad_token_id: int = schema_utils.Integer( default=0, description="The value used to pad input_ids.", ) position_biased_input: bool = schema_utils.Boolean( default=False, description="Whether add absolute position embedding to content embedding.", ) pos_att_type: list[str] = schema_utils.List( default=["p2c", "c2p"], description=( "The type of relative position attention, it can be a combination of `['p2c', 'c2p']`, e.g. `['p2c']`, " "`['p2c', 'c2p']`, `['p2c', 'c2p']`." ), ) layer_norm_eps: float = schema_utils.NonNegativeFloat( default=1e-12, description="The epsilon used by the layer normalization layers.", ) pooler_hidden_size: int = schema_utils.PositiveInteger( default=1536, description="The hidden size of the pooler layers.", ) pooler_dropout: float = schema_utils.NonNegativeFloat( default=0, description="The dropout ratio for the pooler layers.", ) pooler_hidden_act: str = schema_utils.StringOptions( options=["gelu", "relu", "silu", "tanh", "gelu_fast", "mish", "linear", "sigmoid", "gelu_new"], default="gelu", description="The activation function (function or string) in the pooler.", ) position_buckets: int = schema_utils.PositiveInteger( default=256, description="The number of buckets to use for each attention layer.", ) share_att_key: bool = schema_utils.Boolean( default=True, description="Whether to share attention key across layers.", ) norm_rel_ebd: str = schema_utils.StringOptions( options=["layer_norm", "none"], default="layer_norm", description="The normalization method for relative embeddings.", ) ================================================ FILE: ludwig/schema/encoders/text_encoders.py ================================================ from collections.abc import Callable from typing import TYPE_CHECKING from ludwig.api_annotations import DeveloperAPI from ludwig.constants import MODEL_ECD, TEXT from ludwig.error import ConfigValidationError from ludwig.schema import utils as schema_utils from ludwig.schema.encoders.sequence_encoders import SequenceEncoderConfig from ludwig.schema.encoders.text.hf_model_params import DebertaModelParams from ludwig.schema.encoders.utils import register_encoder_config from ludwig.schema.llms.base_model import BaseModelDataclassField from ludwig.schema.llms.model_parameters import ModelParametersConfig, ModelParametersConfigField from ludwig.schema.llms.peft import AdapterDataclassField, BaseAdapterConfig from ludwig.schema.llms.quantization import QuantizationConfig, QuantizationConfigField from ludwig.schema.metadata import ENCODER_METADATA from ludwig.schema.metadata.parameter_metadata import INTERNAL_ONLY, ParameterMetadata from ludwig.schema.utils import ludwig_dataclass if TYPE_CHECKING: from ludwig.schema.features.preprocessing.text import TextPreprocessingConfig class HFEncoderConfig(SequenceEncoderConfig): trainable: bool use_pretrained: bool pretrained_model_name_or_path: str reduce_output: str def set_fixed_preprocessing_params(self, model_type: str, preprocessing: "TextPreprocessingConfig"): model_name = self.pretrained_model_name_or_path if model_name is None and self.use_pretrained: # no default model name, so model name is required by the subclass raise ValueError( f"Missing required parameter for `{self.type}` encoder: `pretrained_model_name_or_path` when " "`use_pretrained` is True." ) preprocessing.tokenizer = "hf_tokenizer" preprocessing.pretrained_model_name_or_path = model_name if not self.can_cache_embeddings(): preprocessing.cache_encoder_embeddings = False def is_pretrained(self) -> bool: return self.use_pretrained def can_cache_embeddings(self) -> bool: """Returns true if the encoder's output embeddings will not change during training.""" return not self.trainable and self.reduce_output != "attention" @DeveloperAPI @ludwig_dataclass class HFEncoderImplConfig(HFEncoderConfig): """This dataclass configures the base HF encoder implmenetation.""" use_pretrained: bool = schema_utils.Boolean( default=True, description="Whether to use the pretrained weights for the model. If false, the model will train from " "scratch which is very computationally expensive.", parameter_metadata=ENCODER_METADATA["HFEncoder"]["use_pretrained"], ) trainable: bool = schema_utils.Boolean( default=False, description="Whether to finetune the model on your dataset.", parameter_metadata=ENCODER_METADATA["HFEncoder"]["trainable"], ) adapter: BaseAdapterConfig | None = AdapterDataclassField() pretrained_kwargs: dict = schema_utils.Dict( default=None, description="Additional kwargs to pass to the pretrained model.", ) # Internal params set based on preprocessing metadata max_sequence_length: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="", parameter_metadata=INTERNAL_ONLY, ) vocab_size: int = schema_utils.PositiveInteger( default=None, description="", parameter_metadata=INTERNAL_ONLY, ) saved_weights_in_checkpoint: bool = schema_utils.Boolean( default=False, description=( "Are the pretrained encoder weights saved in this model's checkpoint? Automatically set to" "True for trained models to prevent loading pretrained encoder weights from model hub." ), parameter_metadata=INTERNAL_ONLY, ) @DeveloperAPI @register_encoder_config("albert", TEXT) @ludwig_dataclass class ALBERTConfig(HFEncoderConfig): """This dataclass configures the schema used for an ALBERT encoder.""" @staticmethod def module_name(): return "ALBERT" type: str = schema_utils.ProtectedString( "albert", description=ENCODER_METADATA["ALBERT"]["type"].long_description, ) max_sequence_length: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Maximum length of the input sequence.", parameter_metadata=ENCODER_METADATA["ALBERT"]["max_sequence_length"], ) use_pretrained: bool = schema_utils.Boolean( default=True, description="Whether to use the pretrained weights for the model. If false, the model will train from " "scratch which is very computationally expensive.", parameter_metadata=ENCODER_METADATA["ALBERT"]["use_pretrained"], ) pretrained_model_name_or_path: str = schema_utils.String( default="albert-base-v2", description="Name or path of the pretrained model.", parameter_metadata=ENCODER_METADATA["ALBERT"]["pretrained_model_name_or_path"], ) saved_weights_in_checkpoint: bool = schema_utils.Boolean( default=False, description="Are the pretrained encoder weights saved in this model's checkpoint? Automatically set to" "True for trained models to prevent loading pretrained encoder weights from model hub.", parameter_metadata=ENCODER_METADATA["ALBERT"]["saved_weights_in_checkpoint"], ) trainable: bool = schema_utils.Boolean( default=False, description="Whether to finetune the model on your dataset.", parameter_metadata=ENCODER_METADATA["ALBERT"]["trainable"], ) adapter: BaseAdapterConfig | None = AdapterDataclassField() reduce_output: str = schema_utils.String( default="cls_pooled", description="The method used to reduce a sequence of tensors down to a single tensor.", parameter_metadata=ENCODER_METADATA["ALBERT"]["reduce_output"], ) vocab: list = schema_utils.List( default=None, description="Vocabulary for the encoder", parameter_metadata=ENCODER_METADATA["ALBERT"]["vocab"], ) vocab_size: int = schema_utils.PositiveInteger( default=30000, description="Vocabulary size of the ALBERT model. Defines the number of different tokens that can be " "represented by the inputs_ids passed.", parameter_metadata=ENCODER_METADATA["ALBERT"]["vocab_size"], ) embedding_size: int = schema_utils.PositiveInteger( default=128, description="Dimensionality of vocabulary embeddings.", parameter_metadata=ENCODER_METADATA["ALBERT"]["embedding_size"], ) hidden_size: int = schema_utils.PositiveInteger( default=768, description="Dimensionality of the encoder layers and the pooler layer.", parameter_metadata=ENCODER_METADATA["ALBERT"]["hidden_size"], ) num_hidden_layers: int = schema_utils.PositiveInteger( default=12, description="Number of hidden layers in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["ALBERT"]["num_hidden_layers"], ) num_hidden_groups: int = schema_utils.PositiveInteger( default=1, description="Number of groups for the hidden layers, parameters in the same group are shared.", parameter_metadata=ENCODER_METADATA["ALBERT"]["num_hidden_groups"], ) num_attention_heads: int = schema_utils.PositiveInteger( default=12, description="Number of attention heads for each attention layer in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["ALBERT"]["num_attention_heads"], ) intermediate_size: int = schema_utils.PositiveInteger( default=3072, description="The dimensionality of the “intermediate” (often named feed-forward) layer in the Transformer " "encoder.", parameter_metadata=ENCODER_METADATA["ALBERT"]["intermediate_size"], ) inner_group_num: int = schema_utils.PositiveInteger( default=1, description="The number of inner repetition of attention and ffn.", parameter_metadata=ENCODER_METADATA["ALBERT"]["inner_group_num"], ) hidden_act: str = schema_utils.StringOptions( ["gelu", "relu", "silu", "gelu_new"], default="gelu_new", description="The non-linear activation function (function or string) in the encoder and pooler.", parameter_metadata=ENCODER_METADATA["ALBERT"]["hidden_act"], ) hidden_dropout_prob: float = schema_utils.FloatRange( default=0.0, min=0, max=1, description="The dropout probability for all fully connected layers in the embeddings, encoder, and pooler.", parameter_metadata=ENCODER_METADATA["ALBERT"]["hidden_dropout_prob"], ) attention_probs_dropout_prob: float = schema_utils.FloatRange( default=0.0, min=0, max=1, description="The dropout ratio for the attention probabilities.", parameter_metadata=ENCODER_METADATA["ALBERT"]["attention_probs_dropout_prob"], ) max_position_embeddings: int = schema_utils.PositiveInteger( default=512, description="The maximum sequence length that this model might ever be used with. Typically set this to " "something large (e.g., 512 or 1024 or 2048).", parameter_metadata=ENCODER_METADATA["ALBERT"]["max_position_embeddings"], ) type_vocab_size: int = schema_utils.PositiveInteger( default=2, description="The vocabulary size of the token_type_ids passed when calling AlbertModel or TFAlbertModel.", parameter_metadata=ENCODER_METADATA["ALBERT"]["type_vocab_size"], ) initializer_range: float = schema_utils.NonNegativeFloat( default=0.02, description="The standard deviation of the truncated_normal_initializer for initializing all weight matrices.", parameter_metadata=ENCODER_METADATA["ALBERT"]["initializer_range"], ) layer_norm_eps: float = schema_utils.NonNegativeFloat( default=1e-12, description="The epsilon used by the layer normalization layers.", parameter_metadata=ENCODER_METADATA["ALBERT"]["layer_norm_eps"], ) classifier_dropout_prob: float = schema_utils.FloatRange( default=0.1, min=0, max=1, description="The dropout ratio for attached classifiers.", parameter_metadata=ENCODER_METADATA["ALBERT"]["classifier_dropout_prob"], ) position_embedding_type: str = schema_utils.StringOptions( ["absolute", "relative_key", "relative_key_query"], default="absolute", description="", parameter_metadata=ENCODER_METADATA["ALBERT"]["position_embedding_type"], ) pad_token_id: int = schema_utils.Integer( default=0, description="The ID of the token to use as padding.", parameter_metadata=ENCODER_METADATA["ALBERT"]["pad_token_id"], ) bos_token_id: int = schema_utils.Integer( default=2, description="The beginning of sequence token ID.", parameter_metadata=ENCODER_METADATA["ALBERT"]["bos_token_id"], ) eos_token_id: int = schema_utils.Integer( default=3, description="The end of sequence token ID.", parameter_metadata=ENCODER_METADATA["ALBERT"]["eos_token_id"], ) pretrained_kwargs: dict = schema_utils.Dict( default=None, description="Additional kwargs to pass to the pretrained model.", parameter_metadata=ENCODER_METADATA["ALBERT"]["pretrained_kwargs"], ) # TODO: uncomment when sentencepiece doesn't cause segfaults: https://github.com/ludwig-ai/ludwig/issues/2983 @DeveloperAPI # @register_encoder_config("mt5", TEXT) @ludwig_dataclass class MT5Config(HFEncoderConfig): """This dataclass configures the schema used for an MT5 encoder.""" @staticmethod def module_name(): return "MT5" type: str = schema_utils.ProtectedString( "mt5", description=ENCODER_METADATA["MT5"]["type"].long_description, ) max_sequence_length: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Maximum length of the input sequence.", parameter_metadata=ENCODER_METADATA["MT5"]["max_sequence_length"], ) use_pretrained: bool = schema_utils.Boolean( default=True, description="Whether to use the pretrained weights for the model. If false, the model will train from " "scratch which is very computationally expensive.", parameter_metadata=ENCODER_METADATA["MT5"]["use_pretrained"], ) pretrained_model_name_or_path: str = schema_utils.String( default="google/mt5-base", description="Name or path of the pretrained model.", parameter_metadata=ENCODER_METADATA["MT5"]["pretrained_model_name_or_path"], ) saved_weights_in_checkpoint: bool = schema_utils.Boolean( default=False, description="Are the pretrained encoder weights saved in this model's checkpoint? Automatically set to" "True for trained models to prevent loading pretrained encoder weights from model hub.", parameter_metadata=ENCODER_METADATA["MT5"]["saved_weights_in_checkpoint"], ) trainable: bool = schema_utils.Boolean( default=False, description="Whether to finetune the model on your dataset.", parameter_metadata=ENCODER_METADATA["MT5"]["trainable"], ) adapter: BaseAdapterConfig | None = AdapterDataclassField() reduce_output: str = schema_utils.String( default="sum", description="The method used to reduce a sequence of tensors down to a single tensor.", parameter_metadata=ENCODER_METADATA["MT5"]["reduce_output"], ) vocab: list = schema_utils.List( default=None, description="Vocabulary for the encoder", parameter_metadata=ENCODER_METADATA["MT5"]["vocab"], ) vocab_size: int = schema_utils.PositiveInteger( default=250112, description="Vocabulary size of the T5 model. Defines the number of different tokens that can be represented " "by the inputs_ids passed when calling T5Model or TFT5Model.", parameter_metadata=ENCODER_METADATA["MT5"]["vocab_size"], ) d_model: int = schema_utils.PositiveInteger( default=512, description="Size of the encoder layers and the pooler layer.", parameter_metadata=ENCODER_METADATA["MT5"]["d_model"], ) d_kv: int = schema_utils.PositiveInteger( default=64, description="Size of the key, query, value projections per attention head. d_kv has to be equal to d_model // " "num_heads.", parameter_metadata=ENCODER_METADATA["MT5"]["d_kv"], ) d_ff: int = schema_utils.PositiveInteger( default=1024, description="Size of the intermediate feed forward layer in each T5Block.", parameter_metadata=ENCODER_METADATA["MT5"]["d_ff"], ) num_layers: int = schema_utils.PositiveInteger( default=8, description="Number of hidden layers in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["MT5"]["num_layers"], ) num_decoder_layers: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Number of hidden layers in the Transformer decoder. Will use the same value as num_layers if not " "set.", parameter_metadata=ENCODER_METADATA["MT5"]["num_decoder_layers"], ) num_heads: int = schema_utils.PositiveInteger( default=6, description="Number of attention heads for each attention layer in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["MT5"]["num_heads"], ) relative_attention_num_buckets: int = schema_utils.PositiveInteger( default=32, description="The number of buckets to use for each attention layer.", parameter_metadata=ENCODER_METADATA["MT5"]["relative_attention_num_buckets"], ) dropout_rate: float = schema_utils.FloatRange( default=0.1, min=0, max=1, description="The ratio for all dropout layers.", parameter_metadata=ENCODER_METADATA["MT5"]["dropout_rate"], ) layer_norm_epsilon: float = schema_utils.NonNegativeFloat( default=1e-06, description="The epsilon used by the layer normalization layers.", parameter_metadata=ENCODER_METADATA["MT5"]["layer_norm_epsilon"], ) initializer_factor: float = schema_utils.NonNegativeFloat( default=1.0, description="A factor for initializing all weight matrices (should be kept to 1, used internally for " "initialization testing)", parameter_metadata=ENCODER_METADATA["MT5"]["initializer_factor"], ) feed_forward_proj: str = schema_utils.StringOptions( ["relu", "gated-gelu"], default="gated-gelu", description="Type of feed forward layer to be used. ", parameter_metadata=ENCODER_METADATA["MT5"]["feed_forward_proj"], ) is_encoder_decoder: bool = schema_utils.Boolean( default=True, description="", parameter_metadata=ENCODER_METADATA["MT5"]["is_encoder_decoder"], ) use_cache: bool = schema_utils.Boolean( default=True, description="", parameter_metadata=ENCODER_METADATA["MT5"]["use_cache"], ) tokenizer_class: str = schema_utils.String( default="T5Tokenizer", description="", parameter_metadata=ENCODER_METADATA["MT5"]["tokenizer_class"], ) tie_word_embeddings: bool = schema_utils.Boolean( default=False, description="Whether the model's input and output word embeddings should be tied.", parameter_metadata=ENCODER_METADATA["MT5"]["tie_word_embeddings"], ) pad_token_id: int = schema_utils.Integer( default=0, description="The ID of the token to use as padding.", parameter_metadata=ENCODER_METADATA["MT5"]["pad_token_id"], ) eos_token_id: int = schema_utils.Integer( default=1, description="The end of sequence token ID.", parameter_metadata=ENCODER_METADATA["MT5"]["eos_token_id"], ) decoder_start_token_id: int = schema_utils.Integer( default=0, description="If an encoder-decoder model starts decoding with a different token than _bos_, the id of that " "token.", parameter_metadata=ENCODER_METADATA["MT5"]["decoder_start_token_id"], ) pretrained_kwargs: dict = schema_utils.Dict( default=None, description="Additional kwargs to pass to the pretrained model.", parameter_metadata=ENCODER_METADATA["MT5"]["pretrained_kwargs"], ) @DeveloperAPI @register_encoder_config("xlmroberta", TEXT) @ludwig_dataclass class XLMRoBERTaConfig(HFEncoderConfig): """This dataclass configures the schema used for an XLMRoBERTa encoder.""" @staticmethod def module_name(): return "XLMRoBERTa" type: str = schema_utils.ProtectedString( "xlmroberta", description=ENCODER_METADATA["XLMRoBERTa"]["type"].long_description, ) max_sequence_length: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Maximum length of the input sequence.", parameter_metadata=ENCODER_METADATA["XLMRoBERTa"]["max_sequence_length"], ) use_pretrained: bool = schema_utils.Boolean( default=True, description="Whether to use the pretrained weights for the model. If false, the model will train from " "scratch which is very computationally expensive.", parameter_metadata=ENCODER_METADATA["XLMRoBERTa"]["use_pretrained"], ) pretrained_model_name_or_path: str = schema_utils.String( default="xlm-roberta-base", description="Name or path of the pretrained model.", parameter_metadata=ENCODER_METADATA["XLMRoBERTa"]["pretrained_model_name_or_path"], ) saved_weights_in_checkpoint: bool = schema_utils.Boolean( default=False, description="Are the pretrained encoder weights saved in this model's checkpoint? Automatically set to" "True for trained models to prevent loading pretrained encoder weights from model hub.", parameter_metadata=ENCODER_METADATA["XLMRoBERTa"]["saved_weights_in_checkpoint"], ) reduce_output: str = schema_utils.String( default="cls_pooled", description="The method used to reduce a sequence of tensors down to a single tensor.", parameter_metadata=ENCODER_METADATA["XLMRoBERTa"]["reduce_output"], ) trainable: bool = schema_utils.Boolean( default=False, description="Whether to finetune the model on your dataset.", parameter_metadata=ENCODER_METADATA["XLMRoBERTa"]["trainable"], ) adapter: BaseAdapterConfig | None = AdapterDataclassField() vocab: list = schema_utils.List( default=None, description="Vocabulary for the encoder", parameter_metadata=ENCODER_METADATA["XLMRoBERTa"]["vocab"], ) vocab_size: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Vocabulary size of the XLMRoBERTa model.", parameter_metadata=ENCODER_METADATA["XLMRoBERTa"]["vocab_size"], ) pad_token_id: int = schema_utils.Integer( default=1, description="The ID of the token to use as padding.", parameter_metadata=ENCODER_METADATA["XLMRoBERTa"]["pad_token_id"], ) bos_token_id: int = schema_utils.Integer( default=0, description="The beginning of sequence token ID.", parameter_metadata=ENCODER_METADATA["XLMRoBERTa"]["bos_token_id"], ) eos_token_id: int = schema_utils.Integer( default=2, description="The end of sequence token ID.", parameter_metadata=ENCODER_METADATA["XLMRoBERTa"]["eos_token_id"], ) max_position_embeddings: int = schema_utils.PositiveInteger( default=514, description="The maximum sequence length that this model might ever be used with. Typically set this to " "something large just in case (e.g., 512 or 1024 or 2048).", parameter_metadata=ENCODER_METADATA["XLMRoBERTa"]["max_position_embeddings"], ) type_vocab_size: int = schema_utils.PositiveInteger( default=1, description="The vocabulary size of the token_type_ids passed in.", parameter_metadata=ENCODER_METADATA["XLMRoBERTa"]["type_vocab_size"], ) add_pooling_layer: bool = schema_utils.Boolean( default=True, description="Whether to add a pooling layer to the encoder.", parameter_metadata=ENCODER_METADATA["XLMRoBERTa"]["add_pooling_layer"], ) pretrained_kwargs: dict = schema_utils.Dict( default=None, description="Additional kwargs to pass to the pretrained model.", parameter_metadata=ENCODER_METADATA["XLMRoBERTa"]["pretrained_kwargs"], ) @DeveloperAPI @register_encoder_config("bert", TEXT) @ludwig_dataclass class BERTConfig(HFEncoderConfig): """This dataclass configures the schema used for an BERT encoder.""" @staticmethod def module_name(): return "BERT" type: str = schema_utils.ProtectedString( "bert", description=ENCODER_METADATA["BERT"]["type"].long_description, ) max_sequence_length: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Maximum length of the input sequence.", parameter_metadata=ENCODER_METADATA["BERT"]["max_sequence_length"], ) use_pretrained: bool = schema_utils.Boolean( default=True, description="Whether to use the pretrained weights for the model. If false, the model will train from " "scratch which is very computationally expensive.", parameter_metadata=ENCODER_METADATA["BERT"]["use_pretrained"], ) pretrained_model_name_or_path: str = schema_utils.String( default="bert-base-uncased", description="Name or path of the pretrained model.", parameter_metadata=ENCODER_METADATA["BERT"]["pretrained_model_name_or_path"], ) saved_weights_in_checkpoint: bool = schema_utils.Boolean( default=False, description="Are the pretrained encoder weights saved in this model's checkpoint? Automatically set to" "True for trained models to prevent loading pretrained encoder weights from model hub.", parameter_metadata=ENCODER_METADATA["BERT"]["saved_weights_in_checkpoint"], ) trainable: bool = schema_utils.Boolean( default=False, description="Whether to finetune the model on your dataset.", parameter_metadata=ENCODER_METADATA["BERT"]["trainable"], ) adapter: BaseAdapterConfig | None = AdapterDataclassField() reduce_output: str = schema_utils.String( default="cls_pooled", description="The method used to reduce a sequence of tensors down to a single tensor.", parameter_metadata=ENCODER_METADATA["BERT"]["reduce_output"], ) vocab: list = schema_utils.List( default=None, description="Vocabulary for the encoder", parameter_metadata=ENCODER_METADATA["BERT"]["vocab"], ) vocab_size: int = schema_utils.PositiveInteger( default=30522, description="Vocabulary size of the BERT model. Defines the number of different tokens that can be " "represented by the inputs_ids passed when calling BertModel or TFBertModel.", parameter_metadata=ENCODER_METADATA["BERT"]["vocab_size"], ) hidden_size: int = schema_utils.PositiveInteger( default=768, description="Dimensionality of the encoder layers and the pooler layer.", parameter_metadata=ENCODER_METADATA["BERT"]["hidden_size"], ) num_hidden_layers: int = schema_utils.PositiveInteger( default=12, description="Number of hidden layers in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["BERT"]["num_hidden_layers"], ) num_attention_heads: int = schema_utils.PositiveInteger( default=12, description="Number of attention heads for each attention layer in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["BERT"]["num_attention_heads"], ) intermediate_size: int = schema_utils.PositiveInteger( default=3072, description="Dimensionality of the “intermediate” (often named feed-forward) layer in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["BERT"]["intermediate_size"], ) hidden_act: str | Callable = schema_utils.StringOptions( # TODO: add support for callable ["gelu", "relu", "silu", "gelu_new"], default="gelu", description="The non-linear activation function (function or string) in the encoder and pooler.", parameter_metadata=ENCODER_METADATA["BERT"]["hidden_act"], ) hidden_dropout_prob: float = schema_utils.FloatRange( default=0.1, min=0, max=1, description="The dropout probability for all fully connected layers in the embeddings, encoder, and pooler.", parameter_metadata=ENCODER_METADATA["BERT"]["hidden_dropout_prob"], ) attention_probs_dropout_prob: float = schema_utils.FloatRange( default=0.1, min=0, max=1, description="The dropout ratio for the attention probabilities.", parameter_metadata=ENCODER_METADATA["BERT"]["attention_probs_dropout_prob"], ) max_position_embeddings: int = schema_utils.PositiveInteger( default=512, description="The maximum sequence length that this model might ever be used with. Typically set this to " "something large just in case (e.g., 512 or 1024 or 2048).", parameter_metadata=ENCODER_METADATA["BERT"]["max_position_embeddings"], ) type_vocab_size: int = schema_utils.PositiveInteger( default=2, description="The vocabulary size of the token_type_ids passed when calling BertModel or TFBertModel.", parameter_metadata=ENCODER_METADATA["BERT"]["type_vocab_size"], ) initializer_range: float = schema_utils.NonNegativeFloat( default=0.02, description="The standard deviation of the truncated_normal_initializer for initializing all weight matrices.", parameter_metadata=ENCODER_METADATA["BERT"]["initializer_range"], ) layer_norm_eps: float = schema_utils.NonNegativeFloat( default=1e-12, description="The epsilon used by the layer normalization layers.", parameter_metadata=ENCODER_METADATA["BERT"]["layer_norm_eps"], ) pad_token_id: int = schema_utils.Integer( default=0, description="The ID of the token to use as padding.", parameter_metadata=ENCODER_METADATA["BERT"]["pad_token_id"], ) gradient_checkpointing: bool = schema_utils.Boolean( default=False, description="Whether to use gradient checkpointing.", parameter_metadata=ENCODER_METADATA["BERT"]["gradient_checkpointing"], ) position_embedding_type: str = schema_utils.StringOptions( ["absolute", "relative_key", "relative_key_query"], default="absolute", description="Type of position embedding.", parameter_metadata=ENCODER_METADATA["BERT"]["position_embedding_type"], ) classifier_dropout: float = schema_utils.FloatRange( default=None, allow_none=True, min=0, max=1, description="The dropout ratio for the classification head.", parameter_metadata=ENCODER_METADATA["BERT"]["classifier_dropout"], ) pretrained_kwargs: dict = schema_utils.Dict( default=None, description="Additional kwargs to pass to the pretrained model.", parameter_metadata=ENCODER_METADATA["BERT"]["pretrained_kwargs"], ) @DeveloperAPI @register_encoder_config("deberta", TEXT) @ludwig_dataclass class DebertaV2Config(HFEncoderImplConfig, DebertaModelParams): """This dataclass configures the schema used for a DeBERTa-v2 / v3 encoder.""" @staticmethod def module_name(): return "DeBERTa" type: str = schema_utils.ProtectedString( "deberta", description=ENCODER_METADATA["DeBERTa"]["type"].long_description, ) pretrained_model_name_or_path: str = schema_utils.String( default="tasksource/deberta-base-long-nli", description="Name or path of the pretrained model.", parameter_metadata=ENCODER_METADATA["DeBERTa"]["pretrained_model_name_or_path"], ) reduce_output: str = schema_utils.StringOptions( ["cls_pooled", "last", "sum", "mean", "max", "concat", "attention"], default="sum", allow_none=True, description="The method used to reduce a sequence of tensors down to a single tensor.", ) # TODO: uncomment once we figure out host memory issue: https://github.com/ludwig-ai/ludwig/issues/3107 @DeveloperAPI # @register_encoder_config("xlm", TEXT) @ludwig_dataclass class XLMConfig(HFEncoderConfig): """This dataclass configures the schema used for an XLM encoder.""" @staticmethod def module_name(): return "XLM" type: str = schema_utils.ProtectedString( "xlm", description=ENCODER_METADATA["XLM"]["type"].long_description, ) max_sequence_length: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Maximum length of the input sequence.", parameter_metadata=ENCODER_METADATA["XLM"]["max_sequence_length"], ) use_pretrained: bool = schema_utils.Boolean( default=True, description="Whether to use the pretrained weights for the model. If false, the model will train from " "scratch which is very computationally expensive.", parameter_metadata=ENCODER_METADATA["XLM"]["use_pretrained"], ) pretrained_model_name_or_path: str = schema_utils.String( default="xlm-mlm-en-2048", description="Name or path of the pretrained model.", parameter_metadata=ENCODER_METADATA["XLM"]["pretrained_model_name_or_path"], ) saved_weights_in_checkpoint: bool = schema_utils.Boolean( default=False, description="Are the pretrained encoder weights saved in this model's checkpoint? Automatically set to" "True for trained models to prevent loading pretrained encoder weights from model hub.", parameter_metadata=ENCODER_METADATA["XLM"]["saved_weights_in_checkpoint"], ) trainable: bool = schema_utils.Boolean( default=False, description="Whether to finetune the model on your dataset.", parameter_metadata=ENCODER_METADATA["XLM"]["trainable"], ) adapter: BaseAdapterConfig | None = AdapterDataclassField() reduce_output: str = schema_utils.String( default="sum", description="The method used to reduce a sequence of tensors down to a single tensor.", parameter_metadata=ENCODER_METADATA["XLM"]["reduce_output"], ) vocab: list = schema_utils.List( default=None, description="Vocabulary for the encoder", parameter_metadata=ENCODER_METADATA["XLM"]["vocab"], ) vocab_size: int = schema_utils.PositiveInteger( default=30145, description="Vocabulary size of the BERT model. Defines the number of different tokens that can be " "represented by the inputs_ids passed when calling XLMModel or TFXLMModel.", parameter_metadata=ENCODER_METADATA["XLM"]["vocab_size"], ) emb_dim: int = schema_utils.PositiveInteger( default=2048, description="Dimensionality of the encoder layers and the pooler layer.", parameter_metadata=ENCODER_METADATA["XLM"]["emb_dim"], ) n_layers: int = schema_utils.PositiveInteger( default=12, description="Number of hidden layers in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["XLM"]["n_layers"], ) n_heads: int = schema_utils.PositiveInteger( default=16, description="Number of attention heads for each attention layer in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["XLM"]["n_heads"], ) dropout: float = schema_utils.FloatRange( default=0.1, min=0, max=1, description="The dropout probability for all fully connected layers in the embeddings, encoder, and pooler.", parameter_metadata=ENCODER_METADATA["XLM"]["dropout"], ) attention_dropout: float = schema_utils.FloatRange( default=0.1, min=0, max=1, description="The dropout probability for the attention mechanism.", parameter_metadata=ENCODER_METADATA["XLM"]["attention_dropout"], ) gelu_activation: bool = schema_utils.Boolean( default=True, description="Whether or not to use gelu for the activations instead of relu.", parameter_metadata=ENCODER_METADATA["XLM"]["gelu_activation"], ) sinusoidal_embeddings: bool = schema_utils.Boolean( default=False, description="Whether or not to use sinusoidal positional embeddings instead of absolute positional embeddings.", parameter_metadata=ENCODER_METADATA["XLM"]["sinusoidal_embeddings"], ) causal: bool = schema_utils.Boolean( default=False, description="Whether or not the model should behave in a causal manner. Causal models use a triangular " "attention mask in order to only attend to the left-side context instead if a bidirectional " "context.", parameter_metadata=ENCODER_METADATA["XLM"]["causal"], ) asm: bool = schema_utils.Boolean( default=False, description="Whether or not to use an adaptive log softmax projection layer instead of a linear layer for the " "prediction layer.", parameter_metadata=ENCODER_METADATA["XLM"]["asm"], ) n_langs: int = schema_utils.PositiveInteger( default=1, description="The number of languages the model handles. Set to 1 for monolingual models.", parameter_metadata=ENCODER_METADATA["XLM"]["n_langs"], ) use_lang_emb: bool = schema_utils.Boolean( default=True, description="Whether to use language embeddings. Some models use additional language embeddings, " "see the multilingual models page for information on how to use them.", parameter_metadata=ENCODER_METADATA["XLM"]["use_lang_emb"], ) max_position_embeddings: int = schema_utils.PositiveInteger( default=512, description="The maximum sequence length that this model might ever be used with. Typically set this to " "something large just in case (e.g., 512 or 1024 or 2048).", parameter_metadata=ENCODER_METADATA["XLM"]["max_position_embeddings"], ) embed_init_std: float = schema_utils.NonNegativeFloat( default=2048**-0.5, description="The standard deviation of the truncated_normal_initializer for initializing the embedding " "matrices.", parameter_metadata=ENCODER_METADATA["XLM"]["embed_init_std"], ) layer_norm_eps: float = schema_utils.NonNegativeFloat( default=1e-12, description="The epsilon used by the layer normalization layers.", parameter_metadata=ENCODER_METADATA["XLM"]["layer_norm_eps"], ) init_std: float = schema_utils.NonNegativeFloat( default=0.02, description="The standard deviation of the truncated_normal_initializer for initializing all weight matrices " "except the embedding matrices.", parameter_metadata=ENCODER_METADATA["XLM"]["init_std"], ) bos_index: int = schema_utils.NonNegativeInteger( default=0, description="The index of the beginning of sentence token in the vocabulary.", parameter_metadata=ENCODER_METADATA["XLM"]["bos_index"], ) eos_index: int = schema_utils.NonNegativeInteger( default=1, description="The index of the end of sentence token in the vocabulary.", parameter_metadata=ENCODER_METADATA["XLM"]["eos_index"], ) pad_index: int = schema_utils.NonNegativeInteger( default=2, description="The index of the padding token in the vocabulary.", parameter_metadata=ENCODER_METADATA["XLM"]["pad_index"], ) unk_index: int = schema_utils.NonNegativeInteger( default=3, description="The index of the unknown token in the vocabulary.", parameter_metadata=ENCODER_METADATA["XLM"]["unk_index"], ) mask_index: int = schema_utils.NonNegativeInteger( default=5, description="The index of the masking token in the vocabulary.", parameter_metadata=ENCODER_METADATA["XLM"]["mask_index"], ) is_encoder: bool = schema_utils.Boolean( default=True, description="Whether or not the initialized model should be a transformer encoder or decoder as seen in " "Vaswani et al.", parameter_metadata=ENCODER_METADATA["XLM"]["is_encoder"], ) start_n_top: int = schema_utils.PositiveInteger( default=5, description="Used in the SQuAD evaluation script.", parameter_metadata=ENCODER_METADATA["XLM"]["start_n_top"], ) end_n_top: int = schema_utils.PositiveInteger( default=5, description="Used in the SQuAD evaluation script.", parameter_metadata=ENCODER_METADATA["XLM"]["end_n_top"], ) mask_token_id: int = schema_utils.Integer( default=0, description="Model agnostic parameter to identify masked tokens when generating text in an MLM context.", parameter_metadata=ENCODER_METADATA["XLM"]["mask_token_id"], ) lang_id: int = schema_utils.Integer( default=0, description="The ID of the language used by the model. This parameter is used when generating text in a given " "language.", parameter_metadata=ENCODER_METADATA["XLM"]["lang_id"], ) pad_token_id: int = schema_utils.Integer( default=2, description="The ID of the token to use as padding.", parameter_metadata=ENCODER_METADATA["XLM"]["pad_token_id"], ) bos_token_id: int = schema_utils.Integer( default=0, description="The beginning of sequence token ID.", parameter_metadata=ENCODER_METADATA["XLM"]["bos_token_id"], ) pretrained_kwargs: dict = schema_utils.Dict( default=None, description="Additional kwargs to pass to the pretrained model.", parameter_metadata=ENCODER_METADATA["XLM"]["pretrained_kwargs"], ) @DeveloperAPI @register_encoder_config("gpt", TEXT) @ludwig_dataclass class GPTConfig(HFEncoderConfig): """This dataclass configures the schema used for an GPT encoder.""" @staticmethod def module_name(): return "GPT" type: str = schema_utils.ProtectedString( "gpt", description=ENCODER_METADATA["GPT"]["type"].long_description, ) max_sequence_length: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Maximum length of the input sequence.", parameter_metadata=ENCODER_METADATA["GPT"]["max_sequence_length"], ) reduce_output: str = schema_utils.String( default="sum", description="The method used to reduce a sequence of tensors down to a single tensor.", parameter_metadata=ENCODER_METADATA["GPT"]["reduce_output"], ) use_pretrained: bool = schema_utils.Boolean( default=True, description="Whether to use the pretrained weights for the model. If false, the model will train from " "scratch which is very computationally expensive.", parameter_metadata=ENCODER_METADATA["GPT"]["use_pretrained"], ) pretrained_model_name_or_path: str = schema_utils.String( default="openai-gpt", description="Name or path of the pretrained model.", parameter_metadata=ENCODER_METADATA["GPT"]["pretrained_model_name_or_path"], ) saved_weights_in_checkpoint: bool = schema_utils.Boolean( default=False, description="Are the pretrained encoder weights saved in this model's checkpoint? Automatically set to" "True for trained models to prevent loading pretrained encoder weights from model hub.", parameter_metadata=ENCODER_METADATA["GPT"]["saved_weights_in_checkpoint"], ) trainable: bool = schema_utils.Boolean( default=False, description="Whether to finetune the model on your dataset.", parameter_metadata=ENCODER_METADATA["GPT"]["trainable"], ) adapter: BaseAdapterConfig | None = AdapterDataclassField() vocab: list = schema_utils.List( default=None, description="Vocabulary for the encoder", parameter_metadata=ENCODER_METADATA["GPT"]["vocab"], ) vocab_size: int = schema_utils.PositiveInteger( default=30522, description="Vocabulary size of the GPT model. Defines the number of different tokens that can be " "represented by the inputs_ids passed when calling OpenAIGPTModel or TFOpenAIGPTModel.", parameter_metadata=ENCODER_METADATA["GPT"]["vocab_size"], ) n_positions: int = schema_utils.PositiveInteger( default=40478, description="The maximum sequence length that this model might ever be used with. Typically set this to " "something large just in case (e.g., 512 or 1024 or 2048).", parameter_metadata=ENCODER_METADATA["GPT"]["n_positions"], ) n_ctx: int = schema_utils.PositiveInteger( default=512, description="Dimensionality of the causal mask (usually same as n_positions)", parameter_metadata=ENCODER_METADATA["GPT"]["n_ctx"], ) n_embd: int = schema_utils.PositiveInteger( default=768, description="Dimensionality of the embeddings and hidden states.", parameter_metadata=ENCODER_METADATA["GPT"]["n_embd"], ) n_layer: int = schema_utils.PositiveInteger( default=12, description="Number of hidden layers in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["GPT"]["n_layer"], ) n_head: int = schema_utils.PositiveInteger( default=12, description="Number of attention heads for each attention layer in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["GPT"]["n_head"], ) afn: str = schema_utils.StringOptions( ["gelu", "relu", "silu"], # gelu_new results in a KeyError. default="gelu", description="The non-linear activation function (function or string) in the encoder and pooler.", parameter_metadata=ENCODER_METADATA["GPT"]["afn"], ) resid_pdrop: float = schema_utils.FloatRange( default=0.1, description="The dropout probability for all fully connected layers in the embeddings, encoder, and pooler.", parameter_metadata=ENCODER_METADATA["GPT"]["resid_pdrop"], ) embd_pdrop: float = schema_utils.FloatRange( default=0.1, description="The dropout ratio for the embeddings.", parameter_metadata=ENCODER_METADATA["GPT"]["embd_pdrop"], ) attn_pdrop: float = schema_utils.FloatRange( default=0.1, description="The dropout ratio for the attention.", parameter_metadata=ENCODER_METADATA["GPT"]["attn_pdrop"], ) layer_norm_epsilon: float = schema_utils.NonNegativeFloat( default=1e-5, description="The epsilon to use in the layer normalization layers", parameter_metadata=ENCODER_METADATA["GPT"]["layer_norm_epsilon"], ) initializer_range: float = schema_utils.NonNegativeFloat( default=0.02, description="The standard deviation of the truncated_normal_initializer for initializing all weight matrices.", parameter_metadata=ENCODER_METADATA["GPT"]["initializer_range"], ) pretrained_kwargs: dict = schema_utils.Dict( default=None, description="Additional kwargs to pass to the pretrained model.", parameter_metadata=ENCODER_METADATA["GPT"]["pretrained_kwargs"], ) @DeveloperAPI @register_encoder_config("gpt2", TEXT) @ludwig_dataclass class GPT2Config(HFEncoderConfig): """This dataclass configures the schema used for an GPT2 encoder.""" @staticmethod def module_name(): return "GPT2" type: str = schema_utils.ProtectedString( "gpt2", description=ENCODER_METADATA["GPT2"]["type"].long_description, ) max_sequence_length: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Maximum length of the input sequence.", parameter_metadata=ENCODER_METADATA["GPT2"]["max_sequence_length"], ) use_pretrained: bool = schema_utils.Boolean( default=True, description="Whether to use the pretrained weights for the model. If false, the model will train from " "scratch which is very computationally expensive.", parameter_metadata=ENCODER_METADATA["GPT2"]["use_pretrained"], ) pretrained_model_name_or_path: str = schema_utils.String( default="gpt2", description="Name or path of the pretrained model.", parameter_metadata=ENCODER_METADATA["GPT2"]["pretrained_model_name_or_path"], ) reduce_output: str = schema_utils.String( default="sum", description="The method used to reduce a sequence of tensors down to a single tensor.", parameter_metadata=ENCODER_METADATA["GPT2"]["reduce_output"], ) trainable: bool = schema_utils.Boolean( default=False, description="Whether to finetune the model on your dataset.", parameter_metadata=ENCODER_METADATA["GPT2"]["trainable"], ) adapter: BaseAdapterConfig | None = AdapterDataclassField() vocab: list = schema_utils.List( default=None, description="Vocabulary for the encoder", parameter_metadata=ENCODER_METADATA["GPT2"]["vocab"], ) vocab_size: int = schema_utils.PositiveInteger( default=50257, description="Vocabulary size of the GPT-2 model. Defines the number of different tokens that can be " "represented by the inputs_ids passed when calling GPT2Model or TFGPT2Model.", parameter_metadata=ENCODER_METADATA["GPT2"]["vocab_size"], ) n_positions: int = schema_utils.PositiveInteger( default=1024, description="The maximum sequence length that this model might ever be used with. Typically set this to " "something large just in case (e.g., 512 or 1024 or 2048).", parameter_metadata=ENCODER_METADATA["GPT2"]["n_positions"], ) n_ctx: int = schema_utils.PositiveInteger( default=1024, description="Dimensionality of the causal mask (usually same as n_positions)", parameter_metadata=ENCODER_METADATA["GPT2"]["n_ctx"], ) n_embd: int = schema_utils.PositiveInteger( default=768, description="Dimensionality of the embeddings and hidden states.", parameter_metadata=ENCODER_METADATA["GPT2"]["n_embd"], ) n_layer: int = schema_utils.PositiveInteger( default=12, description="Number of hidden layers in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["GPT2"]["n_layer"], ) n_head: int = schema_utils.PositiveInteger( default=12, description="Number of attention heads for each attention layer in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["GPT2"]["n_head"], ) n_inner: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Dimensionality of the inner feed-forward layers. None will set it to 4 times n_embd", parameter_metadata=ENCODER_METADATA["GPT2"]["n_inner"], ) activation_function: str = schema_utils.StringOptions( ["relu", "silu", "gelu", "tanh", "gelu_new"], default="gelu_new", description="Activation function, to be selected in the list ['relu', 'silu', 'gelu', 'tanh', 'gelu_new'].", parameter_metadata=ENCODER_METADATA["GPT2"]["activation_function"], ) resid_pdrop: float = schema_utils.FloatRange( default=0.1, min=0, max=1, description="The dropout probability for all fully connected layers in the embeddings, encoder, and pooler.", parameter_metadata=ENCODER_METADATA["GPT2"]["resid_pdrop"], ) embd_pdrop: float = schema_utils.FloatRange( default=0.1, min=0, max=1, description="The dropout ratio for the embeddings.", parameter_metadata=ENCODER_METADATA["GPT2"]["embd_pdrop"], ) attn_pdrop: float = schema_utils.FloatRange( default=0.1, min=0, max=1, description="The dropout ratio for the attention.", parameter_metadata=ENCODER_METADATA["GPT2"]["attn_pdrop"], ) layer_norm_epsilon: float = schema_utils.NonNegativeFloat( default=1e-5, description="The epsilon to use in the layer normalization layers.", parameter_metadata=ENCODER_METADATA["GPT2"]["layer_norm_epsilon"], ) initializer_range: float = schema_utils.NonNegativeFloat( default=0.02, description="The standard deviation of the truncated_normal_initializer for initializing all weight matrices.", parameter_metadata=ENCODER_METADATA["GPT2"]["initializer_range"], ) scale_attn_weights: bool = schema_utils.Boolean( default=True, description="Scale attention weights by dividing by sqrt(hidden_size).", parameter_metadata=ENCODER_METADATA["GPT2"]["scale_attn_weights"], ) pretrained_kwargs: dict = schema_utils.Dict( default=None, description="Additional kwargs to pass to the pretrained model.", parameter_metadata=ENCODER_METADATA["GPT2"]["pretrained_kwargs"], ) @DeveloperAPI @register_encoder_config("roberta", TEXT) @ludwig_dataclass class RoBERTaConfig(HFEncoderConfig): """This dataclass configures the schema used for an RoBERTa encoder.""" @staticmethod def module_name(): return "RoBERTa" type: str = schema_utils.ProtectedString( "roberta", description=ENCODER_METADATA["RoBERTa"]["type"].long_description, ) max_sequence_length: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Maximum length of the input sequence.", parameter_metadata=ENCODER_METADATA["RoBERTa"]["max_sequence_length"], ) use_pretrained: bool = schema_utils.Boolean( default=True, description="Whether to use the pretrained weights for the model. If false, the model will train from " "scratch which is very computationally expensive.", parameter_metadata=ENCODER_METADATA["RoBERTa"]["use_pretrained"], ) pretrained_model_name_or_path: str = schema_utils.String( default="roberta-base", description="Name or path of the pretrained model.", parameter_metadata=ENCODER_METADATA["RoBERTa"]["pretrained_model_name_or_path"], ) saved_weights_in_checkpoint: bool = schema_utils.Boolean( default=False, description="Are the pretrained encoder weights saved in this model's checkpoint? Automatically set to" "True for trained models to prevent loading pretrained encoder weights from model hub.", parameter_metadata=ENCODER_METADATA["RoBERTa"]["saved_weights_in_checkpoint"], ) reduce_output: str = schema_utils.String( default="cls_pooled", description="The method used to reduce a sequence of tensors down to a single tensor.", parameter_metadata=ENCODER_METADATA["RoBERTa"]["reduce_output"], ) trainable: bool = schema_utils.Boolean( default=False, description="Whether to finetune the model on your dataset.", parameter_metadata=ENCODER_METADATA["RoBERTa"]["trainable"], ) adapter: BaseAdapterConfig | None = AdapterDataclassField() vocab: list = schema_utils.List( default=None, description="Vocabulary for the encoder", parameter_metadata=ENCODER_METADATA["RoBERTa"]["vocab"], ) vocab_size: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Vocabulary size of the RoBERTa model.", parameter_metadata=ENCODER_METADATA["RoBERTa"]["vocab_size"], ) pad_token_id: int = schema_utils.Integer( default=1, description="The ID of the token to use as padding.", parameter_metadata=ENCODER_METADATA["RoBERTa"]["pad_token_id"], ) bos_token_id: int = schema_utils.Integer( default=0, description="The beginning of sequence token ID.", parameter_metadata=ENCODER_METADATA["RoBERTa"]["bos_token_id"], ) eos_token_id: int = schema_utils.Integer( default=2, description="The end of sequence token ID.", parameter_metadata=ENCODER_METADATA["RoBERTa"]["eos_token_id"], ) pretrained_kwargs: dict = schema_utils.Dict( default=None, description="Additional kwargs to pass to the pretrained model.", parameter_metadata=ENCODER_METADATA["RoBERTa"]["pretrained_kwargs"], ) @DeveloperAPI @register_encoder_config("transformer_xl", TEXT) @ludwig_dataclass class TransformerXLConfig(HFEncoderConfig): """This dataclass configures the schema used for an TransformerXL encoder.""" @staticmethod def module_name(): return "TransformerXL" type: str = schema_utils.ProtectedString( "transformer_xl", description=ENCODER_METADATA["TransformerXL"]["type"].long_description, ) max_sequence_length: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Maximum length of the input sequence.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["max_sequence_length"], ) use_pretrained: bool = schema_utils.Boolean( default=True, description="Whether to use the pretrained weights for the model. If false, the model will train from " "scratch which is very computationally expensive.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["use_pretrained"], ) pretrained_model_name_or_path: str = schema_utils.String( default="transfo-xl-wt103", description="Name or path of the pretrained model.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["pretrained_model_name_or_path"], ) saved_weights_in_checkpoint: bool = schema_utils.Boolean( default=False, description="Are the pretrained encoder weights saved in this model's checkpoint? Automatically set to" "True for trained models to prevent loading pretrained encoder weights from model hub.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["saved_weights_in_checkpoint"], ) reduce_output: str = schema_utils.String( default="sum", description="The method used to reduce a sequence of tensors down to a single tensor.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["reduce_output"], ) trainable: bool = schema_utils.Boolean( default=False, description="Whether to finetune the model on your dataset.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["trainable"], ) adapter: BaseAdapterConfig | None = AdapterDataclassField() vocab: list = schema_utils.List( default=None, description="Vocabulary for the encoder", parameter_metadata=ENCODER_METADATA["TransformerXL"]["vocab"], ) vocab_size: int = schema_utils.PositiveInteger( default=267735, description="Vocabulary size of the TransfoXL model. Defines the number of different tokens that can be " "represented by the inputs_ids passed when calling TransfoXLModel or TFTransfoXLModel.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["vocab_size"], ) cutoffs: list[int] = schema_utils.List( int, default=[20000, 40000, 200000], description="Cutoffs for the adaptive softmax.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["cutoffs"], ) d_model: int = schema_utils.PositiveInteger( default=1024, description="Dimensionality of the model’s hidden states.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["d_model"], ) d_embed: int = schema_utils.PositiveInteger( default=1024, description="Dimensionality of the embeddings", parameter_metadata=ENCODER_METADATA["TransformerXL"]["d_embed"], ) n_head: int = schema_utils.PositiveInteger( default=16, description="Number of attention heads for each attention layer in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["n_head"], ) d_head: int = schema_utils.PositiveInteger( default=64, description="Dimensionality of the model’s heads.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["d_head"], ) d_inner: int = schema_utils.PositiveInteger( default=4096, description=" Inner dimension in FF", parameter_metadata=ENCODER_METADATA["TransformerXL"]["d_inner"], ) div_val: int = schema_utils.PositiveInteger( default=4, description="Divident value for adapative input and softmax.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["div_val"], ) pre_lnorm: bool = schema_utils.Boolean( default=False, description="Whether or not to apply LayerNorm to the input instead of the output in the blocks.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["pre_lnorm"], ) n_layer: int = schema_utils.PositiveInteger( default=18, description="Number of hidden layers in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["n_layer"], ) mem_len: int = schema_utils.PositiveInteger( default=1600, description="Length of the retained previous heads.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["mem_len"], ) clamp_len: int = schema_utils.PositiveInteger( default=1000, description="Use the same pos embeddings after clamp_len.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["clamp_len"], ) same_length: bool = schema_utils.Boolean( default=True, description="Whether or not to use the same attn length for all tokens", parameter_metadata=ENCODER_METADATA["TransformerXL"]["same_length"], ) proj_share_all_but_first: bool = schema_utils.Boolean( default=True, description="True to share all but first projs, False not to share.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["proj_share_all_but_first"], ) attn_type: int = schema_utils.IntegerRange( default=0, min=0, max=3, description="Attention type. 0 for Transformer-XL, 1 for Shaw et al, 2 for Vaswani et al, 3 for Al Rfou et al.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["attn_type"], ) sample_softmax: int = schema_utils.Integer( default=-1, description="Number of samples in the sampled softmax.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["sample_softmax"], ) adaptive: bool = schema_utils.Boolean( default=True, description="Whether or not to use adaptive softmax.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["adaptive"], ) dropout: float = schema_utils.FloatRange( default=0.1, min=0, max=1, description="The dropout probability for all fully connected layers in the embeddings, encoder, and pooler.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["dropout"], ) dropatt: float = schema_utils.NonNegativeFloat( default=0.0, description="The dropout ratio for the attention probabilities.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["dropatt"], ) untie_r: bool = schema_utils.Boolean( default=True, description="Whether ot not to untie relative position biases.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["untie_r"], ) init: str = schema_utils.String( default="normal", description="Parameter initializer to use.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["init"], ) init_range: float = schema_utils.NonNegativeFloat( default=0.01, description="Parameters initialized by U(-init_range, init_range).", parameter_metadata=ENCODER_METADATA["TransformerXL"]["init_range"], ) proj_init_std: float = schema_utils.NonNegativeFloat( default=0.01, description="Parameters initialized by N(0, init_std)", parameter_metadata=ENCODER_METADATA["TransformerXL"]["proj_init_std"], ) init_std: float = schema_utils.NonNegativeFloat( default=0.02, description="Parameters initialized by N(0, init_std)", parameter_metadata=ENCODER_METADATA["TransformerXL"]["init_std"], ) layer_norm_epsilon: float = schema_utils.NonNegativeFloat( default=1e-5, description="The epsilon to use in the layer normalization layers", parameter_metadata=ENCODER_METADATA["TransformerXL"]["layer_norm_epsilon"], ) eos_token_id: int = schema_utils.Integer( default=0, description="The end of sequence token ID.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["eos_token_id"], ) pretrained_kwargs: dict = schema_utils.Dict( default=None, description="Additional kwargs to pass to the pretrained model.", parameter_metadata=ENCODER_METADATA["TransformerXL"]["pretrained_kwargs"], ) @DeveloperAPI @register_encoder_config("xlnet", TEXT) @ludwig_dataclass class XLNetConfig(HFEncoderConfig): """This dataclass configures the schema used for an XLNet encoder.""" @staticmethod def module_name(): return "XLNet" type: str = schema_utils.ProtectedString( "xlnet", description=ENCODER_METADATA["XLNet"]["type"].long_description, ) max_sequence_length: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Maximum length of the input sequence.", parameter_metadata=ENCODER_METADATA["XLNet"]["max_sequence_length"], ) use_pretrained: bool = schema_utils.Boolean( default=True, description="Whether to use the pretrained weights for the model. If false, the model will train from " "scratch which is very computationally expensive.", parameter_metadata=ENCODER_METADATA["XLNet"]["use_pretrained"], ) pretrained_model_name_or_path: str = schema_utils.String( default="xlnet-base-cased", description="Name or path of the pretrained model.", parameter_metadata=ENCODER_METADATA["XLNet"]["pretrained_model_name_or_path"], ) saved_weights_in_checkpoint: bool = schema_utils.Boolean( default=False, description="Are the pretrained encoder weights saved in this model's checkpoint? Automatically set to" "True for trained models to prevent loading pretrained encoder weights from model hub.", parameter_metadata=ENCODER_METADATA["XLNet"]["saved_weights_in_checkpoint"], ) reduce_output: str = schema_utils.String( default="sum", description="The method used to reduce a sequence of tensors down to a single tensor.", parameter_metadata=ENCODER_METADATA["XLNet"]["reduce_output"], ) trainable: bool = schema_utils.Boolean( default=False, description="Whether to finetune the model on your dataset.", parameter_metadata=ENCODER_METADATA["XLNet"]["trainable"], ) adapter: BaseAdapterConfig | None = AdapterDataclassField() vocab: list = schema_utils.List( default=None, description="Vocabulary for the encoder", parameter_metadata=ENCODER_METADATA["XLNet"]["vocab"], ) vocab_size: int = schema_utils.PositiveInteger( default=32000, description="Vocabulary size of the XLNet model. Defines the number of different tokens that can be " "represented by the inputs_ids passed when calling XLNetModel or TFXLNetModel.", parameter_metadata=ENCODER_METADATA["XLNet"]["vocab_size"], ) d_model: int = schema_utils.PositiveInteger( default=768, description="Dimensionality of the encoder layers and the pooler layer.", parameter_metadata=ENCODER_METADATA["XLNet"]["d_model"], ) n_layer: int = schema_utils.PositiveInteger( default=12, description="Number of hidden layers in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["XLNet"]["n_layer"], ) n_head: int = schema_utils.PositiveInteger( default=12, description="Number of attention heads for each attention layer in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["XLNet"]["n_head"], ) d_inner: int = schema_utils.PositiveInteger( default=3072, description="Dimensionality of the “intermediate” (often named feed-forward) layer in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["XLNet"]["d_inner"], ) ff_activation: str = schema_utils.StringOptions( ["gelu", "relu", "silu", "gelu_new"], default="gelu", description="The non-linear activation function (function or string) in the encoder and pooler. If string, " "'gelu', 'relu', 'silu' and 'gelu_new' are supported.", parameter_metadata=ENCODER_METADATA["XLNet"]["ff_activation"], ) untie_r: bool = schema_utils.Boolean( default=True, description="Whether or not to untie relative position biases", parameter_metadata=ENCODER_METADATA["XLNet"]["untie_r"], ) attn_type: str = schema_utils.StringOptions( ["bi"], default="bi", description="The attention type used by the model. Currently only 'bi' is supported.", parameter_metadata=ENCODER_METADATA["XLNet"]["attn_type"], ) initializer_range: float = schema_utils.NonNegativeFloat( default=0.02, description="The standard deviation of the truncated_normal_initializer for initializing all weight matrices.", parameter_metadata=ENCODER_METADATA["XLNet"]["initializer_range"], ) layer_norm_eps: float = schema_utils.NonNegativeFloat( default=1e-12, description="The epsilon used by the layer normalization layers.", parameter_metadata=ENCODER_METADATA["XLNet"]["layer_norm_eps"], ) dropout: float = schema_utils.FloatRange( default=0.1, description="The dropout probability for all fully connected layers in the embeddings, encoder, and pooler.", parameter_metadata=ENCODER_METADATA["XLNet"]["dropout"], ) mem_len: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="The number of tokens to cache. The key/value pairs that have already been pre-computed in a " "previous forward pass won’t be re-computed. ", parameter_metadata=ENCODER_METADATA["XLNet"]["mem_len"], ) reuse_len: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="The number of tokens in the current batch to be cached and reused in the future.", parameter_metadata=ENCODER_METADATA["XLNet"]["reuse_len"], ) use_mems_eval: bool = schema_utils.Boolean( default=True, description="Whether or not the model should make use of the recurrent memory mechanism in evaluation mode.", parameter_metadata=ENCODER_METADATA["XLNet"]["use_mems_eval"], ) use_mems_train: bool = schema_utils.Boolean( default=False, description="Whether or not the model should make use of the recurrent memory mechanism in train mode.", parameter_metadata=ENCODER_METADATA["XLNet"]["use_mems_train"], ) bi_data: bool = schema_utils.Boolean( default=False, description="Whether or not to use bidirectional input pipeline. Usually set to True during pretraining and " "False during finetuning.", parameter_metadata=ENCODER_METADATA["XLNet"]["bi_data"], ) clamp_len: int = schema_utils.Integer( default=-1, description="Clamp all relative distances larger than clamp_len. Setting this attribute to -1 means no " "clamping.", parameter_metadata=ENCODER_METADATA["XLNet"]["clamp_len"], ) same_length: bool = schema_utils.Boolean( default=False, description="Whether or not to use the same attention length for each token.", parameter_metadata=ENCODER_METADATA["XLNet"]["same_length"], ) summary_type: str = schema_utils.StringOptions( ["last", "first", "mean", "cls_index", "attn"], default="last", description="Argument used when doing sequence summary. Used in the sequence classification and multiple " "choice models.", parameter_metadata=ENCODER_METADATA["XLNet"]["summary_type"], ) summary_use_proj: bool = schema_utils.Boolean( default=True, description="", parameter_metadata=ENCODER_METADATA["XLNet"]["summary_use_proj"], ) summary_activation: str = schema_utils.String( default="tanh", description="Argument used when doing sequence summary. Used in the sequence classification and multiple " "choice models.", parameter_metadata=ENCODER_METADATA["XLNet"]["summary_activation"], ) summary_last_dropout: float = schema_utils.FloatRange( default=0.1, description="Used in the sequence classification and multiple choice models.", parameter_metadata=ENCODER_METADATA["XLNet"]["summary_last_dropout"], ) start_n_top: int = schema_utils.PositiveInteger( default=5, description="Used in the SQuAD evaluation script.", parameter_metadata=ENCODER_METADATA["XLNet"]["start_n_top"], ) end_n_top: int = schema_utils.PositiveInteger( default=5, description=" Used in the SQuAD evaluation script.", parameter_metadata=ENCODER_METADATA["XLNet"]["end_n_top"], ) pad_token_id: int = schema_utils.Integer( default=5, description="The ID of the token to use as padding.", parameter_metadata=ENCODER_METADATA["XLNet"]["pad_token_id"], ) bos_token_id: int = schema_utils.Integer( default=1, description="The beginning of sequence token ID.", parameter_metadata=ENCODER_METADATA["XLNet"]["bos_token_id"], ) eos_token_id: int = schema_utils.Integer( default=2, description="The end of sequence token ID.", parameter_metadata=ENCODER_METADATA["XLNet"]["eos_token_id"], ) pretrained_kwargs: dict = schema_utils.Dict( default=None, description="Additional kwargs to pass to the pretrained model.", parameter_metadata=ENCODER_METADATA["XLNet"]["pretrained_kwargs"], ) @DeveloperAPI @register_encoder_config("distilbert", TEXT) @ludwig_dataclass class DistilBERTConfig(HFEncoderConfig): """This dataclass configures the schema used for an DistilBERT encoder.""" @staticmethod def module_name(): return "DistilBERT" type: str = schema_utils.ProtectedString( "distilbert", description=ENCODER_METADATA["DistilBERT"]["type"].long_description, ) max_sequence_length: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Maximum length of the input sequence.", parameter_metadata=ENCODER_METADATA["DistilBERT"]["max_sequence_length"], ) use_pretrained: bool = schema_utils.Boolean( default=True, description="Whether to use the pretrained weights for the model. If false, the model will train from " "scratch which is very computationally expensive.", parameter_metadata=ENCODER_METADATA["DistilBERT"]["use_pretrained"], ) pretrained_model_name_or_path: str = schema_utils.String( default="distilbert-base-uncased", description="Name or path of the pretrained model.", parameter_metadata=ENCODER_METADATA["DistilBERT"]["pretrained_model_name_or_path"], ) saved_weights_in_checkpoint: bool = schema_utils.Boolean( default=False, description="Are the pretrained encoder weights saved in this model's checkpoint? Automatically set to" "True for trained models to prevent loading pretrained encoder weights from model hub.", parameter_metadata=ENCODER_METADATA["DistilBERT"]["saved_weights_in_checkpoint"], ) reduce_output: str = schema_utils.String( default="sum", description="The method used to reduce a sequence of tensors down to a single tensor.", parameter_metadata=ENCODER_METADATA["DistilBERT"]["reduce_output"], ) trainable: bool = schema_utils.Boolean( default=False, description="Whether to finetune the model on your dataset.", parameter_metadata=ENCODER_METADATA["DistilBERT"]["trainable"], ) adapter: BaseAdapterConfig | None = AdapterDataclassField() vocab: list = schema_utils.List( default=None, description="Vocabulary for the encoder", parameter_metadata=ENCODER_METADATA["DistilBERT"]["vocab"], ) vocab_size: int = schema_utils.PositiveInteger( default=30522, description="Vocabulary size of the DistilBERT model. Defines the number of different tokens that can be " "represented by the inputs_ids passed when calling DistilBertModel or TFDistilBertModel.", parameter_metadata=ENCODER_METADATA["DistilBERT"]["vocab_size"], ) dropout: float = schema_utils.FloatRange( default=0.1, min=0, max=1, description="The dropout probability for all fully connected layers in the embeddings, encoder, and pooler.", parameter_metadata=ENCODER_METADATA["DistilBERT"]["dropout"], ) max_position_embeddings: int = schema_utils.PositiveInteger( default=512, description="The maximum sequence length that this model might ever be used with. Typically set this to " "something large just in case (e.g., 512 or 1024 or 2048).", parameter_metadata=ENCODER_METADATA["DistilBERT"]["max_position_embeddings"], ) sinusoidal_pos_embds: bool = schema_utils.Boolean( default=False, description="Whether to use sinusoidal positional embeddings.", parameter_metadata=ENCODER_METADATA["DistilBERT"]["sinusoidal_pos_embds"], ) n_layers: int = schema_utils.PositiveInteger( default=6, description="Number of hidden layers in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["DistilBERT"]["n_layers"], ) n_heads: int = schema_utils.PositiveInteger( default=12, description="Number of hidden layers in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["DistilBERT"]["n_heads"], ) dim: int = schema_utils.PositiveInteger( default=768, description=" Dimensionality of the encoder layers and the pooler layer.", parameter_metadata=ENCODER_METADATA["DistilBERT"]["dim"], ) hidden_dim: int = schema_utils.PositiveInteger( default=3072, description="The size of the “intermediate” (often named feed-forward) layer in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["DistilBERT"]["hidden_dim"], ) attention_dropout: float = schema_utils.NonNegativeFloat( default=0.1, description="The dropout ratio for the attention probabilities.", parameter_metadata=ENCODER_METADATA["DistilBERT"]["attention_dropout"], ) activation: str | Callable = schema_utils.StringOptions( # TODO: Add support for callable ["gelu", "relu", "silu", "gelu_new"], default="gelu", description="The non-linear activation function (function or string) in the encoder and pooler. If string, " "'gelu', 'relu', 'silu' and 'gelu_new' are supported.", parameter_metadata=ENCODER_METADATA["DistilBERT"]["activation"], ) initializer_range: float = schema_utils.NonNegativeFloat( default=0.02, description="The standard deviation of the truncated_normal_initializer for initializing all weight matrices.", parameter_metadata=ENCODER_METADATA["DistilBERT"]["initializer_range"], ) qa_dropout: float = schema_utils.FloatRange( default=0.1, min=0, max=1, description="The dropout probabilities used in the question answering model DistilBertForQuestionAnswering.", parameter_metadata=ENCODER_METADATA["DistilBERT"]["qa_dropout"], ) seq_classif_dropout: float = schema_utils.FloatRange( default=0.2, min=0, max=1, description="The dropout probabilities used in the sequence classification and the multiple choice model " "DistilBertForSequenceClassification.", parameter_metadata=ENCODER_METADATA["DistilBERT"]["seq_classif_dropout"], ) pretrained_kwargs: dict = schema_utils.Dict( default=None, description="Additional kwargs to pass to the pretrained model.", parameter_metadata=ENCODER_METADATA["DistilBERT"]["pretrained_kwargs"], ) # TODO: uncomment when CTRL bug (https://github.com/ludwig-ai/ludwig/issues/2977) has been fixed to add back in @DeveloperAPI # @register_encoder_config("ctrl", TEXT) @ludwig_dataclass class CTRLConfig(HFEncoderConfig): """This dataclass configures the schema used for an CTRL encoder.""" @staticmethod def module_name(): return "CTRL" type: str = schema_utils.ProtectedString( "ctrl", description=ENCODER_METADATA["CTRL"]["type"].long_description, ) max_sequence_length: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Maximum length of the input sequence.", parameter_metadata=ENCODER_METADATA["CTRL"]["max_sequence_length"], ) use_pretrained: bool = schema_utils.Boolean( default=True, description="Whether to use the pretrained weights for the model. If false, the model will train from " "scratch which is very computationally expensive.", parameter_metadata=ENCODER_METADATA["CTRL"]["use_pretrained"], ) pretrained_model_name_or_path: str = schema_utils.String( default="ctrl", description="Name or path of the pretrained model.", parameter_metadata=ENCODER_METADATA["CTRL"]["pretrained_model_name_or_path"], ) saved_weights_in_checkpoint: bool = schema_utils.Boolean( default=False, description="Are the pretrained encoder weights saved in this model's checkpoint? Automatically set to" "True for trained models to prevent loading pretrained encoder weights from model hub.", parameter_metadata=ENCODER_METADATA["CTRL"]["saved_weights_in_checkpoint"], ) reduce_output: str = schema_utils.String( default="sum", description="The method used to reduce a sequence of tensors down to a single tensor.", parameter_metadata=ENCODER_METADATA["CTRL"]["reduce_output"], ) trainable: bool = schema_utils.Boolean( default=False, description="Whether to finetune the model on your dataset.", parameter_metadata=ENCODER_METADATA["CTRL"]["trainable"], ) adapter: BaseAdapterConfig | None = AdapterDataclassField() vocab: list = schema_utils.List( default=None, description="Vocabulary for the encoder", parameter_metadata=ENCODER_METADATA["CTRL"]["vocab"], ) vocab_size: int = schema_utils.PositiveInteger( default=246534, description="Vocabulary size of the CTRL model. Defines the number of different tokens that can be " "represented by the inputs_ids passed when calling CTRLModel or TFCTRLModel.", parameter_metadata=ENCODER_METADATA["CTRL"]["vocab_size"], ) n_positions: int = schema_utils.PositiveInteger( default=256, description="The maximum sequence length that this model might ever be used with. Typically set this to " "something large just in case (e.g., 512 or 1024 or 2048).", parameter_metadata=ENCODER_METADATA["CTRL"]["n_positions"], ) n_ctx: int = schema_utils.PositiveInteger( default=256, description="Dimensionality of the causal mask (usually same as n_positions)", parameter_metadata=ENCODER_METADATA["CTRL"]["n_ctx"], ) n_embd: int = schema_utils.PositiveInteger( default=1280, description="Dimensionality of the embeddings and hidden states.", parameter_metadata=ENCODER_METADATA["CTRL"]["n_embd"], ) dff: int = schema_utils.PositiveInteger( default=8192, description="Dimensionality of the inner dimension of the feed forward networks (FFN).", parameter_metadata=ENCODER_METADATA["CTRL"]["dff"], ) n_layer: int = schema_utils.PositiveInteger( default=48, description="Number of hidden layers in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["CTRL"]["n_layer"], ) n_head: int = schema_utils.PositiveInteger( default=16, description="Number of attention heads for each attention layer in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["CTRL"]["n_head"], ) resid_pdrop: float = schema_utils.FloatRange( default=0.1, min=0, max=1, description=" The dropout probability for all fully connected layers in the embeddings, encoder, and pooler.", parameter_metadata=ENCODER_METADATA["CTRL"]["resid_pdrop"], ) embd_pdrop: float = schema_utils.NonNegativeFloat( default=0.1, description="The dropout ratio for the embeddings.", parameter_metadata=ENCODER_METADATA["CTRL"]["embd_pdrop"], ) attn_pdrop: float = schema_utils.NonNegativeFloat( default=0.1, description="The dropout ratio for the attention.", parameter_metadata=ENCODER_METADATA["CTRL"]["attn_pdrop"], ) layer_norm_epsilon: float = schema_utils.NonNegativeFloat( default=1e-6, description="The epsilon to use in the layer normalization layers", parameter_metadata=ENCODER_METADATA["CTRL"]["layer_norm_epsilon"], ) initializer_range: float = schema_utils.NonNegativeFloat( default=0.02, description="The standard deviation of the truncated_normal_initializer for initializing all weight matrices.", parameter_metadata=ENCODER_METADATA["CTRL"]["initializer_range"], ) pretrained_kwargs: dict = schema_utils.Dict( default=None, description="Additional kwargs to pass to the pretrained model.", parameter_metadata=ENCODER_METADATA["CTRL"]["pretrained_kwargs"], ) @DeveloperAPI @register_encoder_config("camembert", TEXT) @ludwig_dataclass class CamemBERTConfig(HFEncoderConfig): """This dataclass configures the schema used for an CamemBERT encoder.""" @staticmethod def module_name(): return "CamemBERT" type: str = schema_utils.ProtectedString( "camembert", description=ENCODER_METADATA["CamemBERT"]["type"].long_description, ) max_sequence_length: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Maximum length of the input sequence.", parameter_metadata=ENCODER_METADATA["CamemBERT"]["max_sequence_length"], ) use_pretrained: bool = schema_utils.Boolean( default=True, description="Whether to use the pretrained weights for the model. If false, the model will train from " "scratch which is very computationally expensive.", parameter_metadata=ENCODER_METADATA["CamemBERT"]["use_pretrained"], ) saved_weights_in_checkpoint: bool = schema_utils.Boolean( default=False, description="Are the pretrained encoder weights saved in this model's checkpoint? Automatically set to" "True for trained models to prevent loading pretrained encoder weights from model hub.", parameter_metadata=ENCODER_METADATA["CamemBERT"]["saved_weights_in_checkpoint"], ) pretrained_model_name_or_path: str = schema_utils.String( default="camembert-base", description="Name or path of the pretrained model.", parameter_metadata=ENCODER_METADATA["CamemBERT"]["pretrained_model_name_or_path"], ) reduce_output: str = schema_utils.String( default="sum", description="The method used to reduce a sequence of tensors down to a single tensor.", parameter_metadata=ENCODER_METADATA["CamemBERT"]["reduce_output"], ) trainable: bool = schema_utils.Boolean( default=False, description="Whether to finetune the model on your dataset.", parameter_metadata=ENCODER_METADATA["CamemBERT"]["trainable"], ) adapter: BaseAdapterConfig | None = AdapterDataclassField() vocab: list = schema_utils.List( default=None, description="Vocabulary for the encoder", parameter_metadata=ENCODER_METADATA["CamemBERT"]["vocab"], ) vocab_size: int = schema_utils.PositiveInteger( default=32005, description="Vocabulary size of the CamemBERT model.", parameter_metadata=ENCODER_METADATA["CamemBERT"]["vocab_size"], ) hidden_size: int = schema_utils.PositiveInteger( default=768, description="Dimensionality of the encoder layers and the pooler layer.", parameter_metadata=ENCODER_METADATA["CamemBERT"]["hidden_size"], ) num_hidden_layers: int = schema_utils.PositiveInteger( default=12, description="Number of hidden layers in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["CamemBERT"]["num_hidden_layers"], ) num_attention_heads: int = schema_utils.PositiveInteger( default=12, description="Number of attention heads for each attention layer in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["CamemBERT"]["num_attention_heads"], ) intermediate_size: int = schema_utils.PositiveInteger( default=3072, description="Dimensionality of the “intermediate” (often named feed-forward) layer in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["CamemBERT"]["intermediate_size"], ) hidden_act: str | Callable = schema_utils.StringOptions( # TODO: add support for callable ["gelu", "relu", "silu", "gelu_new"], default="gelu", description="The non-linear activation function (function or string) in the encoder and pooler.", parameter_metadata=ENCODER_METADATA["CamemBERT"]["hidden_act"], ) hidden_dropout_prob: float = schema_utils.FloatRange( default=0.1, min=0, max=1, description="The dropout probability for all fully connected layers in the embeddings, encoder, and pooler.", parameter_metadata=ENCODER_METADATA["CamemBERT"]["hidden_dropout_prob"], ) attention_probs_dropout_prob: float = schema_utils.FloatRange( default=0.1, min=0, max=1, description="The dropout ratio for the attention probabilities.", parameter_metadata=ENCODER_METADATA["CamemBERT"]["attention_probs_dropout_prob"], ) max_position_embeddings: int = schema_utils.PositiveInteger( default=514, description="The maximum sequence length that this model might ever be used with. Typically set this to " "something large just in case (e.g., 512 or 1024 or 2048).", parameter_metadata=ENCODER_METADATA["CamemBERT"]["max_position_embeddings"], ) type_vocab_size: int = schema_utils.PositiveInteger( default=1, description="The vocabulary size of the token_type_ids passed when calling BertModel or TFBertModel.", parameter_metadata=ENCODER_METADATA["CamemBERT"]["type_vocab_size"], ) initializer_range: float = schema_utils.NonNegativeFloat( default=0.02, description="The standard deviation of the truncated_normal_initializer for initializing all weight matrices.", parameter_metadata=ENCODER_METADATA["CamemBERT"]["initializer_range"], ) layer_norm_eps: float = schema_utils.NonNegativeFloat( default=1e-05, description="The epsilon used by the layer normalization layers.", parameter_metadata=ENCODER_METADATA["CamemBERT"]["layer_norm_eps"], ) pad_token_id: int = schema_utils.Integer( default=1, description="The ID of the token to use as padding.", parameter_metadata=ENCODER_METADATA["CamemBERT"]["pad_token_id"], ) gradient_checkpointing: bool = schema_utils.Boolean( default=False, description="Whether to use gradient checkpointing.", parameter_metadata=ENCODER_METADATA["CamemBERT"]["gradient_checkpointing"], ) position_embedding_type: str = schema_utils.StringOptions( ["absolute", "relative_key", "relative_key_query"], default="absolute", description="Type of position embedding.", parameter_metadata=ENCODER_METADATA["CamemBERT"]["position_embedding_type"], ) classifier_dropout: float = schema_utils.FloatRange( default=None, allow_none=True, min=0, max=1, description="The dropout ratio for the classification head.", parameter_metadata=ENCODER_METADATA["CamemBERT"]["classifier_dropout"], ) pretrained_kwargs: dict = schema_utils.Dict( default=None, description="Additional kwargs to pass to the pretrained model.", parameter_metadata=ENCODER_METADATA["CamemBERT"]["pretrained_kwargs"], ) @DeveloperAPI @register_encoder_config("t5", TEXT) @ludwig_dataclass class T5Config(HFEncoderConfig): """This dataclass configures the schema used for an T5 encoder.""" @staticmethod def module_name(): return "T5" type: str = schema_utils.ProtectedString( "t5", description=ENCODER_METADATA["T5"]["type"].long_description, ) max_sequence_length: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Maximum length of the input sequence.", parameter_metadata=ENCODER_METADATA["T5"]["max_sequence_length"], ) use_pretrained: bool = schema_utils.Boolean( default=True, description="Whether to use the pretrained weights for the model. If false, the model will train from " "scratch which is very computationally expensive.", parameter_metadata=ENCODER_METADATA["T5"]["use_pretrained"], ) pretrained_model_name_or_path: str = schema_utils.String( default="t5-small", description="Name or path of the pretrained model.", parameter_metadata=ENCODER_METADATA["T5"]["pretrained_model_name_or_path"], ) saved_weights_in_checkpoint: bool = schema_utils.Boolean( default=False, description="Are the pretrained encoder weights saved in this model's checkpoint? Automatically set to" "True for trained models to prevent loading pretrained encoder weights from model hub.", parameter_metadata=ENCODER_METADATA["T5"]["saved_weights_in_checkpoint"], ) reduce_output: str = schema_utils.String( default="sum", description="The method used to reduce a sequence of tensors down to a single tensor.", parameter_metadata=ENCODER_METADATA["T5"]["reduce_output"], ) trainable: bool = schema_utils.Boolean( default=False, description="Whether to finetune the model on your dataset.", parameter_metadata=ENCODER_METADATA["T5"]["trainable"], ) adapter: BaseAdapterConfig | None = AdapterDataclassField() vocab: list = schema_utils.List( default=None, description="Vocabulary for the encoder", parameter_metadata=ENCODER_METADATA["T5"]["vocab"], ) vocab_size: int = schema_utils.PositiveInteger( default=32128, description="Vocabulary size of the T5 model. Defines the number of different tokens that can be represented " "by the inputs_ids passed when calling T5Model or TFT5Model.", parameter_metadata=ENCODER_METADATA["T5"]["vocab_size"], ) d_model: int = schema_utils.PositiveInteger( default=512, description="Size of the encoder layers and the pooler layer.", parameter_metadata=ENCODER_METADATA["T5"]["d_model"], ) d_kv: int = schema_utils.PositiveInteger( default=64, description="Size of the key, query, value projections per attention head. d_kv has to be equal to d_model // " "num_heads.", parameter_metadata=ENCODER_METADATA["T5"]["d_kv"], ) d_ff: int = schema_utils.PositiveInteger( default=2048, description="Size of the intermediate feed forward layer in each T5Block.", parameter_metadata=ENCODER_METADATA["T5"]["d_ff"], ) num_layers: int = schema_utils.PositiveInteger( default=6, description="Number of hidden layers in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["T5"]["num_layers"], ) num_decoder_layers: int = schema_utils.PositiveInteger( default=6, description="Number of hidden layers in the Transformer decoder. Will use the same value as num_layers if not " "set.", parameter_metadata=ENCODER_METADATA["T5"]["num_decoder_layers"], ) num_heads: int = schema_utils.PositiveInteger( default=8, description="Number of attention heads for each attention layer in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["T5"]["num_heads"], ) relative_attention_num_buckets: int = schema_utils.PositiveInteger( default=32, description="The number of buckets to use for each attention layer.", parameter_metadata=ENCODER_METADATA["T5"]["relative_attention_num_buckets"], ) dropout_rate: float = schema_utils.FloatRange( default=0.1, min=0, max=1, description="The ratio for all dropout layers.", parameter_metadata=ENCODER_METADATA["T5"]["dropout_rate"], ) layer_norm_eps: float = schema_utils.NonNegativeFloat( default=1e-6, description="The epsilon used by the layer normalization layers.", parameter_metadata=ENCODER_METADATA["T5"]["layer_norm_eps"], ) initializer_factor: float = schema_utils.NonNegativeFloat( default=1, description="A factor for initializing all weight matrices (should be kept to 1, used internally for " "initialization testing).", parameter_metadata=ENCODER_METADATA["T5"]["initializer_factor"], ) feed_forward_proj: str = schema_utils.StringOptions( ["relu", "gated-gelu"], default="relu", description="Type of feed forward layer to be used. Should be one of 'relu' or 'gated-gelu'. T5v1.1 uses the " "'gated-gelu' feed forward projection. Original T5 uses 'relu'.", parameter_metadata=ENCODER_METADATA["T5"]["feed_forward_proj"], ) pretrained_kwargs: dict = schema_utils.Dict( default=None, description="Additional kwargs to pass to the pretrained model.", parameter_metadata=ENCODER_METADATA["T5"]["pretrained_kwargs"], ) @DeveloperAPI @register_encoder_config("flaubert", TEXT) @ludwig_dataclass class FlauBERTConfig(HFEncoderConfig): """This dataclass configures the schema used for an FlauBERT encoder.""" @staticmethod def module_name(): return "FlauBERT" type: str = schema_utils.ProtectedString( "flaubert", description=ENCODER_METADATA["FlauBERT"]["type"].long_description, ) max_sequence_length: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Maximum length of the input sequence.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["max_sequence_length"], ) use_pretrained: bool = schema_utils.Boolean( default=True, description="Whether to use the pretrained weights for the model. If false, the model will train from " "scratch which is very computationally expensive.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["use_pretrained"], ) pretrained_model_name_or_path: str = schema_utils.String( default="flaubert/flaubert_small_cased", description="Name of path of the pretrained model.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["pretrained_model_name_or_path"], ) saved_weights_in_checkpoint: bool = schema_utils.Boolean( default=False, description="Are the pretrained encoder weights saved in this model's checkpoint? Automatically set to" "True for trained models to prevent loading pretrained encoder weights from model hub.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["saved_weights_in_checkpoint"], ) reduce_output: str = schema_utils.String( default="sum", description="The method used to reduce a sequence of tensors down to a single tensor.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["reduce_output"], ) trainable: bool = schema_utils.Boolean( default=False, description="Whether to finetune the model on your dataset.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["trainable"], ) adapter: BaseAdapterConfig | None = AdapterDataclassField() vocab: list = schema_utils.List( default=None, description="Vocabulary for the encoder", parameter_metadata=ENCODER_METADATA["FlauBERT"]["vocab"], ) vocab_size: int = schema_utils.PositiveInteger( default=30145, description="Vocabulary size of the FlauBERT model. Defines the number of different tokens that can be " "represented by the inputs_ids passed when calling FlaubertModel or TFFlaubertModel.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["vocab_size"], ) pre_norm: bool = schema_utils.Boolean( default=True, description="Whether to apply the layer normalization before or after the feed forward layer following the " "attention in each layer (Vaswani et al., Tensor2Tensor for Neural Machine Translation. 2018)", parameter_metadata=ENCODER_METADATA["FlauBERT"]["pre_norm"], ) layerdrop: float = schema_utils.FloatRange( default=0.2, min=0, max=1, description="Probability to drop layers during training (Fan et al., Reducing Transformer Depth on Demand " "with Structured Dropout. ICLR 2020)", parameter_metadata=ENCODER_METADATA["FlauBERT"]["layerdrop"], ) emb_dim: int = schema_utils.PositiveInteger( default=512, description="Dimensionality of the encoder layers and the pooler layer.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["emb_dim"], ) n_layers: int = schema_utils.PositiveInteger( default=6, description="Number of hidden layers in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["n_layers"], ) n_heads: int = schema_utils.PositiveInteger( default=8, description="Number of attention heads for each attention layer in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["n_heads"], ) dropout: float = schema_utils.FloatRange( default=0.1, min=0, max=1, description="The dropout probability for all fully connected layers in the embeddings, encoder, and pooler.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["dropout"], ) attention_dropout: float = schema_utils.FloatRange( default=0.1, min=0, max=1, description="The dropout probability for the attention mechanism", parameter_metadata=ENCODER_METADATA["FlauBERT"]["attention_dropout"], ) gelu_activation: bool = schema_utils.Boolean( default=True, description="Whether or not to use a gelu activation instead of relu.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["gelu_activation"], ) sinusoidal_embeddings: bool = schema_utils.Boolean( default=False, description="Whether or not to use sinusoidal positional embeddings instead of absolute positional embeddings.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["sinusoidal_embeddings"], ) causal: bool = schema_utils.Boolean( default=False, description="Whether or not the model should behave in a causal manner. Causal models use a triangular " "attention mask in order to only attend to the left-side context instead if a bidirectional " "context.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["causal"], ) asm: bool = schema_utils.Boolean( default=False, description="Whether or not to use an adaptive log softmax projection layer instead of a linear layer for the " "prediction layer.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["asm"], ) n_langs: int = schema_utils.PositiveInteger( default=1, description="The number of languages the model handles. Set to 1 for monolingual models.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["n_langs"], ) use_lang_emb: bool = schema_utils.Boolean( default=True, description="Whether to use language embeddings. Some models use additional language embeddings, " "see the multilingual models page for information on how to use them.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["use_lang_emb"], ) max_position_embeddings: int = schema_utils.PositiveInteger( default=512, description="The maximum sequence length that this model might ever be used with. Typically set this to " "something large just in case (e.g., 512 or 1024 or 2048).", parameter_metadata=ENCODER_METADATA["FlauBERT"]["max_position_embeddings"], ) embed_init_std: float = schema_utils.NonNegativeFloat( default=2048**-0.5, description="The standard deviation of the truncated_normal_initializer for initializing the embedding " "matrices.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["embed_init_std"], ) init_std: int = schema_utils.NonNegativeFloat( default=0.02, description="The standard deviation of the truncated_normal_initializer for initializing all weight matrices " "except the embedding matrices.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["init_std"], ) layer_norm_eps: float = schema_utils.NonNegativeFloat( default=1e-06, description="The epsilon used by the layer normalization layers.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["layer_norm_eps"], ) bos_index: int = schema_utils.NonNegativeInteger( default=0, description="The index of the beginning of sentence token in the vocabulary.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["bos_index"], ) eos_index: int = schema_utils.NonNegativeInteger( default=1, description="The index of the end of sentence token in the vocabulary.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["eos_index"], ) pad_index: int = schema_utils.NonNegativeInteger( default=2, description="The index of the padding token in the vocabulary.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["pad_index"], ) unk_index: int = schema_utils.NonNegativeInteger( default=3, description="The index of the unknown token in the vocabulary.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["unk_index"], ) mask_index: int = schema_utils.NonNegativeInteger( default=5, description="The index of the masking token in the vocabulary.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["mask_index"], ) is_encoder: bool = schema_utils.Boolean( default=True, description="Whether or not the initialized model should be a transformer encoder or decoder as seen in " "Vaswani et al.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["is_encoder"], ) mask_token_id: int = schema_utils.Integer( default=0, description="Model agnostic parameter to identify masked tokens when generating text in an MLM context.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["mask_token_id"], ) lang_id: int = schema_utils.Integer( default=0, description="The ID of the language used by the model. This parameter is used when generating text in a given " "language.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["lang_id"], ) pretrained_kwargs: dict = schema_utils.Dict( default=None, description="Additional kwargs to pass to the pretrained model.", parameter_metadata=ENCODER_METADATA["FlauBERT"]["pretrained_kwargs"], ) @DeveloperAPI @register_encoder_config("electra", TEXT) @ludwig_dataclass class ELECTRAConfig(HFEncoderConfig): """This dataclass configures the schema used for an ELECTRA encoder.""" @staticmethod def module_name(): return "ELECTRA" type: str = schema_utils.ProtectedString( "electra", description=ENCODER_METADATA["ELECTRA"]["type"].long_description, ) max_sequence_length: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Maximum length of the input sequence.", parameter_metadata=ENCODER_METADATA["ELECTRA"]["max_sequence_length"], ) use_pretrained: bool = schema_utils.Boolean( default=True, description="Whether to use the pretrained weights for the model. If false, the model will train from " "scratch which is very computationally expensive.", parameter_metadata=ENCODER_METADATA["ELECTRA"]["use_pretrained"], ) pretrained_model_name_or_path: str = schema_utils.String( default="google/electra-small-discriminator", description="Name or path of the pretrained model.", parameter_metadata=ENCODER_METADATA["ELECTRA"]["pretrained_model_name_or_path"], ) saved_weights_in_checkpoint: bool = schema_utils.Boolean( default=False, description="Are the pretrained encoder weights saved in this model's checkpoint? Automatically set to" "True for trained models to prevent loading pretrained encoder weights from model hub.", parameter_metadata=ENCODER_METADATA["ELECTRA"]["saved_weights_in_checkpoint"], ) reduce_output: str = schema_utils.String( default="sum", description="The method used to reduce a sequence of tensors down to a single tensor.", parameter_metadata=ENCODER_METADATA["ELECTRA"]["reduce_output"], ) trainable: bool = schema_utils.Boolean( default=False, description="Whether to finetune the model on your dataset.", parameter_metadata=ENCODER_METADATA["ELECTRA"]["trainable"], ) adapter: BaseAdapterConfig | None = AdapterDataclassField() vocab: list = schema_utils.List( default=None, description="Vocabulary for the encoder", parameter_metadata=ENCODER_METADATA["ELECTRA"]["vocab"], ) vocab_size: int = schema_utils.PositiveInteger( default=30522, description="Vocabulary size of the ELECTRA model. Defines the number of different tokens that can be " "represented by the inputs_ids passed when calling ElectraModel or TFElectraModel.", parameter_metadata=ENCODER_METADATA["ELECTRA"]["vocab_size"], ) embedding_size: int = schema_utils.PositiveInteger( default=128, description="Dimensionality of the encoder layers and the pooler layer.", parameter_metadata=ENCODER_METADATA["ELECTRA"]["embedding_size"], ) hidden_size: int = schema_utils.PositiveInteger( default=256, description="Dimensionality of the encoder layers and the pooler layer.", parameter_metadata=ENCODER_METADATA["ELECTRA"]["hidden_size"], ) num_hidden_layers: int = schema_utils.PositiveInteger( default=12, description="Number of hidden layers in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["ELECTRA"]["num_hidden_layers"], ) num_attention_heads: int = schema_utils.PositiveInteger( default=4, description="Number of attention heads for each attention layer in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["ELECTRA"]["num_attention_heads"], ) intermediate_size: int = schema_utils.PositiveInteger( default=1024, description="Dimensionality of the “intermediate” (i.e., feed-forward) layer in the Transformer encoder.", parameter_metadata=ENCODER_METADATA["ELECTRA"]["intermediate_size"], ) hidden_act: str | Callable = schema_utils.StringOptions( # TODO: add support for callable ["gelu", "relu", "silu", "gelu_new"], default="gelu", description="The non-linear activation function (function or string) in the encoder and pooler.", parameter_metadata=ENCODER_METADATA["ELECTRA"]["hidden_act"], ) hidden_dropout_prob: float = schema_utils.FloatRange( default=0.1, min=0, max=1, description="The dropout probability for all fully connected layers in the embeddings, encoder, and pooler.", parameter_metadata=ENCODER_METADATA["ELECTRA"]["hidden_dropout_prob"], ) attention_probs_dropout_prob: float = schema_utils.FloatRange( default=0.1, min=0, max=1, description="The dropout ratio for the attention probabilities.", parameter_metadata=ENCODER_METADATA["ELECTRA"]["attention_probs_dropout_prob"], ) max_position_embeddings: int = schema_utils.PositiveInteger( default=512, description="The maximum sequence length that this model might ever be used with. Typically set this to " "something large just in case (e.g., 512 or 1024 or 2048).", parameter_metadata=ENCODER_METADATA["ELECTRA"]["max_position_embeddings"], ) type_vocab_size: int = schema_utils.PositiveInteger( default=2, description="The vocabulary size of the token_type_ids passed when calling ElectraModel or TFElectraModel.", parameter_metadata=ENCODER_METADATA["ELECTRA"]["type_vocab_size"], ) initializer_range: float = schema_utils.NonNegativeFloat( default=0.02, description="The standard deviation of the truncated_normal_initializer for initializing all weight matrices.", parameter_metadata=ENCODER_METADATA["ELECTRA"]["initializer_range"], ) layer_norm_eps: float = schema_utils.NonNegativeFloat( default=1e-12, description="The epsilon used by the layer normalization layers.", parameter_metadata=ENCODER_METADATA["ELECTRA"]["layer_norm_eps"], ) position_embedding_type: str = schema_utils.StringOptions( ["absolute", "relative_key", "relative_key_query"], default="absolute", description="Type of position embedding.", parameter_metadata=ENCODER_METADATA["ELECTRA"]["position_embedding_type"], ) classifier_dropout: float = schema_utils.FloatRange( default=None, allow_none=True, min=0, max=1, description="The dropout ratio for the classification head.", parameter_metadata=ENCODER_METADATA["ELECTRA"]["classifier_dropout"], ) pretrained_kwargs: dict = schema_utils.Dict( default=None, description="Additional kwargs to pass to the pretrained model.", parameter_metadata=ENCODER_METADATA["ELECTRA"]["pretrained_kwargs"], ) @DeveloperAPI @register_encoder_config("longformer", TEXT) @ludwig_dataclass class LongformerConfig(HFEncoderConfig): """This dataclass configures the schema used for a Longformer encoder.""" @staticmethod def module_name(): return "Longformer" type: str = schema_utils.ProtectedString( "longformer", description=ENCODER_METADATA["Longformer"]["type"].long_description, ) max_sequence_length: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Maximum length of the input sequence.", parameter_metadata=ENCODER_METADATA["Longformer"]["max_sequence_length"], ) use_pretrained: bool = schema_utils.Boolean( default=True, description="Whether to use the pretrained weights for the model. If false, the model will train from " "scratch which is very computationally expensive.", parameter_metadata=ENCODER_METADATA["Longformer"]["use_pretrained"], ) attention_window: list[int] | int = schema_utils.OneOfOptionsField( default=512, allow_none=False, description="Size of an attention window around each token. If an int, use the same size for all layers. To " "specify a different window size for each layer, use a List[int] where len(attention_window) == " "num_hidden_layers.", field_options=[ schema_utils.PositiveInteger(allow_none=False, description="", default=512), schema_utils.List(list_type=int, allow_none=False), ], parameter_metadata=ENCODER_METADATA["Longformer"]["attention_window"], ) sep_token_id: int = schema_utils.Integer( default=2, description="ID of the separator token, which is used when building a sequence from multiple sequences", parameter_metadata=ENCODER_METADATA["Longformer"]["sep_token_id"], ) pretrained_model_name_or_path: str = schema_utils.String( default="allenai/longformer-base-4096", description="Name or path of the pretrained model.", parameter_metadata=ENCODER_METADATA["Longformer"]["pretrained_model_name_or_path"], ) saved_weights_in_checkpoint: bool = schema_utils.Boolean( default=False, description="Are the pretrained encoder weights saved in this model's checkpoint? Automatically set to" "True for trained models to prevent loading pretrained encoder weights from model hub.", parameter_metadata=ParameterMetadata(internal_only=True), ) reduce_output: str = schema_utils.String( default="cls_pooled", description="The method used to reduce a sequence of tensors down to a single tensor.", parameter_metadata=ENCODER_METADATA["Longformer"]["reduce_output"], ) trainable: bool = schema_utils.Boolean( default=False, description="Whether to finetune the model on your dataset.", parameter_metadata=ENCODER_METADATA["Longformer"]["trainable"], ) adapter: BaseAdapterConfig | None = AdapterDataclassField() vocab: list = schema_utils.List( default=None, description="Vocabulary for the encoder", parameter_metadata=ENCODER_METADATA["Longformer"]["vocab"], ) vocab_size: int = schema_utils.PositiveInteger( default=50265, description="Vocabulary size of the Longformer model.", parameter_metadata=ENCODER_METADATA["Longformer"]["vocab_size"], ) max_position_embeddings: int = schema_utils.PositiveInteger( default=4098, description="The maximum sequence length that this model might ever be used with. Typically set this to " "something large just in case (e.g., 512 or 1024 or 2048).", parameter_metadata=ENCODER_METADATA["Longformer"]["max_position_embeddings"], ) type_vocab_size: int = schema_utils.PositiveInteger( default=1, description="The vocabulary size of the token_type_ids passed when calling LongformerEncoder", parameter_metadata=ENCODER_METADATA["Longformer"]["type_vocab_size"], ) pretrained_kwargs: dict = schema_utils.Dict( default=None, description="Additional kwargs to pass to the pretrained model.", parameter_metadata=ENCODER_METADATA["Longformer"]["pretrained_kwargs"], ) @DeveloperAPI @register_encoder_config("auto_transformer", TEXT) @ludwig_dataclass class AutoTransformerConfig(HFEncoderConfig): """This dataclass configures the schema used for an AutoTransformer encoder.""" def __post_init__(self): # Always force use_pretrained=True — we don't support training from scratch for AutoTransformers self.use_pretrained = True if self.pretrained_model_name_or_path is None: raise ConfigValidationError( "`pretrained_model_name_or_path` must be specified for encoder: `auto_transformer`." ) @staticmethod def module_name(): return "AutoTransformer" # Always True — we don't support training from scratch for AutoTransformers use_pretrained: bool = schema_utils.Boolean( default=True, description="Whether to use the pretrained weights for the model. Always True for AutoTransformers.", ) type: str = schema_utils.ProtectedString( "auto_transformer", description=ENCODER_METADATA["AutoTransformer"]["type"].long_description, ) pretrained_model_name_or_path: str = schema_utils.String( default=None, allow_none=True, description="Name or path of the pretrained model.", parameter_metadata=ENCODER_METADATA["AutoTransformer"]["pretrained_model_name_or_path"], ) max_sequence_length: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Maximum length of the input sequence.", parameter_metadata=ENCODER_METADATA["AutoTransformer"]["max_sequence_length"], ) reduce_output: str = schema_utils.ReductionOptions( default="sum", description="The method used to reduce a sequence of tensors down to a single tensor.", parameter_metadata=ENCODER_METADATA["AutoTransformer"]["reduce_output"], ) trainable: bool = schema_utils.Boolean( default=False, description="Whether to finetune the model on your dataset.", parameter_metadata=ENCODER_METADATA["AutoTransformer"]["trainable"], ) adapter: BaseAdapterConfig | None = AdapterDataclassField() vocab: list = schema_utils.List( default=None, description="Vocabulary for the encoder", parameter_metadata=ENCODER_METADATA["AutoTransformer"]["vocab"], ) vocab_size: int = schema_utils.PositiveInteger( default=None, allow_none=True, description=( "Vocabulary size of the AutoTransformer model. If None, the vocab size will be inferred " "from the given pretrained model" ), parameter_metadata=ENCODER_METADATA["AutoTransformer"]["vocab_size"], ) pretrained_kwargs: dict = schema_utils.Dict( default=None, description="Additional kwargs to pass to the pretrained model.", parameter_metadata=ENCODER_METADATA["AutoTransformer"]["pretrained_kwargs"], ) @DeveloperAPI @register_encoder_config("tf_idf", TEXT, model_types=[MODEL_ECD]) @ludwig_dataclass class TfIdfEncoderConfig(SequenceEncoderConfig): type: str = schema_utils.ProtectedString("tf_idf") max_sequence_length: int = schema_utils.Integer(default=None, allow_none=True, parameter_metadata=INTERNAL_ONLY) str2idf: dict[str, int] = schema_utils.Dict(parameter_metadata=INTERNAL_ONLY) vocab: list = schema_utils.List(default=None, parameter_metadata=INTERNAL_ONLY) vocab_size: int = schema_utils.Integer(default=None, allow_none=True, parameter_metadata=INTERNAL_ONLY) def set_fixed_preprocessing_params(self, model_type: str, preprocessing: "TextPreprocessingConfig"): preprocessing.compute_idf = True def can_cache_embeddings(self) -> bool: return True @DeveloperAPI @register_encoder_config("llm", TEXT, model_types=[MODEL_ECD]) @ludwig_dataclass class LLMEncoderConfig(SequenceEncoderConfig): type: str = schema_utils.ProtectedString("llm") base_model: str = BaseModelDataclassField() max_sequence_length: int = schema_utils.Integer(default=None, allow_none=True, parameter_metadata=INTERNAL_ONLY) adapter: BaseAdapterConfig | None = AdapterDataclassField() quantization: QuantizationConfig | None = QuantizationConfigField().get_default_field() model_parameters: ModelParametersConfig | None = ModelParametersConfigField().get_default_field() ================================================ FILE: ludwig/schema/encoders/utils.py ================================================ from dataclasses import Field from typing import Any, TYPE_CHECKING from ludwig.api_annotations import DeveloperAPI from ludwig.constants import MODEL_ECD, TYPE from ludwig.schema import utils as schema_utils from ludwig.schema.metadata import ENCODER_METADATA from ludwig.schema.metadata.parameter_metadata import convert_metadata_to_json from ludwig.utils.registry import Registry if TYPE_CHECKING: from ludwig.schema.encoders.base import BaseEncoderConfig encoder_config_registry = Registry() @DeveloperAPI def register_encoder_config(name: str, features: str | list[str], model_types: list[str] | None = None): if model_types is None: model_types = [MODEL_ECD] if isinstance(features, str): features = [features] def wrap(cls): for model_type in model_types: for feature in features: key = (model_type, feature) feature_registry = encoder_config_registry.get(key, {}) feature_registry[name] = cls encoder_config_registry[key] = feature_registry return cls return wrap @DeveloperAPI def get_encoder_cls(model_type: str, feature: str, name: str): return encoder_config_registry[(model_type, feature)][name] @DeveloperAPI def get_encoder_classes(model_type: str, feature: str) -> dict[str, type["BaseEncoderConfig"]]: return encoder_config_registry[(model_type, feature)] @DeveloperAPI def get_encoder_descriptions(model_type: str, feature_type: str) -> dict[str, Any]: """This function returns a dictionary of encoder descriptions available at the type selection. The process works as follows - 1) Get a dictionary of valid encoders from the encoder config registry, but inverse the key/value pairs since we need to index `valid_encoders` later with an altered version of the encoder config class name. 2) Loop through Encoder Metadata entries, if a metadata entry has an encoder name that matches a valid encoder, add the description metadata to the output dictionary. Args: model_type (str): The model type to get encoder descriptions for feature_type (str): The feature type to get encoder descriptions for Returns: dict: A dictionary mapping encoder registered names to their respective description metadata. """ output = {} valid_encoders = { cls.module_name() if hasattr(cls, "module_name") else None: registered_name for registered_name, cls in get_encoder_classes(model_type, feature_type).items() } for k, v in ENCODER_METADATA.items(): if k in valid_encoders.keys(): output[valid_encoders[k]] = convert_metadata_to_json(v[TYPE]) return output @DeveloperAPI def get_encoder_conds(encoder_classes: dict[str, type["BaseEncoderConfig"]]) -> list[dict[str, Any]]: """Returns a JSON schema of conditionals to validate against encoder types for specific feature types.""" conds = [] for encoder_type, encoder_cls in encoder_classes.items(): other_props = schema_utils.unload_jsonschema_from_marshmallow_class(encoder_cls)["properties"] schema_utils.remove_duplicate_fields(other_props) encoder_cond = schema_utils.create_cond( {"type": encoder_type}, other_props, ) conds.append(encoder_cond) return conds @DeveloperAPI def EncoderDataclassField( model_type: str, feature_type: str, default: str, description: str = "", blocklist: list[str] = [] ) -> Field: """Custom dataclass field that when used inside a dataclass will allow the user to specify an encoder config. Returns: Initialized dataclass field that converts an untyped dict with params to an encoder config. """ encoder_registry = get_encoder_classes(model_type, feature_type) class EncoderSelection(schema_utils.TypeSelection): def __init__(self): super().__init__( registry=encoder_registry, default_value=default, description=description, allow_str_value=True ) def get_schema_from_registry(self, key: str) -> type[schema_utils.BaseMarshmallowConfig]: return encoder_registry[key] def _jsonschema_type_mapping(self): # NOTE: Edit carefully if necessary! We want these enums to remain in a consistent order, so do not use sets # or other unordered data structures to chaperone the registry keys around. # # Also, note the placement inside this function - since this is a list, it will not update with any late # additions to the registry (e.g. in our tests)! enum = [e for e in encoder_registry.keys() if e not in blocklist] return { "type": "object", "properties": { "type": { "type": "string", "enum": enum, "enumDescriptions": get_encoder_descriptions(model_type, feature_type), "default": default, }, }, "title": "encoder_options", "allOf": get_encoder_conds(encoder_registry), } return EncoderSelection().get_default_field() ================================================ FILE: ludwig/schema/export_schema.py ================================================ """Export Ludwig config JSON schema. Usage: python -m ludwig.schema.export_schema [--model-type ecd|llm|combined] [--output FILE] ludwig export_schema [--model-type ecd|llm|combined] [--output FILE] Generates a JSON Schema (Draft 7) for Ludwig config validation. """ import argparse import json from ludwig.config_validation.validation import get_schema from ludwig.constants import MODEL_ECD, MODEL_LLM from ludwig.globals import LUDWIG_VERSION SCHEMA_BASE_URL = "https://ludwig-ai.github.io/schema" def _strip_parameter_metadata(obj): """Recursively remove ``parameter_metadata`` keys from a schema dict. The Ludwig schema generator attaches ``parameter_metadata`` objects to every field (UI display hints, suggested values, etc.). These are useful internally but add significant bloat to the published JSON Schema and are not relevant for validation or IDE auto-complete. """ if isinstance(obj, dict): return {k: _strip_parameter_metadata(v) for k, v in obj.items() if k != "parameter_metadata"} if isinstance(obj, list): return [_strip_parameter_metadata(item) for item in obj] return obj def export_schema(model_type: str = MODEL_ECD, *, strip_metadata: bool = True) -> dict: """Export the full Ludwig config JSON schema for a given model type.""" schema = get_schema(model_type) schema["$schema"] = "http://json-schema.org/draft-07/schema#" schema["$id"] = f"{SCHEMA_BASE_URL}/ludwig-config-{model_type}.json" schema["title"] = f"Ludwig {model_type.upper()} Configuration" schema["description"] = f"Configuration schema for Ludwig {model_type.upper()} models (v{LUDWIG_VERSION})" if strip_metadata: schema = _strip_parameter_metadata(schema) return schema def export_combined_schema(*, strip_metadata: bool = True) -> dict: """Export a combined schema that covers both ECD and LLM model types.""" ecd_schema = get_schema(MODEL_ECD) llm_schema = get_schema(MODEL_LLM) # Merge properties from both schemas all_properties = {} all_properties.update(ecd_schema.get("properties", {})) all_properties.update(llm_schema.get("properties", {})) combined = { "$schema": "http://json-schema.org/draft-07/schema#", "$id": f"{SCHEMA_BASE_URL}/ludwig-config.json", "title": "Ludwig Configuration", "description": f"Configuration schema for Ludwig models (v{LUDWIG_VERSION})", "type": "object", "properties": all_properties, "required": ["input_features", "output_features"], "additionalProperties": True, } if strip_metadata: combined = _strip_parameter_metadata(combined) return combined def main(sys_argv=None): parser = argparse.ArgumentParser(description="Export Ludwig config JSON schema") parser.add_argument( "--model-type", choices=[MODEL_ECD, MODEL_LLM, "combined"], default="combined", help="Model type to export schema for (default: combined)", ) parser.add_argument("--output", "-o", type=str, default=None, help="Output file (default: stdout)") parser.add_argument( "--full", action="store_true", help="Include parameter_metadata in the output (default: stripped)", ) args = parser.parse_args(sys_argv) strip_metadata = not args.full if args.model_type == "combined": schema = export_combined_schema(strip_metadata=strip_metadata) else: schema = export_schema(args.model_type, strip_metadata=strip_metadata) output = json.dumps(schema, indent=2, sort_keys=False) if args.output: with open(args.output, "w") as f: f.write(output) f.write("\n") else: print(output) if __name__ == "__main__": main() ================================================ FILE: ludwig/schema/features/__init__.py ================================================ import ludwig.schema.features.audio_feature import ludwig.schema.features.bag_feature import ludwig.schema.features.binary_feature import ludwig.schema.features.category_feature import ludwig.schema.features.date_feature import ludwig.schema.features.h3_feature import ludwig.schema.features.image_feature import ludwig.schema.features.number_feature import ludwig.schema.features.sequence_feature import ludwig.schema.features.set_feature import ludwig.schema.features.text_feature import ludwig.schema.features.timeseries_feature import ludwig.schema.features.vector_feature # noqa ================================================ FILE: ludwig/schema/features/audio_feature.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.constants import AUDIO, MODEL_ECD from ludwig.schema import utils as schema_utils from ludwig.schema.encoders.base import BaseEncoderConfig from ludwig.schema.encoders.utils import EncoderDataclassField from ludwig.schema.features.base import BaseInputFeatureConfig from ludwig.schema.features.preprocessing.base import BasePreprocessingConfig from ludwig.schema.features.preprocessing.utils import PreprocessingDataclassField from ludwig.schema.features.utils import ecd_defaults_config_registry, ecd_input_config_registry, input_mixin_registry from ludwig.schema.utils import BaseMarshmallowConfig, ludwig_dataclass @DeveloperAPI @ecd_defaults_config_registry.register(AUDIO) @input_mixin_registry.register(AUDIO) @ludwig_dataclass class AudioInputFeatureConfigMixin(BaseMarshmallowConfig): """AudioInputFeatureConfigMixin is a dataclass that configures the parameters used in both the audio input feature and the audio global defaults section of the Ludwig Config.""" preprocessing: BasePreprocessingConfig = PreprocessingDataclassField(feature_type=AUDIO) encoder: BaseEncoderConfig = EncoderDataclassField( MODEL_ECD, feature_type=AUDIO, default="parallel_cnn", ) @DeveloperAPI @ecd_input_config_registry.register(AUDIO) @ludwig_dataclass class AudioInputFeatureConfig(AudioInputFeatureConfigMixin, BaseInputFeatureConfig): """AudioInputFeatureConfig is a dataclass that configures the parameters used for an audio input feature.""" type: str = schema_utils.ProtectedString(AUDIO) ================================================ FILE: ludwig/schema/features/augmentation/__init__.py ================================================ # Register all augmentation schemas import ludwig.schema.features.augmentation.image # noqa: F401 ================================================ FILE: ludwig/schema/features/augmentation/base.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.schema import utils as schema_utils from ludwig.schema.utils import ludwig_dataclass @DeveloperAPI @ludwig_dataclass class BaseAugmentationConfig(schema_utils.BaseMarshmallowConfig): """Base class for augmentation.""" type: str ================================================ FILE: ludwig/schema/features/augmentation/image.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.constants import AUGMENTATION, IMAGE, TYPE from ludwig.schema import utils as schema_utils from ludwig.schema.features.augmentation.base import BaseAugmentationConfig from ludwig.schema.features.augmentation.utils import register_augmentation_config from ludwig.schema.metadata import FEATURE_METADATA from ludwig.schema.utils import ludwig_dataclass @DeveloperAPI @register_augmentation_config(name="auto_augmentation", features=IMAGE) @ludwig_dataclass class AutoAugmentationConfig(BaseAugmentationConfig): """Automatic augmentation operation.""" type: str = schema_utils.ProtectedString( "auto_augmentation", parameter_metadata=FEATURE_METADATA[IMAGE][AUGMENTATION][TYPE], ) method: str = schema_utils.String( default="trivial_augment", description="Specifies the method for applying automatic data augmentation", parameter_metadata=FEATURE_METADATA[IMAGE][AUGMENTATION]["auto_augmentation_method"], ) @DeveloperAPI @register_augmentation_config(name="random_horizontal_flip", features=IMAGE) @ludwig_dataclass class RandomHorizontalFlipConfig(BaseAugmentationConfig): """Random horizontal flip augmentation operation.""" type: str = schema_utils.ProtectedString( "random_horizontal_flip", parameter_metadata=FEATURE_METADATA[IMAGE][AUGMENTATION][TYPE], ) @DeveloperAPI @register_augmentation_config(name="random_vertical_flip", features=IMAGE) @ludwig_dataclass class RandomVerticalFlipConfig(BaseAugmentationConfig): """Random vertical flip augmentation operation.""" type: str = schema_utils.ProtectedString( "random_vertical_flip", parameter_metadata=FEATURE_METADATA[IMAGE][AUGMENTATION][TYPE], ) @DeveloperAPI @register_augmentation_config(name="random_rotate", features=IMAGE) @ludwig_dataclass class RandomRotateConfig(BaseAugmentationConfig): """Random rotation augmentation operation.""" type: str = schema_utils.ProtectedString( "random_rotate", parameter_metadata=FEATURE_METADATA[IMAGE][AUGMENTATION]["type"], ) degree: int = schema_utils.Integer( default=15, description="Range of angle for random rotation, i.e., [-degree, +degree].", parameter_metadata=FEATURE_METADATA[IMAGE][AUGMENTATION]["rotation_degree"], ) @DeveloperAPI @register_augmentation_config(name="random_blur", features=IMAGE) @ludwig_dataclass class RandomBlurConfig(BaseAugmentationConfig): """Random blur augmentation operation.""" type: str = schema_utils.ProtectedString( "random_blur", parameter_metadata=FEATURE_METADATA[IMAGE][AUGMENTATION][TYPE], ) kernel_size: int = schema_utils.Integer( default=3, description="Kernel size for random blur.", parameter_metadata=FEATURE_METADATA[IMAGE][AUGMENTATION]["kernel_size"], ) @DeveloperAPI @register_augmentation_config(name="random_brightness", features=IMAGE) @ludwig_dataclass class RandomBrightnessConfig(BaseAugmentationConfig): """Random brightness augmentation operation.""" type: str = schema_utils.ProtectedString( "random_brightness", parameter_metadata=FEATURE_METADATA[IMAGE][AUGMENTATION][TYPE], ) min: float = schema_utils.FloatRange( default=0.5, description="Minimum factor for random brightness.", parameter_metadata=FEATURE_METADATA[IMAGE][AUGMENTATION]["min_brightness"], ) max: float = schema_utils.FloatRange( default=2.0, description="Maximum factor for random brightness.", parameter_metadata=FEATURE_METADATA[IMAGE][AUGMENTATION]["max_brightness"], ) @DeveloperAPI @register_augmentation_config(name="random_contrast", features=IMAGE) @ludwig_dataclass class RandomContrastConfig(BaseAugmentationConfig): """Random Contrast augmentation operation.""" type: str = schema_utils.ProtectedString( "random_contrast", parameter_metadata=FEATURE_METADATA[IMAGE][AUGMENTATION][TYPE], ) min: float = schema_utils.FloatRange( default=0.5, description="Minimum factor for random contrast.", parameter_metadata=FEATURE_METADATA[IMAGE][AUGMENTATION]["min_contrast"], ) max: float = schema_utils.FloatRange( default=2.0, description="Maximum factor for random contrast.", parameter_metadata=FEATURE_METADATA[IMAGE][AUGMENTATION]["max_contrast"], ) ================================================ FILE: ludwig/schema/features/augmentation/utils.py ================================================ import copy from dataclasses import field from typing import Any from ludwig.api_annotations import DeveloperAPI from ludwig.constants import TYPE from ludwig.error import ConfigValidationError from ludwig.schema import utils as schema_utils from ludwig.schema.features.augmentation.base import BaseAugmentationConfig from ludwig.utils.registry import Registry _augmentation_config_registry = Registry() @DeveloperAPI def get_augmentation_config_registry() -> Registry: return _augmentation_config_registry @DeveloperAPI def register_augmentation_config(name: str, features: str | list[str]): if isinstance(features, str): features = [features] def wrap(cls): for feature in features: augmentation_registry = get_augmentation_config_registry().get(feature, {}) augmentation_registry[name] = cls get_augmentation_config_registry()[feature] = augmentation_registry return cls return wrap @DeveloperAPI def get_augmentation_cls(feature: str, name: str): return get_augmentation_config_registry()[feature][name] @DeveloperAPI def get_augmentation_classes(feature: str): return get_augmentation_config_registry()[feature] @DeveloperAPI def AugmentationDataclassField( feature_type: str, default: str | BaseAugmentationConfig = False, default_augmentations: list[BaseAugmentationConfig] | None = None, description: str = "", ): """Custom dataclass field that when used inside a dataclass will allow the user to specify an augmentation config. Args: default: The default augmentation config to use. default_augmentations: The default list of augmentations to use when param value is set to `True`. description: The description of the augmentation config. Returns: Initialized dataclass field that converts a list with params to an augmentation config. """ default_augmentations = default_augmentations or [] default_augmentations = [a.to_dict() for a in default_augmentations] if isinstance(default, bool): default = default_augmentations if default else [] class AugmentationContainerMarshmallowField(schema_utils.LudwigSchemaField): """Custom field that deserializes a list for a valid augmentation config from the augmentation_registry and creates a corresponding JSON schema for external usage.""" def _deserialize(self, value, attr, data, **kwargs): if isinstance(value, bool): value = default_augmentations if value else [] if not isinstance(value, list): raise ConfigValidationError(f"Augmentation config must be a list, found: {type(value)}") augmentation_classes = get_augmentation_classes(feature_type) augmentation_list = [] for augmentation in value: augmentation_op = augmentation[TYPE] if augmentation_op in augmentation_classes: augmentation_cls = augmentation_classes[augmentation_op] pre = augmentation_cls() try: augmentation_list.append(pre.Schema().load(augmentation)) except (TypeError, ConfigValidationError) as error: raise ConfigValidationError( f"Invalid augmentation params: {value}, see `{pre}` definition. Error: {error}" ) else: raise ConfigValidationError( f"Invalid augmentation type: '{augmentation_op}', " f"expected one of: {list(augmentation_classes.keys())}" ) return augmentation_list def _jsonschema_type_mapping(self): return get_augmentation_list_jsonschema(feature_type, default) try: assert isinstance(default, list), "Augmentation config must be a list." load_augmentation_list = [] dump_augmentation_list = [] for augmentation in default: augmentation_op = augmentation[TYPE] augmentation_cls = get_augmentation_cls(feature_type, augmentation_op) pre = augmentation_cls() try: load_augmentation_list.append(pre.Schema().load(augmentation)) dump_augmentation_list.append(pre.Schema().dump(augmentation)) except (TypeError, ConfigValidationError) as error: raise ConfigValidationError( f"Invalid augmentation params: {default}, see `{pre}` definition. Error: {error}" ) load_default = lambda: copy.deepcopy(load_augmentation_list) dump_default = dump_augmentation_list return field( metadata={ "marshmallow_field": AugmentationContainerMarshmallowField( allow_none=False, dump_default=dump_default, load_default=load_default, ) }, default_factory=load_default, ) except Exception as e: raise ConfigValidationError(f"Unsupported augmentation type. See augmentation_registry. " f"Details: {e}") @DeveloperAPI def get_augmentation_list_jsonschema(feature_type: str, default: list[dict[str, Any]]): """This function returns a JSON augmentation schema. Returns: JSON Schema """ augmentation_types = sorted(list(get_augmentation_config_registry()[feature_type].keys())) schema = { "oneOf": [ { "type": "array", "items": { "type": "object", "properties": { "type": { "type": "string", "enum": augmentation_types, "title": "type", "description": "Type of augmentation to apply.", }, }, "additionalProperties": True, "allOf": get_augmentation_list_conds(feature_type), "required": ["type"], }, "title": "array_option", }, {"type": "boolean", "description": "Apply standard augmentation pipeline.", "title": "boolean_option"}, ], "title": "augmentation", } return schema @DeveloperAPI def get_augmentation_list_conds(feature_type: str): """This function returns a list of if-then JSON clauses for each augmentation type along with their properties and constraints. Returns: List of JSON clauses """ conds = [] for augmentation_op in get_augmentation_classes(feature_type): schema_cls = get_augmentation_cls(feature_type, augmentation_op) augmentation_schema = schema_utils.unload_jsonschema_from_marshmallow_class(schema_cls) augmentation_props = augmentation_schema["properties"] schema_utils.remove_duplicate_fields(augmentation_props) augmentation_cond = schema_utils.create_cond({"type": augmentation_op}, augmentation_props) conds.append(augmentation_cond) return conds ================================================ FILE: ludwig/schema/features/bag_feature.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.constants import BAG, MODEL_ECD from ludwig.schema import utils as schema_utils from ludwig.schema.encoders.base import BaseEncoderConfig from ludwig.schema.encoders.utils import EncoderDataclassField from ludwig.schema.features.base import BaseInputFeatureConfig from ludwig.schema.features.preprocessing.base import BasePreprocessingConfig from ludwig.schema.features.preprocessing.utils import PreprocessingDataclassField from ludwig.schema.features.utils import ecd_defaults_config_registry, ecd_input_config_registry, input_mixin_registry from ludwig.schema.utils import BaseMarshmallowConfig, ludwig_dataclass @DeveloperAPI @ecd_defaults_config_registry.register(BAG) @input_mixin_registry.register(BAG) @ludwig_dataclass class BagInputFeatureConfigMixin(BaseMarshmallowConfig): """BagInputFeatureConfigMixin is a dataclass that configures the parameters used in both the bag input feature and the bag global defaults section of the Ludwig Config.""" preprocessing: BasePreprocessingConfig = PreprocessingDataclassField(feature_type=BAG) encoder: BaseEncoderConfig = EncoderDataclassField( MODEL_ECD, feature_type=BAG, default="embed", ) @DeveloperAPI @ecd_input_config_registry.register(BAG) @ludwig_dataclass class BagInputFeatureConfig(BagInputFeatureConfigMixin, BaseInputFeatureConfig): """BagInputFeatureConfig is a dataclass that configures the parameters used for a bag input feature.""" type: str = schema_utils.ProtectedString(BAG) ================================================ FILE: ludwig/schema/features/base.py ================================================ import logging from collections.abc import Iterable from dataclasses import field from typing import Any, Generic, TypeVar from rich.console import Console from ludwig.api_annotations import DeveloperAPI from ludwig.constants import ( AUDIO, BAG, BINARY, CATEGORY, DATE, H3, IMAGE, MODEL_ECD, MODEL_LLM, NUMBER, SEQUENCE, SET, TEXT, TIMESERIES, VECTOR, ) from ludwig.error import ConfigValidationError from ludwig.schema import utils as schema_utils from ludwig.schema.features.utils import ( ecd_input_config_registry, ecd_output_config_registry, get_input_feature_jsonschema, get_output_feature_jsonschema, llm_input_config_registry, llm_output_config_registry, ) from ludwig.schema.metadata.parameter_metadata import INTERNAL_ONLY, ParameterMetadata from ludwig.schema.utils import ludwig_dataclass logger = logging.getLogger(__name__) _error_console = Console(stderr=True, style="bold red") _info_console = Console(stderr=True, style="bold green") @DeveloperAPI @ludwig_dataclass class BaseFeatureConfig(schema_utils.BaseMarshmallowConfig): """Base class for feature configs.""" def __post_init__(self): # TODO(travis): this should be done through marshmallow dataclass' `required` field param, # but requires a refactor` if self.name is None: raise ConfigValidationError("All features must have a name.") if self.type is None: raise ConfigValidationError(f"Feature {self.name} must have a type.") active: bool = True name: str = schema_utils.String( default=None, allow_none=True, description="Name of the feature.", ) type: str = schema_utils.StringOptions( default=None, allow_none=True, options=[AUDIO, BAG, BINARY, CATEGORY, DATE, H3, IMAGE, NUMBER, SEQUENCE, SET, TEXT, TIMESERIES, VECTOR], description="Type of the feature.", ) column: str = schema_utils.String( allow_none=True, default=None, description="The column name of this feature. Defaults to name if not specified.", ) proc_column: str = schema_utils.String( allow_none=True, default=None, description="The name of the preprocessed column name of this feature. Internal only.", parameter_metadata=ParameterMetadata(internal_only=True), ) def enable(self): """This function allows the user to specify which features from a dataset should be included during model training. This is the equivalent to toggling on a feature in the model creation UI. Returns: None """ if self.active: _error_console.print("This feature is already enabled!") else: self.active = True _info_console.print(f"{self.name} feature enabled!\n") logger.info(self.__repr__()) def disable(self): """This function allows the user to specify which features from a dataset should not be included during model training. This is the equivalent to toggling off a feature in the model creation UI. Returns: None """ if not self.active: _error_console.print("This feature is already disabled!") else: self.active = False _info_console.print(f"{self.name} feature disabled!\n") logger.info(self.__repr__()) @DeveloperAPI @ludwig_dataclass class BaseInputFeatureConfig(BaseFeatureConfig): """Base input feature config class.""" tied: str = schema_utils.String( default=None, allow_none=True, description="Name of input feature to tie the weights of the encoder with. It needs to be the name of a " "feature of the same type and with the same encoder parameters. If text or sequence features are tied, " "consider setting the `sequence_length` parameter in `preprocessing` to ensure that the tied features have " "equal sized outputs. This is necessary when using the `sequence` combiner.", ) def has_augmentation(self) -> bool: return False @DeveloperAPI @ludwig_dataclass class ECDInputFeatureConfig(BaseFeatureConfig): pass @DeveloperAPI @ludwig_dataclass class BaseOutputFeatureConfig(BaseFeatureConfig): """Base output feature config class.""" reduce_input: str = schema_utils.ReductionOptions( default="sum", description="How to reduce an input that is not a vector, but a matrix or a higher order tensor, on the first " "dimension (second if you count the batch dimension)", ) default_validation_metric: str = schema_utils.String( default=None, allow_none=True, description="Internal only use parameter: default validation metric for output feature.", parameter_metadata=INTERNAL_ONLY, ) dependencies: list[str] = schema_utils.List( default=[], description="List of input features that this feature depends on.", ) reduce_dependencies: str = schema_utils.ReductionOptions( default="sum", description="How to reduce the dependencies of the output feature.", ) input_size: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Size of the input to the decoder.", parameter_metadata=ParameterMetadata(internal_only=True), ) num_classes: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Size of the input to the decoder.", parameter_metadata=ParameterMetadata(internal_only=True), ) T = TypeVar("T", bound=BaseFeatureConfig) class FeatureCollection(Generic[T], schema_utils.ListSerializable): def __init__(self, features: list[T]): self._features = features self._name_to_feature = {f.name: f for f in features} for k, v in self._name_to_feature.items(): setattr(self, k, v) def to_list(self) -> list[dict[str, Any]]: out_list = [] for feature in self._features: out_list.append(feature.to_dict()) return out_list def items(self) -> Iterable[tuple[str, T]]: return self._name_to_feature.items() def __iter__(self): return iter(self._features) def __len__(self): return len(self._features) def __getitem__(self, i) -> T: if isinstance(i, str): return self._name_to_feature[i] else: return self._features[i] class FeatureList(schema_utils.LudwigSchemaField): """A schema field that deserializes a list of dicts into a FeatureCollection. Each item is resolved via the inner TypeSelection's resolve() method. """ def __init__( self, inner: schema_utils.TypeSelection, min_length: int | None = None, max_length: int | None = None, equal: int | None = None, metadata: dict | None = None, ): self.inner = inner self.min_length = min_length self.max_length = max_length self.equal = equal self.metadata = metadata or {} def _deserialize(self, value, attr, data, **kwargs) -> FeatureCollection: if not isinstance(value, list): raise ConfigValidationError(f"Expected a list of features for '{attr}', got {type(value).__name__}") # Validate length constraints n = len(value) if self.equal is not None and n != self.equal: raise ConfigValidationError(f"Expected exactly {self.equal} feature(s) for '{attr}', got {n}") if self.min_length is not None and n < self.min_length: raise ConfigValidationError(f"Expected at least {self.min_length} feature(s) for '{attr}', got {n}") if self.max_length is not None and n > self.max_length: raise ConfigValidationError(f"Expected at most {self.max_length} feature(s) for '{attr}', got {n}") feature_list = [self.inner.resolve(item) for item in value] return FeatureCollection(feature_list) def _jsonschema_type_mapping(self): inner_schema = self.inner._jsonschema_type_mapping() or {} result = {"type": "array", "items": inner_schema} if self.min_length is not None: result["minItems"] = self.min_length if self.max_length is not None: result["maxItems"] = self.max_length if self.equal is not None: result["minItems"] = self.equal result["maxItems"] = self.equal return result class FeaturesTypeSelection(schema_utils.TypeSelection): def __init__( self, *args, min_length: int | None = 1, max_length: int | None = None, supplementary_metadata=None, **kwargs, ): super().__init__(*args, **kwargs) self.min_length = min_length self.max_length = max_length self.supplementary_metadata = {} if supplementary_metadata is None else supplementary_metadata def get_list_field(self): min_length = self.min_length max_length = self.max_length equal = None if min_length == max_length: min_length = None max_length = None equal = self.max_length return field( metadata={ "marshmallow_field": FeatureList( self, min_length=min_length, max_length=max_length, equal=equal, metadata=self.supplementary_metadata, ) }, ) class ECDInputFeatureSelection(FeaturesTypeSelection): def __init__(self): super().__init__( registry=ecd_input_config_registry, description="Type of the input feature", supplementary_metadata={"uniqueItemProperties": ["name"]}, ) def _jsonschema_type_mapping(self): return get_input_feature_jsonschema(MODEL_ECD) class LLMInputFeatureSelection(FeaturesTypeSelection): def __init__(self): super().__init__(registry=llm_input_config_registry, description="Type of the input feature") def _jsonschema_type_mapping(self): return get_input_feature_jsonschema(MODEL_LLM) class ECDOutputFeatureSelection(FeaturesTypeSelection): def __init__(self): super().__init__(registry=ecd_output_config_registry, description="Type of the output feature") def _jsonschema_type_mapping(self): return get_output_feature_jsonschema(MODEL_ECD) class LLMOutputFeatureSelection(FeaturesTypeSelection): def __init__(self): # TODO(Arnav): Remove the hard check on max_length once we support multiple output features. super().__init__(max_length=1, registry=llm_output_config_registry, description="Type of the output feature") def _jsonschema_type_mapping(self): return get_output_feature_jsonschema(MODEL_LLM) ================================================ FILE: ludwig/schema/features/binary_feature.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.constants import BINARY, BINARY_WEIGHTED_CROSS_ENTROPY, MODEL_ECD, ROC_AUC from ludwig.schema import utils as schema_utils from ludwig.schema.decoders.base import BaseDecoderConfig from ludwig.schema.decoders.utils import DecoderDataclassField from ludwig.schema.encoders.base import BaseEncoderConfig from ludwig.schema.encoders.utils import EncoderDataclassField from ludwig.schema.features.base import BaseInputFeatureConfig, BaseOutputFeatureConfig from ludwig.schema.features.loss.loss import BaseLossConfig from ludwig.schema.features.loss.utils import LossDataclassField from ludwig.schema.features.preprocessing.base import BasePreprocessingConfig from ludwig.schema.features.preprocessing.utils import PreprocessingDataclassField from ludwig.schema.features.utils import ( ecd_defaults_config_registry, ecd_input_config_registry, ecd_output_config_registry, input_mixin_registry, output_mixin_registry, ) from ludwig.schema.metadata import FEATURE_METADATA from ludwig.schema.metadata.parameter_metadata import INTERNAL_ONLY from ludwig.schema.utils import BaseMarshmallowConfig, ludwig_dataclass @DeveloperAPI @input_mixin_registry.register(BINARY) @ludwig_dataclass class BinaryInputFeatureConfigMixin(BaseMarshmallowConfig): """BinaryInputFeatureConfigMixin is a dataclass that configures the parameters used in both the binary input feature and the binary global defaults section of the Ludwig Config.""" preprocessing: BasePreprocessingConfig = PreprocessingDataclassField(feature_type=BINARY) @DeveloperAPI @ludwig_dataclass class BinaryInputFeatureConfig(BinaryInputFeatureConfigMixin, BaseInputFeatureConfig): """BinaryInputFeatureConfig is a dataclass that configures the parameters used for a binary input feature.""" type: str = schema_utils.ProtectedString(BINARY) encoder: BaseEncoderConfig = None @DeveloperAPI @ecd_input_config_registry.register(BINARY) @ludwig_dataclass class ECDBinaryInputFeatureConfig(BinaryInputFeatureConfig): encoder: BaseEncoderConfig = EncoderDataclassField( MODEL_ECD, feature_type=BINARY, default="passthrough", ) @DeveloperAPI @output_mixin_registry.register(BINARY) @ludwig_dataclass class BinaryOutputFeatureConfigMixin(BaseMarshmallowConfig): """BinaryOutputFeatureConfigMixin is a dataclass that configures the parameters used in both the binary output feature and the binary global defaults section of the Ludwig Config.""" decoder: BaseDecoderConfig = None loss: BaseLossConfig = LossDataclassField( feature_type=BINARY, default=BINARY_WEIGHTED_CROSS_ENTROPY, ) @DeveloperAPI @ludwig_dataclass class BinaryOutputFeatureConfig(BinaryOutputFeatureConfigMixin, BaseOutputFeatureConfig): """BinaryOutputFeatureConfig is a dataclass that configures the parameters used for a binary output feature.""" type: str = schema_utils.ProtectedString(BINARY) calibration: bool = schema_utils.Boolean( default=False, description="Calibrate the model's output probabilities using temperature scaling.", parameter_metadata=FEATURE_METADATA[BINARY]["calibration"], ) default_validation_metric: str = schema_utils.StringOptions( [ROC_AUC], default=ROC_AUC, description="Internal only use parameter: default validation metric for binary output feature.", parameter_metadata=INTERNAL_ONLY, ) dependencies: list = schema_utils.List( default=[], description="List of input features that this feature depends on.", parameter_metadata=FEATURE_METADATA[BINARY]["dependencies"], ) preprocessing: BasePreprocessingConfig = PreprocessingDataclassField(feature_type="binary_output") reduce_dependencies: str = schema_utils.ReductionOptions( default="sum", description="How to reduce the dependencies of the output feature.", parameter_metadata=FEATURE_METADATA[BINARY]["reduce_dependencies"], ) reduce_input: str = schema_utils.ReductionOptions( default="sum", description="How to reduce an input that is not a vector, but a matrix or a higher order tensor, on the first " "dimension (second if you count the batch dimension)", parameter_metadata=FEATURE_METADATA[BINARY]["reduce_input"], ) threshold: float = schema_utils.FloatRange( default=0.5, min=0, max=1, description="The threshold used to convert output probabilities to predictions. Predicted probabilities greater" "than or equal to threshold are mapped to True.", parameter_metadata=FEATURE_METADATA[BINARY]["threshold"], ) @DeveloperAPI @ecd_output_config_registry.register(BINARY) @ludwig_dataclass class ECDBinaryOutputFeatureConfig(BinaryOutputFeatureConfig): decoder: BaseDecoderConfig = DecoderDataclassField( MODEL_ECD, feature_type=BINARY, default="regressor", ) @DeveloperAPI @ecd_defaults_config_registry.register(BINARY) @ludwig_dataclass class BinaryDefaultsConfig(BinaryInputFeatureConfigMixin, BinaryOutputFeatureConfigMixin): # NOTE(travis): defaults use ECD input feature as it contains all the encoders encoder: BaseEncoderConfig = EncoderDataclassField( MODEL_ECD, feature_type=BINARY, default="passthrough", ) decoder: BaseDecoderConfig = DecoderDataclassField( MODEL_ECD, feature_type=BINARY, default="regressor", ) ================================================ FILE: ludwig/schema/features/category_feature.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.constants import ACCURACY, CATEGORY, CATEGORY_DISTRIBUTION, MODEL_ECD, MODEL_LLM, SOFTMAX_CROSS_ENTROPY from ludwig.schema import utils as schema_utils from ludwig.schema.decoders.base import BaseDecoderConfig from ludwig.schema.decoders.utils import DecoderDataclassField from ludwig.schema.encoders.base import BaseEncoderConfig from ludwig.schema.encoders.utils import EncoderDataclassField from ludwig.schema.features.base import BaseInputFeatureConfig, BaseOutputFeatureConfig from ludwig.schema.features.loss.loss import BaseLossConfig from ludwig.schema.features.loss.utils import LossDataclassField from ludwig.schema.features.preprocessing.base import BasePreprocessingConfig from ludwig.schema.features.preprocessing.utils import PreprocessingDataclassField from ludwig.schema.features.utils import ( ecd_defaults_config_registry, ecd_input_config_registry, ecd_output_config_registry, input_mixin_registry, llm_output_config_registry, output_mixin_registry, ) from ludwig.schema.metadata import FEATURE_METADATA from ludwig.schema.metadata.parameter_metadata import INTERNAL_ONLY from ludwig.schema.utils import BaseMarshmallowConfig, ludwig_dataclass @DeveloperAPI @input_mixin_registry.register(CATEGORY) @ludwig_dataclass class CategoryInputFeatureConfigMixin(BaseMarshmallowConfig): """CategoryInputFeatureConfigMixin is a dataclass that configures the parameters used in both the category input feature and the category global defaults section of the Ludwig Config.""" preprocessing: BasePreprocessingConfig = PreprocessingDataclassField(feature_type=CATEGORY) @DeveloperAPI @ludwig_dataclass class CategoryInputFeatureConfig(CategoryInputFeatureConfigMixin, BaseInputFeatureConfig): """CategoryInputFeatureConfig is a dataclass that configures the parameters used for a category input feature.""" type: str = schema_utils.ProtectedString(CATEGORY) encoder: BaseEncoderConfig = None @DeveloperAPI @ecd_input_config_registry.register(CATEGORY) @ludwig_dataclass class ECDCategoryInputFeatureConfig(CategoryInputFeatureConfig): encoder: BaseEncoderConfig = EncoderDataclassField( MODEL_ECD, feature_type=CATEGORY, default="dense", ) @DeveloperAPI @output_mixin_registry.register(CATEGORY) @ludwig_dataclass class CategoryOutputFeatureConfigMixin(BaseMarshmallowConfig): """CategoryOutputFeatureConfigMixin is a dataclass that configures the parameters used in both the category output feature and the category global defaults section of the Ludwig Config.""" decoder: BaseDecoderConfig = None loss: BaseLossConfig = LossDataclassField( feature_type=CATEGORY, default=SOFTMAX_CROSS_ENTROPY, ) @DeveloperAPI @ludwig_dataclass class CategoryOutputFeatureConfig(CategoryOutputFeatureConfigMixin, BaseOutputFeatureConfig): """CategoryOutputFeatureConfig is a dataclass that configures the parameters used for a category output feature.""" type: str = schema_utils.ProtectedString(CATEGORY) calibration: bool = schema_utils.Boolean( default=False, description="Calibrate the model's output probabilities using temperature scaling.", parameter_metadata=FEATURE_METADATA[CATEGORY]["calibration"], ) default_validation_metric: str = schema_utils.StringOptions( [ACCURACY], default=ACCURACY, description="Internal only use parameter: default validation metric for category output feature.", parameter_metadata=INTERNAL_ONLY, ) dependencies: list = schema_utils.List( default=[], description="List of input features that this feature depends on.", parameter_metadata=FEATURE_METADATA[CATEGORY]["dependencies"], ) preprocessing: BasePreprocessingConfig = PreprocessingDataclassField(feature_type="category_output") reduce_dependencies: str = schema_utils.ReductionOptions( default="sum", description="How to reduce the dependencies of the output feature.", parameter_metadata=FEATURE_METADATA[CATEGORY]["reduce_dependencies"], ) reduce_input: str = schema_utils.ReductionOptions( default="sum", description="How to reduce an input that is not a vector, but a matrix or a higher order tensor, on the first " "dimension (second if you count the batch dimension)", parameter_metadata=FEATURE_METADATA[CATEGORY]["reduce_input"], ) top_k: int = schema_utils.PositiveInteger( default=3, description="Determines the parameter k, the number of categories to consider when computing the top_k " "measure. It computes accuracy but considering as a match if the true category appears in the " "first k predicted categories ranked by decoder's confidence.", parameter_metadata=FEATURE_METADATA[CATEGORY]["top_k"], ) @DeveloperAPI @ecd_output_config_registry.register(CATEGORY) @ludwig_dataclass class ECDCategoryOutputFeatureConfig(CategoryOutputFeatureConfig): decoder: BaseDecoderConfig = DecoderDataclassField( MODEL_ECD, feature_type=CATEGORY, default="classifier", ) @DeveloperAPI @ecd_output_config_registry.register(CATEGORY_DISTRIBUTION) @ludwig_dataclass class CategoryDistributionOutputFeatureConfig(CategoryOutputFeatureConfig): """CategoryDistributionOutputFeatureConfig is a dataclass that configures the parameters used for a category_distribution output feature.""" type: str = schema_utils.ProtectedString(CATEGORY_DISTRIBUTION) decoder: BaseDecoderConfig = DecoderDataclassField( MODEL_ECD, feature_type=CATEGORY, default="classifier", ) preprocessing: BasePreprocessingConfig = PreprocessingDataclassField(feature_type="category_distribution_output") @DeveloperAPI @ecd_defaults_config_registry.register(CATEGORY) @ludwig_dataclass class CategoryDefaultsConfig(CategoryInputFeatureConfigMixin, CategoryOutputFeatureConfigMixin): encoder: BaseEncoderConfig = EncoderDataclassField( MODEL_ECD, feature_type=CATEGORY, default="dense", ) decoder: BaseDecoderConfig = DecoderDataclassField( MODEL_ECD, feature_type=CATEGORY, default="classifier", ) @DeveloperAPI @ecd_defaults_config_registry.register(CATEGORY_DISTRIBUTION) @ludwig_dataclass class CategoryDistributionDefaultsConfig(CategoryOutputFeatureConfigMixin): pass @DeveloperAPI @llm_output_config_registry.register(CATEGORY) @ludwig_dataclass class LLMCategoryOutputFeatureConfig(CategoryOutputFeatureConfig): """LLMCategoryOutputFeatureConfig is a dataclass that configures the parameters used for a category output feature when using the Ludwig Light Model.""" preprocessing: BasePreprocessingConfig = PreprocessingDataclassField(feature_type="category_llm") decoder: BaseDecoderConfig = DecoderDataclassField( MODEL_LLM, feature_type=CATEGORY, default="category_extractor", ) ================================================ FILE: ludwig/schema/features/date_feature.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.constants import DATE, MODEL_ECD from ludwig.schema import utils as schema_utils from ludwig.schema.encoders.base import BaseEncoderConfig from ludwig.schema.encoders.utils import EncoderDataclassField from ludwig.schema.features.base import BaseInputFeatureConfig from ludwig.schema.features.preprocessing.base import BasePreprocessingConfig from ludwig.schema.features.preprocessing.utils import PreprocessingDataclassField from ludwig.schema.features.utils import ecd_defaults_config_registry, ecd_input_config_registry, input_mixin_registry from ludwig.schema.utils import BaseMarshmallowConfig, ludwig_dataclass @DeveloperAPI @ecd_defaults_config_registry.register(DATE) @input_mixin_registry.register(DATE) @ludwig_dataclass class DateInputFeatureConfigMixin(BaseMarshmallowConfig): """DateInputFeatureConfigMixin is a dataclass that configures the parameters used in both the date input feature and the date global defaults section of the Ludwig Config.""" preprocessing: BasePreprocessingConfig = PreprocessingDataclassField(feature_type=DATE) encoder: BaseEncoderConfig = EncoderDataclassField( MODEL_ECD, feature_type=DATE, default="embed", ) @DeveloperAPI @ecd_input_config_registry.register(DATE) @ludwig_dataclass class DateInputFeatureConfig(DateInputFeatureConfigMixin, BaseInputFeatureConfig): """DateInputFeature is a dataclass that configures the parameters used for a date input feature.""" type: str = schema_utils.ProtectedString(DATE) ================================================ FILE: ludwig/schema/features/h3_feature.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.constants import H3, MODEL_ECD from ludwig.schema import utils as schema_utils from ludwig.schema.encoders.base import BaseEncoderConfig from ludwig.schema.encoders.utils import EncoderDataclassField from ludwig.schema.features.base import BaseInputFeatureConfig from ludwig.schema.features.preprocessing.base import BasePreprocessingConfig from ludwig.schema.features.preprocessing.utils import PreprocessingDataclassField from ludwig.schema.features.utils import ecd_defaults_config_registry, ecd_input_config_registry, input_mixin_registry from ludwig.schema.utils import BaseMarshmallowConfig, ludwig_dataclass @DeveloperAPI @ecd_defaults_config_registry.register(H3) @input_mixin_registry.register(H3) @ludwig_dataclass class H3InputFeatureConfigMixin(BaseMarshmallowConfig): """H3InputFeatureConfigMixin is a dataclass that configures the parameters used in both the h3 input feature and the h3 global defaults section of the Ludwig Config.""" preprocessing: BasePreprocessingConfig = PreprocessingDataclassField(feature_type=H3) encoder: BaseEncoderConfig = EncoderDataclassField( MODEL_ECD, feature_type=H3, default="embed", ) @DeveloperAPI @ecd_input_config_registry.register(H3) @ludwig_dataclass class H3InputFeatureConfig(H3InputFeatureConfigMixin, BaseInputFeatureConfig): """H3InputFeatureConfig is a dataclass that configures the parameters used for an h3 input feature.""" type: str = schema_utils.ProtectedString(H3) ================================================ FILE: ludwig/schema/features/image_feature.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.constants import IMAGE, LOSS, MODEL_ECD, SOFTMAX_CROSS_ENTROPY from ludwig.schema import utils as schema_utils from ludwig.schema.decoders.base import BaseDecoderConfig from ludwig.schema.decoders.utils import DecoderDataclassField from ludwig.schema.encoders.base import BaseEncoderConfig from ludwig.schema.encoders.utils import EncoderDataclassField from ludwig.schema.features.augmentation.base import BaseAugmentationConfig from ludwig.schema.features.augmentation.image import RandomHorizontalFlipConfig, RandomRotateConfig from ludwig.schema.features.augmentation.utils import AugmentationDataclassField from ludwig.schema.features.base import BaseInputFeatureConfig, BaseOutputFeatureConfig from ludwig.schema.features.loss.loss import BaseLossConfig from ludwig.schema.features.loss.utils import LossDataclassField from ludwig.schema.features.preprocessing.base import BasePreprocessingConfig from ludwig.schema.features.preprocessing.utils import PreprocessingDataclassField from ludwig.schema.features.utils import ( ecd_defaults_config_registry, ecd_input_config_registry, ecd_output_config_registry, input_mixin_registry, output_mixin_registry, ) from ludwig.schema.metadata import FEATURE_METADATA from ludwig.schema.metadata.parameter_metadata import INTERNAL_ONLY from ludwig.schema.utils import BaseMarshmallowConfig, ludwig_dataclass # Augmentation operations when augmentation is set to True AUGMENTATION_DEFAULT_OPERATIONS = [ RandomHorizontalFlipConfig(), RandomRotateConfig(), ] @DeveloperAPI @ecd_defaults_config_registry.register(IMAGE) @input_mixin_registry.register(IMAGE) @ludwig_dataclass class ImageInputFeatureConfigMixin(BaseMarshmallowConfig): """ImageInputFeatureConfigMixin is a dataclass that configures the parameters used in both the image input feature and the image global defaults section of the Ludwig Config.""" preprocessing: BasePreprocessingConfig = PreprocessingDataclassField(feature_type=IMAGE) encoder: BaseEncoderConfig = EncoderDataclassField( MODEL_ECD, feature_type=IMAGE, default="stacked_cnn", ) augmentation: list[BaseAugmentationConfig] = AugmentationDataclassField( feature_type=IMAGE, default=False, default_augmentations=AUGMENTATION_DEFAULT_OPERATIONS, description="Augmentation operation configuration.", ) def has_augmentation(self) -> bool: # Check for None, False, and [] return bool(self.augmentation) @DeveloperAPI @ecd_input_config_registry.register(IMAGE) @ludwig_dataclass class ImageInputFeatureConfig(ImageInputFeatureConfigMixin, BaseInputFeatureConfig): """ImageInputFeatureConfig is a dataclass that configures the parameters used for an image input feature.""" type: str = schema_utils.ProtectedString(IMAGE) @DeveloperAPI @output_mixin_registry.register(IMAGE) @ludwig_dataclass class ImageOutputFeatureConfigMixin(BaseMarshmallowConfig): """ImageOutputFeatureConfigMixin is a dataclass that configures the parameters used in both the image output feature and the image global defaults section of the Ludwig Config.""" decoder: BaseDecoderConfig = DecoderDataclassField( MODEL_ECD, feature_type=IMAGE, default="unet", ) loss: BaseLossConfig = LossDataclassField( feature_type=IMAGE, default=SOFTMAX_CROSS_ENTROPY, ) @DeveloperAPI @ecd_output_config_registry.register(IMAGE) @ludwig_dataclass class ImageOutputFeatureConfig(ImageOutputFeatureConfigMixin, BaseOutputFeatureConfig): """ImageOutputFeatureConfig is a dataclass that configures the parameters used for an image output feature.""" type: str = schema_utils.ProtectedString(IMAGE) dependencies: list = schema_utils.List( default=[], description="List of input features that this feature depends on.", parameter_metadata=FEATURE_METADATA[IMAGE]["dependencies"], ) default_validation_metric: str = schema_utils.StringOptions( [LOSS], default=LOSS, description="Internal only use parameter: default validation metric for image output feature.", parameter_metadata=INTERNAL_ONLY, ) preprocessing: BasePreprocessingConfig = PreprocessingDataclassField(feature_type="image_output") reduce_dependencies: str = schema_utils.ReductionOptions( default=None, description="How to reduce the dependencies of the output feature.", parameter_metadata=FEATURE_METADATA[IMAGE]["reduce_dependencies"], ) reduce_input: str = schema_utils.ReductionOptions( default=None, description="How to reduce an input that is not a vector, but a matrix or a higher order tensor, on the first " "dimension (second if you count the batch dimension)", parameter_metadata=FEATURE_METADATA[IMAGE]["reduce_input"], ) @DeveloperAPI @ecd_defaults_config_registry.register(IMAGE) @ludwig_dataclass class ImageDefaultsConfig(ImageInputFeatureConfigMixin, ImageOutputFeatureConfigMixin): pass ================================================ FILE: ludwig/schema/features/loss/__init__.py ================================================ from ludwig.schema.features.loss.loss import get_loss_classes, get_loss_cls, get_loss_schema_registry # noqa ================================================ FILE: ludwig/schema/features/loss/loss.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.constants import ( BINARY, BINARY_WEIGHTED_CROSS_ENTROPY, CATEGORY, CORN, HUBER, IMAGE, MEAN_ABSOLUTE_ERROR, MEAN_ABSOLUTE_PERCENTAGE_ERROR, MEAN_SQUARED_ERROR, NEXT_TOKEN_SOFTMAX_CROSS_ENTROPY, NUMBER, ROOT_MEAN_SQUARED_ERROR, ROOT_MEAN_SQUARED_PERCENTAGE_ERROR, SEQUENCE, SEQUENCE_SOFTMAX_CROSS_ENTROPY, SET, SIGMOID_CROSS_ENTROPY, SOFTMAX_CROSS_ENTROPY, TEXT, TIMESERIES, VECTOR, ) from ludwig.schema import utils as schema_utils from ludwig.schema.metadata import LOSS_METADATA from ludwig.schema.utils import ludwig_dataclass from ludwig.utils.registry import Registry ROBUST_LAMBDA_DESCRIPTION = ( "Replaces the loss with `(1 - robust_lambda) * loss + robust_lambda / c` where `c` is the number of " "classes. Useful in case of noisy labels." ) CONFIDENCE_PENALTY_DESCRIPTION = ( "Penalizes overconfident predictions (low entropy) by adding an additional term " "that penalizes too confident predictions by adding a `a * (max_entropy - entropy) / max_entropy` " "term to the loss, where a is the value of this parameter. Useful in case of noisy labels." ) CLASS_WEIGHTS_DESCRIPTION = ( "Weights to apply to each class in the loss. If not specified, all classes are weighted equally. " "The value can be a vector of weights, one for each class, that is multiplied to the " "loss of the datapoints that have that class as ground truth. It is an alternative to oversampling in " "case of unbalanced class distribution. The ordering of the vector follows the category to integer ID " "mapping in the JSON metadata file (the `` class needs to be included too). Alternatively, the value " "can be a dictionary with class strings as keys and weights as values, like " "`{class_a: 0.5, class_b: 0.7, ...}`." ) CLASS_SIMILARITIES_DESCRIPTION = ( "If not `null` it is a `c x c` matrix in the form of a list of lists that contains the mutual similarity of " "classes. It is used if `class_similarities_temperature` is greater than 0. The ordering of the vector follows " "the category to integer ID mapping in the JSON metadata file (the `` class needs to be included too)." ) CLASS_SIMILARITIES_TEMPERATURE_DESCRIPTION = ( "The temperature parameter of the softmax that is performed on each row of `class_similarities`. The output of " "that softmax is used to determine the supervision vector to provide instead of the one hot vector that would be " "provided otherwise for each datapoint. The intuition behind it is that errors between similar classes are more " "tolerable than errors between really different classes." ) @DeveloperAPI @ludwig_dataclass class BaseLossConfig(schema_utils.BaseMarshmallowConfig): """Base class for feature configs.""" type: str weight: float = 1.0 @classmethod def name(cls) -> str: return "[undefined]" _loss_registry = Registry[type[BaseLossConfig]]() _loss_feature_registry = Registry[dict[str, type[BaseLossConfig]]]() @DeveloperAPI def get_loss_schema_registry() -> Registry[type[BaseLossConfig]]: return _loss_registry @DeveloperAPI def get_loss_cls(feature: str, name: str) -> type[BaseLossConfig]: return _loss_feature_registry[feature][name] @DeveloperAPI def get_loss_classes(feature: str) -> dict[str, type[BaseLossConfig]]: return _loss_feature_registry[feature] def register_loss(features: str | list[str]): if isinstance(features, str): features = [features] def wrap(cls: type[BaseLossConfig]): _loss_registry[cls.type] = cls for feature in features: feature_registry = _loss_feature_registry.get(feature, {}) feature_registry[cls.type] = cls _loss_feature_registry[feature] = feature_registry return cls return wrap @DeveloperAPI @register_loss([NUMBER, TIMESERIES, VECTOR]) @ludwig_dataclass class MSELossConfig(BaseLossConfig): type: str = schema_utils.ProtectedString( MEAN_SQUARED_ERROR, description="Type of loss.", ) weight: float = schema_utils.NonNegativeFloat( default=1.0, description="Weight of the loss.", parameter_metadata=LOSS_METADATA["MSELoss"]["weight"], ) @classmethod def name(self) -> str: return "Mean Squared Error (MSE)" @DeveloperAPI @register_loss([NUMBER, TIMESERIES, VECTOR]) @ludwig_dataclass class MAELossConfig(BaseLossConfig): type: str = schema_utils.ProtectedString( MEAN_ABSOLUTE_ERROR, description="Type of loss.", ) weight: float = schema_utils.NonNegativeFloat( default=1.0, description="Weight of the loss.", parameter_metadata=LOSS_METADATA["MAELoss"]["weight"], ) @classmethod def name(self) -> str: return "Mean Absolute Error (MAE)" @DeveloperAPI @register_loss([NUMBER, TIMESERIES, VECTOR]) @ludwig_dataclass class MAPELossConfig(BaseLossConfig): type: str = schema_utils.ProtectedString( MEAN_ABSOLUTE_PERCENTAGE_ERROR, description="Type of loss.", ) weight: float = schema_utils.NonNegativeFloat( default=1.0, description="Weight of the loss.", parameter_metadata=LOSS_METADATA["MAELoss"]["weight"], ) @classmethod def name(self) -> str: return "Mean Absolute Percentage Error (MAPE)" @DeveloperAPI @register_loss([NUMBER]) @ludwig_dataclass class RMSELossConfig(BaseLossConfig): type: str = schema_utils.ProtectedString( ROOT_MEAN_SQUARED_ERROR, description="Type of loss.", ) weight: float = schema_utils.NonNegativeFloat( default=1.0, description="Weight of the loss.", parameter_metadata=LOSS_METADATA["RMSELoss"]["weight"], ) @classmethod def name(self) -> str: return "Root Mean Squared Error (RMSE)" @DeveloperAPI @register_loss([NUMBER]) @ludwig_dataclass class RMSPELossConfig(BaseLossConfig): type: str = schema_utils.ProtectedString( ROOT_MEAN_SQUARED_PERCENTAGE_ERROR, description="Type of loss.", ) weight: float = schema_utils.NonNegativeFloat( default=1.0, description="Weight of the loss.", parameter_metadata=LOSS_METADATA["RMSPELoss"]["weight"], ) @classmethod def name(self) -> str: return "Root Mean Squared Percentage Error (RMSPE)" @DeveloperAPI @register_loss([BINARY]) @ludwig_dataclass class BWCEWLossConfig(BaseLossConfig): type: str = schema_utils.ProtectedString( BINARY_WEIGHTED_CROSS_ENTROPY, description="Type of loss.", ) positive_class_weight: float = schema_utils.NonNegativeFloat( default=None, allow_none=True, description="Weight of the positive class.", parameter_metadata=LOSS_METADATA["BWCEWLoss"]["positive_class_weight"], ) robust_lambda: int = schema_utils.NonNegativeInteger( default=0, description=ROBUST_LAMBDA_DESCRIPTION, parameter_metadata=LOSS_METADATA["BWCEWLoss"]["robust_lambda"], ) confidence_penalty: float = schema_utils.NonNegativeFloat( default=0, description=CONFIDENCE_PENALTY_DESCRIPTION, parameter_metadata=LOSS_METADATA["BWCEWLoss"]["confidence_penalty"], ) weight: float = schema_utils.NonNegativeFloat( default=1.0, description="Weight of the loss.", parameter_metadata=LOSS_METADATA["BWCEWLoss"]["weight"], ) @classmethod def name(self) -> str: return "Binary Weighted Cross Entropy (BWCE)" @DeveloperAPI @register_loss([CATEGORY, VECTOR, IMAGE]) @ludwig_dataclass class SoftmaxCrossEntropyLossConfig(BaseLossConfig): type: str = schema_utils.ProtectedString( SOFTMAX_CROSS_ENTROPY, description="Type of loss.", ) class_weights: list[float] | dict | None = schema_utils.OneOfOptionsField( default=None, description=CLASS_WEIGHTS_DESCRIPTION, field_options=[ schema_utils.Dict(default=None, allow_none=True), schema_utils.List(list_type=float, allow_none=False), ], parameter_metadata=LOSS_METADATA["SoftmaxCrossEntropyLoss"]["class_weights"], ) robust_lambda: int = schema_utils.NonNegativeInteger( default=0, description=ROBUST_LAMBDA_DESCRIPTION, parameter_metadata=LOSS_METADATA["SoftmaxCrossEntropyLoss"]["robust_lambda"], ) confidence_penalty: float = schema_utils.NonNegativeFloat( default=0, description=CONFIDENCE_PENALTY_DESCRIPTION, parameter_metadata=LOSS_METADATA["SoftmaxCrossEntropyLoss"]["confidence_penalty"], ) class_similarities: list = schema_utils.List( list, default=None, description=CLASS_SIMILARITIES_DESCRIPTION, parameter_metadata=LOSS_METADATA["SoftmaxCrossEntropyLoss"]["class_similarities"], ) class_similarities_temperature: int = schema_utils.NonNegativeInteger( default=0, description=CLASS_SIMILARITIES_TEMPERATURE_DESCRIPTION, parameter_metadata=LOSS_METADATA["SoftmaxCrossEntropyLoss"]["class_similarities_temperature"], ) weight: float = schema_utils.NonNegativeFloat( default=1.0, description="Weight of the loss.", parameter_metadata=LOSS_METADATA["SoftmaxCrossEntropyLoss"]["weight"], ) @classmethod def name(self) -> str: return "Softmax Cross Entropy" @DeveloperAPI @register_loss([SEQUENCE, TEXT]) @ludwig_dataclass class SequenceSoftmaxCrossEntropyLossConfig(BaseLossConfig): type: str = schema_utils.ProtectedString( SEQUENCE_SOFTMAX_CROSS_ENTROPY, description="Type of loss.", ) class_weights: list[float] | dict | None = schema_utils.OneOfOptionsField( default=None, description=CLASS_WEIGHTS_DESCRIPTION, field_options=[ schema_utils.Dict(default=None, allow_none=True), schema_utils.List(list_type=float, allow_none=False), ], parameter_metadata=LOSS_METADATA["SequenceSoftmaxCrossEntropyLoss"]["class_weights"], ) robust_lambda: int = schema_utils.NonNegativeInteger( default=0, description=ROBUST_LAMBDA_DESCRIPTION, parameter_metadata=LOSS_METADATA["SequenceSoftmaxCrossEntropyLoss"]["robust_lambda"], ) confidence_penalty: float = schema_utils.NonNegativeFloat( default=0, description=CONFIDENCE_PENALTY_DESCRIPTION, parameter_metadata=LOSS_METADATA["SequenceSoftmaxCrossEntropyLoss"]["confidence_penalty"], ) class_similarities: list = schema_utils.List( list, default=None, description=CLASS_SIMILARITIES_DESCRIPTION, parameter_metadata=LOSS_METADATA["SequenceSoftmaxCrossEntropyLoss"]["class_similarities"], ) class_similarities_temperature: int = schema_utils.NonNegativeInteger( default=0, description=CLASS_SIMILARITIES_TEMPERATURE_DESCRIPTION, parameter_metadata=LOSS_METADATA["SequenceSoftmaxCrossEntropyLoss"]["class_similarities_temperature"], ) weight: float = schema_utils.NonNegativeFloat( default=1.0, description="Weight of the loss.", parameter_metadata=LOSS_METADATA["SequenceSoftmaxCrossEntropyLoss"]["weight"], ) unique: bool = schema_utils.Boolean( default=False, description="If true, the loss is only computed for unique elements in the sequence.", parameter_metadata=LOSS_METADATA["SequenceSoftmaxCrossEntropyLoss"]["unique"], ) @classmethod def name(self) -> str: return "Sequence Softmax Cross Entropy" @DeveloperAPI @register_loss([SEQUENCE, TEXT]) @ludwig_dataclass class NextTokenSoftmaxCrossEntropyLossConfig(SequenceSoftmaxCrossEntropyLossConfig): type: str = schema_utils.ProtectedString( NEXT_TOKEN_SOFTMAX_CROSS_ENTROPY, description="Type of loss.", ) @classmethod def name(self) -> str: return "Next Token Softmax Cross Entropy" @DeveloperAPI @register_loss([SET]) @ludwig_dataclass class SigmoidCrossEntropyLossConfig(BaseLossConfig): type: str = schema_utils.ProtectedString( SIGMOID_CROSS_ENTROPY, description="Type of loss.", ) class_weights: list[float] | dict | None = schema_utils.OneOfOptionsField( default=None, description=CLASS_WEIGHTS_DESCRIPTION, field_options=[ schema_utils.Dict(default=None, allow_none=True), schema_utils.List(list_type=float, allow_none=False), ], parameter_metadata=LOSS_METADATA["SigmoidCrossEntropyLoss"]["class_weights"], ) weight: float = schema_utils.NonNegativeFloat( default=1.0, description="Weight of the loss.", parameter_metadata=LOSS_METADATA["SigmoidCrossEntropyLoss"]["weight"], ) @classmethod def name(self) -> str: return "Sigmoid Cross Entropy" @DeveloperAPI @register_loss([NUMBER, TIMESERIES, VECTOR]) @ludwig_dataclass class HuberLossConfig(BaseLossConfig): type: str = schema_utils.ProtectedString( HUBER, description=( "Loss that combines advantages of both `mean_absolute_error` (MAE) and `mean_squared_error` (MSE). The " "delta-scaled L1 region makes the loss less sensitive to outliers than MSE, while the L2 region provides " "smoothness over MAE near 0. See [Huber loss](https://en.wikipedia.org/wiki/Huber_loss) for more details." ), ) delta: float = schema_utils.FloatRange( default=1.0, min=0, min_inclusive=False, description="Threshold at which to change between delta-scaled L1 and L2 loss.", ) weight: float = schema_utils.NonNegativeFloat( default=1.0, description="Weight of the loss.", parameter_metadata=LOSS_METADATA["MSELoss"]["weight"], ) @classmethod def name(self) -> str: return "Huber Loss" @DeveloperAPI @register_loss([CATEGORY]) @ludwig_dataclass class CORNLossConfig(BaseLossConfig): """Conditional Ordinal Regression for Neural networks, used for ordered cateogry values. Source: Xintong Shi, Wenzhi Cao, and Sebastian Raschka (2021). Deep Neural Networks for Rank-Consistent Ordinal Regression Based On Conditional Probabilities. Arxiv preprint; https://arxiv.org/abs/2111.08851 """ type: str = schema_utils.ProtectedString( CORN, description="Type of loss.", ) weight: float = schema_utils.NonNegativeFloat( default=1.0, description="Weight of the loss.", parameter_metadata=LOSS_METADATA["MSELoss"]["weight"], ) @classmethod def name(self) -> str: return "Conditional Ordinal Regression (CORN)" @property def class_weights(self) -> int: return 1.0 @property def class_similarities_temperature(self) -> int: return 0 ================================================ FILE: ludwig/schema/features/loss/utils.py ================================================ from dataclasses import Field from ludwig.api_annotations import DeveloperAPI from ludwig.schema import utils as schema_utils from ludwig.schema.features.loss import get_loss_classes, get_loss_cls @DeveloperAPI def get_loss_conds(feature_type: str): """Returns a JSON schema of conditionals to validate against loss types for specific feature types.""" conds = [] for loss in get_loss_classes(feature_type): loss_cls = get_loss_cls(feature_type, loss) other_props = schema_utils.unload_jsonschema_from_marshmallow_class(loss_cls)["properties"] schema_utils.remove_duplicate_fields(other_props) loss_cond = schema_utils.create_cond( {"type": loss}, other_props, ) conds.append(loss_cond) return conds @DeveloperAPI def LossDataclassField(feature_type: str, default: str) -> Field: loss_registry = get_loss_classes(feature_type) class LossSelection(schema_utils.TypeSelection): def __init__(self): super().__init__(registry=loss_registry, default_value=default) def get_schema_from_registry(self, key: str) -> type[schema_utils.BaseMarshmallowConfig]: return get_loss_cls(feature_type, key) def _jsonschema_type_mapping(self): return { "type": "object", "properties": { "type": {"type": "string", "enum": list(loss_registry.keys()), "default": default}, }, "title": "loss_options", "allOf": get_loss_conds(feature_type), } return LossSelection().get_default_field() ================================================ FILE: ludwig/schema/features/number_feature.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.constants import MEAN_SQUARED_ERROR, MODEL_ECD, NUMBER from ludwig.schema import utils as schema_utils from ludwig.schema.decoders.base import BaseDecoderConfig from ludwig.schema.decoders.utils import DecoderDataclassField from ludwig.schema.encoders.base import BaseEncoderConfig from ludwig.schema.encoders.utils import EncoderDataclassField from ludwig.schema.features.base import BaseInputFeatureConfig, BaseOutputFeatureConfig from ludwig.schema.features.loss.loss import BaseLossConfig from ludwig.schema.features.loss.utils import LossDataclassField from ludwig.schema.features.preprocessing.base import BasePreprocessingConfig from ludwig.schema.features.preprocessing.utils import PreprocessingDataclassField from ludwig.schema.features.utils import ( ecd_defaults_config_registry, ecd_input_config_registry, ecd_output_config_registry, input_mixin_registry, output_mixin_registry, ) from ludwig.schema.metadata import FEATURE_METADATA from ludwig.schema.metadata.parameter_metadata import INTERNAL_ONLY from ludwig.schema.utils import BaseMarshmallowConfig, ludwig_dataclass @DeveloperAPI @input_mixin_registry.register(NUMBER) @ludwig_dataclass class NumberInputFeatureConfigMixin(BaseMarshmallowConfig): """NumberInputFeatureConfigMixin is a dataclass that configures the parameters used in both the number input feature and the number global defaults section of the Ludwig Config.""" preprocessing: BasePreprocessingConfig = PreprocessingDataclassField(feature_type=NUMBER) @DeveloperAPI @ludwig_dataclass class NumberInputFeatureConfig(NumberInputFeatureConfigMixin, BaseInputFeatureConfig): """NumberInputFeatureConfig is a dataclass that configures the parameters used for a number input feature.""" type: str = schema_utils.ProtectedString(NUMBER) encoder: BaseEncoderConfig = None @DeveloperAPI @ecd_input_config_registry.register(NUMBER) @ludwig_dataclass class ECDNumberInputFeatureConfig(NumberInputFeatureConfig): encoder: BaseEncoderConfig = EncoderDataclassField( MODEL_ECD, feature_type=NUMBER, default="passthrough", ) @DeveloperAPI @output_mixin_registry.register(NUMBER) @ludwig_dataclass class NumberOutputFeatureConfigMixin(BaseMarshmallowConfig): """NumberOutputFeatureConfigMixin is a dataclass that configures the parameters used in both the number output feature and the number global defaults section of the Ludwig Config.""" decoder: BaseDecoderConfig = None loss: BaseLossConfig = LossDataclassField( feature_type=NUMBER, default=MEAN_SQUARED_ERROR, ) @DeveloperAPI @ludwig_dataclass class NumberOutputFeatureConfig(NumberOutputFeatureConfigMixin, BaseOutputFeatureConfig): """NumberOutputFeatureConfig is a dataclass that configures the parameters used for a category output feature.""" type: str = schema_utils.ProtectedString(NUMBER) clip: list[int] | tuple[int] = schema_utils.FloatRangeTupleDataclassField( n=2, default=None, allow_none=True, min=0, max=999999999, description="Clip the predicted output to the specified range.", parameter_metadata=FEATURE_METADATA[NUMBER]["clip"], ) default_validation_metric: str = schema_utils.StringOptions( [MEAN_SQUARED_ERROR], default=MEAN_SQUARED_ERROR, description="Internal only use parameter: default validation metric for number output feature.", parameter_metadata=INTERNAL_ONLY, ) dependencies: list = schema_utils.List( default=[], description="List of input features that this feature depends on.", parameter_metadata=FEATURE_METADATA[NUMBER]["dependencies"], ) reduce_dependencies: str = schema_utils.ReductionOptions( default="sum", description="How to reduce the dependencies of the output feature.", parameter_metadata=FEATURE_METADATA[NUMBER]["reduce_dependencies"], ) reduce_input: str = schema_utils.ReductionOptions( default="sum", description="How to reduce an input that is not a vector, but a matrix or a higher order tensor, on the first " "dimension (second if you count the batch dimension)", parameter_metadata=FEATURE_METADATA[NUMBER]["reduce_input"], ) preprocessing: BasePreprocessingConfig = PreprocessingDataclassField(feature_type="number_output") @DeveloperAPI @ecd_output_config_registry.register(NUMBER) @ludwig_dataclass class ECDNumberOutputFeatureConfig(NumberOutputFeatureConfig): decoder: BaseDecoderConfig = DecoderDataclassField( MODEL_ECD, feature_type=NUMBER, default="regressor", ) @DeveloperAPI @ecd_defaults_config_registry.register(NUMBER) @ludwig_dataclass class NumberDefaultsConfig(NumberInputFeatureConfigMixin, NumberOutputFeatureConfigMixin): encoder: BaseEncoderConfig = EncoderDataclassField( MODEL_ECD, feature_type=NUMBER, default="passthrough", ) decoder: BaseDecoderConfig = DecoderDataclassField( MODEL_ECD, feature_type=NUMBER, default="regressor", ) ================================================ FILE: ludwig/schema/features/preprocessing/__init__.py ================================================ # Register all preprocessors from ludwig.schema.features.preprocessing import ( # noqa audio, bag, binary, category, date, h3, image, number, sequence, set, text, timeseries, vector, ) ================================================ FILE: ludwig/schema/features/preprocessing/audio.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.constants import AUDIO, BFILL, MISSING_VALUE_STRATEGY_OPTIONS, PREPROCESSING from ludwig.schema import utils as schema_utils from ludwig.schema.features.preprocessing.base import BasePreprocessingConfig from ludwig.schema.features.preprocessing.utils import register_preprocessor from ludwig.schema.metadata import FEATURE_METADATA from ludwig.schema.utils import ludwig_dataclass @DeveloperAPI @register_preprocessor(AUDIO) @ludwig_dataclass class AudioPreprocessingConfig(BasePreprocessingConfig): audio_file_length_limit_in_s: int = schema_utils.NonNegativeFloat( default=7.5, allow_none=False, description="Float value that defines the maximum limit of the audio file in seconds. All files longer than " "this limit are cut off. All files shorter than this limit are padded with padding_value", parameter_metadata=FEATURE_METADATA[AUDIO][PREPROCESSING]["audio_file_length_limit_in_s"], ) missing_value_strategy: str = schema_utils.StringOptions( MISSING_VALUE_STRATEGY_OPTIONS, default=BFILL, allow_none=False, description="What strategy to follow when there's a missing value in an audio column", parameter_metadata=FEATURE_METADATA[AUDIO][PREPROCESSING]["missing_value_strategy"], ) fill_value: float = schema_utils.NonNegativeFloat( default=None, allow_none=True, description="The value to replace missing values with in case the missing_value_strategy is fill_with_const", parameter_metadata=FEATURE_METADATA[AUDIO][PREPROCESSING]["fill_value"], ) computed_fill_value: float = schema_utils.NonNegativeFloat( default=None, allow_none=True, description="The internally computed fill value to replace missing values with in case the " "missing_value_strategy is fill_with_mode or fill_with_mean", parameter_metadata=FEATURE_METADATA[AUDIO][PREPROCESSING]["computed_fill_value"], ) in_memory: bool = schema_utils.Boolean( default=True, description="Defines whether the audio dataset will reside in memory during the training process or will be " "dynamically fetched from disk (useful for large datasets). In the latter case a training batch " "of input audio will be fetched from disk each training iteration.", parameter_metadata=FEATURE_METADATA[AUDIO][PREPROCESSING]["in_memory"], ) padding_value: float = schema_utils.NonNegativeFloat( default=0.0, allow_none=False, description="Float value that is used for padding.", parameter_metadata=FEATURE_METADATA[AUDIO][PREPROCESSING]["padding_value"], ) norm: str = schema_utils.StringOptions( ["per_file"], default=None, allow_none=True, description="Normalization strategy for the audio files. If None, no normalization is performed. If " "per_file, z-norm is applied on a 'per file' level", parameter_metadata=FEATURE_METADATA[AUDIO][PREPROCESSING]["norm"], ) type: str = schema_utils.StringOptions( ["fbank", "group_delay", "raw", "stft", "stft_phase"], default="fbank", description="Defines the type of audio feature to be used.", parameter_metadata=FEATURE_METADATA[AUDIO][PREPROCESSING]["type"], ) window_length_in_s: float = schema_utils.NonNegativeFloat( default=0.04, description="Defines the window length used for the short time Fourier transformation. This is only needed if " "the audio_feature_type is 'raw'.", parameter_metadata=FEATURE_METADATA[AUDIO][PREPROCESSING]["window_length_in_s"], ) window_shift_in_s: float = schema_utils.NonNegativeFloat( default=0.02, description="Defines the window shift used for the short time Fourier transformation (also called " "hop_length). This is only needed if the audio_feature_type is 'raw'. ", parameter_metadata=FEATURE_METADATA[AUDIO][PREPROCESSING]["window_shift_in_s"], ) num_fft_points: float = schema_utils.NonNegativeFloat( default=None, allow_none=True, description="Defines the number of fft points used for the short time Fourier transformation", parameter_metadata=FEATURE_METADATA[AUDIO][PREPROCESSING]["num_fft_points"], ) window_type: str = schema_utils.StringOptions( ["bartlett", "blackman", "hamming", "hann"], default="hamming", description="Defines the type window the signal is weighted before the short time Fourier transformation.", parameter_metadata=FEATURE_METADATA[AUDIO][PREPROCESSING]["window_type"], ) num_filter_bands: int = schema_utils.PositiveInteger( default=80, description="Defines the number of filters used in the filterbank. Only needed if audio_feature_type " "is 'fbank'", parameter_metadata=FEATURE_METADATA[AUDIO][PREPROCESSING]["num_filter_bands"], ) ================================================ FILE: ludwig/schema/features/preprocessing/bag.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.constants import BAG, FILL_WITH_CONST, MISSING_VALUE_STRATEGY_OPTIONS, PREPROCESSING from ludwig.schema import utils as schema_utils from ludwig.schema.features.preprocessing.base import BasePreprocessingConfig from ludwig.schema.features.preprocessing.utils import register_preprocessor from ludwig.schema.metadata import FEATURE_METADATA from ludwig.schema.utils import ludwig_dataclass from ludwig.utils import strings_utils from ludwig.utils.tokenizers import tokenizer_registry @DeveloperAPI @register_preprocessor(BAG) @ludwig_dataclass class BagPreprocessingConfig(BasePreprocessingConfig): tokenizer: str = schema_utils.StringOptions( tokenizer_registry.keys(), default="space", allow_none=False, description="Defines how to transform the raw text content of the dataset column to a set of elements. The " "default value space splits the string on spaces. Common options include: underscore (splits on " "underscore), comma (splits on comma), json (decodes the string into a set or a list through a " "JSON parser).", parameter_metadata=FEATURE_METADATA[BAG][PREPROCESSING]["tokenizer"], ) missing_value_strategy: str = schema_utils.StringOptions( MISSING_VALUE_STRATEGY_OPTIONS, default=FILL_WITH_CONST, allow_none=False, description="What strategy to follow when there's a missing value in a set column", parameter_metadata=FEATURE_METADATA[BAG][PREPROCESSING]["missing_value_strategy"], ) fill_value: str = schema_utils.String( default=strings_utils.UNKNOWN_SYMBOL, allow_none=False, description="The value to replace missing values with in case the missing_value_strategy is fill_with_const", parameter_metadata=FEATURE_METADATA[BAG][PREPROCESSING]["fill_value"], ) computed_fill_value: str = schema_utils.String( default=strings_utils.UNKNOWN_SYMBOL, allow_none=False, description="The internally computed fill value to replace missing values with in case the " "missing_value_strategy is fill_with_mode or fill_with_mean", parameter_metadata=FEATURE_METADATA[BAG][PREPROCESSING]["computed_fill_value"], ) lowercase: bool = schema_utils.Boolean( default=False, description="If true, converts the string to lowercase before tokenizing.", parameter_metadata=FEATURE_METADATA[BAG][PREPROCESSING]["lowercase"], ) most_common: int = schema_utils.PositiveInteger( default=10000, allow_none=True, description="The maximum number of most common tokens to be considered. If the data contains more than this " "amount, the most infrequent tokens will be treated as unknown.", parameter_metadata=FEATURE_METADATA[BAG][PREPROCESSING]["most_common"], ) ================================================ FILE: ludwig/schema/features/preprocessing/base.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.schema import utils as schema_utils @DeveloperAPI class BasePreprocessingConfig(schema_utils.BaseMarshmallowConfig): """Base class for input feature preprocessing. Not meant to be used directly. The dataclass format prevents arbitrary properties from being set. Consequently, in child classes, all properties from the corresponding input feature class are copied over: check each class to check which attributes are different from the preprocessing of each feature. """ ================================================ FILE: ludwig/schema/features/preprocessing/binary.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.constants import ( BFILL, BINARY, DROP_ROW, FFILL, FILL_WITH_FALSE, FILL_WITH_MODE, FILL_WITH_TRUE, PREPROCESSING, ) from ludwig.schema import utils as schema_utils from ludwig.schema.features.preprocessing.base import BasePreprocessingConfig from ludwig.schema.features.preprocessing.utils import register_preprocessor from ludwig.schema.metadata import FEATURE_METADATA from ludwig.schema.utils import ludwig_dataclass from ludwig.utils import strings_utils @DeveloperAPI @register_preprocessor(BINARY) @ludwig_dataclass class BinaryPreprocessingConfig(BasePreprocessingConfig): """BinaryPreprocessingConfig is a dataclass that configures the parameters used for a binary input feature.""" missing_value_strategy: str = schema_utils.StringOptions( [FILL_WITH_MODE, BFILL, FFILL, DROP_ROW, FILL_WITH_FALSE, FILL_WITH_TRUE], default=FILL_WITH_FALSE, allow_none=False, description="What strategy to follow when there's a missing value in a binary column", parameter_metadata=FEATURE_METADATA[BINARY][PREPROCESSING]["missing_value_strategy"], ) fallback_true_label: str = schema_utils.String( default=None, allow_none=True, description="The label to interpret as 1 (True) when the binary feature doesn't have a " "conventional boolean value", parameter_metadata=FEATURE_METADATA[BINARY][PREPROCESSING]["fallback_true_label"], ) fill_value: int | float | str = schema_utils.OneOfOptionsField( default=None, allow_none=True, field_options=[ schema_utils.FloatRange(default=None, allow_none=True, min=0, max=1, description=""), schema_utils.StringOptions(options=strings_utils.all_bool_strs(), default="Y", allow_none=False), schema_utils.Boolean(default=True, description=""), ], description="The value to replace missing values with in case the missing_value_strategy is fill_with_const", parameter_metadata=FEATURE_METADATA[BINARY][PREPROCESSING]["fill_value"], ) computed_fill_value: int | float | str = schema_utils.OneOfOptionsField( default=None, allow_none=True, field_options=[ schema_utils.FloatRange(default=1.0, allow_none=False, min=0, max=1, description=""), schema_utils.StringOptions(options=strings_utils.all_bool_strs(), default="Y", allow_none=False), schema_utils.Boolean(default=True, description=""), ], description="The internally computed fill value to replace missing values with in case the " "missing_value_strategy is fill_with_mode or fill_with_mean", parameter_metadata=FEATURE_METADATA[BINARY][PREPROCESSING]["computed_fill_value"], ) @DeveloperAPI @register_preprocessor("binary_output") @ludwig_dataclass class BinaryOutputPreprocessingConfig(BinaryPreprocessingConfig): missing_value_strategy: str = schema_utils.StringOptions( [FILL_WITH_MODE, BFILL, FFILL, DROP_ROW, FILL_WITH_FALSE, FILL_WITH_TRUE], default=DROP_ROW, allow_none=False, description="What strategy to follow when there's a missing value in a binary output feature", parameter_metadata=FEATURE_METADATA[BINARY][PREPROCESSING]["missing_value_strategy"], ) fallback_true_label: str = schema_utils.String( default=None, allow_none=True, description="The label to interpret as 1 (True) when the binary feature doesn't have a " "conventional boolean value", parameter_metadata=FEATURE_METADATA[BINARY][PREPROCESSING]["fallback_true_label"], ) ================================================ FILE: ludwig/schema/features/preprocessing/category.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.constants import CATEGORY, DROP_ROW, FILL_WITH_CONST, MISSING_VALUE_STRATEGY_OPTIONS, PREPROCESSING from ludwig.error import ConfigValidationError from ludwig.schema import utils as schema_utils from ludwig.schema.features.preprocessing.base import BasePreprocessingConfig from ludwig.schema.features.preprocessing.utils import register_preprocessor from ludwig.schema.metadata import FEATURE_METADATA, PREPROCESSING_METADATA from ludwig.schema.utils import ludwig_dataclass from ludwig.utils import strings_utils @DeveloperAPI @register_preprocessor(CATEGORY) @ludwig_dataclass class CategoryPreprocessingConfig(BasePreprocessingConfig): """CategoryPreprocessingConfig is a dataclass that configures the parameters used for a category input feature.""" missing_value_strategy: str = schema_utils.StringOptions( MISSING_VALUE_STRATEGY_OPTIONS, default=FILL_WITH_CONST, allow_none=False, description="What strategy to follow when there's a missing value in a category column", parameter_metadata=FEATURE_METADATA[CATEGORY][PREPROCESSING]["missing_value_strategy"], ) fill_value: str = schema_utils.String( default=strings_utils.UNKNOWN_SYMBOL, allow_none=False, description=( "The value to replace missing values with in case the `missing_value_strategy` is `fill_with_const`" ), parameter_metadata=FEATURE_METADATA[CATEGORY][PREPROCESSING]["fill_value"], ) computed_fill_value: str = schema_utils.String( default=strings_utils.UNKNOWN_SYMBOL, allow_none=False, description="The internally computed fill value to replace missing values with in case the " "missing_value_strategy is fill_with_mode or fill_with_mean", parameter_metadata=FEATURE_METADATA[CATEGORY][PREPROCESSING]["computed_fill_value"], ) lowercase: bool = schema_utils.Boolean( default=False, description="Whether the string has to be lowercased before being handled by the tokenizer.", parameter_metadata=FEATURE_METADATA[CATEGORY][PREPROCESSING]["lowercase"], ) most_common: int = schema_utils.PositiveInteger( default=10000, allow_none=True, description="The maximum number of most common tokens to be considered. if the data contains more than this " "amount, the most infrequent tokens will be treated as unknown.", parameter_metadata=FEATURE_METADATA[CATEGORY][PREPROCESSING]["most_common"], ) cache_encoder_embeddings: bool = schema_utils.Boolean( default=False, description=( "For fixed encoders, compute encoder embeddings in preprocessing to avoid this step at train time. " "Can speed up the time taken per step during training, but will invalidate the preprocessed data " "if the encoder type is changed." ), parameter_metadata=PREPROCESSING_METADATA["cache_encoder_embeddings"], ) @DeveloperAPI @register_preprocessor("category_output") @ludwig_dataclass class CategoryOutputPreprocessingConfig(CategoryPreprocessingConfig): missing_value_strategy: str = schema_utils.StringOptions( MISSING_VALUE_STRATEGY_OPTIONS, default=DROP_ROW, allow_none=False, description="What strategy to follow when there's a missing value in a category output feature", parameter_metadata=FEATURE_METADATA[CATEGORY][PREPROCESSING]["missing_value_strategy"], ) lowercase: bool = schema_utils.Boolean( default=False, description="Whether the string has to be lowercased before being handled by the tokenizer.", parameter_metadata=FEATURE_METADATA[CATEGORY][PREPROCESSING]["lowercase"], ) most_common: int = schema_utils.PositiveInteger( default=10000, allow_none=True, description="The maximum number of most common tokens to be considered. if the data contains more than this " "amount, the most infrequent tokens will be treated as unknown.", parameter_metadata=FEATURE_METADATA[CATEGORY][PREPROCESSING]["most_common"], ) @DeveloperAPI @register_preprocessor("category_distribution_output") @ludwig_dataclass class CategoryDistributionOutputPreprocessingConfig(BasePreprocessingConfig): def __post_init__(self): if self.vocab is None: raise ConfigValidationError("`vocab` must be specified for `category_distribution` output feature.") missing_value_strategy: str = schema_utils.StringOptions( MISSING_VALUE_STRATEGY_OPTIONS, default=DROP_ROW, allow_none=False, description="What strategy to follow when there's a missing value in a category output feature", parameter_metadata=FEATURE_METADATA[CATEGORY][PREPROCESSING]["missing_value_strategy"], ) vocab: list[str] = schema_utils.List(default=None) @DeveloperAPI @register_preprocessor("category_llm") @ludwig_dataclass class LLMCategoryOutputPreprocessingConfig(CategoryOutputPreprocessingConfig): def __post_init__(self): if self.vocab is None: raise ConfigValidationError("`vocab` must be specified for `category_llm` output feature.") if self.fallback_label is None: raise ConfigValidationError("`fallback_label` must be specified for `category_llm` output feature.") vocab: list[str] = schema_utils.List( default=None, allow_none=False, description="The list of labels that the model can predict.", ) fallback_label: str = schema_utils.String( default="", allow_none=False, description="The label to use when the model doesn't match any of the labels in the `labels` list.", ) ================================================ FILE: ludwig/schema/features/preprocessing/date.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.constants import BFILL, DATE, DROP_ROW, FFILL, FILL_WITH_CONST, PREPROCESSING from ludwig.schema import utils as schema_utils from ludwig.schema.features.preprocessing.base import BasePreprocessingConfig from ludwig.schema.features.preprocessing.utils import register_preprocessor from ludwig.schema.metadata import FEATURE_METADATA from ludwig.schema.utils import ludwig_dataclass @DeveloperAPI @register_preprocessor(DATE) @ludwig_dataclass class DatePreprocessingConfig(BasePreprocessingConfig): missing_value_strategy: str = schema_utils.StringOptions( [FILL_WITH_CONST, BFILL, FFILL, DROP_ROW], default=FILL_WITH_CONST, allow_none=False, description="What strategy to follow when there's a missing value in a date column", parameter_metadata=FEATURE_METADATA[DATE][PREPROCESSING]["missing_value_strategy"], ) fill_value: str = schema_utils.String( default="", allow_none=False, description="The value to replace missing values with in case the missing_value_strategy is fill_with_const", parameter_metadata=FEATURE_METADATA[DATE][PREPROCESSING]["fill_value"], ) computed_fill_value: str = schema_utils.String( default="", allow_none=False, description="The internally computed fill value to replace missing values with in case the " "missing_value_strategy is fill_with_mode or fill_with_mean", parameter_metadata=FEATURE_METADATA[DATE][PREPROCESSING]["computed_fill_value"], ) datetime_format: str = schema_utils.String( default=None, allow_none=True, description="This parameter can either be a datetime format string, or null, in which case the datetime " "format will be inferred automatically.", parameter_metadata=FEATURE_METADATA[DATE][PREPROCESSING]["datetime_format"], ) ================================================ FILE: ludwig/schema/features/preprocessing/h3.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.constants import FILL_WITH_CONST, H3, MISSING_VALUE_STRATEGY_OPTIONS, PREPROCESSING from ludwig.schema import utils as schema_utils from ludwig.schema.features.preprocessing.base import BasePreprocessingConfig from ludwig.schema.features.preprocessing.utils import register_preprocessor from ludwig.schema.metadata import FEATURE_METADATA from ludwig.schema.utils import ludwig_dataclass @DeveloperAPI @register_preprocessor(H3) @ludwig_dataclass class H3PreprocessingConfig(BasePreprocessingConfig): missing_value_strategy: str = schema_utils.StringOptions( MISSING_VALUE_STRATEGY_OPTIONS, default=FILL_WITH_CONST, allow_none=False, description="What strategy to follow when there's a missing value in an h3 column", parameter_metadata=FEATURE_METADATA[H3][PREPROCESSING]["missing_value_strategy"], ) fill_value: int = schema_utils.PositiveInteger( default=576495936675512319, allow_none=False, description="The value to replace missing values with in case the missing_value_strategy is fill_with_const", parameter_metadata=FEATURE_METADATA[H3][PREPROCESSING]["fill_value"], ) computed_fill_value: int = schema_utils.PositiveInteger( default=576495936675512319, allow_none=False, description="The internally computed fill value to replace missing values with in case the " "missing_value_strategy is fill_with_mode or fill_with_mean", parameter_metadata=FEATURE_METADATA[H3][PREPROCESSING]["computed_fill_value"], ) ================================================ FILE: ludwig/schema/features/preprocessing/image.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.constants import BFILL, DROP_ROW, IMAGE, IMAGENET1K, MISSING_VALUE_STRATEGY_OPTIONS, PREPROCESSING from ludwig.schema import utils as schema_utils from ludwig.schema.features.preprocessing.base import BasePreprocessingConfig from ludwig.schema.features.preprocessing.utils import register_preprocessor from ludwig.schema.metadata import FEATURE_METADATA from ludwig.schema.utils import ludwig_dataclass @DeveloperAPI @register_preprocessor(IMAGE) @ludwig_dataclass class ImagePreprocessingConfig(BasePreprocessingConfig): missing_value_strategy: str = schema_utils.StringOptions( MISSING_VALUE_STRATEGY_OPTIONS, default=BFILL, allow_none=False, description="What strategy to follow when there's a missing value in an image column", parameter_metadata=FEATURE_METADATA[IMAGE][PREPROCESSING]["missing_value_strategy"], ) fill_value: float = schema_utils.NonNegativeFloat( default=None, allow_none=True, description="The maximum number of most common tokens to be considered. If the data contains more than this " "amount, the most infrequent tokens will be treated as unknown.", parameter_metadata=FEATURE_METADATA[IMAGE][PREPROCESSING]["fill_value"], ) computed_fill_value: float = schema_utils.NonNegativeFloat( default=None, allow_none=True, description="The internally computed fill value to replace missing values with in case the " "missing_value_strategy is fill_with_mode or fill_with_mean", parameter_metadata=FEATURE_METADATA[IMAGE][PREPROCESSING]["computed_fill_value"], ) height: int | None = schema_utils.PositiveInteger( default=None, allow_none=True, description="The image height in pixels. If this parameter is set, images will be resized to the specified " "height using the resize_method parameter. If None, images will be resized to the size of the " "first image in the dataset.", parameter_metadata=FEATURE_METADATA[IMAGE][PREPROCESSING]["height"], ) width: int | None = schema_utils.PositiveInteger( default=None, allow_none=True, description="The image width in pixels. If this parameter is set, images will be resized to the specified " "width using the resize_method parameter. If None, images will be resized to the size of the " "first image in the dataset.", parameter_metadata=FEATURE_METADATA[IMAGE][PREPROCESSING]["width"], ) num_channels: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Number of channels in the images. If specified, images will be read in the mode specified by the " "number of channels. If not specified, the number of channels will be inferred from the image " "format of the first valid image in the dataset.", parameter_metadata=FEATURE_METADATA[IMAGE][PREPROCESSING]["num_channels"], ) resize_method: str = schema_utils.StringOptions( ["crop_or_pad", "interpolate"], default="interpolate", allow_none=False, description="The method to use for resizing images.", parameter_metadata=FEATURE_METADATA[IMAGE][PREPROCESSING]["resize_method"], ) infer_image_num_channels: bool = schema_utils.Boolean( default=True, description="If true, then the number of channels in the dataset is inferred from a sample of the first image " "in the dataset.", parameter_metadata=FEATURE_METADATA[IMAGE][PREPROCESSING]["infer_image_num_channels"], ) infer_image_dimensions: bool = schema_utils.Boolean( default=True, description="If true, then the height and width of images in the dataset will be inferred from a sample of " "the first image in the dataset. Each image that doesn't conform to these dimensions will be " "resized according to resize_method. If set to false, then the height and width of images in the " "dataset will be specified by the user.", parameter_metadata=FEATURE_METADATA[IMAGE][PREPROCESSING]["infer_image_dimensions"], ) infer_image_max_height: int = schema_utils.PositiveInteger( default=256, allow_none=False, description="If infer_image_dimensions is set, this is used as the maximum height of the images in " "the dataset.", parameter_metadata=FEATURE_METADATA[IMAGE][PREPROCESSING]["infer_image_max_height"], ) infer_image_max_width: int = schema_utils.PositiveInteger( default=256, allow_none=False, description="If infer_image_dimensions is set, this is used as the maximum width of the images in " "the dataset.", parameter_metadata=FEATURE_METADATA[IMAGE][PREPROCESSING]["infer_image_max_width"], ) infer_image_sample_size: int = schema_utils.PositiveInteger( default=100, allow_none=False, description="The sample size used for inferring dimensions of images in infer_image_dimensions.", parameter_metadata=FEATURE_METADATA[IMAGE][PREPROCESSING]["infer_image_sample_size"], ) standardize_image: str | None = schema_utils.StringOptions( [IMAGENET1K], default=None, allow_none=True, description="Standardize image by per channel mean centering and standard deviation scaling .", parameter_metadata=FEATURE_METADATA[IMAGE][PREPROCESSING]["standardize_image"], ) in_memory: bool = schema_utils.Boolean( default=True, description="Defines whether image dataset will reside in memory during the training process or will be " "dynamically fetched from disk (useful for large datasets). In the latter case a training batch " "of input images will be fetched from disk each training iteration.", parameter_metadata=FEATURE_METADATA[IMAGE][PREPROCESSING]["in_memory"], ) num_processes: int = schema_utils.PositiveInteger( default=1, allow_none=False, description="Specifies the number of processes to run for preprocessing images.", parameter_metadata=FEATURE_METADATA[IMAGE][PREPROCESSING]["num_processes"], ) requires_equal_dimensions: bool = schema_utils.Boolean( default=False, description="If true, then width and height must be equal.", parameter_metadata=FEATURE_METADATA[IMAGE][PREPROCESSING]["requires_equal_dimensions"], ) num_classes: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Number of channel classes in the images. If specified, this value will be validated " "against the inferred number of classes. Use 2 to convert grayscale images to binary images.", parameter_metadata=FEATURE_METADATA[IMAGE][PREPROCESSING]["num_classes"], ) infer_image_num_classes: bool = schema_utils.Boolean( default=False, description="If true, then the number of channel classes in the dataset will be inferred from a sample of " "the first image in the dataset. Each unique channel value will be mapped to a class and preprocessing will " "create a masked image based on the channel classes. ", parameter_metadata=FEATURE_METADATA[IMAGE][PREPROCESSING]["infer_image_num_classes"], ) @DeveloperAPI @register_preprocessor("image_output") @ludwig_dataclass class ImageOutputPreprocessingConfig(ImagePreprocessingConfig): missing_value_strategy: str = schema_utils.StringOptions( MISSING_VALUE_STRATEGY_OPTIONS, default=DROP_ROW, allow_none=False, description="What strategy to follow when there's a missing value in an image column", parameter_metadata=FEATURE_METADATA[IMAGE][PREPROCESSING]["missing_value_strategy"], ) ================================================ FILE: ludwig/schema/features/preprocessing/number.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.constants import ( DROP_ROW, FILL_WITH_CONST, FILL_WITH_MEAN, MISSING_VALUE_STRATEGY_OPTIONS, NUMBER, PREPROCESSING, ) from ludwig.schema import utils as schema_utils from ludwig.schema.features.preprocessing.base import BasePreprocessingConfig from ludwig.schema.features.preprocessing.utils import register_preprocessor from ludwig.schema.metadata import FEATURE_METADATA from ludwig.schema.utils import ludwig_dataclass @DeveloperAPI @register_preprocessor(NUMBER) @ludwig_dataclass class NumberPreprocessingConfig(BasePreprocessingConfig): """NumberPreprocessingConfig is a dataclass that configures the parameters used for a number input feature.""" missing_value_strategy: str = schema_utils.StringOptions( MISSING_VALUE_STRATEGY_OPTIONS + [FILL_WITH_MEAN], default=FILL_WITH_CONST, allow_none=False, description="What strategy to follow when there's a missing value in a number column", parameter_metadata=FEATURE_METADATA[NUMBER][PREPROCESSING]["missing_value_strategy"], ) fill_value: float = schema_utils.FloatRange( default=0.0, allow_none=False, description="The value to replace missing values with in case the missing_value_strategy is fill_with_const", parameter_metadata=FEATURE_METADATA[NUMBER][PREPROCESSING]["fill_value"], ) computed_fill_value: float = schema_utils.FloatRange( default=0.0, allow_none=False, description="The internally computed fill value to replace missing values with in case the " "missing_value_strategy is fill_with_mode or fill_with_mean", parameter_metadata=FEATURE_METADATA[NUMBER][PREPROCESSING]["computed_fill_value"], ) normalization: str = schema_utils.StringOptions( ["zscore", "minmax", "log1p", "iq"], default="zscore", allow_none=True, description=( "Normalization strategy to use for this number feature. If the value is `null` no normalization is " "performed." ), parameter_metadata=FEATURE_METADATA[NUMBER][PREPROCESSING]["normalization"], ) outlier_strategy: str | None = schema_utils.StringOptions( MISSING_VALUE_STRATEGY_OPTIONS + [FILL_WITH_MEAN, None], default=None, allow_none=True, description=( "Determines how outliers will be handled in the dataset. In most cases, replacing outliers with the " "column mean (`fill_with_mean`) will be sufficient, but in others the outliers may be damaging enough " "to merit dropping the entire row of data (`drop_row`). In some cases, the best way to handle outliers " "is to leave them in the data, which is the behavior when this parameter is left as `null`." ), parameter_metadata=FEATURE_METADATA[NUMBER][PREPROCESSING]["outlier_strategy"], ) outlier_threshold: float | None = schema_utils.FloatRange( default=3.0, allow_none=False, min=0.0, description=( "Standard deviations from the mean past which a value is considered an outlier. The 3-sigma " "rule in statistics tells us that when data is normally distributed, 95% of the data will lie within 2 " "standard deviations of the mean, and greater than 99% of the data will lie within 3 standard deviations " "of the mean (see: [68–95–99.7 rule](https://en.wikipedia.org/wiki/68%E2%80%9395%E2%80%9399.7_rule)). " "As such anything farther away than that is highly likely to be an outlier, and may distort the learning " "process by disproportionately affecting the model." ), parameter_metadata=FEATURE_METADATA[NUMBER][PREPROCESSING]["outlier_threshold"], ) computed_outlier_fill_value: float = schema_utils.FloatRange( default=0.0, allow_none=False, description="The internally computed fill value to replace outliers with in case the " "outlier_strategy is fill_with_mode or fill_with_mean", parameter_metadata=FEATURE_METADATA[NUMBER][PREPROCESSING]["computed_outlier_fill_value"], ) @DeveloperAPI @register_preprocessor("number_output") @ludwig_dataclass class NumberOutputPreprocessingConfig(NumberPreprocessingConfig): missing_value_strategy: str = schema_utils.StringOptions( MISSING_VALUE_STRATEGY_OPTIONS + [FILL_WITH_MEAN], default=DROP_ROW, allow_none=False, description="What strategy to follow when there's a missing value in a number output feature", parameter_metadata=FEATURE_METADATA[NUMBER][PREPROCESSING]["missing_value_strategy"], ) normalization: str = schema_utils.StringOptions( ["zscore", "minmax", "log1p", "iq"], default=None, allow_none=True, description="Normalization strategy to use for this number feature.", parameter_metadata=FEATURE_METADATA[NUMBER][PREPROCESSING]["normalization"], ) ================================================ FILE: ludwig/schema/features/preprocessing/sequence.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.constants import DROP_ROW, FILL_WITH_CONST, MISSING_VALUE_STRATEGY_OPTIONS, PREPROCESSING, SEQUENCE from ludwig.schema import utils as schema_utils from ludwig.schema.features.preprocessing.base import BasePreprocessingConfig from ludwig.schema.features.preprocessing.utils import register_preprocessor from ludwig.schema.metadata import FEATURE_METADATA, PREPROCESSING_METADATA from ludwig.schema.utils import ludwig_dataclass from ludwig.utils import strings_utils @DeveloperAPI @register_preprocessor(SEQUENCE) @ludwig_dataclass class SequencePreprocessingConfig(BasePreprocessingConfig): tokenizer: str = schema_utils.String( default="space", allow_none=False, description="Defines how to map from the raw string content of the dataset column to a sequence of elements.", parameter_metadata=FEATURE_METADATA[SEQUENCE][PREPROCESSING]["tokenizer"], ) vocab_file: str = schema_utils.String( default=None, allow_none=True, description="Filepath string to a UTF-8 encoded file containing the sequence's vocabulary. On each line the " "first string until \t or \n is considered a word.", parameter_metadata=FEATURE_METADATA[SEQUENCE][PREPROCESSING]["vocab_file"], ) sequence_length: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="The desired length (number of tokens) of the sequence. Sequences that are longer than this value " "will be truncated and sequences shorter than this value will be padded. If None, sequence length will be " "inferred from the training dataset.", parameter_metadata=FEATURE_METADATA[SEQUENCE][PREPROCESSING]["sequence_length"], ) max_sequence_length: int = schema_utils.PositiveInteger( default=256, allow_none=True, description="The maximum length (number of tokens) of the sequence. Sequences that are longer than this value " "will be truncated. Useful as a stopgap measure if `sequence_length` is set to `None`. If `None`, max sequence " "length will be inferred from the training dataset.", parameter_metadata=FEATURE_METADATA[SEQUENCE][PREPROCESSING]["max_sequence_length"], ) most_common: int = schema_utils.PositiveInteger( default=20000, allow_none=False, description="The maximum number of most common tokens in the vocabulary. If the data contains more than this " "amount, the most infrequent symbols will be treated as unknown.", parameter_metadata=FEATURE_METADATA[SEQUENCE][PREPROCESSING]["most_common"], ) padding_symbol: str = schema_utils.String( default=strings_utils.PADDING_SYMBOL, allow_none=False, description="The string used as a padding symbol. This special token is mapped to the integer ID 0 in the " "vocabulary.", parameter_metadata=FEATURE_METADATA[SEQUENCE][PREPROCESSING]["padding_symbol"], ) unknown_symbol: str = schema_utils.String( default=strings_utils.UNKNOWN_SYMBOL, allow_none=False, description="The string used as an unknown placeholder. This special token is mapped to the integer ID 1 in " "the vocabulary.", parameter_metadata=FEATURE_METADATA[SEQUENCE][PREPROCESSING]["unknown_symbol"], ) padding: str = schema_utils.StringOptions( ["left", "right"], default="right", allow_none=False, description="The direction of the padding.", parameter_metadata=FEATURE_METADATA[SEQUENCE][PREPROCESSING]["padding"], ) lowercase: bool = schema_utils.Boolean( default=False, description="If true, converts the string to lowercase before tokenizing.", parameter_metadata=FEATURE_METADATA[SEQUENCE][PREPROCESSING]["lowercase"], ) missing_value_strategy: str = schema_utils.StringOptions( MISSING_VALUE_STRATEGY_OPTIONS, default=FILL_WITH_CONST, allow_none=False, description="What strategy to follow when there's a missing value in a text column", parameter_metadata=FEATURE_METADATA[SEQUENCE][PREPROCESSING]["missing_value_strategy"], ) fill_value: str = schema_utils.String( default=strings_utils.UNKNOWN_SYMBOL, allow_none=False, description="The value to replace missing values with in case the missing_value_strategy is fill_with_const", parameter_metadata=FEATURE_METADATA[SEQUENCE][PREPROCESSING]["fill_value"], ) computed_fill_value: str = schema_utils.String( default=strings_utils.UNKNOWN_SYMBOL, allow_none=False, description="The internally computed fill value to replace missing values with in case the " "missing_value_strategy is fill_with_mode or fill_with_mean", parameter_metadata=FEATURE_METADATA[SEQUENCE][PREPROCESSING]["computed_fill_value"], ) ngram_size: int = schema_utils.PositiveInteger( default=2, allow_none=False, description="The size of the ngram when using the `ngram` tokenizer (e.g, 2 = bigram, 3 = trigram, etc.).", parameter_metadata=FEATURE_METADATA[SEQUENCE][PREPROCESSING]["ngram_size"], ) cache_encoder_embeddings: bool = schema_utils.Boolean( default=False, description="Compute encoder embeddings in preprocessing, speeding up training time considerably.", parameter_metadata=PREPROCESSING_METADATA["cache_encoder_embeddings"], ) @DeveloperAPI @register_preprocessor("sequence_output") @ludwig_dataclass class SequenceOutputPreprocessingConfig(SequencePreprocessingConfig): missing_value_strategy: str = schema_utils.StringOptions( MISSING_VALUE_STRATEGY_OPTIONS, default=DROP_ROW, allow_none=False, description="What strategy to follow when there's a missing value in a sequence output feature", parameter_metadata=FEATURE_METADATA[SEQUENCE][PREPROCESSING]["missing_value_strategy"], ) sequence_length: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="The desired length (number of tokens) of the sequence. Sequences that are longer than this value " "will be truncated and sequences shorter than this value will be padded. If None, sequence length will be " "inferred from the training dataset.", ) max_sequence_length: int = schema_utils.PositiveInteger( default=256, allow_none=True, description="The maximum length (number of tokens) of the sequence. Sequences that are longer than this value " "will be truncated. Useful as a stopgap measure if `sequence_length` is set to `None`. If `None`, max sequence " "length will be inferred from the training dataset.", parameter_metadata=FEATURE_METADATA[SEQUENCE][PREPROCESSING]["max_sequence_length"], ) tokenizer: str = schema_utils.String( default="space", allow_none=False, description="Defines how to map from the raw string content of the dataset column to a sequence of elements.", parameter_metadata=FEATURE_METADATA[SEQUENCE][PREPROCESSING]["tokenizer"], ) lowercase: bool = schema_utils.Boolean( default=False, description="If true, converts the string to lowercase before tokenizing.", parameter_metadata=FEATURE_METADATA[SEQUENCE][PREPROCESSING]["lowercase"], ) most_common: int = schema_utils.PositiveInteger( default=20000, allow_none=False, description="The maximum number of most common tokens in the vocabulary. If the data contains more than this " "amount, the most infrequent symbols will be treated as unknown.", parameter_metadata=FEATURE_METADATA[SEQUENCE][PREPROCESSING]["most_common"], ) ngram_size: int = schema_utils.PositiveInteger( default=2, allow_none=False, description="The size of the ngram when using the `ngram` tokenizer (e.g, 2 = bigram, 3 = trigram, etc.).", parameter_metadata=FEATURE_METADATA[SEQUENCE][PREPROCESSING]["ngram_size"], ) ================================================ FILE: ludwig/schema/features/preprocessing/set.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.constants import DROP_ROW, FILL_WITH_CONST, MISSING_VALUE_STRATEGY_OPTIONS, PREPROCESSING, SET from ludwig.schema import utils as schema_utils from ludwig.schema.features.preprocessing.base import BasePreprocessingConfig from ludwig.schema.features.preprocessing.utils import register_preprocessor from ludwig.schema.metadata import FEATURE_METADATA from ludwig.schema.utils import ludwig_dataclass from ludwig.utils import strings_utils @DeveloperAPI @register_preprocessor(SET) @ludwig_dataclass class SetPreprocessingConfig(BasePreprocessingConfig): tokenizer: str = schema_utils.String( default="space", allow_none=False, description="Defines how to transform the raw text content of the dataset column to a set of elements. The " "default value space splits the string on spaces. Common options include: underscore (splits on " "underscore), comma (splits on comma), json (decodes the string into a set or a list through a " "JSON parser).", parameter_metadata=FEATURE_METADATA[SET][PREPROCESSING]["tokenizer"], ) missing_value_strategy: str = schema_utils.StringOptions( MISSING_VALUE_STRATEGY_OPTIONS, default=FILL_WITH_CONST, allow_none=False, description="What strategy to follow when there's a missing value in a set column", parameter_metadata=FEATURE_METADATA[SET][PREPROCESSING]["missing_value_strategy"], ) fill_value: str = schema_utils.String( default=strings_utils.UNKNOWN_SYMBOL, allow_none=False, description="The value to replace missing values with in case the missing_value_strategy is fill_with_const", parameter_metadata=FEATURE_METADATA[SET][PREPROCESSING]["fill_value"], ) computed_fill_value: str = schema_utils.String( default=strings_utils.UNKNOWN_SYMBOL, allow_none=False, description="The internally computed fill value to replace missing values with in case the " "missing_value_strategy is fill_with_mode or fill_with_mean", parameter_metadata=FEATURE_METADATA[SET][PREPROCESSING]["computed_fill_value"], ) lowercase: bool = schema_utils.Boolean( default=False, description="If true, converts the string to lowercase before tokenizing.", parameter_metadata=FEATURE_METADATA[SET][PREPROCESSING]["lowercase"], ) most_common: int = schema_utils.PositiveInteger( default=10000, allow_none=True, description="The maximum number of most common tokens to be considered. If the data contains more than this " "amount, the most infrequent tokens will be treated as unknown.", parameter_metadata=FEATURE_METADATA[SET][PREPROCESSING]["most_common"], ) @DeveloperAPI @register_preprocessor("set_output") @ludwig_dataclass class SetOutputPreprocessingConfig(SetPreprocessingConfig): missing_value_strategy: str = schema_utils.StringOptions( MISSING_VALUE_STRATEGY_OPTIONS, default=DROP_ROW, allow_none=False, description="What strategy to follow when there's a missing value in a set output feature", parameter_metadata=FEATURE_METADATA[SET][PREPROCESSING]["missing_value_strategy"], ) tokenizer: str = schema_utils.String( default="space", allow_none=False, description="Defines how to transform the raw text content of the dataset column to a set of elements. The " "default value space splits the string on spaces. Common options include: underscore (splits on " "underscore), comma (splits on comma), json (decodes the string into a set or a list through a " "JSON parser).", parameter_metadata=FEATURE_METADATA[SET][PREPROCESSING]["tokenizer"], ) lowercase: bool = schema_utils.Boolean( default=False, description="If true, converts the string to lowercase before tokenizing.", parameter_metadata=FEATURE_METADATA[SET][PREPROCESSING]["lowercase"], ) most_common: int = schema_utils.PositiveInteger( default=10000, allow_none=True, description="The maximum number of most common tokens to be considered. If the data contains more than this " "amount, the most infrequent tokens will be treated as unknown.", parameter_metadata=FEATURE_METADATA[SET][PREPROCESSING]["most_common"], ) ================================================ FILE: ludwig/schema/features/preprocessing/text.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.constants import DROP_ROW, FILL_WITH_CONST, MISSING_VALUE_STRATEGY_OPTIONS, PREPROCESSING, TEXT from ludwig.schema import utils as schema_utils from ludwig.schema.features.preprocessing.base import BasePreprocessingConfig from ludwig.schema.features.preprocessing.utils import register_preprocessor from ludwig.schema.llms.prompt import PromptConfig, PromptConfigField from ludwig.schema.metadata import FEATURE_METADATA, PREPROCESSING_METADATA from ludwig.schema.metadata.parameter_metadata import INTERNAL_ONLY from ludwig.schema.utils import ludwig_dataclass from ludwig.utils import strings_utils from ludwig.utils.tokenizers import tokenizer_registry @DeveloperAPI @ludwig_dataclass class BaseTextPreprocessingConfig(BasePreprocessingConfig): """TextPreprocessingConfig is a dataclass that configures the parameters used for a text input feature.""" pretrained_model_name_or_path: str = schema_utils.String( default=None, allow_none=True, description="This can be either the name of a pretrained HuggingFace model or a path where it was downloaded.", parameter_metadata=FEATURE_METADATA[TEXT][PREPROCESSING]["pretrained_model_name_or_path"], ) tokenizer: str = schema_utils.StringOptions( tokenizer_registry.keys(), default="space_punct", allow_none=False, description="Defines how to map from the raw string content of the dataset column to a sequence of elements.", parameter_metadata=FEATURE_METADATA[TEXT][PREPROCESSING]["tokenizer"], ) vocab_file: str = schema_utils.String( default=None, allow_none=True, description="Filepath string to a UTF-8 encoded file containing the sequence's vocabulary. On each line the " "first string until `\\t` or `\\n` is considered a word.", parameter_metadata=FEATURE_METADATA[TEXT][PREPROCESSING]["vocab_file"], ) sequence_length: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="The desired length (number of tokens) of the sequence. Sequences that are longer than this value " "will be truncated and sequences shorter than this value will be padded. If None, sequence length will be " "inferred from the training dataset.", ) max_sequence_length: int = schema_utils.PositiveInteger( default=256, allow_none=True, description="The maximum length (number of tokens) of the sequence. Sequences that are longer than this value " "will be truncated. Useful as a stopgap measure if `sequence_length` is set to `None`. If `None`, max sequence " "length will be inferred from the training dataset.", parameter_metadata=FEATURE_METADATA[TEXT][PREPROCESSING]["max_sequence_length"], ) most_common: int = schema_utils.PositiveInteger( default=20000, allow_none=False, description="The maximum number of most common tokens in the vocabulary. If the data contains more than this " "amount, the most infrequent symbols will be treated as unknown.", parameter_metadata=FEATURE_METADATA[TEXT][PREPROCESSING]["most_common"], ) padding_symbol: str = schema_utils.String( default=strings_utils.PADDING_SYMBOL, allow_none=False, description="The string used as the padding symbol for sequence features. Ignored for features using " "huggingface encoders, which have their own vocabulary.", parameter_metadata=FEATURE_METADATA[TEXT][PREPROCESSING]["padding_symbol"], ) unknown_symbol: str = schema_utils.String( default=strings_utils.UNKNOWN_SYMBOL, allow_none=False, description="The string used as the unknown symbol for sequence features. Ignored for features using " "huggingface encoders, which have their own vocabulary.", parameter_metadata=FEATURE_METADATA[TEXT][PREPROCESSING]["unknown_symbol"], ) padding: str = schema_utils.StringOptions( ["left", "right"], default="right", allow_none=False, description="The direction of the padding.", parameter_metadata=FEATURE_METADATA[TEXT][PREPROCESSING]["padding"], ) lowercase: bool = schema_utils.Boolean( default=False, description="If true, converts the string to lowercase before tokenizing.", parameter_metadata=FEATURE_METADATA[TEXT][PREPROCESSING]["lowercase"], ) missing_value_strategy: str = schema_utils.StringOptions( MISSING_VALUE_STRATEGY_OPTIONS, default=FILL_WITH_CONST, allow_none=False, description="What strategy to follow when there's a missing value in a text column.", parameter_metadata=FEATURE_METADATA[TEXT][PREPROCESSING]["missing_value_strategy"], ) fill_value: str = schema_utils.String( default=strings_utils.UNKNOWN_SYMBOL, allow_none=False, description=( "The value to replace missing values with in case the `missing_value_strategy` is `fill_with_const`." ), parameter_metadata=FEATURE_METADATA[TEXT][PREPROCESSING]["fill_value"], ) computed_fill_value: str = schema_utils.String( default=strings_utils.UNKNOWN_SYMBOL, allow_none=False, description="The internally computed fill value to replace missing values with in case the " "`missing_value_strategy` is `fill_with_mode` or `fill_with_mean`.", parameter_metadata=FEATURE_METADATA[TEXT][PREPROCESSING]["computed_fill_value"], ) ngram_size: int = schema_utils.PositiveInteger( default=2, allow_none=False, description="The size of the ngram when using the `ngram` tokenizer (e.g, 2 = bigram, 3 = trigram, etc.).", parameter_metadata=FEATURE_METADATA[TEXT][PREPROCESSING]["ngram_size"], ) cache_encoder_embeddings: bool = schema_utils.Boolean( default=False, description=( "For pretrained encoders, compute encoder embeddings in preprocessing, " "speeding up training time considerably. Only supported when `encoder.trainable=false`." ), parameter_metadata=PREPROCESSING_METADATA["cache_encoder_embeddings"], ) compute_idf: bool = schema_utils.Boolean( default=False, parameter_metadata=INTERNAL_ONLY, ) @DeveloperAPI @register_preprocessor(TEXT) @ludwig_dataclass class TextPreprocessingConfig(BaseTextPreprocessingConfig): """TextPreprocessingConfig is a dataclass that configures the parameters used for a text input feature.""" prompt: PromptConfig = PromptConfigField().get_default_field() @DeveloperAPI @register_preprocessor("text_llm_input") @ludwig_dataclass class LLMTextInputPreprocessingConfig(BaseTextPreprocessingConfig): """LLMs require the prompt to be provided at the top-level, not preprocessing.""" max_sequence_length: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="The maximum length (number of tokens) of the sequence. Sequences that are longer than this value " "will be truncated. Useful as a stopgap measure if `sequence_length` is set to `None`. If `None`, max sequence " "length will be inferred from the training dataset.", parameter_metadata=FEATURE_METADATA[TEXT][PREPROCESSING]["max_sequence_length"], ) @DeveloperAPI @register_preprocessor("text_output") @ludwig_dataclass class TextOutputPreprocessingConfig(BaseTextPreprocessingConfig): missing_value_strategy: str = schema_utils.StringOptions( MISSING_VALUE_STRATEGY_OPTIONS, default=DROP_ROW, allow_none=False, description="What strategy to follow when there's a missing value in a text output feature.", parameter_metadata=FEATURE_METADATA[TEXT][PREPROCESSING]["missing_value_strategy"], ) sequence_length: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="The desired length (number of tokens) of the sequence. Sequences that are longer than this value " "will be truncated and sequences shorter than this value will be padded. If None, sequence length will be " "inferred from the training dataset.", parameter_metadata=FEATURE_METADATA[TEXT][PREPROCESSING]["sequence_length"], ) max_sequence_length: int = schema_utils.PositiveInteger( default=256, allow_none=True, description="The maximum length (number of tokens) of the sequence. Sequences that are longer than this value " "will be truncated. Useful as a stopgap measure if `sequence_length` is set to `None`. If `None`, max sequence " "length will be inferred from the training dataset.", parameter_metadata=FEATURE_METADATA[TEXT][PREPROCESSING]["max_sequence_length"], ) tokenizer: str = schema_utils.StringOptions( tokenizer_registry.keys(), default="space_punct", allow_none=False, description="Defines how to map from the raw string content of the dataset column to a sequence of elements.", parameter_metadata=FEATURE_METADATA[TEXT][PREPROCESSING]["tokenizer"], ) lowercase: bool = schema_utils.Boolean( default=False, description="If true, converts the string to lowercase before tokenizing.", parameter_metadata=FEATURE_METADATA[TEXT][PREPROCESSING]["lowercase"], ) most_common: int = schema_utils.PositiveInteger( default=20000, allow_none=False, description="The maximum number of most common tokens in the vocabulary. If the data contains more than this " "amount, the most infrequent symbols will be treated as unknown.", parameter_metadata=FEATURE_METADATA[TEXT][PREPROCESSING]["most_common"], ) ngram_size: int = schema_utils.PositiveInteger( default=2, allow_none=False, description="The size of the ngram when using the `ngram` tokenizer (e.g, 2 = bigram, 3 = trigram, etc.).", parameter_metadata=FEATURE_METADATA[TEXT][PREPROCESSING]["ngram_size"], ) @DeveloperAPI @register_preprocessor("text_llm_output") @ludwig_dataclass class LLMTextOutputPreprocessingConfig(TextOutputPreprocessingConfig): max_sequence_length: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="The maximum length (number of tokens) of the sequence. Sequences that are longer than this value " "will be truncated. Useful as a stopgap measure if `sequence_length` is set to `None`. If `None`, max sequence " "length will be inferred from the training dataset.", parameter_metadata=FEATURE_METADATA[TEXT][PREPROCESSING]["max_sequence_length"], ) ================================================ FILE: ludwig/schema/features/preprocessing/timeseries.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.constants import DROP_ROW, FILL_WITH_CONST, MISSING_VALUE_STRATEGY_OPTIONS, PREPROCESSING, TIMESERIES from ludwig.schema import utils as schema_utils from ludwig.schema.features.preprocessing.base import BasePreprocessingConfig from ludwig.schema.features.preprocessing.utils import register_preprocessor from ludwig.schema.metadata import FEATURE_METADATA from ludwig.schema.utils import ludwig_dataclass from ludwig.utils.tokenizers import tokenizer_registry @ludwig_dataclass class BaseTimeseriesPreprocessingConfig(BasePreprocessingConfig): tokenizer: str = schema_utils.StringOptions( tokenizer_registry.keys(), default="space", allow_none=False, description="Defines how to map from the raw string content of the dataset column to a sequence of elements.", parameter_metadata=FEATURE_METADATA[TIMESERIES][PREPROCESSING]["tokenizer"], ) timeseries_length_limit: int = schema_utils.PositiveInteger( default=256, allow_none=False, description="Defines the maximum length of the timeseries. All timeseries longer than this limit are cut off.", parameter_metadata=FEATURE_METADATA[TIMESERIES][PREPROCESSING]["timeseries_length_limit"], ) padding_value: float = schema_utils.NonNegativeFloat( default=0.0, allow_none=False, description="Float value that is used for padding and replacing missing values within a row.", parameter_metadata=FEATURE_METADATA[TIMESERIES][PREPROCESSING]["padding_value"], ) padding: str = schema_utils.StringOptions( ["left", "right"], default="right", allow_none=False, description="The direction of the padding.", parameter_metadata=FEATURE_METADATA[TIMESERIES][PREPROCESSING]["padding"], ) missing_value_strategy: str = schema_utils.StringOptions( MISSING_VALUE_STRATEGY_OPTIONS, default=FILL_WITH_CONST, allow_none=False, description=( "What strategy to follow when there's a missing value in a column. Currently applies only to a row missing " "in its entirety, not invididual elements within the row. For now, `NaN` values within a row are filled " "using the `padding_value`." ), parameter_metadata=FEATURE_METADATA[TIMESERIES][PREPROCESSING]["missing_value_strategy"], ) fill_value: str = schema_utils.String( default="", allow_none=False, description=( "The value to replace missing values with in case the `missing_value_strategy` is `fill_with_const`." ), parameter_metadata=FEATURE_METADATA[TIMESERIES][PREPROCESSING]["fill_value"], ) computed_fill_value: str = schema_utils.String( default="", allow_none=False, description=( "The internally computed fill value to replace missing values with in case the " "`missing_value_strategy` is `fill_with_mode` or `fill_with_mean`." ), parameter_metadata=FEATURE_METADATA[TIMESERIES][PREPROCESSING]["computed_fill_value"], ) @DeveloperAPI @register_preprocessor(TIMESERIES) @ludwig_dataclass class TimeseriesPreprocessingConfig(BaseTimeseriesPreprocessingConfig): window_size: int = schema_utils.NonNegativeInteger( default=0, allow_none=False, description=( "Optional lookback window size used to convert a column-major dataset (one observation per row) " "into a row-major dataset (each row has a timeseries window of observations). Starting from a given " "observation, a sliding window is taken going `window_size - 1` rows back to form the timeseries input " "feature. If this value is left as 0, then it is assumed that the dataset has been provided in row-major " "format (i.e., it has already been preprocessed such that each row is a timeseries window)." ), ) missing_value_strategy: str = schema_utils.StringOptions( MISSING_VALUE_STRATEGY_OPTIONS, default=FILL_WITH_CONST, allow_none=False, description="What strategy to follow when a row of data is missing.", parameter_metadata=FEATURE_METADATA[TIMESERIES][PREPROCESSING]["missing_value_strategy"], ) @DeveloperAPI @register_preprocessor("timeseries_output") @ludwig_dataclass class TimeseriesOutputPreprocessingConfig(BaseTimeseriesPreprocessingConfig): horizon: int = schema_utils.NonNegativeInteger( default=0, allow_none=False, description=( "Optional forecasting horizon used to convert a column-major dataset (one observation per row) " "into a row-major dataset (each row has a timeseries window of observations). Starting from a given " "observation, a sliding window is token going `horizon` rows forward in time, excluding the observation " "in the current row. If this value is left as 0, then it is assumed that the dataset has been provided in " "row-major format (i.e., it has already been preprocessed such that each row is a timeseries window)." ), ) missing_value_strategy: str = schema_utils.StringOptions( MISSING_VALUE_STRATEGY_OPTIONS, default=DROP_ROW, allow_none=False, description="What strategy to follow when a row of data is missing.", parameter_metadata=FEATURE_METADATA[TIMESERIES][PREPROCESSING]["missing_value_strategy"], ) ================================================ FILE: ludwig/schema/features/preprocessing/utils.py ================================================ from dataclasses import field from ludwig.api_annotations import DeveloperAPI from ludwig.error import ConfigValidationError from ludwig.schema import utils as schema_utils from ludwig.schema.features.preprocessing.base import BasePreprocessingConfig from ludwig.utils.registry import Registry preprocessing_registry = Registry() @DeveloperAPI def register_preprocessor(name: str): def wrap(preprocessing_config: BasePreprocessingConfig): preprocessing_registry[name] = preprocessing_config return preprocessing_config return wrap @DeveloperAPI def PreprocessingDataclassField(feature_type: str): """Custom dataclass field that when used inside a dataclass will allow the user to specify a preprocessing config. Returns: Initialized dataclass field that converts an untyped dict with params to a preprocessing config. """ class PreprocessingMarshmallowField(schema_utils.LudwigSchemaField): """Custom field that deserializes a dict for a valid preprocessing config from the preprocessing_registry and creates a corresponding JSON schema for external usage.""" def _deserialize(self, value, attr, data, **kwargs): if value is None: return None if isinstance(value, dict): if feature_type in preprocessing_registry: pre = preprocessing_registry[feature_type] try: return pre.Schema().load(value) except (TypeError, ConfigValidationError) as error: raise ConfigValidationError( f"Invalid preprocessing params: {value}, see `{pre}` definition. Error: {error}" ) raise ConfigValidationError( f"Invalid params for preprocessor: {value}, expect dict with at least a valid `type` attribute." ) raise ConfigValidationError("Field should be None or dict") def _jsonschema_type_mapping(self): preprocessor_cls = preprocessing_registry[feature_type] props = schema_utils.unload_jsonschema_from_marshmallow_class(preprocessor_cls)["properties"] return { "type": "object", "properties": props, "title": "preprocessing_options", "additionalProperties": True, } try: preprocessor = preprocessing_registry[feature_type] load_default = lambda: preprocessor.Schema().load({}) dump_default = preprocessor.Schema().dump({}) return field( metadata={ "marshmallow_field": PreprocessingMarshmallowField( allow_none=False, dump_default=dump_default, load_default=load_default, ) }, default_factory=load_default, ) except Exception as e: raise ConfigValidationError( f"Unsupported preprocessing type: {feature_type}. See preprocessing_registry. " f"Details: {e}" ) ================================================ FILE: ludwig/schema/features/preprocessing/vector.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.constants import DROP_ROW, FILL_WITH_CONST, MISSING_VALUE_STRATEGY_OPTIONS, PREPROCESSING, VECTOR from ludwig.schema import utils as schema_utils from ludwig.schema.features.preprocessing.base import BasePreprocessingConfig from ludwig.schema.features.preprocessing.utils import register_preprocessor from ludwig.schema.metadata import FEATURE_METADATA from ludwig.schema.utils import ludwig_dataclass @DeveloperAPI @register_preprocessor(VECTOR) @ludwig_dataclass class VectorPreprocessingConfig(BasePreprocessingConfig): vector_size: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="The size of the vector. If None, the vector size will be inferred from the data.", parameter_metadata=FEATURE_METADATA[VECTOR][PREPROCESSING]["vector_size"], ) missing_value_strategy: str = schema_utils.StringOptions( MISSING_VALUE_STRATEGY_OPTIONS, default=FILL_WITH_CONST, allow_none=False, description="What strategy to follow when there's a missing value in a vector column", parameter_metadata=FEATURE_METADATA[VECTOR][PREPROCESSING]["missing_value_strategy"], ) fill_value: str = schema_utils.String( default="", allow_none=False, pattern=r"^([0-9]+(\.[0-9]*)?\s*)*$", description="The value to replace missing values with in case the missing_value_strategy is fill_with_const", parameter_metadata=FEATURE_METADATA[VECTOR][PREPROCESSING]["fill_value"], ) computed_fill_value: str = schema_utils.String( default="", allow_none=False, pattern=r"^([0-9]+(\.[0-9]*)?\s*)*$", description="The internally computed fill value to replace missing values with in case the " "missing_value_strategy is fill_with_mode or fill_with_mean", parameter_metadata=FEATURE_METADATA[VECTOR][PREPROCESSING]["computed_fill_value"], ) @DeveloperAPI @register_preprocessor("vector_output") @ludwig_dataclass class VectorOutputPreprocessingConfig(VectorPreprocessingConfig): missing_value_strategy: str = schema_utils.StringOptions( MISSING_VALUE_STRATEGY_OPTIONS, default=DROP_ROW, allow_none=False, description="What strategy to follow when there's a missing value in a vector output feature", parameter_metadata=FEATURE_METADATA[VECTOR][PREPROCESSING]["missing_value_strategy"], ) vector_size: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="The size of the vector. If None, the vector size will be inferred from the data.", parameter_metadata=FEATURE_METADATA[VECTOR][PREPROCESSING]["vector_size"], ) ================================================ FILE: ludwig/schema/features/sequence_feature.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.constants import LOSS, MODEL_ECD, SEQUENCE, SEQUENCE_SOFTMAX_CROSS_ENTROPY from ludwig.schema import utils as schema_utils from ludwig.schema.decoders.base import BaseDecoderConfig from ludwig.schema.decoders.utils import DecoderDataclassField from ludwig.schema.encoders.base import BaseEncoderConfig from ludwig.schema.encoders.utils import EncoderDataclassField from ludwig.schema.features.base import BaseInputFeatureConfig, BaseOutputFeatureConfig from ludwig.schema.features.loss.loss import BaseLossConfig from ludwig.schema.features.loss.utils import LossDataclassField from ludwig.schema.features.preprocessing.base import BasePreprocessingConfig from ludwig.schema.features.preprocessing.utils import PreprocessingDataclassField from ludwig.schema.features.utils import ( ecd_defaults_config_registry, ecd_input_config_registry, ecd_output_config_registry, input_mixin_registry, output_mixin_registry, ) from ludwig.schema.metadata import FEATURE_METADATA from ludwig.schema.metadata.parameter_metadata import INTERNAL_ONLY from ludwig.schema.utils import BaseMarshmallowConfig, ludwig_dataclass @DeveloperAPI @input_mixin_registry.register(SEQUENCE) @ludwig_dataclass class SequenceInputFeatureConfigMixin(BaseMarshmallowConfig): """SequenceInputFeatureConfigMixin is a dataclass that configures the parameters used in both the sequence input feature and the sequence global defaults section of the Ludwig Config.""" preprocessing: BasePreprocessingConfig = PreprocessingDataclassField(feature_type=SEQUENCE) encoder: BaseEncoderConfig = EncoderDataclassField( MODEL_ECD, feature_type=SEQUENCE, default="embed", ) @DeveloperAPI @ecd_input_config_registry.register(SEQUENCE) @ludwig_dataclass class SequenceInputFeatureConfig(SequenceInputFeatureConfigMixin, BaseInputFeatureConfig): """SequenceInputFeatureConfig is a dataclass that configures the parameters used for a sequence input feature.""" type: str = schema_utils.ProtectedString(SEQUENCE) @DeveloperAPI @output_mixin_registry.register(SEQUENCE) @ludwig_dataclass class SequenceOutputFeatureConfigMixin(BaseMarshmallowConfig): """SequenceOutputFeatureConfigMixin is a dataclass that configures the parameters used in both the sequence output feature and the sequence global defaults section of the Ludwig Config.""" decoder: BaseDecoderConfig = DecoderDataclassField( MODEL_ECD, feature_type=SEQUENCE, default="generator", ) loss: BaseLossConfig = LossDataclassField( feature_type=SEQUENCE, default=SEQUENCE_SOFTMAX_CROSS_ENTROPY, ) @DeveloperAPI @ecd_output_config_registry.register(SEQUENCE) @ludwig_dataclass class SequenceOutputFeatureConfig(SequenceOutputFeatureConfigMixin, BaseOutputFeatureConfig): """SequenceOutputFeatureConfig is a dataclass that configures the parameters used for a sequence output feature.""" type: str = schema_utils.ProtectedString(SEQUENCE) default_validation_metric: str = schema_utils.StringOptions( [LOSS], default=LOSS, description="Internal only use parameter: default validation metric for sequence output feature.", parameter_metadata=INTERNAL_ONLY, ) dependencies: list = schema_utils.List( default=[], description="List of input features that this feature depends on.", parameter_metadata=FEATURE_METADATA[SEQUENCE]["dependencies"], ) preprocessing: BasePreprocessingConfig = PreprocessingDataclassField(feature_type="sequence_output") reduce_dependencies: str = schema_utils.ReductionOptions( default="sum", description="How to reduce the dependencies of the output feature.", parameter_metadata=FEATURE_METADATA[SEQUENCE]["reduce_dependencies"], ) reduce_input: str = schema_utils.ReductionOptions( default="sum", description="How to reduce an input that is not a vector, but a matrix or a higher order tensor, on the first " "dimension (second if you count the batch dimension)", parameter_metadata=FEATURE_METADATA[SEQUENCE]["reduce_input"], ) @DeveloperAPI @ecd_defaults_config_registry.register(SEQUENCE) @ludwig_dataclass class SequenceDefaultsConfig(SequenceInputFeatureConfigMixin, SequenceOutputFeatureConfigMixin): pass ================================================ FILE: ludwig/schema/features/set_feature.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.constants import JACCARD, MODEL_ECD, SET, SIGMOID_CROSS_ENTROPY from ludwig.schema import utils as schema_utils from ludwig.schema.decoders.base import BaseDecoderConfig from ludwig.schema.decoders.utils import DecoderDataclassField from ludwig.schema.encoders.base import BaseEncoderConfig from ludwig.schema.encoders.utils import EncoderDataclassField from ludwig.schema.features.base import BaseInputFeatureConfig, BaseOutputFeatureConfig from ludwig.schema.features.loss.loss import BaseLossConfig from ludwig.schema.features.loss.utils import LossDataclassField from ludwig.schema.features.preprocessing.base import BasePreprocessingConfig from ludwig.schema.features.preprocessing.utils import PreprocessingDataclassField from ludwig.schema.features.utils import ( ecd_defaults_config_registry, ecd_input_config_registry, ecd_output_config_registry, input_mixin_registry, output_mixin_registry, ) from ludwig.schema.metadata import FEATURE_METADATA from ludwig.schema.metadata.parameter_metadata import INTERNAL_ONLY from ludwig.schema.utils import BaseMarshmallowConfig, ludwig_dataclass @DeveloperAPI @input_mixin_registry.register(SET) @ludwig_dataclass class SetInputFeatureConfigMixin(BaseMarshmallowConfig): """SetInputFeatureConfigMixin is a dataclass that configures the parameters used in both the set input feature and the set global defaults section of the Ludwig Config.""" preprocessing: BasePreprocessingConfig = PreprocessingDataclassField(feature_type=SET) encoder: BaseEncoderConfig = EncoderDataclassField( MODEL_ECD, feature_type=SET, default="embed", ) @DeveloperAPI @ecd_input_config_registry.register(SET) @ludwig_dataclass class SetInputFeatureConfig(SetInputFeatureConfigMixin, BaseInputFeatureConfig): """SetInputFeatureConfig is a dataclass that configures the parameters used for a set input feature.""" type: str = schema_utils.ProtectedString(SET) @DeveloperAPI @output_mixin_registry.register(SET) @ludwig_dataclass class SetOutputFeatureConfigMixin(BaseMarshmallowConfig): """SetOutputFeatureConfigMixin is a dataclass that configures the parameters used in both the set output feature and the set global defaults section of the Ludwig Config.""" decoder: BaseDecoderConfig = DecoderDataclassField( MODEL_ECD, feature_type=SET, default="classifier", ) loss: BaseLossConfig = LossDataclassField( feature_type=SET, default=SIGMOID_CROSS_ENTROPY, ) @DeveloperAPI @ecd_output_config_registry.register(SET) @ludwig_dataclass class SetOutputFeatureConfig(SetOutputFeatureConfigMixin, BaseOutputFeatureConfig): """SetOutputFeatureConfig is a dataclass that configures the parameters used for a set output feature.""" type: str = schema_utils.ProtectedString(SET) default_validation_metric: str = schema_utils.StringOptions( [JACCARD], default=JACCARD, description="Internal only use parameter: default validation metric for set output feature.", parameter_metadata=INTERNAL_ONLY, ) dependencies: list = schema_utils.List( default=[], description="List of input features that this feature depends on.", parameter_metadata=FEATURE_METADATA[SET]["dependencies"], ) preprocessing: BasePreprocessingConfig = PreprocessingDataclassField(feature_type="set_output") reduce_dependencies: str = schema_utils.ReductionOptions( default="sum", description="How to reduce the dependencies of the output feature.", parameter_metadata=FEATURE_METADATA[SET]["reduce_dependencies"], ) reduce_input: str = schema_utils.ReductionOptions( default="sum", description="How to reduce an input that is not a vector, but a matrix or a higher order tensor, on the first " "dimension (second if you count the batch dimension)", parameter_metadata=FEATURE_METADATA[SET]["reduce_input"], ) threshold: float = schema_utils.FloatRange( default=0.5, min=0, max=1, description="The threshold used to convert output probabilities to predictions. Tokens with predicted" "probabilities greater than or equal to threshold are predicted to be in the output set (True).", parameter_metadata=FEATURE_METADATA[SET]["threshold"], ) @DeveloperAPI @ecd_defaults_config_registry.register(SET) @ludwig_dataclass class SetDefaultsConfig(SetInputFeatureConfigMixin, SetOutputFeatureConfigMixin): pass ================================================ FILE: ludwig/schema/features/text_feature.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.constants import ( LOSS, MODEL_ECD, MODEL_LLM, NEXT_TOKEN_SOFTMAX_CROSS_ENTROPY, SEQUENCE_SOFTMAX_CROSS_ENTROPY, TEXT, ) from ludwig.schema import utils as schema_utils from ludwig.schema.decoders.base import BaseDecoderConfig from ludwig.schema.decoders.utils import DecoderDataclassField from ludwig.schema.encoders.base import BaseEncoderConfig from ludwig.schema.encoders.utils import EncoderDataclassField from ludwig.schema.features.base import BaseInputFeatureConfig, BaseOutputFeatureConfig from ludwig.schema.features.loss.loss import BaseLossConfig from ludwig.schema.features.loss.utils import LossDataclassField from ludwig.schema.features.preprocessing.base import BasePreprocessingConfig from ludwig.schema.features.preprocessing.utils import PreprocessingDataclassField from ludwig.schema.features.utils import ( ecd_defaults_config_registry, ecd_input_config_registry, ecd_output_config_registry, input_mixin_registry, llm_defaults_config_registry, llm_input_config_registry, llm_output_config_registry, output_mixin_registry, ) from ludwig.schema.metadata import FEATURE_METADATA from ludwig.schema.metadata.parameter_metadata import INTERNAL_ONLY from ludwig.schema.utils import BaseMarshmallowConfig, ludwig_dataclass @DeveloperAPI @input_mixin_registry.register(TEXT) @ludwig_dataclass class TextInputFeatureConfigMixin(BaseMarshmallowConfig): """TextInputFeatureConfigMixin is a dataclass that configures the parameters used in both the text input feature and the text global defaults section of the Ludwig Config.""" preprocessing: BasePreprocessingConfig = PreprocessingDataclassField(feature_type=TEXT) @DeveloperAPI @ludwig_dataclass class TextInputFeatureConfig(TextInputFeatureConfigMixin, BaseInputFeatureConfig): """TextInputFeatureConfig is a dataclass that configures the parameters used for a text input feature.""" type: str = schema_utils.ProtectedString(TEXT) encoder: BaseEncoderConfig = None @DeveloperAPI @ecd_input_config_registry.register(TEXT) @ludwig_dataclass class ECDTextInputFeatureConfig(TextInputFeatureConfig): encoder: BaseEncoderConfig = EncoderDataclassField( MODEL_ECD, feature_type=TEXT, default="parallel_cnn", ) @DeveloperAPI @llm_input_config_registry.register(TEXT) @ludwig_dataclass class LLMTextInputFeatureConfig(TextInputFeatureConfig): preprocessing: BasePreprocessingConfig = PreprocessingDataclassField(feature_type="text_llm_input") encoder: BaseEncoderConfig = EncoderDataclassField( MODEL_LLM, feature_type=TEXT, default="passthrough", ) @DeveloperAPI @output_mixin_registry.register(TEXT) @ludwig_dataclass class TextOutputFeatureConfigMixin(BaseMarshmallowConfig): """TextOutputFeatureConfigMixin is a dataclass that configures the parameters used in both the text output feature and the text global defaults section of the Ludwig Config.""" decoder: BaseDecoderConfig = None loss: BaseLossConfig = LossDataclassField( feature_type=TEXT, default=SEQUENCE_SOFTMAX_CROSS_ENTROPY, ) @DeveloperAPI @ludwig_dataclass class TextOutputFeatureConfig(TextOutputFeatureConfigMixin, BaseOutputFeatureConfig): """TextOutputFeatureConfig is a dataclass that configures the parameters used for a text output feature.""" type: str = schema_utils.ProtectedString(TEXT) class_similarities: list = schema_utils.List( list, default=None, description="If not null this parameter is a c x c matrix in the form of a list of lists that contains the " "mutual similarity of classes. It is used if `class_similarities_temperature` is greater than 0. ", parameter_metadata=FEATURE_METADATA[TEXT]["class_similarities"], ) default_validation_metric: str = schema_utils.StringOptions( [LOSS], default=LOSS, description="Internal only use parameter: default validation metric for binary output feature.", parameter_metadata=INTERNAL_ONLY, ) dependencies: list = schema_utils.List( default=[], description="List of input features that this feature depends on.", parameter_metadata=FEATURE_METADATA[TEXT]["dependencies"], ) preprocessing: BasePreprocessingConfig = PreprocessingDataclassField(feature_type="text_output") reduce_dependencies: str = schema_utils.ReductionOptions( default="sum", description="How to reduce the dependencies of the output feature.", parameter_metadata=FEATURE_METADATA[TEXT]["reduce_dependencies"], ) reduce_input: str = schema_utils.ReductionOptions( default="sum", description="How to reduce an input that is not a vector, but a matrix or a higher order tensor, on the first " "dimension (second if you count the batch dimension)", parameter_metadata=FEATURE_METADATA[TEXT]["reduce_input"], ) @DeveloperAPI @ecd_output_config_registry.register(TEXT) @ludwig_dataclass class ECDTextOutputFeatureConfig(TextOutputFeatureConfig): decoder: BaseDecoderConfig = DecoderDataclassField( MODEL_ECD, feature_type=TEXT, default="generator", ) @DeveloperAPI @llm_output_config_registry.register(TEXT) @ludwig_dataclass class LLMTextOutputFeatureConfig(TextOutputFeatureConfig): default_validation_metric: str = schema_utils.StringOptions( [LOSS], default=LOSS, description="Internal only use parameter: default validation metric for text output feature for LLMs.", parameter_metadata=INTERNAL_ONLY, ) preprocessing: BasePreprocessingConfig = PreprocessingDataclassField(feature_type="text_llm_output") decoder: BaseDecoderConfig = DecoderDataclassField( MODEL_LLM, feature_type=TEXT, default="text_extractor", ) loss: BaseLossConfig = LossDataclassField( feature_type=TEXT, default=NEXT_TOKEN_SOFTMAX_CROSS_ENTROPY, ) @DeveloperAPI @ecd_defaults_config_registry.register(TEXT) @ludwig_dataclass class ECDTextDefaultsConfig(TextInputFeatureConfigMixin, TextOutputFeatureConfigMixin): encoder: BaseEncoderConfig = EncoderDataclassField( MODEL_ECD, feature_type=TEXT, default="parallel_cnn", ) decoder: BaseDecoderConfig = DecoderDataclassField( MODEL_ECD, feature_type=TEXT, default="generator", ) loss: BaseLossConfig = LossDataclassField( feature_type=TEXT, default=SEQUENCE_SOFTMAX_CROSS_ENTROPY, ) @DeveloperAPI @llm_defaults_config_registry.register(TEXT) @ludwig_dataclass class LLMTextDefaultsConfig(TextInputFeatureConfigMixin, TextOutputFeatureConfigMixin): encoder: BaseEncoderConfig = EncoderDataclassField( MODEL_LLM, feature_type=TEXT, default="passthrough", ) decoder: BaseDecoderConfig = DecoderDataclassField( MODEL_LLM, feature_type=TEXT, default="text_extractor", ) # TODO(Arnav): Refactor LossDataclassField to only accept loss types that are valid for the model loss: BaseLossConfig = LossDataclassField( feature_type=TEXT, default=NEXT_TOKEN_SOFTMAX_CROSS_ENTROPY, ) ================================================ FILE: ludwig/schema/features/timeseries_feature.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.constants import HUBER, MEAN_SQUARED_ERROR, MODEL_ECD, TIMESERIES, VECTOR from ludwig.schema import utils as schema_utils from ludwig.schema.decoders.base import BaseDecoderConfig from ludwig.schema.decoders.utils import DecoderDataclassField from ludwig.schema.encoders.base import BaseEncoderConfig from ludwig.schema.encoders.utils import EncoderDataclassField from ludwig.schema.features.base import BaseInputFeatureConfig, BaseOutputFeatureConfig from ludwig.schema.features.loss.loss import BaseLossConfig from ludwig.schema.features.loss.utils import LossDataclassField from ludwig.schema.features.preprocessing.base import BasePreprocessingConfig from ludwig.schema.features.preprocessing.utils import PreprocessingDataclassField from ludwig.schema.features.utils import ( ecd_defaults_config_registry, ecd_input_config_registry, ecd_output_config_registry, input_mixin_registry, output_mixin_registry, ) from ludwig.schema.metadata import FEATURE_METADATA from ludwig.schema.metadata.parameter_metadata import INTERNAL_ONLY from ludwig.schema.utils import BaseMarshmallowConfig, ludwig_dataclass @DeveloperAPI @input_mixin_registry.register(TIMESERIES) @ludwig_dataclass class TimeseriesInputFeatureConfigMixin(BaseMarshmallowConfig): """TimeseriesInputFeatureConfigMixin is a dataclass that configures the parameters used in both the timeseries input feature and the timeseries global defaults section of the Ludwig Config.""" preprocessing: BasePreprocessingConfig = PreprocessingDataclassField(feature_type=TIMESERIES) encoder: BaseEncoderConfig = EncoderDataclassField( MODEL_ECD, feature_type=TIMESERIES, default="parallel_cnn", ) @DeveloperAPI @ecd_input_config_registry.register(TIMESERIES) @ludwig_dataclass class TimeseriesInputFeatureConfig(TimeseriesInputFeatureConfigMixin, BaseInputFeatureConfig): """TimeseriesInputFeatureConfig is a dataclass that configures the parameters used for a timeseries input feature.""" type: str = schema_utils.ProtectedString(TIMESERIES) @DeveloperAPI @output_mixin_registry.register(TIMESERIES) @ludwig_dataclass class TimeseriesOutputFeatureConfigMixin(BaseMarshmallowConfig): """TimeseriesOutputFeatureConfigMixin configures the parameters used in both the timeseries output feature and the timeseries global defaults section of the Ludwig Config.""" decoder: BaseDecoderConfig = DecoderDataclassField( MODEL_ECD, feature_type=TIMESERIES, default="projector", ) loss: BaseLossConfig = LossDataclassField( feature_type=TIMESERIES, default=HUBER, ) @DeveloperAPI @ecd_output_config_registry.register(TIMESERIES) @ludwig_dataclass class TimeseriesOutputFeatureConfig(BaseOutputFeatureConfig, TimeseriesOutputFeatureConfigMixin): """TimeseriesOutputFeatureConfig configures the parameters used for a timeseries output feature.""" type: str = schema_utils.ProtectedString(TIMESERIES) dependencies: list = schema_utils.List( default=[], description="List of input features that this feature depends on.", parameter_metadata=FEATURE_METADATA[VECTOR]["dependencies"], ) default_validation_metric: str = schema_utils.StringOptions( [MEAN_SQUARED_ERROR], default=MEAN_SQUARED_ERROR, description="Internal parameter.", parameter_metadata=INTERNAL_ONLY, ) preprocessing: BasePreprocessingConfig = PreprocessingDataclassField(feature_type="timeseries_output") reduce_dependencies: str = schema_utils.ReductionOptions( default=None, description="How to reduce the dependencies of the output feature.", parameter_metadata=FEATURE_METADATA[VECTOR]["reduce_dependencies"], ) reduce_input: str = schema_utils.ReductionOptions( default=None, description="How to reduce an input that is not a vector, but a matrix or a higher order tensor, on the first " "dimension (second if you count the batch dimension)", parameter_metadata=FEATURE_METADATA[VECTOR]["reduce_input"], ) horizon: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Internal parameter. Obtained from preprocessing", parameter_metadata=INTERNAL_ONLY, ) @DeveloperAPI @ecd_defaults_config_registry.register(TIMESERIES) @ludwig_dataclass class TimeseriesDefaultsConfig(TimeseriesInputFeatureConfigMixin, TimeseriesOutputFeatureConfigMixin): pass ================================================ FILE: ludwig/schema/features/utils.py ================================================ from collections import defaultdict from ludwig.api_annotations import DeveloperAPI from ludwig.constants import MODEL_ECD, MODEL_LLM from ludwig.schema import utils as schema_utils from ludwig.utils.registry import Registry input_config_registries = defaultdict(Registry) output_config_registries = defaultdict(Registry) ecd_input_config_registry = input_config_registries[MODEL_ECD] llm_input_config_registry = input_config_registries[MODEL_LLM] ecd_output_config_registry = output_config_registries[MODEL_ECD] llm_output_config_registry = output_config_registries[MODEL_LLM] input_mixin_registry = Registry() output_mixin_registry = Registry() """ECD models support the full range of feature parameters available in Ludwig, so any feature schema can be registered into it. See `BinaryDefaultsConfig` for an example. """ ecd_defaults_config_registry = Registry() llm_defaults_config_registry = Registry() def input_config_registry(model_type: str) -> Registry: return input_config_registries[model_type] def output_config_registry(model_type: str) -> Registry: return output_config_registries[model_type] @DeveloperAPI def get_input_feature_cls(model_type: str, name: str): # TODO(travis): not needed once we remove existing model config implementation return input_config_registries[model_type][name] @DeveloperAPI def get_output_feature_cls(model_type: str, name: str): # TODO(ksbrar): What is this? return output_config_registries[model_type][name] @DeveloperAPI def get_input_feature_jsonschema(model_type: str): """This function returns a JSON schema structured to only requires a `type` key and then conditionally applies a corresponding input feature's field constraints. Returns: JSON Schema """ input_feature_types = sorted(list(input_config_registry(model_type).keys())) schema = { "type": "object", "properties": { "name": {"type": "string", "title": "name", "description": "Name of the input feature."}, "type": { "type": "string", "enum": input_feature_types, "title": "type", "description": "Type of the input feature", }, "column": {"type": "string", "title": "column", "description": "Name of the column."}, }, "additionalProperties": True, "allOf": get_input_feature_conds(model_type), "required": ["name", "type"], "title": "input_feature", } return schema @DeveloperAPI def get_input_feature_conds(model_type: str): """This function returns a list of if-then JSON clauses for each input feature type along with their properties and constraints. Returns: List of JSON clauses """ input_feature_types = sorted(list(input_config_registry(model_type).keys())) conds = [] for feature_type in input_feature_types: schema_cls = get_input_feature_cls(model_type, feature_type) feature_schema = schema_utils.unload_jsonschema_from_marshmallow_class(schema_cls) feature_props = feature_schema["properties"] schema_utils.remove_duplicate_fields(feature_props) feature_cond = schema_utils.create_cond({"type": feature_type}, feature_props) conds.append(feature_cond) return conds @DeveloperAPI def get_output_feature_jsonschema(model_type: str): """This function returns a JSON schema structured to only requires a `type` key and then conditionally applies a corresponding output feature's field constraints. Returns: JSON Schema """ output_feature_types = sorted(list(output_config_registry(model_type).keys())) schema = { "type": "object", "properties": { "name": {"type": "string", "title": "name", "description": "Name of the output feature."}, "type": { "type": "string", "enum": output_feature_types, "title": "type", "description": "Type of the output feature", }, "column": {"type": "string", "title": "column", "description": "Name of the column."}, }, "additionalProperties": True, "allOf": get_output_feature_conds(model_type), "required": ["name", "type"], "title": "output_feature", } return schema @DeveloperAPI def get_output_feature_conds(model_type: str): """This function returns a list of if-then JSON clauses for each output feature type along with their properties and constraints. Returns: List of JSON clauses """ output_feature_types = sorted(list(output_config_registry(model_type).keys())) conds = [] for feature_type in output_feature_types: schema_cls = get_output_feature_cls(model_type, feature_type) feature_schema = schema_utils.unload_jsonschema_from_marshmallow_class(schema_cls) feature_props = feature_schema["properties"] schema_utils.remove_duplicate_fields(feature_props) feature_cond = schema_utils.create_cond({"type": feature_type}, feature_props) conds.append(feature_cond) return conds ================================================ FILE: ludwig/schema/features/vector_feature.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.constants import MEAN_SQUARED_ERROR, MODEL_ECD, VECTOR from ludwig.schema import utils as schema_utils from ludwig.schema.decoders.base import BaseDecoderConfig from ludwig.schema.decoders.utils import DecoderDataclassField from ludwig.schema.encoders.base import BaseEncoderConfig from ludwig.schema.encoders.utils import EncoderDataclassField from ludwig.schema.features.base import BaseInputFeatureConfig, BaseOutputFeatureConfig from ludwig.schema.features.loss.loss import BaseLossConfig from ludwig.schema.features.loss.utils import LossDataclassField from ludwig.schema.features.preprocessing.base import BasePreprocessingConfig from ludwig.schema.features.preprocessing.utils import PreprocessingDataclassField from ludwig.schema.features.utils import ( ecd_defaults_config_registry, ecd_input_config_registry, ecd_output_config_registry, input_mixin_registry, output_mixin_registry, ) from ludwig.schema.metadata import FEATURE_METADATA from ludwig.schema.metadata.parameter_metadata import INTERNAL_ONLY from ludwig.schema.utils import BaseMarshmallowConfig, ludwig_dataclass @DeveloperAPI @input_mixin_registry.register(VECTOR) @ludwig_dataclass class VectorInputFeatureConfigMixin(BaseMarshmallowConfig): """VectorInputFeatureConfigMixin is a dataclass that configures the parameters used in both the vector input feature and the vector global defaults section of the Ludwig Config.""" preprocessing: BasePreprocessingConfig = PreprocessingDataclassField(feature_type=VECTOR) encoder: BaseEncoderConfig = EncoderDataclassField( MODEL_ECD, feature_type=VECTOR, default="dense", ) @DeveloperAPI @ecd_input_config_registry.register(VECTOR) @ludwig_dataclass class VectorInputFeatureConfig(VectorInputFeatureConfigMixin, BaseInputFeatureConfig): """VectorInputFeatureConfig is a dataclass that configures the parameters used for a vector input feature.""" type: str = schema_utils.ProtectedString(VECTOR) @DeveloperAPI @output_mixin_registry.register(VECTOR) @ludwig_dataclass class VectorOutputFeatureConfigMixin(BaseMarshmallowConfig): """VectorOutputFeatureConfigMixin is a dataclass that configures the parameters used in both the vector output feature and the vector global defaults section of the Ludwig Config.""" decoder: BaseDecoderConfig = DecoderDataclassField( MODEL_ECD, feature_type=VECTOR, default="projector", ) loss: BaseLossConfig = LossDataclassField( feature_type=VECTOR, default=MEAN_SQUARED_ERROR, ) @DeveloperAPI @ecd_output_config_registry.register(VECTOR) @ludwig_dataclass class VectorOutputFeatureConfig(VectorOutputFeatureConfigMixin, BaseOutputFeatureConfig): """VectorOutputFeatureConfig is a dataclass that configures the parameters used for a vector output feature.""" type: str = schema_utils.ProtectedString(VECTOR) dependencies: list = schema_utils.List( default=[], description="List of input features that this feature depends on.", parameter_metadata=FEATURE_METADATA[VECTOR]["dependencies"], ) default_validation_metric: str = schema_utils.StringOptions( [MEAN_SQUARED_ERROR], default=MEAN_SQUARED_ERROR, description="Internal only use parameter: default validation metric for binary output feature.", parameter_metadata=INTERNAL_ONLY, ) preprocessing: BasePreprocessingConfig = PreprocessingDataclassField(feature_type="vector_output") reduce_dependencies: str = schema_utils.ReductionOptions( default=None, description="How to reduce the dependencies of the output feature.", parameter_metadata=FEATURE_METADATA[VECTOR]["reduce_dependencies"], ) reduce_input: str = schema_utils.ReductionOptions( default=None, description="How to reduce an input that is not a vector, but a matrix or a higher order tensor, on the first " "dimension (second if you count the batch dimension)", parameter_metadata=FEATURE_METADATA[VECTOR]["reduce_input"], ) softmax: bool = schema_utils.Boolean( default=False, description="Determines whether to apply a softmax at the end of the decoder. This is useful for predicting a " "vector of values that sum up to 1 and can be interpreted as probabilities.", parameter_metadata=FEATURE_METADATA[VECTOR]["softmax"], ) vector_size: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="The size of the vector. If None, the vector size will be inferred from the data.", parameter_metadata=FEATURE_METADATA[VECTOR]["vector_size"], ) @DeveloperAPI @ecd_defaults_config_registry.register(VECTOR) @ludwig_dataclass class VectorDefaultsConfig(VectorInputFeatureConfigMixin, VectorOutputFeatureConfigMixin): pass ================================================ FILE: ludwig/schema/hyperopt/__init__.py ================================================ from abc import ABC import ludwig.schema.hyperopt.parameter # noqa: F401 from ludwig.api_annotations import DeveloperAPI from ludwig.constants import LOSS, TEST, TRAIN, VALIDATION from ludwig.modules import metric_modules # noqa: Needed to ensure that the metric registry is populated. from ludwig.modules.metric_registry import get_metric_registry from ludwig.schema import utils as schema_utils from ludwig.schema.hyperopt.executor import ExecutorConfig, ExecutorDataclassField from ludwig.schema.hyperopt.search_algorithm import BaseSearchAlgorithmConfig, SearchAlgorithmDataclassField from ludwig.schema.utils import ludwig_dataclass as dataclass @DeveloperAPI @dataclass class HyperoptConfig(schema_utils.BaseMarshmallowConfig, ABC): """Basic hyperopt settings.""" output_feature: str = schema_utils.String( # TODO: make more restrictive default="combined", description=( "The name of the output feature that we want to optimize the metric or loss of. Available values " "are `combined` or the name of any output feature provided in the configuration. `combined` is a special " "output feature that allows to optimize for the aggregated loss and metrics of all output features." ), ) goal: str = schema_utils.StringOptions( options=["minimize", "maximize"], default="minimize", allow_none=False, description=( "Indicates if to minimize or maximize a metric or a loss of any of the output features on any of the " "dataset splits. Available values are: minimize (default) or maximize." ), ) metric: str = schema_utils.StringOptions( options=get_metric_registry().keys(), default=LOSS, allow_none=False, description=( "The metric that we want to optimize for. The default one is loss, but depending on the type of the " "feature defined in output_feature, different metrics and losses are available. Check the metrics section " "of the specific output feature type to figure out what metrics are available to use." ), ) split: str = schema_utils.StringOptions( options=[TRAIN, VALIDATION, TEST], default=VALIDATION, allow_none=False, description=( "The split of data that we want to compute our metric on. By default it is the validation split, but " "you have the flexibility to specify also train or test splits." ), ) eval_split: str = schema_utils.StringOptions( options=[TRAIN, VALIDATION, TEST], default=VALIDATION, allow_none=False, description=( "The split of data that we want to run evaluation on. By default it is the validation split, but " "you have the flexibility to specify also train or test splits." ), ) search_alg: BaseSearchAlgorithmConfig = SearchAlgorithmDataclassField( description=( "Specifies the algorithm to sample the defined parameters space. Candidate algorithms are those " "found in [Ray Tune's Search Algorithms](https://docs.ray.io/en/latest/tune/api/suggestion.html)." ) ) executor: ExecutorConfig = ExecutorDataclassField( description=( "specifies how to execute the hyperparameter optimization. The execution could happen locally in a serial " "manner or in parallel across multiple workers and with GPUs as well if available. The executor section " "includes specification for work scheduling and the number of samples to generate." ) ) parameters: dict = schema_utils.Dict( allow_none=False, description=( "This section consists of a set of hyperparameters to optimize. They are provided as keys (the names of " "the parameters) and values associated with them (that define the search space). The values vary depending " "on the type of the hyperparameter. Syntax for this section is based on [Ray Tune's Search Space " "parameters](https://docs.ray.io/en/latest/tune/api/search_space.html)." ), ) @DeveloperAPI def get_hyperopt_jsonschema(): props = schema_utils.unload_jsonschema_from_marshmallow_class(HyperoptConfig)["properties"] return { "type": ["object", "null"], "properties": props, "title": "hyperopt_options", "description": "Settings for hyperopt", } @DeveloperAPI class HyperoptField(schema_utils.DictMarshmallowField): def __init__(self): super().__init__(HyperoptConfig, default_missing=True) def _jsonschema_type_mapping(self): return get_hyperopt_jsonschema() ================================================ FILE: ludwig/schema/hyperopt/executor.py ================================================ from dataclasses import field from ludwig.api_annotations import DeveloperAPI from ludwig.constants import RAY from ludwig.error import ConfigValidationError from ludwig.schema import utils as schema_utils from ludwig.schema.hyperopt.scheduler import BaseSchedulerConfig, SchedulerDataclassField from ludwig.schema.utils import ludwig_dataclass @DeveloperAPI @ludwig_dataclass class ExecutorConfig(schema_utils.BaseMarshmallowConfig): """Basic executor settings.""" type: str = schema_utils.ProtectedString(RAY) num_samples: int = schema_utils.PositiveInteger( default=None, allow_none=True, description=( "This parameter, along with the `space` specifications in the `parameters` section, controls how many " "trials are generated." ), ) time_budget_s: int = schema_utils.PositiveInteger( default=3600, allow_none=True, description="The number of seconds for the entire hyperopt run." ) trial_driver_resources: dict[str, float] = schema_utils.Dict( default=None, description=( "The resources reserved by each trial driver. This differs from cpu_resources_per_trial and " "gpu_resources_per_trial because these resources are reserved for the driver, not its subsequent " "workers. Only used when the trials themselves are on the Ray backend. Defaults to 1 CPU." ), ) cpu_resources_per_trial: int = schema_utils.PositiveInteger( default=1, description="The number of CPU cores allocated to each trial" ) gpu_resources_per_trial: int = schema_utils.NonNegativeInteger( default=0, description="The number of GPU devices allocated to each trial" ) kubernetes_namespace: str | None = schema_utils.String( default=None, allow_none=True, description=( "When running on Kubernetes, provide the namespace of the Ray cluster to sync results between " "pods. See the Ray docs for more info." ), ) max_concurrent_trials: str | int | None = schema_utils.OneOfOptionsField( default="auto", allow_none=True, description=("The maximum number of trials to train concurrently. Defaults to auto if not specified."), field_options=[ schema_utils.PositiveInteger( default=1, allow_none=False, description="Manually set a number of concurrent trials." ), schema_utils.StringOptions( options=["auto"], default="auto", allow_none=False, description="Automatically set number of concurrent trials.", ), ], ) scheduler: BaseSchedulerConfig = SchedulerDataclassField(description="") @DeveloperAPI def ExecutorDataclassField(description: str, default: dict = {}): class ExecutorMarshmallowField(schema_utils.LudwigSchemaField): def _deserialize(self, value, attr, data, **kwargs): if isinstance(value, dict): try: return ExecutorConfig.Schema().load(value) except (TypeError, ConfigValidationError): raise ConfigValidationError(f"Invalid params for executor: {value}, see ExecutorConfig class.") raise ConfigValidationError("Field should be dict") def _jsonschema_type_mapping(self): return { **schema_utils.unload_jsonschema_from_marshmallow_class(ExecutorConfig), "title": "executor", "description": description, } if not isinstance(default, dict): raise ConfigValidationError(f"Invalid default: `{default}`") load_default = lambda: ExecutorConfig.Schema().load(default) dump_default = ExecutorConfig.Schema().dump(default) return field( metadata={ "marshmallow_field": ExecutorMarshmallowField( allow_none=False, load_default=load_default, dump_default=dump_default, metadata={"description": description, "parameter_metadata": None}, ) }, default_factory=load_default, ) ================================================ FILE: ludwig/schema/hyperopt/parameter.py ================================================ from pydantic.fields import FieldInfo from ludwig.api_annotations import DeveloperAPI from ludwig.schema import utils as schema_utils from ludwig.schema.hyperopt.utils import register_parameter_config from ludwig.schema.utils import ludwig_dataclass def quantization_number_field(dtype: type[float] | type[int] = float, default=None) -> FieldInfo: description = ( "Quantization number. Output values will be rounded to the nearest increment of `q` in range." "Quantization makes the upper bound inclusive." ) if dtype is int: field = schema_utils.Integer(default=default, allow_none=True, description=description) else: field = schema_utils.FloatRange(default=default, allow_none=True, description=description) return field def log_base_field(default: float = 10) -> FieldInfo: return schema_utils.FloatRange(default=default, description="Logarithmic base.") @DeveloperAPI @register_parameter_config("choice") @ludwig_dataclass class ChoiceParameterConfig(schema_utils.BaseMarshmallowConfig): """Config for a randomly sampled categorical search space.""" space: str = schema_utils.ProtectedString("choice") categories: list = schema_utils.OneOfOptionsField( default=None, allow_none=True, description=( "The list of values to use in creating the categorical space. The type of each value of the list is " "general, i.e., they could be strings, integers, floats and anything else, even entire dictionaries." ), field_options=[ schema_utils.List(list_type=float, allow_none=False, description="The list of floats to randomly sample."), schema_utils.List(list_type=int, allow_none=False, description="The list of integers to randomly sample."), schema_utils.List(list_type=str, allow_none=False, description="The list of strings to randomly sample."), schema_utils.List( list_type=list, inner_type=dict, allow_none=False, description="The list of lists of configs to randomly sample.", ), schema_utils.DictList(allow_none=False, description="A list of nested config parameters to sample."), ], ) @DeveloperAPI @register_parameter_config("grid_search") @ludwig_dataclass class GridSearchParameterConfig(schema_utils.BaseMarshmallowConfig): """Config for a grid search space.""" space: str = schema_utils.ProtectedString("grid_search") values: list = schema_utils.OneOfOptionsField( default=None, allow_none=True, description=( "The list of values to use in creating the grid search space. The type of each value of the list is " "general, i.e., they could be strings, integers, floats and anything else, even entire dictionaries." ), field_options=[ schema_utils.List(list_type=float, allow_none=False, description="The list of floats to randomly sample."), schema_utils.List(list_type=int, allow_none=False, description="The list of integers to randomly sample."), schema_utils.List(list_type=str, allow_none=False, description="The list of strings to randomly sample."), ], ) @DeveloperAPI @register_parameter_config("uniform") @ludwig_dataclass class UniformParameterConfig(schema_utils.BaseMarshmallowConfig): """Config for a real-valued uniform search space.""" space: str = schema_utils.ProtectedString("uniform") lower: float = schema_utils.FloatRange(default=None, description="The minimum value the parameter can have.") upper: float = schema_utils.FloatRange(default=None, description="The maximum value the parameter can have.") @DeveloperAPI @register_parameter_config("quniform") @ludwig_dataclass class QUniformParameterConfig(UniformParameterConfig): """Config for a real-valued uniform search space with quantization.""" space: str = schema_utils.ProtectedString("quniform") q: float = quantization_number_field() @DeveloperAPI @register_parameter_config("loguniform") @ludwig_dataclass class LogUniformParameterConfig(UniformParameterConfig): """Config for a log-scaled real-valued uniform numeric search space.""" space: str = schema_utils.ProtectedString("loguniform") base: float = log_base_field() @DeveloperAPI @register_parameter_config("qloguniform") @ludwig_dataclass class QLogUniformParameterConfig(UniformParameterConfig): """Config for a log-scaled real-valued uniform search space with quantization.""" space: str = schema_utils.ProtectedString("qloguniform") q: float = quantization_number_field() base: float = log_base_field() @DeveloperAPI @register_parameter_config("randn") @ludwig_dataclass class RandnParameterConfig(schema_utils.BaseMarshmallowConfig): """Config for a Gaussian search space.""" space: str = schema_utils.ProtectedString("randn") mean: float = schema_utils.FloatRange(default=0.0, description="Mean of the normal distribution.") sd: float = schema_utils.FloatRange(default=1.0, description="Standard deviation of the normal distribution.") @DeveloperAPI @register_parameter_config("qrandn") @ludwig_dataclass class QRandnParameterConfig(RandnParameterConfig): """Config for a Gaussian search space with quantization.""" space: str = schema_utils.ProtectedString("qrandn") q: float = quantization_number_field() @DeveloperAPI @register_parameter_config("randint") @ludwig_dataclass class RandintParameterConfig(schema_utils.BaseMarshmallowConfig): """Config for an integer-valued uniform search space.""" space: str = schema_utils.ProtectedString("randint") lower: int = schema_utils.Integer(default=None, description="The minimum value the parameter can have.") upper: int = schema_utils.Integer(default=None, description="The maximum value the parameter can have.") @DeveloperAPI @register_parameter_config("qrandint") @ludwig_dataclass class QRandintParameterConfig(RandintParameterConfig): """Config for an integer-valued uniform search space with quantization.""" space: str = schema_utils.ProtectedString("qrandint") q: int = quantization_number_field(dtype=int) @DeveloperAPI @register_parameter_config("lograndint") @ludwig_dataclass class LogRandintParameterConfig(RandintParameterConfig): """Config for an log-scaled integer-valued search space.""" space: str = schema_utils.ProtectedString("lograndint") base: float = log_base_field() @DeveloperAPI @register_parameter_config("qlograndint") @ludwig_dataclass class QLogRandintParameterConfig(RandintParameterConfig): """Config for an log-scaled integer-valued search space with quantization.""" space: str = schema_utils.ProtectedString("qlograndint") q: int = quantization_number_field(dtype=int) base: float = log_base_field() ================================================ FILE: ludwig/schema/hyperopt/scheduler.py ================================================ from abc import ABC from collections.abc import Callable from dataclasses import field from importlib import import_module from ludwig.api_annotations import DeveloperAPI from ludwig.error import ConfigValidationError from ludwig.schema import utils as schema_utils from ludwig.schema.hyperopt import utils as hyperopt_utils from ludwig.schema.utils import ludwig_dataclass # ---------------------------------------------------------------------------------------------------------------------- # To prevent direct dependency on ray import, the following static key stores are duplicated: # from ray.tune.schedulers import SCHEDULER_IMPORT # https://github.com/ray-project/ray/blob/137a1b12c3b31a3622fa5f721a05a64e9b559b05/python/ray/tune/schedulers/__init__.py#L28 # from ray.tune.result import DEFAULT_RESULT_KEYS # Taken from https://github.com/ray-project/ray/blob/137a1b12c3b31a3622fa5f721a05a64e9b559b05/python/ray/tune/result.py TRAINING_ITERATION = "training_iteration" TIME_TOTAL_S = "time_total_s" TIMESTEPS_TOTAL = "timesteps_total" MEAN_ACCURACY = "mean_accuracy" MEAN_LOSS = "mean_loss" DEFAULT_RESULT_KEYS = (TRAINING_ITERATION, TIME_TOTAL_S, TIMESTEPS_TOTAL, MEAN_ACCURACY, MEAN_LOSS) # from ray.tune.result import DEFAULT_METRIC RAY_TUNE_DESULT_DEFAULT_METRIC = "_metric" # ---------------------------------------------------------------------------------------------------------------------- # Field aliases to cut down on code reuse: @DeveloperAPI def metric_alias(default=None): return schema_utils.StringOptions( options=list(DEFAULT_RESULT_KEYS) + [RAY_TUNE_DESULT_DEFAULT_METRIC], default=default, allow_none=default is None, description=( "The training result objective value attribute. Stopping procedures will use this attribute. If None but a " "mode was passed, the ray.tune.result.DEFAULT_METRIC will be used per default." ), ) @DeveloperAPI def time_attr_alias(default=TRAINING_ITERATION): return schema_utils.StringOptions( options=list(DEFAULT_RESULT_KEYS), default=default, allow_none=False, description=( "A training result attr to use for comparing time. Note that you can pass in something non-temporal such as" " training_iteration as a measure of progress, the only requirement is that the attribute should increase " "monotonically." ), ) @DeveloperAPI def max_t_alias(default=100): return schema_utils.PositiveInteger( default=default, description=( "max time units per trial. Trials will be stopped after max_t time units (determined by time_attr) have " "passed." ), ) @DeveloperAPI @ludwig_dataclass class BaseSchedulerConfig(schema_utils.BaseMarshmallowConfig, ABC): """Base class for schedulers. Not meant to be used directly. """ type: str """Name corresponding to a scheduler in `ludwig.schema.hyperopt.scheduler.scheduler_registry`. Technically mutable, but attempting to load a derived scheduler with `type` set to a mismatched value will result in a `ValidationError`. """ time_attr: str = time_attr_alias() metric: str | None = metric_alias() mode: str | None = schema_utils.StringOptions( options=["min", "max"], default=None, allow_none=True, description=( "One of {min, max}. Determines whether objective is minimizing or maximizing the metric attribute." ), ) def dependencies_installed(self): """Some search algorithms require additional packages to be installed, check that they are available.""" missing_packages = [] missing_installs = [] for package_name, install_name in hyperopt_utils.get_scheduler_dependencies(self.type): try: import_module(package_name) except ImportError: missing_packages.append(package_name) missing_installs.append(install_name) if missing_packages: missing_packages = ", ".join(missing_packages) missing_installs = " ".join(missing_installs) raise ImportError( f"Some packages needed to use hyperopt scheduler {self.type} are not installed: " f"{missing_packages}. To add these dependencies, run `pip install {missing_installs}`. For more " "details, please refer to Ray Tune documentation for this scheduler." ) return True @DeveloperAPI @ludwig_dataclass class BaseHyperbandSchedulerConfig(BaseSchedulerConfig): max_t: int = max_t_alias() @DeveloperAPI @hyperopt_utils.register_scheduler_config("async_hyperband") @hyperopt_utils.register_scheduler_config("asynchyperband") @hyperopt_utils.register_scheduler_config("asha") @ludwig_dataclass class AsyncHyperbandSchedulerConfig(BaseHyperbandSchedulerConfig): """Asynchronous hyperband (ASHA) scheduler settings.""" type: str = schema_utils.ProtectedString("async_hyperband") max_t: int = max_t_alias() grace_period: int = schema_utils.PositiveInteger( default=1, description=( "Only stop trials at least this old in time. The units are the same as the attribute named by `time_attr`." ), ) reduction_factor: int = schema_utils.NonNegativeFloat( default=4, description=("Used to set halving rate and amount. This is simply a unit-less scalar.") ) brackets: int = schema_utils.PositiveInteger( default=1, description=( "Number of brackets. Each bracket has a different halving rate, specified by the reduction factor." ), ) stop_last_trials: bool = schema_utils.Boolean( default=True, description="Whether to terminate the trials after reaching `max_t`." ) @DeveloperAPI @hyperopt_utils.register_scheduler_config("hyperband") @ludwig_dataclass class HyperbandSchedulerConfig(BaseHyperbandSchedulerConfig): """Standard hyperband scheduler settings.""" type: str = schema_utils.ProtectedString("hyperband") max_t: int = max_t_alias(default=81) reduction_factor: int = schema_utils.NonNegativeFloat( default=3, description=("Used to set halving rate and amount. This is simply a unit-less scalar.") ) stop_last_trials: bool = schema_utils.Boolean( default=True, description=("Whether to terminate the trials after reaching max_t. Defaults to True.") ) @DeveloperAPI @hyperopt_utils.register_scheduler_config("median_stopping_rule") @hyperopt_utils.register_scheduler_config("medianstoppingrule") @ludwig_dataclass class MedianStoppingRuleSchedulerConfig(BaseSchedulerConfig): """Median Stopping Rule scheduler settings.""" type: str = schema_utils.ProtectedString("median_stopping_rule") time_attr: str = time_attr_alias(TIME_TOTAL_S) grace_period: float = schema_utils.NonNegativeFloat( default=60.0, description=( "Only stop trials at least this old in time. The mean will only be computed from this time onwards. The " "units are the same as the attribute named by `time_attr`." ), ) min_samples_required: int = schema_utils.PositiveInteger( default=3, description=("Minimum number of trials to compute median over.") ) min_time_slice: int = schema_utils.NonNegativeInteger( default=0, description=( "Each trial runs at least this long before yielding (assuming it isn't stopped). Note: trials ONLY yield " "if there are not enough samples to evaluate performance for the current result AND there are other " "trials waiting to run. The units are the same as the attribute named by `time_attr`." ), ) hard_stop: bool = schema_utils.Boolean( default=True, description=( "If False, pauses trials instead of stopping them. When all other trials are complete, paused trials will " "be resumed and allowed to run FIFO." ), ) @DeveloperAPI @hyperopt_utils.register_scheduler_config("pbt") @ludwig_dataclass class PopulationBasedTrainingSchedulerConfig(BaseSchedulerConfig): """Population Based Training scheduler settings.""" type: str = schema_utils.ProtectedString("pbt") time_attr: str = time_attr_alias(TIME_TOTAL_S) perturbation_interval: float = schema_utils.NonNegativeFloat( default=60.0, description=( "Models will be considered for perturbation at this interval of `time_attr`. Note that perturbation incurs " "checkpoint overhead, so you shouldn't set this to be too frequent." ), ) burn_in_period: float = schema_utils.NonNegativeFloat( default=60.0, description=( "Models will not be considered for perturbation before this interval of time_attr has passed. This " "guarantees that models are trained for at least a certain amount of time or timesteps before being " "perturbed." ), ) hyperparam_mutations: dict | None = schema_utils.Dict( default=None, description=( "Hyperparams to mutate. The format is as follows: for each key, either a list, function, or a tune search " "space object (`tune.loguniform`, tune.uniform, etc.) can be provided. A list specifies an allowed set of " "categorical values. A function or tune search space object specifies the distribution of a continuous " "parameter. You must use `tune.choice`, `tune.uniform`, `tune.loguniform`, etc.. Arbitrary " "`tune.sample_from` objects are not supported. A key can also hold a dict for nested hyperparameters. You " "must specify at least one of `hyperparam_mutations` or `custom_explore_fn`. Tune will sample the search " "space provided by `hyperparam_mutations` for the initial hyperparameter values if the corresponding " "hyperparameters are not present in a trial's initial config." ), ) quantile_fraction: float = schema_utils.FloatRange( default=0.25, allow_none=False, min=0, max=0.5, description=( "Parameters are transferred from the top `quantile_fraction` fraction of trials to the bottom " "`quantile_fraction` fraction. Needs to be between 0 and 0.5. Setting it to 0 essentially implies doing " "no exploitation at all." ), ) resample_probability: float = schema_utils.NonNegativeFloat( default=0.25, description=( "The probability of resampling from the original distribution when applying `hyperparam_mutations`. If " "not resampled, the value will be perturbed by a factor chosen from `perturbation_factors` if continuous, " "or changed to an adjacent value if discrete." ), ) perturbation_factors: tuple[float, float] = schema_utils.FloatRangeTupleDataclassField( default=(1.2, 0.8), allow_none=False, max=None, description=("Scaling factors to choose between when mutating a continuous hyperparameter."), ) # TODO: Add schema support for Callable custom_explore_fn: str | Callable = schema_utils.String( default=None, allow_none=True, description=( "You can also specify a custom exploration function. This function is invoked as `f(config)` after " "built-in perturbations from `hyperparam_mutations` are applied, and should return config updated as " "needed. You must specify at least one of `hyperparam_mutations` or `custom_explore_fn`." ), ) log_config: bool = schema_utils.Boolean( default=True, description=( "Whether to log the ray config of each model to `local_dir` at each exploit. Allows config schedule to be " "reconstructed." ), ) require_attrs: bool = schema_utils.Boolean( default=True, description=( "Whether to require `time_attr` and metric to appear in result for every iteration. If True, error will " "be raised if these values are not present in trial result." ), ) synch: bool = schema_utils.Boolean( default=False, description=( "If False, will use asynchronous implementation of PBT. Trial perturbations occur every " "`perturbation_interval` for each trial independently. If True, will use synchronous implementation of " "PBT. Perturbations will occur only after all trials are synced at the same `time_attr` every " "`perturbation_interval`. Defaults to False. See Appendix A.1 here https://arxiv.org/pdf/1711.09846.pdf." ), ) @DeveloperAPI @hyperopt_utils.register_scheduler_config("pbt_replay") @ludwig_dataclass class PopulationBasedTrainingReplaySchedulerConfig(BaseSchedulerConfig): """Population Based Training Replay scheduler settings.""" type: str = schema_utils.ProtectedString("pbt_replay") # TODO: This should technically be a required paremeter. Do we need to add support for required params? policy_file: str = schema_utils.String( default=None, allow_none=True, description=( "The PBT policy file. Usually this is stored in `~/ray_results/experiment_name/pbt_policy_xxx.txt` where " "`xxx` is the trial ID." ), ) @DeveloperAPI @hyperopt_utils.register_scheduler_config("pb2", dependencies=[("sklearn", "scikit-learn"), ("GPy", "GPy")]) @ludwig_dataclass class PopulationBasedBanditsSchedulerConfig(BaseSchedulerConfig): """Population Based Bandits (PB2) scheduler settings.""" type: str = schema_utils.ProtectedString("pb2") time_attr: str = time_attr_alias(TIME_TOTAL_S) perturbation_interval: float = schema_utils.NonNegativeFloat( default=60.0, description=( "Models will be considered for perturbation at this interval of `time_attr`. Note that perturbation " "incurs checkpoint overhead, so you shouldn't set this to be too frequent." ), ) hyperparam_bounds: dict | None = schema_utils.Dict( default=None, description=( "Hyperparameters to mutate. The format is as follows: for each key, enter a list of the form [min, max] " "representing the minimum and maximum possible hyperparameter values." ), ) quantile_fraction: float = schema_utils.FloatRange( default=0.25, allow_none=False, min=0, max=0.5, description=( "Parameters are transferred from the top `quantile_fraction` fraction of trials to the bottom " "`quantile_fraction` fraction. Needs to be between 0 and 0.5. Setting it to 0 essentially implies doing " "no exploitation at all." ), ) log_config: bool = schema_utils.Boolean( default=True, description=( "Whether to log the ray config of each model to `local_dir` at each exploit. Allows config schedule to be " "reconstructed." ), ) require_attrs: bool = schema_utils.Boolean( default=True, description=( "Whether to require `time_attr` and metric to appear in result for every iteration. If True, error will " "be raised if these values are not present in trial result." ), ) synch: bool = schema_utils.Boolean( default=False, description=( "If False, will use asynchronous implementation of PBT. Trial perturbations occur every " "`perturbation_interval` for each trial independently. If True, will use synchronous implementation of " "PBT. Perturbations will occur only after all trials are synced at the same `time_attr` every " "`perturbation_interval`. Defaults to False. See Appendix A.1 here https://arxiv.org/pdf/1711.09846.pdf." ), ) @DeveloperAPI @hyperopt_utils.register_scheduler_config("hb_bohb") @ludwig_dataclass class BOHBSchedulerConfig(BaseHyperbandSchedulerConfig): """Hyperband for BOHB (hb_bohb) scheduler settings.""" type: str = schema_utils.ProtectedString("hb_bohb") max_t: int = max_t_alias(default=81) reduction_factor: int = schema_utils.NonNegativeFloat( default=3, description=("Used to set halving rate and amount. This is simply a unit-less scalar.") ) stop_last_trials: bool = schema_utils.Boolean( default=True, description=("Whether to terminate the trials after reaching `max_t`. Defaults to True.") ) # TODO: Double-check support for this @DeveloperAPI @hyperopt_utils.register_scheduler_config("fifo") @ludwig_dataclass class FIFOSchedulerConfig(BaseSchedulerConfig): """FIFO trial scheduler settings.""" type: str = schema_utils.ProtectedString("fifo") # TODO: Double-check support for this as well as whether Callable args work properly @DeveloperAPI @hyperopt_utils.register_scheduler_config("resource_changing") @ludwig_dataclass class ResourceChangingSchedulerConfig(BaseSchedulerConfig): """Resource changing scheduler settings.""" type: str = schema_utils.ProtectedString("resource_changing") base_scheduler: str | None | Callable = schema_utils.String( default=None, allow_none=True, description=("The scheduler to provide decisions about trials. If None, a default FIFOScheduler will be used."), ) resources_allocation_function: str | Callable = schema_utils.String( default=None, allow_none=True, description=( "The callable used to change live trial resource requiements during tuning. This callable will be called on" " each trial as it finishes one step of training. The callable must take four arguments: `TrialRunner`, " "current `Trial`, current result `dict` and the `ResourceChangingScheduler` calling it. The callable must " "return a `PlacementGroupFactory`, `Resources`, `dict` or None (signifying no need for an update). If " "`resources_allocation_function` is None, no resource requirements will be changed at any time. By " " default, `DistributeResources` will be used, distributing available CPUs and GPUs over all running " "trials in a robust way, without any prioritization." ), ) @DeveloperAPI def get_scheduler_conds(): """Returns a JSON schema of conditionals to validate against scheduler types defined in `ludwig.schema.hyperopt.scheduler_registry`.""" conds = [] for scheduler_config in hyperopt_utils.scheduler_config_registry: scheduler_cls = hyperopt_utils.scheduler_config_registry[scheduler_config] other_props = schema_utils.unload_jsonschema_from_marshmallow_class(scheduler_cls)["properties"] schema_utils.remove_duplicate_fields(other_props) preproc_cond = schema_utils.create_cond( {"type": scheduler_config}, other_props, ) conds.append(preproc_cond) return conds @DeveloperAPI def SchedulerDataclassField(default={"type": "fifo"}, description="Hyperopt scheduler settings."): """Custom dataclass field that when used inside of a dataclass will allow any scheduler in `ludwig.schema.hyperopt.scheduler.scheduler_registry`. Sets default scheduler to 'fifo'. :param default: Dict specifying a scheduler with a `type` field and its associated parameters. Will attempt to use `type` to load scheduler from registry with given params. (default: {"type": "fifo"}). :return: Initialized dataclass field that converts untyped dicts with params to scheduler dataclass instances. """ class SchedulerMarshmallowField(schema_utils.LudwigSchemaField): """Custom field that deserializes a dict to a valid scheduler from `ludwig.schema.hyperopt.scheduler_registry` and creates a corresponding `oneOf` JSON schema for external usage.""" def _deserialize(self, value, attr, data, **kwargs): if value is None: return None if isinstance(value, dict): if "type" in value and value["type"] in hyperopt_utils.scheduler_config_registry: scheduler_config_cls = hyperopt_utils.scheduler_config_registry[value["type"].lower()] try: return scheduler_config_cls.Schema().load(value) except (TypeError, ConfigValidationError) as e: raise ConfigValidationError( f"Invalid params for scheduler: {value}, see `{opt}` definition. Error: {e}" ) raise ConfigValidationError( f"Invalid params for scheduler: {value}, expect dict with at least a valid `type` attribute." ) raise ConfigValidationError("Field should be None or dict") def _jsonschema_type_mapping(self): # Note that this uses the same conditional pattern as combiners: return { "type": "object", "properties": { "type": { "type": "string", "enum": list(hyperopt_utils.scheduler_config_registry.keys()), "default": default["type"], "description": "The type of scheduler to use during hyperopt", }, }, "title": "scheduler_options", "allOf": get_scheduler_conds(), "required": ["type"], "description": description, } if ( not isinstance(default, dict) or "type" not in default or default["type"] not in hyperopt_utils.scheduler_config_registry ): raise ConfigValidationError(f"Invalid default: `{default}`") try: opt = hyperopt_utils.scheduler_config_registry[default["type"].lower()] load_default = lambda: opt.Schema().load(default) dump_default = opt.Schema().dump(default) return field( metadata={ "marshmallow_field": SchedulerMarshmallowField( allow_none=False, dump_default=dump_default, load_default=load_default, metadata={"description": description}, ) }, default_factory=load_default, ) except Exception as e: raise ConfigValidationError( f"Unsupported scheduler type: {default['type']}. See scheduler_config_registry. Details: {e}" ) ================================================ FILE: ludwig/schema/hyperopt/search_algorithm.py ================================================ from dataclasses import field from importlib.util import find_spec from typing import Any from ludwig.api_annotations import DeveloperAPI from ludwig.error import ConfigValidationError from ludwig.schema import utils as schema_utils from ludwig.schema.hyperopt import utils as hyperopt_utils from ludwig.schema.utils import ludwig_dataclass def points_to_evaluate_field(description: str | None = None): return schema_utils.DictList( description=description or ( "Initial parameter suggestions to be run first. This is for when you already have some good parameters " "you want to run first to help the algorithm make better suggestions for future parameters. Needs to be " "a list of dicts containing the configurations." ), ) def evaluated_rewards_field(description: str | None = None): return schema_utils.List( description=description or ( "If you have previously evaluated the parameters passed in as points_to_evaluate you can avoid re-running " "those trials by passing in the reward attributes as a list so the optimiser can be told the results " "without needing to re-compute the trial. Must be the same length as `points_to_evaluate`." ) ) @DeveloperAPI @ludwig_dataclass class BaseSearchAlgorithmConfig(schema_utils.BaseMarshmallowConfig): """Basic search algorithm settings.""" type: str = schema_utils.String(default="variant_generator", description="The search algorithm to use.") def set_random_state(self, ludwig_random_state: int) -> None: """Overwrite the config random state. Search algorithms refer to random state by different names, however we want to overwrite unset random states with the Ludwig random state. This method uses a registry of random state field names to provide a single interface across all search algorithms. """ rs_field = hyperopt_utils.get_search_algorithm_random_state_field(self.type) if rs_field is not None and self.__getattribute__(rs_field) is None: self.__setattr__(rs_field, ludwig_random_state) def dependencies_installed(self) -> bool: """Some search algorithms require additional packages to be installed, check that they are available.""" missing_packages = [] missing_installs = [] for package_name, install_name in hyperopt_utils.get_search_algorithm_dependencies(self.type): if find_spec(package_name) is None: missing_packages.append(package_name) missing_installs.append(install_name) if missing_packages: missing_packages = ", ".join(missing_packages) missing_installs = " ".join(missing_installs) raise ImportError( f"Some packages needed to use hyperopt search algorithm {self.type} are not installed: " f"{missing_packages}. To add these dependencies, run `pip install {missing_installs}`. For more " "details, please refer to Ray Tune documentation for this search algorithm." ) return True @DeveloperAPI def SearchAlgorithmDataclassField(description: str = "", default: dict = {"type": "variant_generator"}): class SearchAlgorithmMarshmallowField(schema_utils.LudwigSchemaField): def _deserialize(self, value, attr, data, **kwargs): if isinstance(value, dict): try: return BaseSearchAlgorithmConfig.Schema().load(value) except (TypeError, ConfigValidationError): raise ConfigValidationError( f"Invalid params for scheduler: {value}, see SearchAlgorithmConfig class." ) raise ConfigValidationError("Field should be dict") def _jsonschema_type_mapping(self): return { # **schema_utils.unload_jsonschema_from_marshmallow_class(BaseSearchAlgorithmConfig), "type": "object", "properties": { "type": { "type": "string", "enum": list(hyperopt_utils.search_algorithm_config_registry.keys()), "default": default["type"], "description": "The type of scheduler to use during hyperopt", }, }, "title": "search_algorithm_options", "required": ["type"], "description": description, } if not isinstance(default, dict): raise ConfigValidationError(f"Invalid default: `{default}`") load_default = lambda: BaseSearchAlgorithmConfig.Schema().load(default) dump_default = BaseSearchAlgorithmConfig.Schema().dump(default) return field( metadata={ "marshmallow_field": SearchAlgorithmMarshmallowField( allow_none=False, load_default=load_default, dump_default=dump_default, metadata={"description": description, "parameter_metadata": None}, ) }, default_factory=load_default, ) @DeveloperAPI @hyperopt_utils.register_search_algorithm_config("random", random_state_field="random_state") @hyperopt_utils.register_search_algorithm_config("variant_generator", random_state_field="random_state") @ludwig_dataclass class BasicVariantSAConfig(BaseSearchAlgorithmConfig): type: str = schema_utils.StringOptions(options=["random", "variant_generator"], default="random", allow_none=False) points_to_evaluate: list[dict] | None = schema_utils.DictList( description=( "Initial parameter suggestions to be run first. This is for when you already have some good parameters " "you want to run first to help the algorithm make better suggestions for future parameters. Needs to be " "a list of dicts containing the configurations." ) ) max_concurrent: int = schema_utils.NonNegativeInteger( default=0, description="Maximum number of concurrently running trials. If 0 (default), no maximum is enforced." ) constant_grid_search: bool = schema_utils.Boolean( default=False, description=( "If this is set to True, Ray Tune will first try to sample random values and keep them constant over grid " "search parameters. If this is set to False (default), Ray Tune will sample new random parameters in each " "grid search condition." ), ) random_state: int = schema_utils.Integer( default=None, allow_none=True, description=( "Seed or numpy random generator to use for reproducible results. If None (default), will use the global " "numpy random generator (np.random). Please note that full reproducibility cannot be guaranteed in a " "distributed environment." ), ) @DeveloperAPI @hyperopt_utils.register_search_algorithm_config( "ax", dependencies=[("ax", "ax-platform"), ("sqlalchemy", "sqlalchemy")] ) @ludwig_dataclass class AxSAConfig(BaseSearchAlgorithmConfig): type: str = schema_utils.ProtectedString("ax") space: list[dict] | None = schema_utils.DictList( description=( r"Parameters in the experiment search space. Required elements in the dictionaries are: \“name\” (name of " r"this parameter, string), \“type\” (type of the parameter: \“range\”, \“fixed\”, or \“choice\”, string), " r"\“bounds\” for range parameters (list of two values, lower bound first), \“values\” for choice " r"parameters (list of values), and \“value\” for fixed parameters (single value)." ) ) points_to_evaluate: list[dict] | None = points_to_evaluate_field() parameter_constraints: list | None = schema_utils.List( description=r"Parameter constraints, such as \“x3 >= x4\” or \“x3 + x4 >= 2\”." ) outcome_constraints: list | None = schema_utils.List( description=r"Outcome constraints of form \“metric_name >= bound\”, like \“m1 <= 3.\”" ) @DeveloperAPI @hyperopt_utils.register_search_algorithm_config( "bayesopt", random_state_field="random_state", dependencies=[("bayes_opt", "bayesian-optimization")] ) @ludwig_dataclass class BayesOptSAConfig(BaseSearchAlgorithmConfig): type: str = schema_utils.ProtectedString("bayesopt") space: dict | None = schema_utils.Dict( description=( "Continuous search space. Parameters will be sampled from this space which will be used to run trials" ) ) points_to_evaluate: list[dict] | None = points_to_evaluate_field() utility_kwargs: dict | None = schema_utils.Dict( description=( "Parameters to define the utility function. The default value is a dictionary with three keys: " "- kind: ucb (Upper Confidence Bound) - kappa: 2.576 - xi: 0.0" ) ) random_state: int = schema_utils.Integer(default=None, allow_none=True, description="Used to initialize BayesOpt.") random_search_steps: int = schema_utils.Integer( default=10, description=( "Number of initial random searches. This is necessary to avoid initial local overfitting of " "the Bayesian process." ), ) verbose: int = schema_utils.IntegerOptions( options=[0, 1, 2], default=0, description="The level of verbosity. `0` is least verbose, `2` is most verbose." ) patience: int = schema_utils.NonNegativeInteger( default=5, description="Number of epochs to wait for a change in the top models." ) skip_duplicate: bool = schema_utils.Boolean( default=True, description=( "If False, the optimizer will allow duplicate points to be registered. This behavior may be desired in " "high noise situations where repeatedly probing the same point will give different answers. In other " "situations, the acquisition may occasionaly generate a duplicate point." ), ) @DeveloperAPI @hyperopt_utils.register_search_algorithm_config("blendsearch", dependencies=[("flaml", "flaml[blendsearch]")]) @ludwig_dataclass class BlendsearchSAConfig(BaseSearchAlgorithmConfig): type: str = schema_utils.ProtectedString("blendsearch") @DeveloperAPI @hyperopt_utils.register_search_algorithm_config( "bohb", random_state_field="seed", dependencies=[("hpbandster", "hpbandster"), ("ConfigSpace", "ConfigSpace")] ) @ludwig_dataclass class BOHBSAConfig(BaseSearchAlgorithmConfig): type: str = schema_utils.ProtectedString("bohb") space: dict | None = schema_utils.Dict( description=( "Continuous ConfigSpace search space. Parameters will be sampled from this space which will be used " "to run trials." ) ) bohb_config: dict | None = schema_utils.Dict(description="configuration for HpBandSter BOHB algorithm") points_to_evaluate: list[dict] | None = points_to_evaluate_field() seed: int | None = schema_utils.Integer( default=None, allow_none=True, description=( "Optional random seed to initialize the random number generator. Setting this should lead to identical " "initial configurations at each run." ), ) max_concurrent: int = schema_utils.Integer( default=0, description=( "Number of maximum concurrent trials. If this Searcher is used in a `ConcurrencyLimiter`, the " "`max_concurrent` value passed to it will override the value passed here. Set to <= 0 for no limit on " "concurrency." ), ) @DeveloperAPI @hyperopt_utils.register_search_algorithm_config("cfo", dependencies=[("flaml", "flaml")]) @ludwig_dataclass class CFOSAConfig(BaseSearchAlgorithmConfig): type: str = schema_utils.ProtectedString("cfo") @DeveloperAPI @hyperopt_utils.register_search_algorithm_config( "dragonfly", random_state_field="random_state_seed", dependencies=[("dragonfly", "dragonfly-opt")] ) @ludwig_dataclass class DragonflySAConfig(BaseSearchAlgorithmConfig): type: str = schema_utils.ProtectedString("dragonfly") optimizer: str | None = schema_utils.StringOptions( options=["random", "bandit", "genetic"], default=None, allow_none=True, description=( "Optimizer provided from dragonfly. Choose an optimiser that extends `BlackboxOptimiser`. If this is a " "string, `domain` must be set and `optimizer` must be one of [random, bandit, genetic]." ), ) domain: str | None = schema_utils.StringOptions( options=["cartesian", "euclidean"], default=None, allow_none=True, description=( "Optional domain. Should only be set if you don't pass an optimizer as the `optimizer` argument. If set, " "has to be one of `[cartesian, euclidean]`." ), ) space: list[dict] | None = schema_utils.DictList( description=( "Search space. Should only be set if you don't pass an optimizer as the `optimizer` argument. Defines the " "search space and requires a `domain` to be set. Can be automatically converted from the `param_space` " "dict passed to `tune.Tuner()`." ) ) points_to_evaluate: list[dict] | None = points_to_evaluate_field() evaluated_rewards: list | None = evaluated_rewards_field() random_state_seed: int | None = schema_utils.Integer( default=None, allow_none=True, description=( "Seed for reproducible results. Defaults to None. Please note that setting this to a value will change " "global random state for `numpy` on initalization and loading from checkpoint." ), ) @DeveloperAPI @hyperopt_utils.register_search_algorithm_config( "hebo", random_state_field="random_state_seed", dependencies=[("hebo", "HEBO")] ) @ludwig_dataclass class HEBOSAConfig(BaseSearchAlgorithmConfig): type: str = schema_utils.ProtectedString("hebo") space: list[dict] | None = schema_utils.DictList( description="A dict mapping parameter names to Tune search spaces or a HEBO DesignSpace object." ) points_to_evaluate: list[dict] | None = points_to_evaluate_field() evaluated_rewards: list | None = evaluated_rewards_field() random_state_seed: int | None = schema_utils.Integer( default=None, allow_none=True, description=( "Seed for reproducible results. Defaults to None. Please note that setting this to a value will change " "global random state for `numpy` on initalization and loading from checkpoint." ), ) max_concurrent: int = schema_utils.NonNegativeInteger( default=8, description=( "Number of maximum concurrent trials. If this Searcher is used in a `ConcurrencyLimiter`, the " "`max_concurrent` value passed to it will override the value passed here." ), ) @DeveloperAPI @hyperopt_utils.register_search_algorithm_config( "hyperopt", random_state_field="random_state_seed", dependencies=[("hyperopt", "hyperopt")] ) @ludwig_dataclass class HyperoptSAConfig(BaseSearchAlgorithmConfig): type: str = schema_utils.ProtectedString("hyperopt") space: list[dict] | None = schema_utils.DictList( description=( "HyperOpt configuration. Parameters will be sampled from this configuration and will be used to override " "parameters generated in the variant generation process." ) ) points_to_evaluate: list[dict] | None = points_to_evaluate_field() n_initial_points: int = schema_utils.PositiveInteger( default=20, description=( "The number of random evaluations of the objective function before starting to approximate it with tree " "parzen estimators. Defaults to 20." ), ) random_state_seed: int | None = schema_utils.Integer( default=None, allow_none=True, description=("Seed for reproducible results. Defaults to None."), ) gamma: float = schema_utils.FloatRange( min=0.0, max=1.0, default=0.25, description=( "The split to use in TPE. TPE models two splits of the evaluated hyperparameters: the top performing " "`gamma` percent, and the remaining examples. For more details, see [Making a Science of Model Search: " "Hyperparameter Optimization in Hundreds of Dimensions for Vision Architectures.]" "(http://proceedings.mlr.press/v28/bergstra13.pdf)." ), ) @DeveloperAPI @hyperopt_utils.register_search_algorithm_config("nevergrad", dependencies=[("nevergrad", "nevergrad")]) @ludwig_dataclass class NevergradSAConfig(BaseSearchAlgorithmConfig): type: str = schema_utils.ProtectedString("nevergrad") # TODO: Add a registry mapping string names to nevergrad optimizers # optimizer: Optional[str] = None # TODO: Add schemas for nevergrad optimizer kwargs optimizer_kwargs: dict | None = schema_utils.Dict(description="Kwargs passed in when instantiating the optimizer.") space: list[dict] | None = schema_utils.DictList( description=( "Nevergrad parametrization to be passed to optimizer on instantiation, or list of parameter names if you " "passed an optimizer object." ) ) points_to_evaluate: list[dict] | None = points_to_evaluate_field() @DeveloperAPI @hyperopt_utils.register_search_algorithm_config( "optuna", random_state_field="seed", dependencies=[("optuna", "optuna")] ) @ludwig_dataclass class OptunaSAConfig(BaseSearchAlgorithmConfig): type: str = schema_utils.ProtectedString("optuna") space: dict | None = schema_utils.Dict( description=( "Hyperparameter search space definition for Optuna's sampler. This can be either a dict with parameter " "names as keys and optuna.distributions as values, or a Callable - in which case, it should be a " "define-by-run function using optuna.trial to obtain the hyperparameter values. The function should " "return either a dict of constant values with names as keys, or None. For more information, see " "[the Optuna docs]" "(https://optuna.readthedocs.io/en/stable/tutorial/10_key_features/002_configurations.html)." ) ) points_to_evaluate: list[dict] | None = points_to_evaluate_field() # TODO: Add a registry of Optuna samplers schemas # sampler = None seed: int | None = schema_utils.Integer( default=None, allow_none=True, description=( "Seed to initialize sampler with. This parameter is only used when `sampler=None`. In all other cases, " "the sampler you pass should be initialized with the seed already." ), ) evaluated_rewards: list | None = evaluated_rewards_field() @DeveloperAPI @hyperopt_utils.register_search_algorithm_config("skopt", dependencies=[("skopt", "scikit-optimize")]) class SkoptSAConfig(BaseSearchAlgorithmConfig): type: str = schema_utils.ProtectedString("skopt") optimizer: Any | None = None space: dict | None = schema_utils.Dict( description=( "A dict mapping parameter names to valid parameters, i.e. tuples for numerical parameters and lists " "for categorical parameters. If you passed an optimizer instance as the optimizer argument, this should " "be a list of parameter names instead." ) ) points_to_evaluate: list[dict] | None = points_to_evaluate_field() evaluated_rewards: list | None = evaluated_rewards_field( description=( "If you have previously evaluated the parameters passed in as points_to_evaluate you can avoid " "re-running those trials by passing in the reward attributes as a list so the optimiser can be told the " "results without needing to re-compute the trial. Must be the same length as points_to_evaluate. (See " "tune/examples/skopt_example.py)" ) ) convert_to_python: bool = schema_utils.Boolean( default=True, description="SkOpt outputs numpy primitives (e.g. `np.int64`) instead of Python types. If this setting is set " "to `True`, the values will be converted to Python primitives.", ) @DeveloperAPI @hyperopt_utils.register_search_algorithm_config("zoopt", dependencies=[("zoopt", "zoopt")]) @ludwig_dataclass class ZooptSAConfig(BaseSearchAlgorithmConfig): type: str = schema_utils.ProtectedString("zoopt") algo: str = schema_utils.ProtectedString( pstring="asracos", description="To specify an algorithm in zoopt you want to use. Only support ASRacos currently.", ) budget: int | None = schema_utils.PositiveInteger( default=None, allow_none=True, description="Optional. Number of samples." ) dim_dict: dict | None = schema_utils.Dict( description=( "Dimension dictionary. For continuous dimensions: (continuous, search_range, precision); For discrete " "dimensions: (discrete, search_range, has_order); For grid dimensions: (grid, grid_list). More details " "can be found in zoopt package." ) ) points_to_evaluate: list[dict] | None = points_to_evaluate_field() parallel_num: int = schema_utils.PositiveInteger( default=1, description=( "How many workers to parallel. Note that initial phase may start less workers than this number. More " "details can be found in zoopt package." ), ) ================================================ FILE: ludwig/schema/hyperopt/utils.py ================================================ from collections.abc import Callable from ludwig.api_annotations import DeveloperAPI from ludwig.utils.registry import Registry parameter_config_registry = Registry() scheduler_config_registry = Registry() scheduler_dependencies_registry = Registry() search_algorithm_config_registry = Registry() search_algorithm_dependencies_registry = Registry() search_algorithm_random_state_field_registry = Registry() @DeveloperAPI def get_parameter_cls(name: str) -> type["BaseParameterConfig"]: # noqa: F821 """Get a registered hyperopt parameter config class by name. Args: name: the name of a parameter config class registered in `ludwig.schema.hyperopt.parameter` Returns: A parameter config class from `ludwig.schema.hyperopt.parameter` """ return parameter_config_registry[name] @DeveloperAPI def get_scheduler_cls(name: str) -> type["BaseSchedulerConfig"]: # noqa: F821 """Get a registered hyperopt scheduler config class by name. Args: name: the name of a scheduler config class registered in `ludwig.schema.hyperopt.scheduler` Returns: A scheduler config class from `ludwig.schema.hyperopt.scheduler` """ return search_algorithm_config_registry[name] @DeveloperAPI def get_scheduler_dependencies(name: str) -> list[str]: """Get the list of dependencies for a registered hyperopt scheduler. Args: name: the name of a scheduler config class registered in `ludwig.schema.hyperopt.scheduler` Returns: The list of imports needed to use the scheduler """ return scheduler_dependencies_registry[name] @DeveloperAPI def get_search_algorithm_cls(name: str) -> type["BaseSearchAlgorithmConfig"]: # noqa: F821 """Get a registered hyperopt search algorithm config class by name. Args: name: the name of a search algorithm config class registered in `ludwig.schema.hyperopt.search_algorithm` Returns: A scheduler config class from `ludwig.schema.hyperopt.search_algorithm` """ return search_algorithm_config_registry[name] @DeveloperAPI def get_search_algorithm_dependencies(name: str) -> list[str]: """Get the list of dependencies for a registered hyperopt search algorithm. Args: name: the name of a search algorithm config class registered in `ludwig.schema.hyperopt.search_algorithm` Returns: The list of imports needed to use the search algorithm """ return search_algorithm_dependencies_registry[name] @DeveloperAPI def get_search_algorithm_random_state_field(name: str): """Get the field name of the random state for a registered hyperopt search algorithm. Args: name: the name of a search algorithm config class registered in `ludwig.schema.hyperopt.search_algorithm` Returns: The name of the random state field in the config """ return search_algorithm_random_state_field_registry[name] @DeveloperAPI def register_parameter_config(name: str) -> Callable: """Register a parameter config class by name. Args: name: the name to register the parameter class under, does not need to correspond to the value of `space` Returns: Wrapper function to decorate a `BaseParameterConfig` subclass """ def wrap(cls: type["BaseParameterConfig"]) -> type["BaseParameterConfig"]: # noqa: F821 """Add a parameter config class to the registry. Args: cls: a subclass of `BaseParameterConfig` Returns: `cls` unaltered """ parameter_config_registry[name] = cls return cls return wrap @DeveloperAPI def register_scheduler_config(name: str, dependencies: list[tuple[str]] | None = None): """Register a scheduler config class by name. Args: name: the name to scheduler the parameter class under, does not need to correspond to the value of `type` dependencies: the list of scheduler dependency package name/install name pairs, e.g. `("sklearn", "scikit-learn")` Returns: Wrapper function to decorate a `BaseSchedulerConfig` subclass """ def wrap(scheduler_config: type["BaseSchedulerConfig"]) -> type["BaseSchedulerConfig"]: # noqa: F821 """Add a parameter config class to the registry. Args: cls: a subclass of `BaseParameterConfig` Returns: `cls` unaltered """ scheduler_config_registry[name] = scheduler_config scheduler_dependencies_registry[name] = dependencies if dependencies is not None else [] return scheduler_config return wrap # TODO: create a search alg metadata class to register in place of individual metadata args @DeveloperAPI def register_search_algorithm_config( name: str, random_state_field: str | None = None, dependencies: list[tuple[str, str]] | None = None ) -> Callable: """Register a search algorithm config class by name. Args: name: the name to register the search algorithm class under, does not need to correspond to the value of `type` random_state_field: the name of the random state in this search algorithm dependencies: the list of search algorithm dependency package name/install name pairs, e.g. `("sklearn", "scikit-learn")` Returns: Wrapper function to decorate a `BaseSearchAlgorithmConfig` subclass """ def wrap(cls: type["BaseSearchAlgorithmConfig"]) -> type["BaseSearchAlgorithmConfig"]: # noqa: F821 search_algorithm_config_registry[name] = cls search_algorithm_dependencies_registry[name] = dependencies if dependencies is not None else [] search_algorithm_random_state_field_registry[name] = random_state_field return cls return wrap ================================================ FILE: ludwig/schema/jsonschema.py ================================================ """JSON Schema generation for Ludwig config classes. Uses pydantic's model_json_schema() under the hood, replacing the previous marshmallow-based converter. """ def marshmallow_schema_to_jsonschema_dict(schema_instance): """Backward-compatible JSON schema generation. Previously converted marshmallow schemas. Now uses pydantic's model_json_schema(). The schema_instance can be either: - A pydantic model class (BaseMarshmallowConfig subclass) - A _SchemaAdapter instance - Legacy: called with a marshmallow Schema instance (raises helpful error) """ from ludwig.schema.utils import _SchemaAdapter, BaseMarshmallowConfig # Handle _SchemaAdapter if isinstance(schema_instance, _SchemaAdapter): cls = schema_instance._cls elif isinstance(schema_instance, type) and issubclass(schema_instance, BaseMarshmallowConfig): cls = schema_instance elif isinstance(schema_instance, BaseMarshmallowConfig): cls = type(schema_instance) else: raise TypeError( f"Expected a Ludwig config class or schema adapter, got {type(schema_instance)}. " "Marshmallow schemas are no longer supported. Use pydantic BaseModel subclasses." ) schema_dict = cls.model_json_schema() name = cls.__name__ # Wrap in definitions format for backward compat return { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": {name: schema_dict}, "$ref": f"#/definitions/{name}", } ================================================ FILE: ludwig/schema/llms/__init__.py ================================================ ================================================ FILE: ludwig/schema/llms/base_model.py ================================================ import logging import os from dataclasses import field from transformers import AutoConfig from ludwig.api_annotations import DeveloperAPI from ludwig.constants import BASE_MODEL from ludwig.error import ConfigValidationError from ludwig.schema import utils as schema_utils from ludwig.schema.metadata import LLM_METADATA from ludwig.schema.metadata.parameter_metadata import convert_metadata_to_json logger = logging.getLogger(__name__) # Maps a preset LLM name to the full slash-delimited HF path. If the user chooses a preset LLM, the preset LLM name is # replaced with the full slash-delimited HF path using this map, after JSON validation but before config object # initialization. MODEL_PRESETS = { # Bloom "bloomz-3b": "bigscience/bloomz-3b", "bloomz-7b1": "bigscience/bloomz-7b1", # CodeLlama "codellama-7b": "codellama/CodeLlama-7b-hf", "codellama-13b": "codellama/CodeLlama-13b-hf", "codellama-34b": "codellama/CodeLlama-34b-hf", "codellama-7b-instruct": "codellama/CodeLlama-7b-instruct-hf", "codellama-13b-instruct": "codellama/CodeLlama-13b-instruct-hf", "codellama-34b-instruct": "codellama/CodeLlama-34b-instruct-hf", # GPT Neo and GPT J "gpt-neo-2.7B": "EleutherAI/gpt-neo-2.7B", "gpt-j-6b": "EleutherAI/gpt-j-6b", # LLama-2 "llama-2-7b": "meta-llama/Llama-2-7b-hf", "llama-2-13b": "meta-llama/Llama-2-13b-hf", "llama-2-70b": "meta-llama/Llama-2-70b-hf", "llama-2-7b-chat": "meta-llama/Llama-2-7b-chat-hf", "llama-2-13b-chat": "meta-llama/Llama-2-13b-chat-hf", "llama-2-70b-chat": "meta-llama/Llama-2-70b-chat-hf", # Mistral "mistral-7b": "mistralai/Mistral-7B-v0.1", "mistral-7b-instruct": "mistralai/Mistral-7B-Instruct-v0.1", # Mixtral "mixtral-8x7b": "mistralai/Mixtral-8x7B-v0.1", "mixtral-8x7b-instruct": "mistralai/Mixtral-8x7B-Instruct-v0.1", # OPT "opt-350m": "facebook/opt-350m", "opt-1.3b": "facebook/opt-1.3b", "opt-6.7b": "facebook/opt-6.7b", # Pythia "pythia-2.8b": "EleutherAI/pythia-2.8b", "pythia-12b": "EleutherAI/pythia-12b", # Vicuna "vicuna-7b": "lmsys/vicuna-7b-v1.3", "vicuna-13b": "lmsys/vicuna-13b-v1.3", # Zephyr "zephyr-7b-alpha": "HuggingFaceH4/zephyr-7b-alpha", "zephyr-7b-beta": "HuggingFaceH4/zephyr-7b-beta", # Phi "phi-1": "microsoft/phi-1", "phi-1_5": "microsoft/phi-1_5", "phi-2": "microsoft/phi-2", } @DeveloperAPI def BaseModelDataclassField(): description = ( "Base pretrained model to use. This can be one of the presets defined by Ludwig, a fully qualified " "name of a pretrained model from the HuggingFace Hub, or a path to a directory containing a " "pretrained model." ) def validate(model_name: str): """Validates and upgrades the given model name to its full path, if applicable. If the name exists in `MODEL_PRESETS`, returns the corresponding value from the dict; otherwise checks if the given name (which should be a full path) exists locally or in the transformers library. """ if isinstance(model_name, str): if model_name in MODEL_PRESETS: return MODEL_PRESETS[model_name] if os.path.isdir(model_name): return model_name try: AutoConfig.from_pretrained(model_name, trust_remote_code=True) return model_name except OSError: raise ConfigValidationError( f"Specified base model `{model_name}` could not be loaded. If this is a private repository, make " f"sure to set HUGGING_FACE_HUB_TOKEN in your environment. Check that {model_name} is a valid " "pretrained CausalLM listed on huggingface or a valid local directory containing the weights for a " "pretrained CausalLM from huggingface. See: " "https://huggingface.co/models?pipeline_tag=text-generation&sort=downloads for a full list." ) raise ConfigValidationError( f"`base_model` should be a string, instead given: {model_name}. This can be a preset or any pretrained " "CausalLM on huggingface. See: https://huggingface.co/models?pipeline_tag=text-generation&sort=downloads" ) class BaseModelField(schema_utils.LudwigSchemaField): def _serialize(self, value, attr, obj, **kwargs): if isinstance(value, str): return value raise ConfigValidationError(f"Value to serialize is not a string: {value}") def _deserialize(self, value, attr, obj, **kwargs): return validate(value) def _jsonschema_type_mapping(self): return { "anyOf": [ { "type": "string", "enum": list(MODEL_PRESETS.keys()), "description": ( "Pick from a set of popular LLMs of different sizes across a variety of architecture types." ), "title": "preset", "parameter_metadata": convert_metadata_to_json(LLM_METADATA[BASE_MODEL]["_anyOf"]["preset"]), }, { "type": "string", "description": "Enter the full path to a huggingface LLM.", "title": "custom", "parameter_metadata": convert_metadata_to_json(LLM_METADATA[BASE_MODEL]["_anyOf"]["custom"]), }, ], "description": description, "title": "base_model_options", "parameter_metadata": convert_metadata_to_json(LLM_METADATA[BASE_MODEL]["_meta"]), } return field( metadata={ "marshmallow_field": BaseModelField( required=True, allow_none=False, validate=validate, metadata={ # TODO: extra metadata dict probably unnecessary, but currently a widespread pattern "description": description, "parameter_metadata": convert_metadata_to_json(LLM_METADATA[BASE_MODEL]["_meta"]), }, ), }, # TODO: This is an unfortunate side-effect of dataclass init order - you cannot have non-default fields follow # default fields, so we have to give `base_model` a fake default of `None`. default=None, ) ================================================ FILE: ludwig/schema/llms/generation.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.schema import utils as schema_utils from ludwig.schema.metadata import LLM_METADATA @DeveloperAPI @schema_utils.ludwig_dataclass class LLMGenerationConfig(schema_utils.BaseMarshmallowConfig): """Parameters for LLM Generation Config. Should match the parameters in https://huggingface.co/docs/transformers/v4.28.0/en/main_classes/text_generation#transformers.GenerationConfig """ # Parameters that control the length of the output max_new_tokens: int | None = schema_utils.PositiveInteger( default=32, allow_none=True, description="The maximum number of new tokens to generate, ignoring the number of tokens in the input prompt. " "If not set, this is dynamically determined by Ludwig based on either the `max_sequence_length` of the ouput " "feature, the global_max_sequence_length specified in preprocessing (if specified), or the " "maximum context length supported by the model (in the order specified).", parameter_metadata=LLM_METADATA["generation"]["max_new_tokens"], ) min_new_tokens: int | None = schema_utils.NonNegativeInteger( default=None, allow_none=True, description="The minimum number of new tokens to generate, ignoring the number of tokens in the input prompt.", parameter_metadata=LLM_METADATA["generation"]["min_new_tokens"], ) max_length: int = schema_utils.PositiveInteger( default=32, allow_none=True, description="The maximum length the generated tokens can have. Corresponds to the length of the input prompt " "+ max_new_tokens. Its effect is overridden by max_new_tokens, if also set.", parameter_metadata=LLM_METADATA["generation"]["max_length"], ) min_length: int = schema_utils.NonNegativeInteger( default=0, allow_none=True, description="The minimum length of the sequence to be generated. Corresponds to the length of the " "input prompt + min_new_tokens. Its effect is overridden by min_new_tokens, if also set.", parameter_metadata=LLM_METADATA["generation"]["min_length"], ) early_stopping: bool | str | None = schema_utils.Boolean( default=False, description="Controls the stopping condition for beam-based methods, like beam-search. It accepts the following" " values: True, where the generation stops as soon as there are num_beams complete candidates; False, where an " "heuristic is applied and the generation stops when is it very unlikely to find better candidates; `never`, " "where the beam search procedure only stops when there cannot be better candidates (canonical beam search " "algorithm)", ) max_time: float | None = schema_utils.FloatRange( default=None, min=None, max=None, allow_none=True, description="The maximum amount of time you allow the computation to run for in seconds. generation will still" " finish the current pass after allocated time has been passed. ", ) # Parameters that control the generation strategy used do_sample: bool | None = schema_utils.Boolean( default=True, description="Whether or not to use sampling ; use greedy decoding otherwise.", parameter_metadata=LLM_METADATA["generation"]["do_sample"], ) num_beams: int | None = schema_utils.PositiveInteger( default=1, allow_none=True, description="Number of beams for beam search. 1 means no beam search and is the default value." " The beam search strategy generates the translation word by word from left-to-right while keeping a fixed" " number (beam) of active candidates at each time step during token generation. By increasing the beam size," " the translation performance can increase at the expense of significantly reducing the decoder speed.", parameter_metadata=LLM_METADATA["generation"]["num_beams"], ) num_beam_groups: int | None = schema_utils.PositiveInteger( default=1, allow_none=True, description="Number of groups to divide num_beams into in order to ensure diversity among different groups of " "beams. 1 means no group beam search.", ) penalty_alpha: float | None = schema_utils.NonNegativeFloat( default=None, allow_none=True, description="The values balance the model confidence and the degeneration penalty in contrastive " " search decoding.", ) use_cache: bool | None = schema_utils.Boolean( default=True, description="Whether or not the model should use the past last key/values attentions (if applicable to the " "model) to speed up decoding.", parameter_metadata=LLM_METADATA["generation"]["use_cache"], ) prompt_lookup_num_tokens: int | None = schema_utils.NonNegativeInteger( default=None, allow_none=True, description="The number of tokens to consider as a candidate from the prompt for prompt lookup decoding, " " an alternate way of performing assisted generation. If set to 0, the prompt lookup decoding is not used.", parameter_metadata=LLM_METADATA["generation"]["prompt_lookup_num_tokens"], ) # Parameters for manipulation of the model output logits temperature: float | None = schema_utils.NonNegativeFloat( default=0.1, allow_none=True, description="Temperature is used to control the randomness of predictions." " A high temperature value (closer to 1) makes the output more diverse and random, while a lower temperature" " (closer to 0) makes the model's responses more deterministic and focused on the most likely outcome." " In other words, temperature adjusts the probability distribution from which the model picks the next token.", parameter_metadata=LLM_METADATA["generation"]["temperature"], ) top_k: int | None = schema_utils.PositiveInteger( default=50, allow_none=True, description="The number of highest probability vocabulary tokens to keep for top-k-filtering.", parameter_metadata=LLM_METADATA["generation"]["top_k"], ) top_p: float | None = schema_utils.FloatRange( default=1.0, min=0.0, max=1.0, allow_none=True, description="If set to float < 1, only the most probable tokens with probabilities that add up to " "top_p or higher are kept for generation.", parameter_metadata=LLM_METADATA["generation"]["top_p"], ) typical_p: float | None = schema_utils.FloatRange( default=1.0, min=0.0, max=1.0, allow_none=True, description="Local typicality measures how similar the conditional probability of predicting a target token " "next is to the expected conditional probability of predicting a random token next, given the partial text " "already generated. If set to float < 1, the smallest set of the most locally typical tokens with " "probabilities that add up to typical_p or higher are kept for generation.", ) epsilon_cutoff: float | None = schema_utils.FloatRange( default=0.0, min=0.0, max=1.0, allow_none=True, description="If set to float strictly between 0 and 1, only tokens with a conditional probability greater " "than epsilon_cutoff will be sampled. In the paper, suggested values range from 3e-4 to 9e-4, depending on the" " size of the model.", ) eta_cutoff: float | None = schema_utils.FloatRange( default=0.0, min=0.0, max=1.0, allow_none=True, description="Eta sampling is a hybrid of locally typical sampling and epsilon sampling. If set to float " "strictly between 0 and 1, a token is only considered if it is greater than either eta_cutoff or " "sqrt(eta_cutoff) * exp(-entropy(softmax(next_token_logits))). The latter term is intuitively the expected next" " token probability, scaled by sqrt(eta_cutoff). In the paper, suggested values range from 3e-4 to 2e-3, " "depending on the size of the model.", ) diversity_penalty: float | None = schema_utils.NonNegativeFloat( default=0.0, allow_none=True, description="The value used to control the diversity of the generated text. The higher the value, the more " "diverse the text will be. If set to 0, no diversity is enforced." "This value is subtracted from a beam(s) score if it generates a token same as any beam from other group at a" "particular time. Note that diversity_penalty is only effective if group beam search is enabled.", ) repetition_penalty: float | None = schema_utils.NonNegativeFloat( default=1.0, allow_none=True, description="The parameter for repetition penalty. 1.0 means no penalty. " "See [this paper](https://arxiv.org/pdf/1909.05858.pdf) for more details.", ) encoder_repetition_penalty: float | None = schema_utils.NonNegativeFloat( default=1.0, allow_none=True, description="The paramater for encoder_repetition_penalty. An exponential penalty on sequences that are not" " in the original input. 1.0 means no penalty.", ) length_penalty: float | None = schema_utils.Float( default=1.0, allow_none=True, description="Exponential penalty to the length that is used with beam-based generation. It is applied as an " "exponent to the sequence length, which in turn is used to divide the score of the sequence. Since the score is" " the log likelihood of the sequence (i.e. negative), length_penalty > 0.0 promotes longer sequences, while " "length_penalty < 0.0 encourages shorter sequences.", ) no_repeat_ngram_size: int | None = schema_utils.NonNegativeInteger( default=0, allow_none=True, description="If set to int > 0, all ngrams of that size can only occur once.", ) bad_words_ids: list[list[int]] | None = schema_utils.List( default=None, allow_none=True, description="List of token ids that are not allowed to be generated. In order to get the tokens of the words " "that should not appear in the generated text, use tokenizer(bad_word, add_prefix_space=True).input_ids.", ) force_words_ids: list[list[int]] | None = schema_utils.List( default=None, allow_none=True, description="List of token ids that are forced to be generated by the model. In order to get the tokens of the" " words that should appear in the generated text, use tokenizer(force_word, add_prefix_space=True).input_ids.", ) renormalize_logits: bool | None = schema_utils.Boolean( default=False, description="Whether to renormalize the logits after temperature and top_k/top_p filtering.", ) # TODO(This needs to be defined based on the Constraint class) # constraints: forced_bos_token_id: int | None = schema_utils.Integer( default=None, allow_none=True, description="The id of the token to force as the first generated token after the decoder_start_token_id." "Useful for multilingual models like mBART where the first generated token needs to be the target language" "token.", ) forced_eos_token_id: int | list[int] | None = schema_utils.Integer( default=None, allow_none=True, description="The id of the token to force as the last generated token when max_length is reached. Optionally, " "use a list to set multiple end-of-sequence tokens.", ) remove_invalid_values: bool | None = schema_utils.Boolean( default=False, description="Whether to remove possible nan and inf outputs of the model to prevent the generation method to " "crash. Note that using remove_invalid_values can slow down generation.", ) exponential_decay_length_penalty: tuple[int, float] | None = schema_utils.FloatRange( default=None, min=0.0, max=1.0, allow_none=True, description="This Tuple adds an exponentially increasing length penalty, after a certain amount of tokens have " "been generated. The tuple shall consist of: (start_index, decay_factor) where start_index indicates where " "penalty starts and decay_factor represents the factor of exponential decay", ) suppress_tokens: list[int] | None = schema_utils.List( list_type=int, default=None, allow_none=True, description="A list of tokens that will be suppressed at generation. The SupressTokens logit processor will set" " their log probs to -inf so that they are not sampled.", ) begin_suppress_tokens: list[int] | None = schema_utils.List( list_type=int, default=None, allow_none=True, description="A list of tokens that will be suppressed at the beginning of the generation. The " "SupressBeginTokens logit processor will set their log probs to -inf so that they are not sampled.", ) forced_decoder_ids: list[list[int]] | None = schema_utils.List( default=None, allow_none=True, description="A list of forced decoder ids. The ForcedDecoderIds logit processor will set the log probs of all " "tokens that are not in the list to -inf so that they are not sampled.", ) sequence_bias: dict[tuple[int], float] | None = schema_utils.Dict( default=None, allow_none=True, description="A dictionary of token ids to bias the generation towards. The SequenceBias logit processor will " "add the bias to the log probs of the tokens in the dictionary. Positive biases increase the odds of the " "sequence being selected, while negative biases do the opposite. ", ) guidance_scale: float | None = schema_utils.FloatRange( default=None, min=0.0, allow_none=True, description="The guidance scale for classifier free guidance (CFG). CFG is enabled by setting guidance_scale >" " 1. Higher guidance scale encourages the model to generate samples that are more closely linked to the input" " prompt, usually at the expense of poorer quality.", ) # Special tokens that can be used at generation time pad_token_id: int | None = schema_utils.Integer( default=None, allow_none=True, description="The id of the padding token. If not set, the padding token id of the tokenizer is used.", ) bos_token_id: int | None = schema_utils.Integer( default=None, allow_none=True, description="The id of the beginning of sentence token. If not set, the bos token id of the tokenizer is used.", ) eos_token_id: int | list[int] | None = schema_utils.Integer( default=None, allow_none=True, description="The id of the end of sentence token. If not set, the eos token id of the tokenizer is used.", ) @DeveloperAPI class LLMGenerationConfigField(schema_utils.DictMarshmallowField): def __init__(self): super().__init__(LLMGenerationConfig) def _jsonschema_type_mapping(self): return schema_utils.unload_jsonschema_from_marshmallow_class(LLMGenerationConfig) ================================================ FILE: ludwig/schema/llms/model_parameters.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.error import ConfigValidationError from ludwig.schema import utils as schema_utils from ludwig.schema.utils import ludwig_dataclass @DeveloperAPI @ludwig_dataclass class RoPEScalingConfig(schema_utils.BaseMarshmallowConfig): """Dynamic RoPE-scaling (rotary position embeddings) to extend the context length of LLM like LLaMA, GPT-NeoX, or Falcon. This parameter is a dictionary containing the scaling configuration for the RoPE embeddings. Currently supports three scaling strategies: linear and dynamic. Their scaling factor must be an float greater than 1. The expected format is {'rope_type': strategy name, 'factor': scaling factor} """ def __post_init__(self): # Both parameters must be set, or none. if not self.rope_type: raise ConfigValidationError( f"`rope_scaling`'s `rope_type` field must be one of ['linear', 'dynamic'], got {self.rope_type}" ) if not self.factor: raise ConfigValidationError( f"When using `rope_scaling`, `factor` must be specified and be > 1. Got {self.factor}." ) rope_type: str | None = schema_utils.StringOptions( options=["linear", "dynamic"], default=None, allow_none=True, description="Currently supports two strategies: linear and dynamic scaling.", ) factor: float | None = schema_utils.FloatRange( default=None, allow_none=True, min=1.0, min_inclusive=False, description="The scaling factor for RoPE embeddings.", ) @DeveloperAPI class RoPEScalingConfigField(schema_utils.DictMarshmallowField): def __init__(self): super().__init__(RoPEScalingConfig, default_missing=True) def _jsonschema_type_mapping(self): return schema_utils.unload_jsonschema_from_marshmallow_class(RoPEScalingConfig, title="rope_scaling") @DeveloperAPI @ludwig_dataclass class ModelParametersConfig(schema_utils.BaseMarshmallowConfig): rope_scaling: RoPEScalingConfig = RoPEScalingConfigField().get_default_field() neftune_noise_alpha: int | None = schema_utils.IntegerRange( default=0, min=0, allow_none=True, description="The alpha parameter for the embedding noise, which controls the amount of noise added to the " "embeddings. The higher the value, the more noise is added. This is based on the paper NEFTune: Noisy " "Embeddings Improve Instruction Finetuning. Paper: https://arxiv.org/pdf/2310.05914.pdf. Default: 0." "Suggested values: 5, 10", ) def to_dict(self): config = {} if self.rope_scaling: config["rope_scaling"] = self.rope_scaling.to_dict() if self.neftune_noise_alpha: config["neftune_noise_alpha"] = self.neftune_noise_alpha return config @DeveloperAPI class ModelParametersConfigField(schema_utils.DictMarshmallowField): def __init__(self): super().__init__(ModelParametersConfig, default_missing=True) def _jsonschema_type_mapping(self): return { "oneOf": [ {"type": "null", "title": "disabled", "description": "Skip configurable model parameters."}, { **schema_utils.unload_jsonschema_from_marshmallow_class(ModelParametersConfig), "title": "enabled", "description": "Set model parameters options.", }, ], "title": "Model Parameters", "description": "Configurable model parameters for LLMs.", } ================================================ FILE: ludwig/schema/llms/peft.py ================================================ from abc import ABC, abstractmethod from typing import TYPE_CHECKING from ludwig.api_annotations import DeveloperAPI from ludwig.error import ConfigValidationError from ludwig.schema import utils as schema_utils from ludwig.schema.metadata import LLM_METADATA from ludwig.schema.metadata.parameter_metadata import convert_metadata_to_json from ludwig.schema.utils import ludwig_dataclass from ludwig.utils.registry import Registry if TYPE_CHECKING: from peft import PeftConfig adapter_registry = Registry() @DeveloperAPI def register_adapter(name: str): def wrap(config: BaseAdapterConfig): adapter_registry[name] = config return config return wrap @DeveloperAPI @ludwig_dataclass class LoraPostprocessorConfig(schema_utils.BaseMarshmallowConfig): """This Dataclass is a schema for the nested postprocessing config under adapter of type "lora".""" merge_adapter_into_base_model: bool = schema_utils.Boolean( default=False, description="""Instructs whether or not the fine-tuned LoRA weights are to be merged into the base LLM model so that the complete fine-tuned model is available to be used and/or persisted, and then reused upon loading as a single model (rather than having to load base and fine-tuned models separately).""", ) progressbar: bool = schema_utils.Boolean( default=False, description="Instructs whether or not to show a progress bar indicating the unload and merge process.", ) @DeveloperAPI class LoraPostprocessorConfigField(schema_utils.DictMarshmallowField): def __init__(self): super().__init__(LoraPostprocessorConfig) def _jsonschema_type_mapping(self): return schema_utils.unload_jsonschema_from_marshmallow_class(LoraPostprocessorConfig, title="LoraPostprocessor") @DeveloperAPI @ludwig_dataclass class BaseAdapterConfig(schema_utils.BaseMarshmallowConfig, ABC): type: str pretrained_adapter_weights: str | None = schema_utils.String( default=None, description="Path to pretrained weights.", allow_none=True ) postprocessor: LoraPostprocessorConfig = LoraPostprocessorConfigField().get_default_field() @abstractmethod def to_config(self, **kwargs) -> "PeftConfig": pass @DeveloperAPI @register_adapter(name="lora") @ludwig_dataclass class LoraConfig(BaseAdapterConfig): def __post_init__(self): if self.alpha is None: self.alpha = self.r * 2 type: str = schema_utils.ProtectedString( "lora", description=LLM_METADATA["adapter"]["lora"]["type"].long_description, ) r: int = schema_utils.PositiveInteger( default=8, description="Lora attention dimension.", parameter_metadata=LLM_METADATA["adapter"]["lora"]["r"], ) alpha: int | None = schema_utils.PositiveInteger( default=None, allow_none=True, description="The alpha parameter for Lora scaling. Defaults to `2 * r`.", parameter_metadata=LLM_METADATA["adapter"]["lora"]["alpha"], ) dropout: float = schema_utils.NonNegativeFloat( default=0.05, description="The dropout probability for Lora layers.", parameter_metadata=LLM_METADATA["adapter"]["lora"]["dropout"], ) # TODO(travis): figure out why calling this `bias` doesn't work bias_type: str = schema_utils.StringOptions( options=["none", "all", "lora_only"], default="none", description="Bias type for Lora.", ) target_modules: list[str] | None = schema_utils.List( default=None, allow_none=True, description=( "List of module names or regex expression of the module names to replace with LoRA. " "For example, ['q', 'v'] or '.*decoder.*(SelfAttention|EncDecAttention).*(q|v)$'. " "Defaults to targeting the query and value matrices of all self-attention and encoder-decoder attention " "layers." ), parameter_metadata=LLM_METADATA["adapter"]["lora"]["target_modules"], ) use_rslora: bool = schema_utils.Boolean( default=False, description=( "When set to True, uses Rank-Stabilized LoRA which sets the adapter scaling factor to " "lora_alpha/math.sqrt(r), since it was proven to work better. Otherwise, it will use the original " "default value of lora_alpha/r. Paper: https://arxiv.org/abs/2312.03732." ), parameter_metadata=LLM_METADATA["adapter"]["lora"]["use_rslora"], ) use_dora: bool = schema_utils.Boolean( default=False, description=( "Enable 'Weight-Decomposed Low-Rank Adaptation' (DoRA). This technique decomposes the updates of the " "weights into two parts, magnitude and direction. Direction is handled by normal LoRA, whereas the " "magnitude is handled by a separate learnable parameter. This can improve the performance of LoRA, " "especially at low ranks. Right now, DoRA only supports non-quantized linear layers. DoRA introduces a " "bigger overhead than pure LoRA, so it is recommended to merge weights for inference. For more " "information, see https://arxiv.org/abs/2402.09353" ), parameter_metadata=LLM_METADATA["adapter"]["lora"]["use_dora"], ) def to_config(self, task_type: str = None, **kwargs) -> "PeftConfig": from peft import LoraConfig as _LoraConfig return _LoraConfig( r=self.r, lora_alpha=self.alpha, lora_dropout=self.dropout, bias=self.bias_type, target_modules=self.target_modules, task_type=task_type, use_rslora=self.use_rslora, use_dora=self.use_dora, ) @classmethod def name(cls) -> str: return "LoRA" @classmethod def description(cls) -> str: return LLM_METADATA["adapter"]["lora"]["type"].long_description @DeveloperAPI @ludwig_dataclass class BasePromptLearningConfig(BaseAdapterConfig): """Config for prompt learning adapters. Not meant to be used directly. Adapted from https://github.com/huggingface/peft/blob/main/src/peft/utils/config.py (PromptLearningConfig) """ num_virtual_tokens: int = schema_utils.PositiveInteger( default=8, description="Number of virtual tokens to add to the prompt. Virtual tokens are used to control the behavior of " " the model during inference. ", parameter_metadata=LLM_METADATA["adapter"]["prompt_learning"]["num_virtual_tokens"], ) token_dim: int | None = schema_utils.PositiveInteger( default=None, allow_none=True, description="The hidden embedding dimension of the base transformer model.", ) num_transformer_submodules: int | None = schema_utils.PositiveInteger( default=None, allow_none=True, description="The number of transformer submodules in the base transformer model.", ) num_attention_heads: int | None = schema_utils.PositiveInteger( default=None, allow_none=True, description="The number of attention heads in the base transformer model.", ) num_layers: int | None = schema_utils.PositiveInteger( default=None, allow_none=True, description="The number of layers in the base transformer model.", ) # TODO(travis): fix text generation when using prompt tuning: # RuntimeError: shape '[-1, 17]' is invalid for input of size 9 # @DeveloperAPI # @register_adapter("prompt_tuning") # @ludwig_dataclass # class PromptTuningConfig(BasePromptLearningConfig): # """Adapted from https://github.com/huggingface/peft/blob/main/src/peft/tuners/prompt_tuning.py.""" # def __post_init__(self): # if self.prompt_tuning_init == "TEXT" and not self.prompt_tuning_init_text: # raise ConfigValidationError( # "Must provide `prompt_tuning_init_text` when `prompt_tuning_init` is set to `TEXT`." # ) """# type: str = schema_utils.ProtectedString("prompt_tuning")""" # Quotes allow mypy to run without syntax errors. # prompt_tuning_init: str = schema_utils.StringOptions( # ["RANDOM", "TEXT"], # default="RANDOM", # description="The type of initialization to use for the prompt embedding. ", # parameter_metadata=LLM_METADATA["adapter"]["prompt_tuning"]["prompt_tuning_init"], # ) # prompt_tuning_init_text: str = schema_utils.String( # default="", # description="The text to use to initialize the prompt embedding.", # parameter_metadata=LLM_METADATA["adapter"]["prompt_tuning"]["prompt_tuning_init_text"], # ) # def to_config(self, **kwargs) -> "PeftConfig": # from peft import PromptTuningConfig as _PromptTuningConfig # return _PromptTuningConfig( # num_virtual_tokens=self.num_virtual_tokens, # token_dim=self.token_dim, # num_transformer_submodules=self.num_transformer_submodules, # num_attention_heads=self.num_attention_heads, # num_layers=self.num_layers, # prompt_tuning_init=self.prompt_tuning_init, # prompt_tuning_init_text=self.prompt_tuning_init_text, # **kwargs # ) # TODO(travis): fix prefix tuning and p-tuning to work with DDP # @DeveloperAPI # @register_adapter("prefix_tuning") # @schema_utils.ludwig_dataclass # class PrefixTuningConfig(BasePromptLearningConfig): # """Adapted from https://github.com/huggingface/peft/blob/main/src/peft/tuners/prefix_tuning.py.""" """# type: str = schema_utils.ProtectedString("prefix_tuning")""" # Quotes allow mypy to run without syntax errors. # encoder_hidden_size: Optional[int] = schema_utils.Integer( # default=None, # allow_none=True, # description="The hidden embedding dimension of the prompt encoder.", # ) # prefix_projection: bool = schema_utils.Boolean( # default=False, # description="Whether to use a projection layer in the prompt encoder to project the prefix tokens", # ) # def to_config(self, task_type: str = None, **kwargs) -> "PeftConfig": # from peft import PrefixTuningConfig as _PrefixTuningConfig # return _PrefixTuningConfig( # num_virtual_tokens=self.num_virtual_tokens, # token_dim=self.token_dim, # num_transformer_submodules=self.num_transformer_submodules, # num_attention_heads=self.num_attention_heads, # num_layers=self.num_layers, # encoder_hidden_size=self.encoder_hidden_size, # prefix_projection=self.prefix_projection, # task_type=task_type, # ) # @DeveloperAPI # @register_adapter("p_tuning") # @ludwig_dataclass # class PTuningConfig(BasePromptLearningConfig): """# type: str = schema_utils.ProtectedString("p_tuning")""" # Quotes allow mypy to run without syntax errors. # encoder_reparameterization_type: str = schema_utils.StringOptions( # ["MLP", "LSTM"], # default="MLP", # allow_none=False, # description="The type of reparameterization to use for the prompt encoder.", # ) # encoder_hidden_size: Optional[int] = schema_utils.PositiveInteger( # default=None, # allow_none=True, # description="The hidden embedding dimension of the prompt encoder.", # ) # encoder_num_layers: Optional[int] = schema_utils.PositiveInteger( # default=2, # allow_none=True, # description="The number of layers in the prompt encoder.", # ) # encoder_dropout: Optional[float] = schema_utils.FloatRange( # default=0.0, # min=0.0, # max=1.0, # description="The dropout probability for the prompt encoder.", # ) # def to_config(self, task_type: str = None, **kwargs) -> "PeftConfig": # from peft import PromptEncoderConfig as _PromptEncoderConfig # return _PromptEncoderConfig( # num_virtual_tokens=self.num_virtual_tokens, # token_dim=self.token_dim, # num_transformer_submodules=self.num_transformer_submodules, # num_attention_heads=self.num_attention_heads, # num_layers=self.num_layers, # encoder_reparameterization_type=self.encoder_reparameterization_type, # encoder_hidden_size=self.encoder_hidden_size, # encoder_num_layers=self.encoder_num_layers, # encoder_dropout=self.encoder_dropout, # task_type=task_type, # ) @DeveloperAPI @register_adapter("adalora") @ludwig_dataclass class AdaloraConfig(LoraConfig): """Adapted from https://github.com/huggingface/peft/blob/main/src/peft/tuners/adalora.py.""" type: str = schema_utils.ProtectedString( "adalora", description=LLM_METADATA["adapter"]["adalora"]["type"].long_description, ) target_r: int = schema_utils.PositiveInteger( default=8, description="Target Lora Matrix Dimension. The target average rank of incremental matrix.", ) init_r: int = schema_utils.PositiveInteger( default=12, description="Initial Lora Matrix Dimension. The initial rank for each incremental matrix.", ) tinit: int = schema_utils.NonNegativeInteger( default=0, description="The steps of initial fine-tuning warmup.", ) tfinal: int = schema_utils.NonNegativeInteger( default=0, description="The steps of final fine-tuning warmup.", ) delta_t: int = schema_utils.NonNegativeInteger( default=1, description="The time internval between two budget allocations. The step interval of rank allocation.", ) beta1: float = schema_utils.FloatRange( default=0.85, min=0.0, max=1.0, description="The hyperparameter of EMA for sensitivity smoothing.", ) beta2: float = schema_utils.FloatRange( default=0.85, min=0.0, max=1.0, description=" The hyperparameter of EMA for undertainty quantification.", ) orth_reg_weight: float = schema_utils.FloatRange( default=0.5, min=0.0, max=1.0, description="The coefficient of orthogonality regularization.", ) total_step: int = schema_utils.PositiveInteger( default=10000, allow_none=False, description="The total training steps for AdaLoRA rank allocation scheduling. " "Must be a positive integer (required by peft >= 0.14).", ) rank_pattern: dict | None = schema_utils.Dict( default=None, allow_none=True, description="The allocated rank for each weight matrix by RankAllocator.", ) def to_config(self, **kwargs) -> "PeftConfig": from peft import AdaLoraConfig as _AdaLoraConfig return _AdaLoraConfig( r=self.r, lora_alpha=self.alpha, lora_dropout=self.dropout, bias=self.bias_type, target_r=self.target_r, init_r=self.init_r, tinit=self.tinit, tfinal=self.tfinal, deltaT=self.delta_t, beta1=self.beta1, beta2=self.beta2, orth_reg_weight=self.orth_reg_weight, total_step=self.total_step, rank_pattern=self.rank_pattern, ) @classmethod def name(cls) -> str: return "AdaLoRA" @classmethod def description(cls) -> str: return LLM_METADATA["adapter"]["adalora"]["type"].long_description @DeveloperAPI # TODO: 02/21/2024: Disabling AdaptionPrompt (waiting for PEFT release to fix # "TypeError: LlamaRotaryEmbedding.forward() missing 1 required positional argument: 'position_ids')" # (this is reflected in https://github.com/ludwig-ai/ludwig/issues/3938). # # @register_adapter("adaption_prompt") @ludwig_dataclass class AdaptionPromptConfig(BaseAdapterConfig): """Adapted from https://github.com/huggingface/peft/blob/main/src/peft/tuners/adaption_prompt/config.py.""" def __post_init__(self): if not self.adapter_len: raise ConfigValidationError( "`adapter_len` must be set to a value greater than 0 when finetuning is enabled and the adapter" "type is `adaption_prompt`. This is the length of the adaption prompt to insert." ) if not self.adapter_layers: raise ConfigValidationError( "`adapter_layers` must be set to a value greater than 0 when finetuning is enabled and the adapter" "type is `adaption_prompt`. This is the number of adapter layers to insert." ) type: str = schema_utils.ProtectedString( "adaption_prompt", description=LLM_METADATA["adapter"]["adaption_prompt"]["type"].long_description, ) adapter_len: int = schema_utils.PositiveInteger( default=4, description="Number of adapter tokens to insert.", parameter_metadata=LLM_METADATA["adapter"]["adaption_prompt"]["adapter_len"], ) adapter_layers: int = schema_utils.PositiveInteger( default=1, allow_none=False, description="Number of adapter layers to insert (from the top).", parameter_metadata=LLM_METADATA["adapter"]["adaption_prompt"]["adapter_layers"], ) def to_config(self, task_type: str = None, **kwargs) -> "PeftConfig": from peft import AdaptionPromptConfig as _AdaptionPromptConfig return _AdaptionPromptConfig( adapter_len=self.adapter_len, adapter_layers=self.adapter_layers, task_type=task_type, ) @classmethod def name(cls) -> str: return "Adaption Prompt" @classmethod def description(cls) -> str: return LLM_METADATA["adapter"]["adaption_prompt"]["type"].long_description @DeveloperAPI @register_adapter("ia3") @ludwig_dataclass class IA3Config(BaseAdapterConfig): type: str = schema_utils.ProtectedString( "ia3", description=LLM_METADATA["adapter"]["ia3"]["type"].long_description, ) target_modules: list[str] | None = schema_utils.List( default=None, allow_none=True, description="The names of the modules to apply (IA)^3 to.", parameter_metadata=LLM_METADATA["adapter"]["ia3"]["target_modules"], ) feedforward_modules: list[str] | None = schema_utils.List( default=None, allow_none=True, description=( "The names of the modules to be treated as feedforward modules, as in the original paper. These modules " "will have (IA)^3 vectors multiplied to the input, instead of the output. feedforward_modules must be a " "name or a subset of names present in target_modules." ), parameter_metadata=LLM_METADATA["adapter"]["ia3"]["feedforward_modules"], ) fan_in_fan_out: bool = schema_utils.Boolean( default=False, description=( "Set this to True if the layer to replace stores weight like (fan_in, fan_out). For example, gpt-2 uses " "Conv1D which stores weights like (fan_in, fan_out) and hence this should be set to True. " ), parameter_metadata=LLM_METADATA["adapter"]["ia3"]["fan_in_fan_out"], ) modules_to_save: list[str] | None = schema_utils.List( list_type=str, default=None, allow_none=True, description=( "List of modules apart from (IA)^3 layers to be set as trainable and saved in the final checkpoint." ), parameter_metadata=LLM_METADATA["adapter"]["ia3"]["modules_to_save"], ) init_ia3_weights: bool = schema_utils.Boolean( default=True, description="Whether to initialize the vectors in the (IA)^3 layers, defaults to True.", parameter_metadata=LLM_METADATA["adapter"]["ia3"]["init_ia3_weights"], ) def to_config(self, task_type: str = None, **kwargs) -> "PeftConfig": from peft import IA3Config as _IA3Config return _IA3Config( target_modules=self.target_modules, feedforward_modules=self.feedforward_modules, fan_in_fan_out=self.fan_in_fan_out, modules_to_save=self.modules_to_save, init_ia3_weights=self.init_ia3_weights, task_type=task_type, ) @classmethod def name(cls) -> str: return "IA3" @classmethod def description(cls) -> str: return LLM_METADATA["adapter"]["ia3"]["type"].long_description @DeveloperAPI def get_adapter_conds(): conds = [] for adapter_type, adapter_cls in adapter_registry.items(): other_props = schema_utils.unload_jsonschema_from_marshmallow_class(adapter_cls)["properties"] schema_utils.remove_duplicate_fields(other_props) preproc_cond = schema_utils.create_cond( {"type": adapter_type}, other_props, ) conds.append(preproc_cond) return conds @DeveloperAPI def AdapterDataclassField(default: str | None = None): description = "Whether to use parameter-efficient fine-tuning" class AdapterSelection(schema_utils.TypeSelection): def __init__(self): super().__init__( registry=adapter_registry, default_value=default, description=description, parameter_metadata=None, allow_str_value=True, allow_none=True, ) def get_schema_from_registry(self, key: str) -> type[schema_utils.BaseMarshmallowConfig]: return adapter_registry[key] @staticmethod def _jsonschema_type_mapping(): return { "oneOf": [ { "type": "object", "properties": { "type": { "type": "string", "enum": list(adapter_registry.keys()), "description": "The type of PEFT adapter to use during fine-tuning", }, }, "title": "Perform parameter efficient fine-tuning", "allOf": get_adapter_conds(), "required": ["type"], "description": "The type of PEFT adapter to use during fine-tuning", "parameter_metadata": convert_metadata_to_json(LLM_METADATA["adapter"]["_oneOf"]["allOf"]), }, { "type": "null", "title": "adapter_null_option", "description": "Disable the adapter.", "parameter_metadata": convert_metadata_to_json(LLM_METADATA["adapter"]["_oneOf"]["none"]), }, ], "title": "adapter_options", "description": "Whether to use parameter-efficient fine-tuning", "parameter_metadata": convert_metadata_to_json(LLM_METADATA["adapter"]["_meta"]), "default": default, } return AdapterSelection().get_default_field() ================================================ FILE: ludwig/schema/llms/prompt.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.constants import SEMANTIC from ludwig.error import ConfigValidationError from ludwig.schema import utils as schema_utils from ludwig.schema.metadata import LLM_METADATA from ludwig.schema.utils import ludwig_dataclass @DeveloperAPI @ludwig_dataclass class RetrievalConfig(schema_utils.BaseMarshmallowConfig): """This Dataclass is a schema for the nested retrieval config under prompt.""" def __post_init__(self): # TODO: have a dynamically loaded schema based on the selection of the type param # https://github.com/ludwig-ai/ludwig/pull/3351#discussion_r1181910954 # Ensure k is non-zero if we're using a retrieval strategy if self.type is not None and self.k == 0: self.k = 1 if self.type is None and self.k != 0: raise ConfigValidationError("k must be 0 if retrieval type is None.") elif self.type is not None and self.k <= 0: raise ConfigValidationError("k must be greater than 0 if retrieval type is not None.") if self.type is None and self.model_name is not None: raise ConfigValidationError("model_name must be None if retrieval type is None.") elif self.type == SEMANTIC and self.model_name is None: raise ConfigValidationError(f"model_name must not be None if retrieval type is '{SEMANTIC}'.") type: str = schema_utils.String( default=None, allow_none=True, description=( "The type of retrieval to use for the prompt. If `None`, then no retrieval is used, and the task " "is framed as a zero-shot learning problem. If not `None` (e.g. either 'random' or 'semantic'), then " "samples are retrieved from an index of the training set and used to augment the input to the model " "in a few-shot learning setting." ), parameter_metadata=LLM_METADATA["prompt"]["retrieval"]["type"], ) index_name: str = schema_utils.String( default=None, allow_none=True, description="The name of the index to use for the prompt. Indices are stored in the ludwig cache by default.", parameter_metadata=LLM_METADATA["prompt"]["retrieval"]["index_name"], ) model_name: str = schema_utils.String( default=None, allow_none=True, description="The model used to generate the embeddings used to retrieve samples to inject in the prompt.", parameter_metadata=LLM_METADATA["prompt"]["retrieval"]["model_name"], ) k: int = schema_utils.NonNegativeInteger( default=0, description="The number of samples to retrieve.", parameter_metadata=LLM_METADATA["prompt"]["retrieval"]["k"], ) @DeveloperAPI class RetrievalConfigField(schema_utils.DictMarshmallowField): def __init__(self): super().__init__(RetrievalConfig) def _jsonschema_type_mapping(self): return schema_utils.unload_jsonschema_from_marshmallow_class(RetrievalConfig, title="Retrieval") @DeveloperAPI @ludwig_dataclass class PromptConfig(schema_utils.BaseMarshmallowConfig): """This Dataclass is a schema for the nested prompt config under preprocessing.""" template: str = schema_utils.String( default=None, allow_none=True, description=( "The template to use for the prompt. Must contain at least one of the columns from the input dataset " "or `__sample__` as a variable surrounded in curly brackets {} to indicate where to insert the " "current feature. Multiple columns can be inserted, e.g.: `The {color} {animal} jumped over " "the {size} {object}`, where every term in curly brackets is a column in the dataset. If a `task` " "is specified, then the template must also contain the `__task__` variable. If `retrieval` is specified, " "then the template must also contain the `__context__` variable. If no template is provided, then a " "default will be used based on the retrieval settings, and a task must be set in the config." ), parameter_metadata=LLM_METADATA["prompt"]["template"], ) task: str = schema_utils.String( default=None, allow_none=True, description="The task to use for the prompt. Required if `template` is not set.", parameter_metadata=LLM_METADATA["prompt"]["task"], ) retrieval: RetrievalConfig = RetrievalConfigField().get_default_field() @DeveloperAPI class PromptConfigField(schema_utils.DictMarshmallowField): def __init__(self): super().__init__(PromptConfig) def _jsonschema_type_mapping(self): return schema_utils.unload_jsonschema_from_marshmallow_class(PromptConfig) ================================================ FILE: ludwig/schema/llms/quantization.py ================================================ import warnings from transformers import BitsAndBytesConfig from ludwig.api_annotations import DeveloperAPI from ludwig.schema import utils as schema_utils from ludwig.schema.metadata import LLM_METADATA from ludwig.schema.metadata.parameter_metadata import convert_metadata_to_json from ludwig.schema.utils import ludwig_dataclass warnings.filterwarnings( action="ignore", category=UserWarning, module="bitsandbytes.cuda_setup.main", ) @DeveloperAPI @ludwig_dataclass class QuantizationConfig(schema_utils.BaseMarshmallowConfig): bits: int = schema_utils.IntegerOptions( options=[4, 8], default=4, description="The quantization level to apply to weights on load.", parameter_metadata=LLM_METADATA["quantization"]["bits"], ) llm_int8_threshold: float = schema_utils.NonNegativeFloat( default=6.0, description=( "This corresponds to the outlier threshold for outlier detection as described in `LLM.int8() : 8-bit " "Matrix Multiplication for Transformers at Scale` paper: https://arxiv.org/abs/2208.07339. Any hidden " "states value that is above this threshold will be considered an outlier and the operation on those " "values will be done in fp16. Values are usually normally distributed, that is, most values are in the " "range [-3.5, 3.5], but there are some exceptional systematic outliers that are very differently " "distributed for large models. These outliers are often in the interval [-60, -6] or [6, 60]. Int8 " "quantization works well for values of magnitude ~5, but beyond that, there is a significant performance " "penalty. A good default threshold is 6, but a lower threshold might be needed for more unstable models " "(small models, fine-tuning)." ), ) llm_int8_has_fp16_weight: bool = schema_utils.Boolean( default=False, description=( "This flag runs LLM.int8() with 16-bit main weights. This is useful for fine-tuning as the weights do " "not have to be converted back and forth for the backward pass." ), ) bnb_4bit_compute_dtype: str = schema_utils.StringOptions( options=["float32", "float16", "bfloat16"], default="float16", description=( "This sets the computational type which might be different than the input type. For example, inputs " "might be fp32, but computation can be set to bf16 for speedups." ), ) bnb_4bit_use_double_quant: bool = schema_utils.Boolean( default=True, description=( "This flag is used for nested quantization where the quantization constants from the first quantization " "are quantized again." ), ) bnb_4bit_quant_type: str = schema_utils.StringOptions( options=["fp4", "nf4"], default="nf4", description="This sets the quantization data type in the bnb.nn.Linear4Bit layers.", ) def to_bitsandbytes(self) -> BitsAndBytesConfig: return BitsAndBytesConfig( load_in_4bit=self.bits == 4, load_in_8bit=self.bits == 8, llm_int8_threshold=self.llm_int8_threshold, llm_int8_has_fp16_weight=self.llm_int8_has_fp16_weight, bnb_4bit_compute_dtype=self.bnb_4bit_compute_dtype, bnb_4bit_use_double_quant=self.bnb_4bit_use_double_quant, bnb_4bit_quant_type=self.bnb_4bit_quant_type, ) @DeveloperAPI class QuantizationConfigField(schema_utils.DictMarshmallowField): def __init__(self): super().__init__(QuantizationConfig, default_missing=True) def _jsonschema_type_mapping(self): return { "oneOf": [ { "type": "null", "title": "disabled", "description": "Disable quantization.", "parameter_metadata": convert_metadata_to_json(LLM_METADATA["quantization"]["_oneOf"]["none"]), }, { **schema_utils.unload_jsonschema_from_marshmallow_class(QuantizationConfig), "title": "enabled", "description": "Set quantization options.", "parameter_metadata": convert_metadata_to_json(LLM_METADATA["quantization"]["_oneOf"]["object"]), }, ], "title": "quantization", "description": "Set quantization options.", "parameter_metadata": convert_metadata_to_json(LLM_METADATA["quantization"]["_meta"]), } ================================================ FILE: ludwig/schema/lr_scheduler.py ================================================ from abc import ABC from dataclasses import field import ludwig.schema.utils as schema_utils from ludwig.api_annotations import DeveloperAPI from ludwig.constants import LOSS, MODEL_ECD, TRAINING from ludwig.error import ConfigValidationError from ludwig.schema.metadata import TRAINER_METADATA from ludwig.schema.utils import ludwig_dataclass @DeveloperAPI @ludwig_dataclass class LRSchedulerConfig(schema_utils.BaseMarshmallowConfig, ABC): """Configuration for learning rate scheduler parameters.""" decay: str = schema_utils.StringOptions( options=["linear", "exponential", "cosine"], default=None, allow_none=True, description="Turn on decay of the learning rate.", parameter_metadata=TRAINER_METADATA[MODEL_ECD]["learning_rate_scheduler"]["decay"], ) decay_rate: float = schema_utils.FloatRange( default=0.96, min=0, max=1, description="Decay per epoch (%): Factor to decrease the Learning rate.", parameter_metadata=TRAINER_METADATA[MODEL_ECD]["learning_rate_scheduler"]["decay_rate"], ) decay_steps: int = schema_utils.PositiveInteger( default=10000, description="The number of steps to take in the exponential learning rate decay.", parameter_metadata=TRAINER_METADATA[MODEL_ECD]["learning_rate_scheduler"]["decay_steps"], ) staircase: bool = schema_utils.Boolean( default=False, description="Decays the learning rate at discrete intervals.", parameter_metadata=TRAINER_METADATA[MODEL_ECD]["learning_rate_scheduler"]["staircase"], ) reduce_on_plateau: int = schema_utils.NonNegativeInteger( default=0, description=( "How many times to reduce the learning rate when the algorithm hits a plateau (i.e. the performance on the " "training set does not improve)" ), parameter_metadata=TRAINER_METADATA[MODEL_ECD]["learning_rate_scheduler"]["reduce_on_plateau"], ) reduce_on_plateau_patience: int = schema_utils.NonNegativeInteger( default=10, description=( "How many evaluation steps have to pass before the learning rate reduces " "when `reduce_on_plateau > 0`." ), parameter_metadata=TRAINER_METADATA[MODEL_ECD]["learning_rate_scheduler"]["reduce_on_plateau_patience"], ) reduce_on_plateau_rate: float = schema_utils.FloatRange( default=0.1, min=0, max=1, description="Rate at which we reduce the learning rate when `reduce_on_plateau > 0`.", parameter_metadata=TRAINER_METADATA[MODEL_ECD]["learning_rate_scheduler"]["reduce_on_plateau_rate"], ) warmup_evaluations: int = schema_utils.NonNegativeFloat( default=0, description="Number of evaluation steps to warmup the learning rate for.", parameter_metadata=TRAINER_METADATA[MODEL_ECD]["learning_rate_scheduler"]["warmup_evaluations"], ) warmup_fraction: float = schema_utils.NonNegativeFloat( default=0.0, description="Fraction of total training steps to warmup the learning rate for.", parameter_metadata=TRAINER_METADATA[MODEL_ECD]["learning_rate_scheduler"]["warmup_fraction"], ) reduce_eval_metric: str = schema_utils.String( default=LOSS, allow_none=False, description=( "Metric plateau used to trigger when we reduce the learning rate " "when `reduce_on_plateau > 0`." ), parameter_metadata=TRAINER_METADATA[MODEL_ECD]["learning_rate_scheduler"]["reduce_eval_metric"], ) reduce_eval_split: str = schema_utils.String( default=TRAINING, allow_none=False, description=( "Which dataset split to listen on for reducing the learning rate " "when `reduce_on_plateau > 0`." ), parameter_metadata=TRAINER_METADATA[MODEL_ECD]["learning_rate_scheduler"]["reduce_eval_split"], ) # Parameters for CosineAnnealingWarmRestarts scheduler t_0: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Number of steps before the first restart for cosine annealing decay. If not specified, it" " will be set to `steps_per_checkpoint`.", parameter_metadata=TRAINER_METADATA[MODEL_ECD]["learning_rate_scheduler"]["t_0"], ) t_mult: int = schema_utils.PositiveInteger( default=1, description="Period multiplier after each restart for cosine annealing decay. Defaults to 1, i.e.," " restart every `t_0` steps. If set to a larger value, the period between restarts increases by that" " multiplier. For e.g., if t_mult is 2, then the periods would be: t_0, 2*t_0, 2^2*t_0, 2^3*t_0, etc.", parameter_metadata=TRAINER_METADATA[MODEL_ECD]["learning_rate_scheduler"]["t_mult"], ) eta_min: float = schema_utils.FloatRange( default=0, min=0, max=1, description="Minimum learning rate allowed for cosine annealing decay. Default: 0.", parameter_metadata=TRAINER_METADATA[MODEL_ECD]["learning_rate_scheduler"]["eta_min"], ) # TODO(travis): too much boilerplate here, we should find a way to abstract all this and only require specifying the # minimal amount needed for the new config object. @DeveloperAPI def LRSchedulerDataclassField(description: str, default: dict = None): """Returns custom dataclass field for `LRSchedulerConfig`. Allows `None` by default. Args: description: Description of the dataclass field default: dict that specifies param values that will be loaded by its schema class (default: None). """ allow_none = True default = default or {} class LRSchedulerMarshmallowField(schema_utils.LudwigSchemaField): """Custom field class for learning rate scheduler. Deserializes a dict to a valid instance of `LRSchedulerConfig` and creates a corresponding JSON schema for external usage. """ def _deserialize(self, value, attr, data, **kwargs): if value is None: return value if isinstance(value, dict): try: return LRSchedulerConfig.Schema().load(value) except (TypeError, ConfigValidationError) as e: raise ConfigValidationError( f"Invalid params for learning rate scheduler: {value}, see LRSchedulerConfig class. Error: {e}" ) raise ConfigValidationError("Field should be None or dict") def _jsonschema_type_mapping(self): return { **schema_utils.unload_jsonschema_from_marshmallow_class(LRSchedulerConfig), "title": "learning_rate_scheduler_options", "description": description, } if not isinstance(default, dict): raise ConfigValidationError(f"Invalid default: `{default}`") load_default = lambda: LRSchedulerConfig.Schema().load(default) dump_default = LRSchedulerConfig.Schema().dump(default) return field( metadata={ "marshmallow_field": LRSchedulerMarshmallowField( allow_none=allow_none, load_default=load_default, dump_default=dump_default, metadata={ "description": description, }, ) }, default_factory=load_default, ) ================================================ FILE: ludwig/schema/metadata/__init__.py ================================================ import os from typing import Any import yaml from ludwig.schema.metadata.parameter_metadata import ParameterMetadata _PATH_HERE = os.path.abspath(os.path.dirname(__file__)) _CONFIG_DIR = os.path.join(_PATH_HERE, "configs") def _to_metadata(d: dict[str, Any]) -> ParameterMetadata | dict[str, Any]: is_nested = False for k, v in list(d.items()): if isinstance(v, dict): d[k] = _to_metadata(v) is_nested = True if is_nested: return d return ParameterMetadata.from_dict(d) def _load(fname: str) -> dict[str, Any]: with open(os.path.join(_CONFIG_DIR, fname)) as f: return _to_metadata(yaml.safe_load(f)) COMMON_METADATA = _load("common.yaml") COMBINER_METADATA = _load("combiners.yaml") DECODER_METADATA = _load("decoders.yaml") ENCODER_METADATA = _load("encoders.yaml") FEATURE_METADATA = _load("features.yaml") PREPROCESSING_METADATA = _load("preprocessing.yaml") TRAINER_METADATA = _load("trainer.yaml") OPTIMIZER_METADATA = _load("optimizers.yaml") LOSS_METADATA = _load("loss.yaml") LLM_METADATA = _load("llm.yaml") ================================================ FILE: ludwig/schema/metadata/configs/combiners.yaml ================================================ comparator: type: short_description: Used for recommendation problems, features associated with distinct entities, output depends on entity-level comparison. long_description: The comparator combiner compares the hidden representation of two entities defined by lists of features. It assumes all outputs from encoders are tensors of size `b x h` where `b` is the batch size and `h` is the hidden dimension, which can be different for each input. If the input tensors have a different shape, it automatically flattens them. It then concatenates the representations of each entity and projects them both to vectors of size `output_size`. Finally, it compares the two entity representations by dot product, element-wise multiplication, absolute difference and bilinear product. It returns the final `b x h` tensor where `h` is the size of the concatenation of the four comparisons. activation: default_value_reasoning: The Rectified Linear Units (ReLU) function is the standard activation function used for adding non-linearity. It is simple, fast, and empirically works well (https://arxiv.org/abs/1803.08375). description_implications: Changing the activation functions has an impact on the computational load of the model and might require further hypterparameter tuning expected_impact: 2 suggested_values: The default value will work well in the majority of the cases ui_display_name: Activation bias_initializer: default_value_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. description_implications: It's rare to see any performance gains from choosing a different bias initialization. Some practitioners like to use a small constant value such as 0.01 for all biases to ensure that all ReLU units are activated in the beginning and have some effect on the gradient. However, it's still an open question as to whether this provides consistent improvement. expected_impact: 1 literature_references: - https://cs231n.github.io/neural-networks-2/ related_parameters: - weights_initializer suggested_values: zeros suggested_values_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. For ReLU non-linearities, some people like to use small constant value such as 0.01 for all biases because this ensures that all ReLU units fire in the beginning and therefore obtain and propagate some gradient. However, it is not clear if this provides a consistent improvement (in fact some results seem to indicate that this performs worse) and it is more common to simply use 0 bias initialization. ui_display_name: Bias Initializer dropout: default_value_reasoning: Dropout can cause training to become less stable. Consider start with a dropout-free baseline, and add dropout gradually in subsequent experiments. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 3 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: Dropout entity_1: literature_references: - https://ludwig.ai/0.6/configuration/combiner/#comparator-combiner ui_display_name: Entity 1 expected_impact: 3 entity_2: literature_references: - https://ludwig.ai/0.6/configuration/combiner/#comparator-combiner ui_display_name: Entity 2 expected_impact: 3 fc_layers: default_value_reasoning: By default the stack is built by using num_fc_layers, output_size, use_bias, weights_initializer, bias_initializer, norm, norm_params, activation, dropout. When a list of dictionaries is provided, the stack is built following the parameters of each dict for building each layer. description_implications: The more layers that are specified the deeper and higher capacity the model will be. This makes it possible to potentially achieve better performance when a big anough amount of data is provided, but also makes the model more computationally expensive and potentially more prone to overfitting. example_value: - dropout: 0.1 output_size: 128 - norm: layer output_size: 64 expected_impact: 1 related_parameters: - output_size - use_bias - weights_initializer - bias_initializer - norm - norm_params - activation - dropout suggested_values_reasoning: It is easier to define a stack of fully connected layers by just specifying num_fc_layers, output_size and the other individual parameters. It will create a stack of layers with identical properties. Use this parameter only if you need a fine grained level of control of each individual layer in the stack. ui_display_name: Fully Connected Layers norm: default_value_reasoning: While batch normalization and layer normalization usually lead to improvements, it can be useful to start with fewer bells and whistles. description_implications: Normalization helps stabilize the learning process and can have a regularizing effect that can help with generalization. It's often suggested that with normalization, you can use a higher learning rate. example_value: - batch expected_impact: 3 literature_references: - https://machinelearningmastery.com/batch-normalization-for-training-of-deep-neural-networks/ related_parameters: - norm_params suggested_values: '"batch" or "layer"' suggested_values_reasoning: Normalization tries to solve "internal covariate shift" that comes from the changing distributions of the inputs to layers deep in the network when weights are updated. For example, batch normalization standardizes the inputs to a layer for each mini-batch. Try out different normalizations to see if that helps with training stability ui_display_name: Normalization Type norm_params: ui_display_name: null expected_impact: 1 num_fc_layers: default_value_reasoning: The encoder already has learnable parameters.Sometimes the default is 1 for modules where the FC stack is used for shape management, or the only source of learnable parameters. description_implications: Increasing num_fc_layers will increase the capacity of the model. The model will be slower to train, and there's a higher risk of overfitting. example_value: - 1 expected_impact: 3 other_information: Not all modules that have fc_layers also have an accompanying num_fc_layers parameter. Where both are present, fc_layers takes precedent over num_fc_layers. Specifying num_fc_layers alone uses fully connected layers that are configured by the defaults in FCStack. related_parameters: - fc_layers suggested_values: 0-1 suggested_values_reasoning: The full model likely contains many learnable parameters. Consider starting with very few, or without any additional fully connected layers and add them if you observe evidence of limited model capacity. Sometimes the default is 1 for modules where the FC stack is used for shape management, or the only source of learnable parameters. ui_display_name: Number of Fully Connected Layers output_size: default_value_reasoning: A modest value, not too small, not too large. description_implications: If there are fully connected layers in this module, increasing the output size of each fully connected layer will increase the capacity of the model. However, the model may be slower to train, and there's a higher risk of overfitting. If it seems like the model could use even more capacity, consider increasing the number of fully connected layers, or explore other architectures. expected_impact: 3 other_information: If num_fc_layers=0 and fc_layers=None, and there are no fully connected layers defined on the module, then this parameter may have no effect on the module's final output shape. related_parameters: - num_fc_layers, fc_layers suggested_values: 15 - 1024 suggested_values_reasoning: Increasing the output size increases the capacity of the model. If this seems to have a positive effect, then it could be worth increasing the number of layers, or trying a different architecture with a larger capacity. ui_display_name: Output Size use_bias: default_value_reasoning: "Bias terms may improve model accuracy, and don't have much impact in terms of memory or training speed. For most models it is reasonable to use bias terms. Batch Normalization, however, adds a trainable shift parameter which is added to the activation. When Batch Normalization is used in a layer, bias terms are redundant and may be removed." description_implications: Bias terms may improve model accuracy, and don't have much impact in terms of memory or training speed. For most models it is reasonable to leave this parameter set to True. example_value: - true expected_impact: 1 other_information: If fc_layers is not specified, or use_bias is not specified for individual layers, the value of use_bias will be used as the default for all layers. related_parameters: - bias_initializer, fc_layers suggested_values: "TRUE" ui_display_name: Use Bias weights_initializer: default_value_reasoning: Taken from [this paper](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf). description_implications: The method you choose to initialize layer weights during training can have a big impact on performance as well as the reproducibility of your final model between runs. As an example, if you were to randomly initialize weights you would risk non-reproducibility (and possibly general training performance), but sticking with constant values for initialization might significantly increase the time needed for model convergence. Generally, choosing one of the probabilistic approaches strikes a balance between the two extremes, and the literature kicked off by the landmark [*Xavier et al.* paper](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf) provides a few good options. See this nice discussion from [Weights and Biases](https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster.) for more information. expected_impact: 1 literature_references: - "Weights and Biases blog post: https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster." - "Xavier et al. paper: http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf" suggested_values: xavier_uniform suggested_values_reasoning: Changing the weights initialization scheme is something to consider if a model is having trouble with convergence, or otherwise it is something to experiment with after other factors are considered. The default choice (`xavier_uniform`) is a suitable starting point for most tasks. ui_display_name: Layer Weights Initializer concat: type: short_description: Concatenates outputs of all encoders and passes concatenated representation through stack of fully connected layers. long_description: The concat combiner assumes all outputs from encoders are tensors of size `b x h` where `b` is the batch size and `h` is the hidden dimension, which can differ for each input. It concatenates along the `h` dimension, and then (optionally) passes the concatenated tensor through a stack of fully connected layers. It returns the final `b x h` tensor where `h` is the size of the last fully connected layer or the sum of the sizes of the `h` of all inputs in the case there are no fully connected layers. If there is only a single input feature and no fully connected layers, the output of the input feature encoder is passed through the combiner unchanged. activation: default_value_reasoning: The Rectified Linear Units (ReLU) function is the standard activation function used for adding non-linearity. It is simple, fast, and empirically works well (https://arxiv.org/abs/1803.08375). description_implications: Changing the activation functions has an impact on the computational load of the model and might require further hypterparameter tuning expected_impact: 2 suggested_values: The default value will work well in the majority of the cases ui_display_name: Activation bias_initializer: default_value_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. description_implications: It's rare to see any performance gains from choosing a different bias initialization. Some practitioners like to use a small constant value such as 0.01 for all biases to ensure that all ReLU units are activated in the beginning and have some effect on the gradient. However, it's still an open question as to whether this provides consistent improvement. expected_impact: 1 literature_references: - https://cs231n.github.io/neural-networks-2/ related_parameters: - weights_initializer suggested_values: zeros suggested_values_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. For ReLU non-linearities, some people like to use small constant value such as 0.01 for all biases because this ensures that all ReLU units fire in the beginning and therefore obtain and propagate some gradient. However, it is not clear if this provides a consistent improvement (in fact some results seem to indicate that this performs worse) and it is more common to simply use 0 bias initialization. ui_display_name: Bias Initializer dropout: default_value_reasoning: Dropout can cause training to become less stable. Consider start with a dropout-free baseline, and add dropout gradually in subsequent experiments. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 3 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: Dropout fc_layers: default_value_reasoning: By default the stack is built by using num_fc_layers, output_size, use_bias, weights_initializer, bias_initializer, norm, norm_params, activation, dropout. When a list of dictionaries is provided, the stack is built following the parameters of each dict for building each layer. description_implications: The more layers that are specified the deeper and higher capacity the model will be. This makes it possible to potentially achieve better performance when a big anough amount of data is provided, but also makes the model more computationally expensive and potentially more prone to overfitting. example_value: - dropout: 0.1 output_size: 128 - norm: layer output_size: 64 expected_impact: 1 related_parameters: - output_size - use_bias - weights_initializer - bias_initializer - norm - norm_params - activation - dropout suggested_values_reasoning: It is easier to define a stack of fully connected layers by just specifying num_fc_layers, output_size and the other individual parameters. It will create a stack of layers with identical properties. Use this parameter only if you need a fine grained level of control of each individual layer in the stack. ui_display_name: Fully Connected Layers flatten_inputs: ui_display_name: null expected_impact: 1 norm: default_value_reasoning: While batch normalization and layer normalization usually lead to improvements, it can be useful to start with fewer bells and whistles. description_implications: Normalization helps stabilize the learning process and can have a regularizing effect that can help with generalization. It's often suggested that with normalization, you can use a higher learning rate. example_value: - batch expected_impact: 3 literature_references: - https://machinelearningmastery.com/batch-normalization-for-training-of-deep-neural-networks/ related_parameters: - norm_params suggested_values: '"batch" or "layer"' suggested_values_reasoning: Normalization tries to solve "internal covariate shift" that comes from the changing distributions of the inputs to layers deep in the network when weights are updated. For example, batch normalization standardizes the inputs to a layer for each mini-batch. Try out different normalizations to see if that helps with training stability ui_display_name: Normalization Type norm_params: ui_display_name: null expected_impact: 1 num_fc_layers: default_value_reasoning: The encoder already has learnable parameters.Sometimes the default is 1 for modules where the FC stack is used for shape management, or the only source of learnable parameters. description_implications: Increasing num_fc_layers will increase the capacity of the model. The model will be slower to train, and there's a higher risk of overfitting. example_value: - 1 expected_impact: 3 other_information: Not all modules that have fc_layers also have an accompanying num_fc_layers parameter. Where both are present, fc_layers takes precedent over num_fc_layers. Specifying num_fc_layers alone uses fully connected layers that are configured by the defaults in FCStack. related_parameters: - fc_layers suggested_values: 0-1 suggested_values_reasoning: The full model likely contains many learnable parameters. Consider starting with very few, or without any additional fully connected layers and add them if you observe evidence of limited model capacity. Sometimes the default is 1 for modules where the FC stack is used for shape management, or the only source of learnable parameters. ui_display_name: Number of Fully Connected Layers output_size: default_value_reasoning: A modest value, not too small, not too large. description_implications: If there are fully connected layers in this module, increasing the output size of each fully connected layer will increase the capacity of the model. However, the model may be slower to train, and there's a higher risk of overfitting. If it seems like the model could use even more capacity, consider increasing the number of fully connected layers, or explore other architectures. expected_impact: 3 other_information: If num_fc_layers=0 and fc_layers=None, and there are no fully connected layers defined on the module, then this parameter may have no effect on the module's final output shape. related_parameters: - num_fc_layers, fc_layers suggested_values: 16 - 1024 suggested_values_reasoning: Increasing the output size increases the capacity of the model. If this seems to have a positive effect, then it could be worth increasing the number of layers, or trying a different architecture with a larger capacity. ui_display_name: Output Size residual: ui_display_name: null expected_impact: 1 use_bias: default_value_reasoning: "Bias terms may improve model accuracy, and don't have much impact in terms of memory or training speed. For most models it is reasonable to use bias terms. Batch Normalization, however, adds a trainable shift parameter which is added to the activation. When Batch Normalization is used in a layer, bias terms are redundant and may be removed." description_implications: Bias terms may improve model accuracy, and don't have much impact in terms of memory or training speed. For most models it is reasonable to leave this parameter set to True. example_value: - true expected_impact: 1 other_information: If fc_layers is not specified, or use_bias is not specified for individual layers, the value of use_bias will be used as the default for all layers. related_parameters: - bias_initializer, fc_layers suggested_values: "TRUE" ui_display_name: Use Bias weights_initializer: default_value_reasoning: Taken from [this paper](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf). description_implications: The method you choose to initialize layer weights during training can have a big impact on performance as well as the reproducibility of your final model between runs. As an example, if you were to randomly initialize weights you would risk non-reproducibility (and possibly general training performance), but sticking with constant values for initialization might significantly increase the time needed for model convergence. Generally, choosing one of the probabilistic approaches strikes a balance between the two extremes, and the literature kicked off by the landmark [*Xavier et al.* paper](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf) provides a few good options. See this nice discussion from [Weights and Biases](https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster.) for more information. expected_impact: 1 literature_references: - "Weights and Biases blog post: https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster." - "Xavier et al. paper: http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf" suggested_values: xavier_uniform suggested_values_reasoning: Changing the weights initialization scheme is something to consider if a model is having trouble with convergence, or otherwise it is something to experiment with after other factors are considered. The default choice (`xavier_uniform`) is a suitable starting point for most tasks. ui_display_name: Layer Weights Initializer project_aggregate: type: short_description: Projects the encoder outputs to a common size then takes the average. long_description: The project aggregate combiner projects the input vectors to a common size and then aggregates them by taking the average across all the vectors. activation: default_value_reasoning: The Rectified Linear Units (ReLU) function is the standard activation function used for adding non-linearity. It is simple, fast, and empirically works well (https://arxiv.org/abs/1803.08375). description_implications: Changing the activation functions has an impact on the computational load of the model and might require further hypterparameter tuning expected_impact: 2 suggested_values: The default value will work well in the majority of the cases ui_display_name: Activation bias_initializer: default_value_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. description_implications: It's rare to see any performance gains from choosing a different bias initialization. Some practitioners like to use a small constant value such as 0.01 for all biases to ensure that all ReLU units are activated in the beginning and have some effect on the gradient. However, it's still an open question as to whether this provides consistent improvement. expected_impact: 1 literature_references: - https://cs231n.github.io/neural-networks-2/ related_parameters: - weights_initializer suggested_values: zeros suggested_values_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. For ReLU non-linearities, some people like to use small constant value such as 0.01 for all biases because this ensures that all ReLU units fire in the beginning and therefore obtain and propagate some gradient. However, it is not clear if this provides a consistent improvement (in fact some results seem to indicate that this performs worse) and it is more common to simply use 0 bias initialization. ui_display_name: Bias Initializer dropout: default_value_reasoning: Dropout can cause training to become less stable. Consider start with a dropout-free baseline, and add dropout gradually in subsequent experiments. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 3 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: Dropout fc_layers: default_value_reasoning: By default the stack is built by using num_fc_layers, output_size, use_bias, weights_initializer, bias_initializer, norm, norm_params, activation, dropout. When a list of dictionaries is provided, the stack is built following the parameters of each dict for building each layer. description_implications: The more layers that are specified the deeper and higher capacity the model will be. This makes it possible to potentially achieve better performance when a big anough amount of data is provided, but also makes the model more computationally expensive and potentially more prone to overfitting. example_value: - dropout: 0.1 output_size: 128 - norm: layer output_size: 64 expected_impact: 1 related_parameters: - output_size - use_bias - weights_initializer - bias_initializer - norm - norm_params - activation - dropout suggested_values_reasoning: It is easier to define a stack of fully connected layers by just specifying num_fc_layers, output_size and the other individual parameters. It will create a stack of layers with identical properties. Use this parameter only if you need a fine grained level of control of each individual layer in the stack. ui_display_name: Fully Connected Layers norm: default_value_reasoning: While batch normalization and layer normalization usually lead to improvements, it can be useful to start with fewer bells and whistles. description_implications: Normalization helps stabilize the learning process and can have a regularizing effect that can help with generalization. It's often suggested that with normalization, you can use a higher learning rate. example_value: - batch expected_impact: 3 literature_references: - https://machinelearningmastery.com/batch-normalization-for-training-of-deep-neural-networks/ related_parameters: - norm_params suggested_values: '"batch" or "layer"' suggested_values_reasoning: Normalization tries to solve "internal covariate shift" that comes from the changing distributions of the inputs to layers deep in the network when weights are updated. For example, batch normalization standardizes the inputs to a layer for each mini-batch. Try out different normalizations to see if that helps with training stability ui_display_name: Normalization Type norm_params: ui_display_name: null expected_impact: 1 num_fc_layers: default_value_reasoning: The encoder already has learnable parameters.Sometimes the default is 1 for modules where the FC stack is used for shape management, or the only source of learnable parameters. description_implications: Increasing num_fc_layers will increase the capacity of the model. The model will be slower to train, and there's a higher risk of overfitting. example_value: - 1 expected_impact: 3 other_information: Not all modules that have fc_layers also have an accompanying num_fc_layers parameter. Where both are present, fc_layers takes precedent over num_fc_layers. Specifying num_fc_layers alone uses fully connected layers that are configured by the defaults in FCStack. related_parameters: - fc_layers suggested_values: 0-1 suggested_values_reasoning: The full model likely contains many learnable parameters. Consider starting with very few, or without any additional fully connected layers and add them if you observe evidence of limited model capacity. Sometimes the default is 1 for modules where the FC stack is used for shape management, or the only source of learnable parameters. ui_display_name: Number of Fully Connected Layers output_size: default_value_reasoning: A modest value, not too small, not too large. description_implications: If there are fully connected layers in this module, increasing the output size of each fully connected layer will increase the capacity of the model. However, the model may be slower to train, and there's a higher risk of overfitting. If it seems like the model could use even more capacity, consider increasing the number of fully connected layers, or explore other architectures. expected_impact: 3 other_information: If num_fc_layers=0 and fc_layers=None, and there are no fully connected layers defined on the module, then this parameter may have no effect on the module's final output shape. related_parameters: - num_fc_layers, fc_layers suggested_values: 17 - 1024 suggested_values_reasoning: Increasing the output size increases the capacity of the model. If this seems to have a positive effect, then it could be worth increasing the number of layers, or trying a different architecture with a larger capacity. ui_display_name: Output Size projection_size: ui_display_name: null expected_impact: 1 residual: ui_display_name: null expected_impact: 1 use_bias: default_value_reasoning: "Bias terms may improve model accuracy, and don't have much impact in terms of memory or training speed. For most models it is reasonable to use bias terms. Batch Normalization, however, adds a trainable shift parameter which is added to the activation. When Batch Normalization is used in a layer, bias terms are redundant and may be removed." description_implications: Bias terms may improve model accuracy, and don't have much impact in terms of memory or training speed. For most models it is reasonable to leave this parameter set to True. example_value: - true expected_impact: 1 other_information: If fc_layers is not specified, or use_bias is not specified for individual layers, the value of use_bias will be used as the default for all layers. related_parameters: - bias_initializer, fc_layers suggested_values: "TRUE" ui_display_name: Use Bias weights_initializer: default_value_reasoning: Taken from [this paper](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf). description_implications: The method you choose to initialize layer weights during training can have a big impact on performance as well as the reproducibility of your final model between runs. As an example, if you were to randomly initialize weights you would risk non-reproducibility (and possibly general training performance), but sticking with constant values for initialization might significantly increase the time needed for model convergence. Generally, choosing one of the probabilistic approaches strikes a balance between the two extremes, and the literature kicked off by the landmark [*Xavier et al.* paper](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf) provides a few good options. See this nice discussion from [Weights and Biases](https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster.) for more information. expected_impact: 1 literature_references: - "Weights and Biases blog post: https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster." - "Xavier et al. paper: http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf" suggested_values: xavier_uniform suggested_values_reasoning: Changing the weights initialization scheme is something to consider if a model is having trouble with convergence, or otherwise it is something to experiment with after other factors are considered. The default choice (`xavier_uniform`) is a suitable starting point for most tasks. ui_display_name: Layer Weights Initializer sequence: type: short_description: Stacks a sequence concat combiner with a sequence encoder. long_description: The sequence combiner stacks a sequence concat combiner with a sequence encoder. All the considerations about input tensor ranks described for the sequence concat combiner apply also in this case, but the main difference is that this combiner uses the `b x s x h` output of the sequence concat combiner, where `b` is the batch size, `s` is the sequence length and `h` is the sum of the hidden dimensions of all input features, as input for any of the sequence encoders described in the sequence features encoders section. All considerations on the shape of the outputs for the sequence encoders also apply to the sequence combiner. encoder: ui_display_name: null expected_impact: 3 main_sequence_feature: ui_display_name: null expected_impact: 3 reduce_output: ui_display_name: null expected_impact: 1 sequence_concat: type: short_description: Concatenates the outputs of multiple sequence features. long_description: The sequence_concat combiner assumes at least one output from the encoders is a tensor of size `b x s x h` where `b` is the batch size, `s` is the length of the sequence and `h` is the hidden dimension. A sequence-like (sequence, text or time series) input feature can be specified with the `main_sequence_feature` parameter which takes the name of sequence-like input feature as its value. If no `main_sequence_feature` is specified, the combiner will look through all the features in the order they are defined in the configuration and will look for a feature with a rank 3 tensor output (sequence, text or time series). If it cannot find one it will raise an exception, otherwise the output of that feature will be used for concatenating the other features along the sequence `s` dimension. If there are other input features with a rank 3 output tensor, the combiner will concatenate them alongside the s dimension, which means that all of them must have identical s dimension, otherwise a dimension mismatch error will be returned thrown during training when a datapoint with two sequential features of different lengths are provided. Other features that have a b x h rank 2 tensor output will be replicated s times and concatenated to the s dimension. The final output is a b x s x h' tensor where h' is the size of the concatenation of the h dimensions of all input features. main_sequence_feature: ui_display_name: null expected_impact: 3 reduce_output: ui_display_name: null expected_impact: 1 tabnet: type: short_description: Tabnet is specifically tailored for high performance on tabular data. long_description: The tabnet combiner implements the TabNet model, which uses attention and sparsity to achieve high performance on tabular data. It assumes all outputs from encoders are tensors of size b x h where b is the batch size and h is the hidden dimension, which can be different for each input. If the input tensors have a different shape, it automatically flattens them. It returns the final b x h' tensor where h' is the user-specified output size. literature_references: - https://arxiv.org/abs/1908.07442 compute_tier: 1 bn_epsilon: default_value_reasoning: Default value found in popular ML packages like Keras and Tensorflow. description_implications: An epsilon is added to the denominator of the batch normalization operation so that the function converges. Setting the epsilon to 0 is inadvisable. example_value: - 1.0e-05 expected_impact: 1 literature_references: - "[Keras example](https://keras.io/api/layers/normalization_layers/batch_normalization/)" suggested_values: 1e-3-1e-9 suggested_values_reasoning: Common epsilon choices ui_display_name: Batch Normalization Epsilon bn_momentum: description_implications: "Higher values result in faster updates, but more sensitivity to noise in the dataset. Lower values result in slower updates. If momentum is set to 0, moving statistics will not be updated during training. This is likely to cause variance between train and test performance, and is not recommended." example_value: - 0.05 literature_references: - "TabNet Paper: https://arxiv.org/abs/1908.07442" - "Torch Batch Norm: https://pytorch.org/docs/stable/generated/torch.nn.BatchNorm1d.html" other_information: "`bn_momentum` is only used if `norm`: `batch`. For other values of `norm` it has no effect. `bn_momentum` is different from optimizer momentum. Batch norm moving estimate statistics are updated according to the rule: x_hat = (1 - momentum) * x_hat + momentum * x_t, where x_hat is the estimated statistic and x_t is the new observed value." suggested_values: 0.01-0.2 ui_display_name: Batch Norm Momentum expected_impact: 1 bn_virtual_bs: default_value_reasoning: Paper default. description_implications: Virtual Batch Normalization is a normalization method that extends batch normalization. Regular batch normalization causes the output of a neural network for an input example to be highly dependent on several other inputs in the same minibatch. To avoid this problem in virtual batch normalization (VBN), each example is normalized based on the statistics collected on a reference batch of examples that are chosen once and fixed at the start of training, and on itself. The reference batch is normalized using only its own statistics. VBN is computationally expensive because it requires running forward propagation on two minibatches of data, so the authors use it only in the generator network. A higher virtual batch size could improve normalization, but it also causes training to run slower since each batch will be sampled multiple times. expected_impact: 1 literature_references: - https://paperswithcode.com/method/virtual-batch-normalization ui_display_name: "Ghost Normalization: Virtual batch size" dropout: default_value_reasoning: Taken from published literature (https://arxiv.org/abs/1908.07442). description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 3 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: Dropout entmax_alpha: ui_display_name: null expected_impact: 1 entmax_mode: ui_display_name: null expected_impact: 1 num_shared_blocks: ui_display_name: null expected_impact: 1 num_steps: ui_display_name: null expected_impact: 1 num_total_blocks: ui_display_name: null expected_impact: 1 output_size: default_value_reasoning: A modest value, not too small, not too large. description_implications: If there are fully connected layers in this module, increasing the output size of each fully connected layer will increase the capacity of the model. However, the model may be slower to train, and there's a higher risk of overfitting. If it seems like the model could use even more capacity, consider increasing the number of fully connected layers, or explore other architectures. expected_impact: 3 other_information: If num_fc_layers=0 and fc_layers=None, and there are no fully connected layers defined on the module, then this parameter may have no effect on the module's final output shape. related_parameters: - num_fc_layers, fc_layers suggested_values: 18 - 1024 suggested_values_reasoning: Increasing the output size increases the capacity of the model. If this seems to have a positive effect, then it could be worth increasing the number of layers, or trying a different architecture with a larger capacity. ui_display_name: Output Size relaxation_factor: ui_display_name: null expected_impact: 1 size: ui_display_name: null expected_impact: 3 sparsity: ui_display_name: null expected_impact: 1 tabtransformer: type: short_description: Projects and concatenates features, then passes them through a transformer. long_description: The tabtransformer combiner combines input features in the following sequence of operations. Except for binary and number features, the combiner projects features to an embedding size. These features are concatenated as if they were a sequence and passed through a transformer. After the transformer, the number and binary features are concatenated (which are of size 1) and then concatenated with the output of the transformer and is passed to a stack of fully connected layers (from TabTransformer Tabular Data Modeling Using Contextual Embeddings). It assumes all outputs from encoders are tensors of size `b x h` where `b` is the batch size and `h` is the hidden dimension, which can be different for each input. If the input tensors have a different shape, it automatically flattens them. It then projects each input tensor to the same hidden / embedding size and encodes them with a stack of Transformer layers. Finally, the transformer combiner applies a reduction to the outputs of the Transformer stack, followed by the above concatenation and optional fully connected layers. The output is a `b x h` tensor where `h` is the size of the last fully connected layer or the hidden / embedding size, or a `b x n x h` where `n` is the number of input features and `h` is the hidden / embedding size if no reduction is applied. literature_references: - https://arxiv.org/abs/2012.06678 compute_tier: 2 bias_initializer: default_value_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. description_implications: It's rare to see any performance gains from choosing a different bias initialization. Some practitioners like to use a small constant value such as 0.01 for all biases to ensure that all ReLU units are activated in the beginning and have some effect on the gradient. However, it's still an open question as to whether this provides consistent improvement. expected_impact: 1 literature_references: - https://cs231n.github.io/neural-networks-2/ related_parameters: - weights_initializer suggested_values: zeros suggested_values_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. For ReLU non-linearities, some people like to use small constant value such as 0.01 for all biases because this ensures that all ReLU units fire in the beginning and therefore obtain and propagate some gradient. However, it is not clear if this provides a consistent improvement (in fact some results seem to indicate that this performs worse) and it is more common to simply use 0 bias initialization. ui_display_name: Bias Initializer dropout: default_value_reasoning: Taken from published literature (https://arxiv.org/abs/1706.03762). description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 3 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: Dropout embed_input_feature_name: default_value_reasoning: Though the ideal embedding size depends on the task and dataset, setting the feature embedding size equal to the hidden size and adding feature embeddings to hidden representations ('add') is a good starting point. description_implications: Input feature name embeddings have been shown to improve performance of deep learning methods on tabular data. Feature name embeddings play a similar role to positional embeddings in a language model, allowing the network to learn conditional dependencies between input features. example_value: - 64 literature_references: - "TabTransformer: Tabular Data Modeling Using Contextual Embeddings" other_information: Must be an integer, 'add', or null. If an integer, specifies the embedding size for input feature names. Input feature name embeddings will be concatenated to hidden representations. Must be less than or equal to hidden_size. If 'add', input feature names use embeddings the same size as hidden_size, and are added (element-wise) to the hidden representations. If null, input feature embeddings are not used. related_parameters: - hidden_size ui_display_name: Embed Input Feature Name expected_impact: 3 fc_activation: default_value_reasoning: The Rectified Linear Units (ReLU) function is the standard activation function used for adding non-linearity. It is simple, fast, and empirically works well (https://arxiv.org/abs/1803.08375). description_implications: Changing the activation functions has an impact on the computational load of the model and might require further hypterparameter tuning example_value: - relu expected_impact: 1 literature_references: - https://ml-cheatsheet.readthedocs.io/en/latest/activation_functions.html related_parameters: - activation, activation_function, conv_activation, recurrent_activation suggested_values: relu, alternatively leakyRelu or elu suggested_values_reasoning: The default value will work well in the majority of the cases ui_display_name: FC Activation fc_dropout: default_value_reasoning: Dropout can cause training to become less stable. Consider start with a dropout-free baseline, and add dropout gradually in subsequent experiments. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 1 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: FC Dropout fc_layers: default_value_reasoning: By default the stack is built by using num_fc_layers, output_size, use_bias, weights_initializer, bias_initializer, norm, norm_params, activation, dropout. When a list of dictionaries is provided, the stack is built following the parameters of each dict for building each layer. description_implications: The more layers that are specified the deeper and higher capacity the model will be. This makes it possible to potentially achieve better performance when a big anough amount of data is provided, but also makes the model more computationally expensive and potentially more prone to overfitting. example_value: - dropout: 0.1 output_size: 128 - norm: layer output_size: 64 expected_impact: 1 suggested_values_reasoning: It is easier to define a stack of fully connected layers by just specifying num_fc_layers, output_size and the other individual parameters. It will create a stack of layers with identical properties. Use this parameter only if you need a fine grained level of control of each individual layer in the stack. ui_display_name: Fully Connected Layers fc_residual: ui_display_name: null expected_impact: 1 hidden_size: default_value_reasoning: Not too big, not too small. description_implications: Increasing the hidden size makes the model larger and slower to train, increases the model's capacity to capture more complexity. It also increases the chance of overfitting. expected_impact: 2 suggested_values: 10 - 2048 suggested_values_reasoning: Increasing the hidden size makes sense if the model is underfitting. It's useful to train both smaller and larger models to see how model capacity affects performance. This should only be explored after the architecture of the model has been settled. ui_display_name: Hidden Size norm: default_value_reasoning: While batch normalization and layer normalization usually lead to improvements, it can be useful to start with fewer bells and whistles. description_implications: Normalization helps stabilize the learning process and can have a regularizing effect that can help with generalization. It's often suggested that with normalization, you can use a higher learning rate. example_value: - batch expected_impact: 3 literature_references: - https://machinelearningmastery.com/batch-normalization-for-training-of-deep-neural-networks/ related_parameters: - norm_params suggested_values: '"batch" or "layer"' suggested_values_reasoning: Normalization tries to solve "internal covariate shift" that comes from the changing distributions of the inputs to layers deep in the network when weights are updated. For example, batch normalization standardizes the inputs to a layer for each mini-batch. Try out different normalizations to see if that helps with training stability ui_display_name: Normalization Type norm_params: ui_display_name: null expected_impact: 1 num_fc_layers: default_value_reasoning: The encoder already has learnable parameters.Sometimes the default is 1 for modules where the FC stack is used for shape management, or the only source of learnable parameters. description_implications: Increasing num_fc_layers will increase the capacity of the model. The model will be slower to train, and there's a higher risk of overfitting. example_value: - 1 expected_impact: 3 other_information: Not all modules that have fc_layers also have an accompanying num_fc_layers parameter. Where both are present, fc_layers takes precedent over num_fc_layers. Specifying num_fc_layers alone uses fully connected layers that are configured by the defaults in FCStack. related_parameters: - fc_layers suggested_values: 0-1 suggested_values_reasoning: The full model likely contains many learnable parameters. Consider starting with very few, or without any additional fully connected layers and add them if you observe evidence of limited model capacity. Sometimes the default is 1 for modules where the FC stack is used for shape management, or the only source of learnable parameters. ui_display_name: Number of Fully Connected Layers num_heads: default_value_reasoning: "The middle value explored in the original TabTransformer paper. Source: https://arxiv.org/pdf/2012.06678.pdf" description_implications: Increasing the number of attention heads can increase model performance at the cost of additional compute and memory. example_value: - 8 expected_impact: 1 literature_references: - https://arxiv.org/pdf/2012.06678.pdf suggested_values: 16 suggested_values_reasoning: If your model is underperforming, increasing the number of attention heads can improve its ability to correlate items in a sequence. ui_display_name: Number of attention heads num_layers: default_value_reasoning: The ideal number of layers depends on the data. For many data types, one layer is sufficient. description_implications: "The ideal number of transformer layers depends on the length and complexity of input sequences, as well as the task. For more complex tasks, and higher number of transformer layers may be useful. However, too many layers will increase memory and slow training while providing diminishing returns of model performance." example_value: - 1 expected_impact: 1 suggested_values: 1 - 12 suggested_values_reasoning: Increasing the number of layers may improve encoder performance. However, more layers will increase training time and may cause overfitting. Small numbers of layers usually work best. ui_display_name: Number of Transformer Layers output_size: default_value_reasoning: A modest value, not too small, not too large. description_implications: If there are fully connected layers in this module, increasing the output size of each fully connected layer will increase the capacity of the model. However, the model may be slower to train, and there's a higher risk of overfitting. If it seems like the model could use even more capacity, consider increasing the number of fully connected layers, or explore other architectures. expected_impact: 3 other_information: If num_fc_layers=0 and fc_layers=None, and there are no fully connected layers defined on the module, then this parameter may have no effect on the module's final output shape. related_parameters: - num_fc_layers, fc_layers suggested_values: 19 - 1024 suggested_values_reasoning: Increasing the output size increases the capacity of the model. If this seems to have a positive effect, then it could be worth increasing the number of layers, or trying a different architecture with a larger capacity. ui_display_name: Output Size reduce_output: ui_display_name: null expected_impact: 1 transformer_output_size: default_value_reasoning: A modest value, not too small, not too large. description_implications: If there are fully connected layers in this module, increasing the output size of each fully connected layer will increase the capacity of the model. However, the model may be slower to train, and there's a higher risk of overfitting. If it seems like the model could use even more capacity, consider increasing the number of fully connected layers, or explore other architectures. expected_impact: 2 other_information: If num_fc_layers=0 and fc_layers=None, and there are no fully connected layers defined on the module, then this parameter may have no effect on the module's final output shape. related_parameters: - num_fc_layers, fc_layers suggested_values: 20 - 1024 suggested_values_reasoning: Increasing the output size increases the capacity of the model. If this seems to have a positive effect, then it could be worth increasing the number of layers, or trying a different architecture with a larger capacity. ui_display_name: Transformer Output Size use_bias: default_value_reasoning: "Bias terms may improve model accuracy, and don't have much impact in terms of memory or training speed. For most models it is reasonable to use bias terms. Batch Normalization, however, adds a trainable shift parameter which is added to the activation. When Batch Normalization is used in a layer, bias terms are redundant and may be removed." description_implications: Bias terms may improve model accuracy, and don't have much impact in terms of memory or training speed. For most models it is reasonable to leave this parameter set to True. example_value: - true expected_impact: 1 other_information: If fc_layers is not specified, or use_bias is not specified for individual layers, the value of use_bias will be used as the default for all layers. related_parameters: - bias_initializer, fc_layers suggested_values: "TRUE" ui_display_name: Use Bias weights_initializer: default_value_reasoning: Taken from [this paper](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf). description_implications: The method you choose to initialize layer weights during training can have a big impact on performance as well as the reproducibility of your final model between runs. As an example, if you were to randomly initialize weights you would risk non-reproducibility (and possibly general training performance), but sticking with constant values for initialization might significantly increase the time needed for model convergence. Generally, choosing one of the probabilistic approaches strikes a balance between the two extremes, and the literature kicked off by the landmark [*Xavier et al.* paper](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf) provides a few good options. See this nice discussion from [Weights and Biases](https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster.) for more information. expected_impact: 1 literature_references: - "Weights and Biases blog post: https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster." - "Xavier et al. paper: http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf" suggested_values: xavier_uniform suggested_values_reasoning: Changing the weights initialization scheme is something to consider if a model is having trouble with convergence, or otherwise it is something to experiment with after other factors are considered. The default choice (`xavier_uniform`) is a suitable starting point for most tasks. ui_display_name: Layer Weights Initializer transformer: type: short_description: The transformer combiner combines input features using a stack of Transformer blocks. long_description: The transformer combiner combines input features using a stack of Transformer blocks (from Attention Is All You Need). It assumes all outputs from encoders are tensors of size `b x h` where `b` is the batch size and `h` is the hidden dimension, which can be different for each input. If the input tensors have a different shape, it automatically flattens them. It then projects each input tensor to the same hidden / embedding size and encodes them with a stack of Transformer layers. Finally, the transformer combiner applies a reduction to the outputs of the Transformer stack, followed by optional fully connected layers. The output is a `b x h` tensor where `h` is the size of the last fully connected layer or the hidden / embedding size, or a `b x n x h` where `n` is the number of input features and `h` is the hidden / embedding size if no reduction is applied. literature_references: - https://arxiv.org/abs/1706.03762 compute_tier: 2 bias_initializer: default_value_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. description_implications: It's rare to see any performance gains from choosing a different bias initialization. Some practitioners like to use a small constant value such as 0.01 for all biases to ensure that all ReLU units are activated in the beginning and have some effect on the gradient. However, it's still an open question as to whether this provides consistent improvement. expected_impact: 1 literature_references: - https://cs231n.github.io/neural-networks-2/ related_parameters: - weights_initializer suggested_values: zeros suggested_values_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. For ReLU non-linearities, some people like to use small constant value such as 0.01 for all biases because this ensures that all ReLU units fire in the beginning and therefore obtain and propagate some gradient. However, it is not clear if this provides a consistent improvement (in fact some results seem to indicate that this performs worse) and it is more common to simply use 0 bias initialization. ui_display_name: Bias Initializer dropout: default_value_reasoning: Taken from published literature (https://arxiv.org/abs/1706.03762). description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 3 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: Dropout fc_activation: default_value_reasoning: The Rectified Linear Units (ReLU) function is the standard activation function used for adding non-linearity. It is simple, fast, and empirically works well (https://arxiv.org/abs/1803.08375). description_implications: Changing the activation functions has an impact on the computational load of the model and might require further hypterparameter tuning example_value: - relu expected_impact: 1 literature_references: - https://ml-cheatsheet.readthedocs.io/en/latest/activation_functions.html related_parameters: - activation, activation_function, conv_activation, recurrent_activation suggested_values: relu, alternatively leakyRelu or elu suggested_values_reasoning: The default value will work well in the majority of the cases ui_display_name: FC Activation fc_dropout: default_value_reasoning: Dropout can cause training to become less stable. Consider start with a dropout-free baseline, and add dropout gradually in subsequent experiments. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 1 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: FC Dropout fc_layers: default_value_reasoning: By default the stack is built by using num_fc_layers, output_size, use_bias, weights_initializer, bias_initializer, norm, norm_params, activation, dropout. When a list of dictionaries is provided, the stack is built following the parameters of each dict for building each layer. description_implications: The more layers that are specified the deeper and higher capacity the model will be. This makes it possible to potentially achieve better performance when a big anough amount of data is provided, but also makes the model more computationally expensive and potentially more prone to overfitting. example_value: - dropout: 0.1 output_size: 128 - norm: layer output_size: 64 expected_impact: 1 suggested_values_reasoning: It is easier to define a stack of fully connected layers by just specifying num_fc_layers, output_size and the other individual parameters. It will create a stack of layers with identical properties. Use this parameter only if you need a fine grained level of control of each individual layer in the stack. ui_display_name: Fully Connected Layers fc_residual: ui_display_name: null hidden_size: default_value_reasoning: Not too big, not too small. description_implications: Increasing the hidden size makes the model larger and slower to train, increases the model's capacity to capture more complexity. It also increases the chance of overfitting. expected_impact: 2 suggested_values: 10 - 2048 suggested_values_reasoning: Increasing the hidden size makes sense if the model is underfitting. It's useful to train both smaller and larger models to see how model capacity affects performance. This should only be explored after the architecture of the model has been settled. ui_display_name: Hidden Size norm: default_value_reasoning: While batch normalization and layer normalization usually lead to improvements, it can be useful to start with fewer bells and whistles. description_implications: Normalization helps stabilize the learning process and can have a regularizing effect that can help with generalization. It's often suggested that with normalization, you can use a higher learning rate. example_value: - batch expected_impact: 3 literature_references: - https://machinelearningmastery.com/batch-normalization-for-training-of-deep-neural-networks/ related_parameters: - norm_params suggested_values: '"batch" or "layer"' suggested_values_reasoning: Normalization tries to solve "internal covariate shift" that comes from the changing distributions of the inputs to layers deep in the network when weights are updated. For example, batch normalization standardizes the inputs to a layer for each mini-batch. Try out different normalizations to see if that helps with training stability ui_display_name: Normalization Type norm_params: ui_display_name: null expected_impact: 1 num_fc_layers: default_value_reasoning: The encoder already has learnable parameters.Sometimes the default is 1 for modules where the FC stack is used for shape management, or the only source of learnable parameters. description_implications: Increasing num_fc_layers will increase the capacity of the model. The model will be slower to train, and there's a higher risk of overfitting. example_value: - 1 expected_impact: 3 other_information: Not all modules that have fc_layers also have an accompanying num_fc_layers parameter. Where both are present, fc_layers takes precedent over num_fc_layers. Specifying num_fc_layers alone uses fully connected layers that are configured by the defaults in FCStack. related_parameters: - fc_layers suggested_values: 0-1 suggested_values_reasoning: The full model likely contains many learnable parameters. Consider starting with very few, or without any additional fully connected layers and add them if you observe evidence of limited model capacity. Sometimes the default is 1 for modules where the FC stack is used for shape management, or the only source of learnable parameters. ui_display_name: Number of Fully Connected Layers num_heads: ui_display_name: null expected_impact: 1 num_layers: default_value_reasoning: The ideal number of layers depends on the data. For many data types, one layer is sufficient. description_implications: "The ideal number of transformer layers depends on the length and complexity of input sequences, as well as the task. For more complex tasks, and higher number of transformer layers may be useful. However, too many layers will increase memory and slow training while providing diminishing returns of model performance." example_value: - 1 expected_impact: 1 suggested_values: 1 - 12 suggested_values_reasoning: Increasing the number of layers may improve encoder performance. However, more layers will increase training time and may cause overfitting. Small numbers of layers usually work best. ui_display_name: Number of Transformer Layers output_size: default_value_reasoning: A modest value, not too small, not too large. description_implications: If there are fully connected layers in this module, increasing the output size of each fully connected layer will increase the capacity of the model. However, the model may be slower to train, and there's a higher risk of overfitting. If it seems like the model could use even more capacity, consider increasing the number of fully connected layers, or explore other architectures. expected_impact: 3 other_information: If num_fc_layers=0 and fc_layers=None, and there are no fully connected layers defined on the module, then this parameter may have no effect on the module's final output shape. related_parameters: - num_fc_layers, fc_layers suggested_values: 21 - 1024 suggested_values_reasoning: Increasing the output size increases the capacity of the model. If this seems to have a positive effect, then it could be worth increasing the number of layers, or trying a different architecture with a larger capacity. ui_display_name: Output Size reduce_output: ui_display_name: null expected_impact: 1 transformer_output_size: default_value_reasoning: A modest value, not too small, not too large. description_implications: If there are fully connected layers in this module, increasing the output size of each fully connected layer will increase the capacity of the model. However, the model may be slower to train, and there's a higher risk of overfitting. If it seems like the model could use even more capacity, consider increasing the number of fully connected layers, or explore other architectures. expected_impact: 2 other_information: If num_fc_layers=0 and fc_layers=None, and there are no fully connected layers defined on the module, then this parameter may have no effect on the module's final output shape. related_parameters: - num_fc_layers, fc_layers suggested_values: 22 - 1024 suggested_values_reasoning: Increasing the output size increases the capacity of the model. If this seems to have a positive effect, then it could be worth increasing the number of layers, or trying a different architecture with a larger capacity. ui_display_name: Transformer Output Size use_bias: default_value_reasoning: "Bias terms may improve model accuracy, and don't have much impact in terms of memory or training speed. For most models it is reasonable to use bias terms. Batch Normalization, however, adds a trainable shift parameter which is added to the activation. When Batch Normalization is used in a layer, bias terms are redundant and may be removed." description_implications: Bias terms may improve model accuracy, and don't have much impact in terms of memory or training speed. For most models it is reasonable to leave this parameter set to True. example_value: - true expected_impact: 1 other_information: If fc_layers is not specified, or use_bias is not specified for individual layers, the value of use_bias will be used as the default for all layers. related_parameters: - bias_initializer, fc_layers suggested_values: "TRUE" ui_display_name: Use Bias weights_initializer: default_value_reasoning: Taken from [this paper](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf). description_implications: The method you choose to initialize layer weights during training can have a big impact on performance as well as the reproducibility of your final model between runs. As an example, if you were to randomly initialize weights you would risk non-reproducibility (and possibly general training performance), but sticking with constant values for initialization might significantly increase the time needed for model convergence. Generally, choosing one of the probabilistic approaches strikes a balance between the two extremes, and the literature kicked off by the landmark [*Xavier et al.* paper](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf) provides a few good options. See this nice discussion from [Weights and Biases](https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster.) for more information. expected_impact: 1 literature_references: - "Weights and Biases blog post: https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster." - "Xavier et al. paper: http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf" suggested_values: xavier_uniform suggested_values_reasoning: Changing the weights initialization scheme is something to consider if a model is having trouble with convergence, or otherwise it is something to experiment with after other factors are considered. The default choice (`xavier_uniform`) is a suitable starting point for most tasks. ui_display_name: Layer Weights Initializer ================================================ FILE: ludwig/schema/metadata/configs/common.yaml ================================================ activation: default_value_reasoning: The Rectified Linear Units (ReLU) function is the standard activation function used for adding non-linearity. It is simple, fast, and empirically works well (https://arxiv.org/abs/1803.08375). description_implications: Changing the activation functions has an impact on the computational load of the model and might require further hypterparameter tuning expected_impact: 2 suggested_values: relu suggested_values_reasoning: ReLU will work well in the majority of the cases ui_display_name: Activation bias_initializer: default_value_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. description_implications: It's rare to see any performance gains from choosing a different bias initialization. Some practitioners like to use a small constant value such as 0.01 for all biases to ensure that all ReLU units are activated in the beginning and have some effect on the gradient. However, it's still an open question as to whether this provides consistent improvement. expected_impact: 1 literature_references: - https://cs231n.github.io/neural-networks-2/ related_parameters: - weights_initializer suggested_values: zeros suggested_values_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. For ReLU non-linearities, some people like to use small constant value such as 0.01 for all biases because this ensures that all ReLU units fire in the beginning and therefore obtain and propagate some gradient. However, it is not clear if this provides a consistent improvement (in fact some results seem to indicate that this performs worse) and it is more common to simply use 0 bias initialization. ui_display_name: Bias Initializer dropout: default_value_reasoning: Dropout can cause training to become less stable. Consider start with a dropout-free baseline, and add dropout gradually in subsequent experiments. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 3 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: Dropout fc_layers: default_value_reasoning: By default the stack is built by using num_fc_layers, output_size, use_bias, weights_initializer, bias_initializer, norm, norm_params, activation, dropout. When a list of dictionaries is provided, the stack is built following the parameters of each dict for building each layer. description_implications: The more layers that are specified the deeper and higher capacity the model will be. This makes it possible to potentially achieve better performance when a big anough amount of data is provided, but also makes the model more computationally expensive and potentially more prone to overfitting. example_value: - dropout: 0.1 output_size: 128 - norm: layer output_size: 64 expected_impact: 1 related_parameters: - output_size - use_bias - weights_initializer - bias_initializer - norm - norm_params - activation - dropout suggested_values_reasoning: It is easier to define a stack of fully connected layers by just specifying num_fc_layers, output_size and the other individual parameters. It will create a stack of layers with identical properties. Use this parameter only if you need a fine grained level of control of each individual layer in the stack. ui_display_name: Fully Connected Layers flatten_inputs: ui_display_name: null expected_impact: 1 norm: default_value_reasoning: While batch normalization and layer normalization usually lead to improvements, it can be useful to start with fewer bells and whistles. description_implications: Normalization helps stabilize the learning process and can have a regularizing effect that can help with generalization. It's often suggested that with normalization, you can use a higher learning rate. example_value: - batch expected_impact: 3 literature_references: - https://machinelearningmastery.com/batch-normalization-for-training-of-deep-neural-networks/ related_parameters: - norm_params suggested_values_reasoning: Normalization tries to solve "internal covariate shift" that comes from the changing distributions of the inputs to layers deep in the network when weights are updated. For example, batch normalization standardizes the inputs to a layer for each mini-batch. Try out different normalizations to see if that helps with training stability ui_display_name: Normalization Type norm_params: ui_display_name: null expected_impact: 1 num_fc_layers: default_value_reasoning: The encoder already has learnable parameters.Sometimes the default is 1 for modules where the FC stack is used for shape management, or the only source of learnable parameters. description_implications: Increasing num_fc_layers will increase the capacity of the model. The model will be slower to train, and there's a higher risk of overfitting. example_value: - 1 expected_impact: 3 other_information: Not all modules that have fc_layers also have an accompanying num_fc_layers parameter. Where both are present, fc_layers takes precedent over num_fc_layers. Specifying num_fc_layers alone uses fully connected layers that are configured by the defaults in FCStack. related_parameters: - fc_layers suggested_values: 0-1 suggested_values_reasoning: The full model likely contains many learnable parameters. Consider starting with very few, or without any additional fully connected layers and add them if you observe evidence of limited model capacity. Sometimes the default is 1 for modules where the FC stack is used for shape management, or the only source of learnable parameters. ui_display_name: Number of Fully Connected Layers output_size: default_value_reasoning: A modest value, not too small, not too large. description_implications: If there are fully connected layers in this module, increasing the output size of each fully connected layer will increase the capacity of the model. However, the model may be slower to train, and there's a higher risk of overfitting. If it seems like the model could use even more capacity, consider increasing the number of fully connected layers, or explore other architectures. expected_impact: 3 other_information: If num_fc_layers=0 and fc_layers=None, and there are no fully connected layers defined on the module, then this parameter may have no effect on the module's final output shape. related_parameters: - num_fc_layers, fc_layers suggested_values: 16 - 1024 suggested_values_reasoning: Increasing the output size increases the capacity of the model. If this seems to have a positive effect, then it could be worth increasing the number of layers, or trying a different architecture with a larger capacity. ui_display_name: Output Size residual: ui_display_name: null expected_impact: 1 use_bias: default_value_reasoning: "Bias terms may improve model accuracy, and don't have much impact in terms of memory or training speed. For most models it is reasonable to use bias terms. Batch Normalization, however, adds a trainable shift parameter which is added to the activation. When Batch Normalization is used in a layer, bias terms are redundant and may be removed." description_implications: Bias terms may improve model accuracy, and don't have much impact in terms of memory or training speed. For most models it is reasonable to leave this parameter set to True. example_value: - true expected_impact: 1 other_information: If fc_layers is not specified, or use_bias is not specified for individual layers, the value of use_bias will be used as the default for all layers. related_parameters: - bias_initializer, fc_layers suggested_values: "TRUE" ui_display_name: Use Bias weights_initializer: default_value_reasoning: Taken from [this paper](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf). description_implications: The method you choose to initialize layer weights during training can have a big impact on performance as well as the reproducibility of your final model between runs. As an example, if you were to randomly initialize weights you would risk non-reproducibility (and possibly general training performance), but sticking with constant values for initialization might significantly increase the time needed for model convergence. Generally, choosing one of the probabilistic approaches strikes a balance between the two extremes, and the literature kicked off by the landmark [*Xavier et al.* paper](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf) provides a few good options. See this nice discussion from [Weights and Biases](https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster.) for more information. expected_impact: 1 literature_references: - "Weights and Biases blog post: https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster." - "Xavier et al. paper: http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf" suggested_values: xavier_uniform suggested_values_reasoning: Changing the weights initialization scheme is something to consider if a model is having trouble with convergence, or otherwise it is something to experiment with after other factors are considered. The default choice (`xavier_uniform`) is a suitable starting point for most tasks. ui_display_name: Layer Weights Initializer embedding_initializer: default_value_reasoning: According to https://arxiv.org/abs/1711.09160, choice of embedding initialization is not important as long as the variance is kept reasonably low. description_implications: According to https://arxiv.org/abs/1711.09160, choice of embedding initialization is not important as long as the variance is kept reasonably low. example_value: - kaiming expected_impact: 1 literature_references: - https://arxiv.org/abs/1711.09160 suggested_values: kaiming suggested_values_reasoning: https://discuss.huggingface.co/t/state-of-the-art-technique-for-initializing-embedding-matrix/326 ui_display_name: Embedding Initialization embedding_size: default_value_reasoning: Not too big, not too small. description_implications: 'An embedding is a relatively low-dimensional space that is used to translate high-dimensional vectors like words, which can have a large vocbulary size. Ideally, after an embedding is trained, it captures some of the semantics of the input by placing semantically similar inputs close together in the embedding space. In most cases, the embedding size is chosen empirically, by trial and error. From https://www.amazon.com/dp/1098115783, "one rule of thumb is to use the fourth root of the total number of unique categorical elements while another is that the embedding dimension should be approximately 1.6 times the square root of the number of unique elements in the category, and no less than 600." Increasing the embedding size may cause the model to train more slowly, but the higher dimensionality can also improve overall quality.' expected_impact: 3 literature_references: - https://developers.google.com/machine-learning/crash-course/embeddings/video-lecture suggested_values: 1.6 * sqrt(vocab_size) suggested_values_reasoning: Rule of thumb suggested by a deep learning textbook. Try models with smaller or larger embedding sizes to observe relative impact. ui_display_name: Embedding Size embeddings_on_cpu: default_value_reasoning: By default embeddings matrices are stored on GPU memory if a GPU is used, as it allows for faster access. description_implications: By default embeddings matrices are stored on GPU memory if a GPU is used, as it allows for faster access. However, in some cases when the vocabulary size is very large, the full embedding matrix may be really big and unwieldy to have in GPU memory. This parameter forces the placement of the embedding matrix in regular memory and the CPU is used to access them. This may slow down training due to additional data transfer between CPU and GPU memory, but can lead to healthier GPU memory resource usage. expected_impact: 1 suggested_values: - false suggested_values_reasoning: If GPU memory is not a constraint, having embeddings stored and accessed within the GPU is faster. ui_display_name: Embeddings on CPU embeddings_trainable: default_value_reasoning: If trained from scratch, embedding vectors are typically learned alongside the rest of the model. description_implications: Typically this value is only set to False if pre-trained embeddings are uploaded. Even then, it is reasonable to leave it as True in order to fine-tune the embeddings. expected_impact: 1 related_parameters: - embedding_size, representation, pretrained_embeddings ui_display_name: (under Embeddings header) Trainable? pretrained_embeddings: default_value_reasoning: Embeddings are commonly trained from scratch, or incorporated as part of a pre-trained model package. description_implications: If pretrained embeddings are specified, then the model may have a head start in its representation of various input entities. example_value: - ~/Downloads/glove.6B.100d.txt expected_impact: 0 related_parameters: - embedding_size, embeddings_trainable ui_display_name: Pretrained embeddings path max_sequence_length: default_value_reasoning: Sets the maximum sequence length of the expected inputs, so input/output shapes are computed accurately. internal_only: true ui_display_name: null vocab: default_value_reasoning: Computed and passed along internally according to preprocessing settings. example_value: - a - b - c internal_only: true ui_display_name: Not Displayed vocab_size: internal_only: true ui_display_name: Not displayed representation: default_value_reasoning: Trainable, randomly initialized embedding vectors often lead to more subtle representations of input entities than one-hot vectors. description_implications: If set to sparse, the representations for input entities are fixed as one-hot vectors. This leads to less flexible representations for input entities, but could lead to faster training since there are less learnable parameters. expected_impact: 1 other_information: "" related_parameters: - embedding_size, embeddings_trainable, pretrained_embeddings ui_display_name: Representation approach reduce_output: default_value_reasoning: Sums the tensors along the sequence dimension. description_implications: "\"last\", \"sum\", \"mean\", and \"max\" are the\ \ fastest and most memory-efficient operations\u2013 they result in tensors\ \ that are the same-size as a single item in the input sequence. However,\ \ these are simple aggregation operations, therefore some information\ \ may be lost. \n\n\"concat\" concatenates each tensor together, creating\ \ a `(sequence length)*(tensor size)`-element tensor. \"concat\" preserves\ \ this information, but can be very memory-intensive and should only be\ \ applied if the sequence length and/or tensor size is small. \n\n\"attention\"\ \ takes a weighted sum of the items in the sequence, where the weights\ \ for each item in the sequence are determined by the model on-the-fly\ \ based on the features of the item itself. This is both slower and and\ \ more memory-intensive than the other operations; however, it can also\ \ provide a richer \"global\" representation of the sequence." expected_impact: 1 related_parameters: - max_sequence_length suggested_values: '"attention". This and the default covers 95% of use cases.' suggested_values_reasoning: If you would like better performance and are not compute/memory-constrained, attention-based reduction can potentially provide a richer global representation than the default, but note that attention reduction does not work with `cache_encoder_embeddings=true`. ui_display_name: Sequence Reducer ================================================ FILE: ludwig/schema/metadata/configs/decoders.yaml ================================================ BaseDecoder: type: expected_impact: 1 fc_layers: expected_impact: 1 num_fc_layers: expected_impact: 3 fc_output_size: expected_impact: 3 fc_use_bias: expected_impact: 1 fc_weights_initializer: expected_impact: 1 fc_bias_initializer: expected_impact: 1 fc_norm: expected_impact: 2 fc_norm_params: expected_impact: 1 fc_activation: expected_impact: 2 fc_dropout: expected_impact: 3 Classifier: type: short_description: Projects combiner output to a vector the size of the number of available classes. long_description: The classifier decoder is a (potentially empty) stack of fully connected layers, followed by a projection into a vector of size of the number of available classes, followed by a sigmoid. expected_impact: 0 bias_initializer: default_value_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. description_implications: It's rare to see any performance gains from choosing a different bias initialization. Some practitioners like to use a small constant value such as 0.01 for all biases to ensure that all ReLU units are activated in the beginning and have some effect on the gradient. However, it's still an open question as to whether this provides consistent improvement. expected_impact: 1 literature_references: - https://cs231n.github.io/neural-networks-2/ related_parameters: - weights_initializer suggested_values: zeros suggested_values_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. For ReLU non-linearities, some people like to use small constant value such as 0.01 for all biases because this ensures that all ReLU units fire in the beginning and therefore obtain and propagate some gradient. However, it is not clear if this provides a consistent improvement (in fact some results seem to indicate that this performs worse) and it is more common to simply use 0 bias initialization. ui_display_name: Bias Initializer input_size: other_information: Internal Only internal_only: true related_parameters: - "No" ui_display_name: Not Displayed num_classes: other_information: Internal Only internal_only: true ui_display_name: Not Displayed use_bias: default_value_reasoning: "Bias terms may improve model accuracy, and don't have much impact in terms of memory or training speed. For most models it is reasonable to use bias terms. Batch Normalization, however, adds a trainable shift parameter which is added to the activation. When Batch Normalization is used in a layer, bias terms are redundant and may be removed." description_implications: Bias terms may improve model accuracy, and don't have much impact in terms of memory or training speed. For most models it is reasonable to leave this parameter set to True. example_value: - true expected_impact: 1 other_information: If fc_layers is not specified, or use_bias is not specified for individual layers, the value of use_bias will be used as the default for all layers. related_parameters: - bias_initializer, fc_layers suggested_values: true ui_display_name: Use Bias weights_initializer: default_value_reasoning: Taken from [this paper](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf). description_implications: The method you choose to initialize layer weights during training can have a big impact on performance as well as the reproducibility of your final model between runs. As an example, if you were to randomly initialize weights you would risk non-reproducibility (and possibly general training performance), but sticking with constant values for initialization might significantly increase the time needed for model convergence. Generally, choosing one of the probabilistic approaches strikes a balance between the two extremes, and the literature kicked off by the landmark [*Xavier et al.* paper](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf) provides a few good options. See this nice discussion from [Weights and Biases](https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster.) for more information. expected_impact: 1 literature_references: - "Weights and Biases blog post: https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster." - "Xavier et al. paper: http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf" suggested_values: xavier_uniform suggested_values_reasoning: Changing the weights initialization scheme is something to consider if a model is having trouble with convergence, or otherwise it is something to experiment with after other factors are considered. The default choice (`xavier_uniform`) is a suitable starting point for most tasks. ui_display_name: Layer Weights Initializer Projector: type: short_description: Projects combiner output into an output vector. long_description: The Projector decoder is a (potentially empty) stack of fully connected layers, followed by a projection into a tensor of the vector size (optionally followed by a softmax in the case of multi-class classification). expected_impact: 0 activation: description_implications: Changing the activation functions has an impact on the computational load of the model and might require further hypterparameter tuning expected_impact: 2 suggested_values: The default value will work well in the majority of the cases ui_display_name: Activation bias_initializer: default_value_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. description_implications: It's rare to see any performance gains from choosing a different bias initialization. Some practitioners like to use a small constant value such as 0.01 for all biases to ensure that all ReLU units are activated in the beginning and have some effect on the gradient. However, it's still an open question as to whether this provides consistent improvement. expected_impact: 1 literature_references: - https://cs231n.github.io/neural-networks-2/ related_parameters: - weights_initializer suggested_values: zeros suggested_values_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. For ReLU non-linearities, some people like to use small constant value such as 0.01 for all biases because this ensures that all ReLU units fire in the beginning and therefore obtain and propagate some gradient. However, it is not clear if this provides a consistent improvement (in fact some results seem to indicate that this performs worse) and it is more common to simply use 0 bias initialization. ui_display_name: Bias Initializer clip: ui_display_name: null expected_impact: 1 input_size: other_information: Internal Only internal_only: true related_parameters: - "No" ui_display_name: Not Displayed output_size: default_value_reasoning: A modest value, not too small, not too large. description_implications: If there are fully connected layers in this module, increasing the output size of each fully connected layer will increase the capacity of the model. However, the model may be slower to train, and there's a higher risk of overfitting. If it seems like the model could use even more capacity, consider increasing the number of fully connected layers, or explore other architectures. expected_impact: 3 other_information: If num_fc_layers=0 and fc_layers=None, and there are no fully connected layers defined on the module, then this parameter may have no effect on the module's final output shape. related_parameters: - num_fc_layers, fc_layers suggested_values: 10 - 1024 suggested_values_reasoning: Increasing the output size increases the capacity of the model. If this seems to have a positive effect, then it could be worth increasing the number of layers, or trying a different architecture with a larger capacity. ui_display_name: Output Size use_bias: default_value_reasoning: "Bias terms may improve model accuracy, and don't have much impact in terms of memory or training speed. For most models it is reasonable to use bias terms. Batch Normalization, however, adds a trainable shift parameter which is added to the activation. When Batch Normalization is used in a layer, bias terms are redundant and may be removed." description_implications: Bias terms may improve model accuracy, and don't have much impact in terms of memory or training speed. For most models it is reasonable to leave this parameter set to True. example_value: - true expected_impact: 1 other_information: If fc_layers is not specified, or use_bias is not specified for individual layers, the value of use_bias will be used as the default for all layers. related_parameters: - bias_initializer, fc_layers suggested_values: true ui_display_name: Use Bias weights_initializer: default_value_reasoning: Taken from [this paper](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf). description_implications: The method you choose to initialize layer weights during training can have a big impact on performance as well as the reproducibility of your final model between runs. As an example, if you were to randomly initialize weights you would risk non-reproducibility (and possibly general training performance), but sticking with constant values for initialization might significantly increase the time needed for model convergence. Generally, choosing one of the probabilistic approaches strikes a balance between the two extremes, and the literature kicked off by the landmark [*Xavier et al.* paper](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf) provides a few good options. See this nice discussion from [Weights and Biases](https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster.) for more information. expected_impact: 1 literature_references: - "Weights and Biases blog post: https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster." - "Xavier et al. paper: http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf" suggested_values: xavier_uniform suggested_values_reasoning: Changing the weights initialization scheme is something to consider if a model is having trouble with convergence, or otherwise it is something to experiment with after other factors are considered. The default choice (`xavier_uniform`) is a suitable starting point for most tasks. ui_display_name: Layer Weights Initializer Regressor: type: short_description: Projects combiner output to a single number. long_description: The regressor decoder is a (potentially empty) stack of fully connected layers, followed by a projection to a single number. expected_impact: 0 activation: ui_display_name: null expected_impact: 2 bias_initializer: default_value_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. description_implications: It's rare to see any performance gains from choosing a different bias initialization. Some practitioners like to use a small constant value such as 0.01 for all biases to ensure that all ReLU units are activated in the beginning and have some effect on the gradient. However, it's still an open question as to whether this provides consistent improvement. expected_impact: 1 literature_references: - https://cs231n.github.io/neural-networks-2/ related_parameters: - weights_initializer suggested_values: zeros suggested_values_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. For ReLU non-linearities, some people like to use small constant value such as 0.01 for all biases because this ensures that all ReLU units fire in the beginning and therefore obtain and propagate some gradient. However, it is not clear if this provides a consistent improvement (in fact some results seem to indicate that this performs worse) and it is more common to simply use 0 bias initialization. ui_display_name: Bias Initializer input_size: other_information: Internal Only internal_only: true related_parameters: - "No" ui_display_name: Not Displayed use_bias: default_value_reasoning: "Bias terms may improve model accuracy, and don't have much impact in terms of memory or training speed. For most models it is reasonable to use bias terms. Batch Normalization, however, adds a trainable shift parameter which is added to the activation. When Batch Normalization is used in a layer, bias terms are redundant and may be removed." description_implications: Bias terms may improve model accuracy, and don't have much impact in terms of memory or training speed. For most models it is reasonable to leave this parameter set to True. example_value: - true expected_impact: 1 other_information: If fc_layers is not specified, or use_bias is not specified for individual layers, the value of use_bias will be used as the default for all layers. related_parameters: - bias_initializer, fc_layers suggested_values: true ui_display_name: Use Bias weights_initializer: default_value_reasoning: Taken from [this paper](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf). description_implications: The method you choose to initialize layer weights during training can have a big impact on performance as well as the reproducibility of your final model between runs. As an example, if you were to randomly initialize weights you would risk non-reproducibility (and possibly general training performance), but sticking with constant values for initialization might significantly increase the time needed for model convergence. Generally, choosing one of the probabilistic approaches strikes a balance between the two extremes, and the literature kicked off by the landmark [*Xavier et al.* paper](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf) provides a few good options. See this nice discussion from [Weights and Biases](https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster.) for more information. expected_impact: 1 literature_references: - "Weights and Biases blog post: https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster." - "Xavier et al. paper: http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf" suggested_values: xavier_uniform suggested_values_reasoning: Changing the weights initialization scheme is something to consider if a model is having trouble with convergence, or otherwise it is something to experiment with after other factors are considered. The default choice (`xavier_uniform`) is a suitable starting point for most tasks. ui_display_name: Layer Weights Initializer PassthroughDecoder: type: short_description: Provides the raw input from the combiner. long_description: The passthrough decoder simply returns the raw output coming from the combiner. expected_impact: 0 input_size: other_information: Internal Only internal_only: true related_parameters: - "No" ui_display_name: Not Displayed SequenceGeneratorDecoder: type: short_description: Generates a sequence by sampling from the model. long_description: The generator decoder is a (potentially empty) stack of fully connected layers, followed by an RNN that generates outputs feeding on its own previous predictions and generates a tensor of size `b x s' x c`, where `b` is the batch size, `s'` is the length of the generated sequence and `c` is the number of classes, followed by a softmax_cross_entropy. During training teacher forcing is adopted, meaning the list of targets is provided as both inputs and outputs (shifted by 1), while at evaluation time greedy decoding (generating one token at a time and feeding it as input for the next step) is performed by beam search, using a beam of 1 by default. In general a generator expects a `b x h` shaped input tensor, where `h` is a hidden dimension. The `h` vectors are (after an optional stack of fully connected layers) fed into the rnn generator. One exception is when the generator uses attention, as in that case the expected size of the input tensor is `b x s x h`, which is the output of a sequence, text or time series input feature without reduced outputs or the output of a sequence-based combiner. If a `b x h` input is provided to a generator decoder using an RNN with attention instead, an error will be raised during model building. expected_impact: 0 cell_type: ui_display_name: null expected_impact: 3 input_size: other_information: Internal Only internal_only: true related_parameters: - "No" ui_display_name: Not Displayed max_sequence_length: expected_impact: 3 ui_display_name: null num_layers: default_value_reasoning: The ideal number of layers depends on the data and task. For many data types, one layer is sufficient. description_implications: Increasing the number of layers may improve model performance for longer sequences or more complex tasks. example_value: - 1 expected_impact: 3 suggested_values: 1-3 suggested_values_reasoning: Increasing the number of layers may improve encoder performance. However, more layers will increase training time and may cause overfitting. Small numbers of layers usually work best. ui_display_name: Number of Recurrent Layers reduce_input: description_implications: "\u201Clast\u201D: Reduces tensor by taking the\ \ last non-zero element per sequence in the sequence dimension.\n\u201C\ sum\u201D: Reduces tensor by summing across the sequence dimension.\n\u201C\ mean\u201D: Reduces tensor by taking the mean of the sequence dimension.\n\ \u201Cavg\u201D: synonym for \u201Cmean\u201D.\n\u201Cmax\u201D: Reduces\ \ tensor by taking the maximum value of the last dimension across the\ \ sequence dimension.\n\u201Cconcat\u201D: Reduces tensor by concatenating\ \ the second and last dimension.\n\u201Cattention\u201D: Reduces tensor\ \ by summing across the sequence dimension after applying feedforward\ \ attention.\n\u201Cnone\u201D: no reduction." expected_impact: 2 ui_display_name: Combiner Reduce Mode vocab_size: ui_display_name: Not displayed SequenceTaggerDecoder: type: short_description: Used for classifying each element of an input sequence. long_description: The tagger decoder is a (potentially empty) stack of fully connected layers, followed by a projection into a tensor of size `b x s x c`, where `b` is the batch size, `s` is the length of the sequence and `c` is the number of classes, followed by a `softmax_cross_entropy`. This decoder requires its input to be shaped as `b x s x h`, where `h` is a hidden dimension, which is the output of a sequence, text or time series input feature without reduced outputs or the output of a sequence-based combiner. This can be done by ensuring that at least one of the sequence, text or time series input feature's encoders has `reduce_output` set to `None`. This will prevent a `b x h` input from being provided to this decoder and an error from being raised during model building. The tagger decoder also requires the `reduce_input` parameter of the output feature to be set to `None`. If this is not set, Ludwig will automatically override the value by setting it to None and log a warning. expected_impact: 0 attention_embedding_size: default_value_reasoning: Not too big, not too small. description_implications: Increasing the embedding size may cause the model to train more slowly, but the higher dimensionality can also improve overall quality. expected_impact: 2 suggested_values: 128 - 2048 suggested_values_reasoning: Try models with smaller or larger embedding sizes to observe relative impact. ui_display_name: Attention Embedding Size attention_num_heads: ui_display_name: null expected_impact: 1 input_size: other_information: Internal Only internal_only: true related_parameters: - "No" ui_display_name: Not Displayed max_sequence_length: expected_impact: 3 ui_display_name: null use_attention: ui_display_name: null expected_impact: 1 use_bias: default_value_reasoning: "Bias terms may improve model accuracy, and don't have much impact in terms of memory or training speed. For most models it is reasonable to use bias terms. Batch Normalization, however, adds a trainable shift parameter which is added to the activation. When Batch Normalization is used in a layer, bias terms are redundant and may be removed." description_implications: Bias terms may improve model accuracy, and don't have much impact in terms of memory or training speed. For most models it is reasonable to leave this parameter set to True. example_value: - true expected_impact: 1 other_information: If fc_layers is not specified, or use_bias is not specified for individual layers, the value of use_bias will be used as the default for all layers. related_parameters: - bias_initializer, fc_layers suggested_values: - true ui_display_name: Use Bias vocab_size: ui_display_name: Not displayed internal_only: true UNetDecoder: type: short_description: The UNet decoder convolutional and up-conv layers long_description: Stacks of two 2D convolutional layers with optional normalization and relu activation, preceeded by an up-conv layer in all but the final level of the decoder. compute_tier: 1 conv_norm: expected_impact: 2 ui_display_name: Convolutional Normalization height: default_value_reasoning: Computed internally, automatically, based on image data preprocessing. internal_only: true ui_display_name: NOT DISPLAYED input_size: other_information: Internal Only internal_only: true related_parameters: - "No" ui_display_name: Not Displayed num_channels: default_value_reasoning: Computed internally, automatically, based on image data preprocessing. internal_only: true ui_display_name: NOT DISPLAYED num_classes: default_value_reasoning: Computed internally, automatically, based on image data preprocessing. internal_only: true ui_display_name: NOT DISPLAYED width: default_value_reasoning: Computed internally, automatically, based on image data preprocessing. internal_only: true ui_display_name: NOT DISPLAYED ================================================ FILE: ludwig/schema/metadata/configs/encoders.yaml ================================================ BaseEncoder: skip: internal_only: true other_information: Internal Only ui_display_name: Not Displayed HFEncoder: trainable: default_value_reasoning: Trainable is disabled by default to make the model useful for generating fast baselines, which can be further sped up by setting `preprocessing.cache_encoder_embeddings`. In many cases strong performance can be achieved without adjusting the weights of the pretrained model, but for best performance we recommend setting this to true. description_implications: "Ludwig currently supports two variations on fine-tuning, configured via the trainable encoder parameter: (1) modifying the weights of the pretrained encoder to adapt them to the downstream task (trainable=true), or (2) keeping the pretrained encoder weights fixed and training a stack of dense layers that sit downstream as the combiner and decoder modules (trainable=false, default). This is sometimes distinguished as transfer learning. Allowing the weights to be modified by setting trainable=true can significantly improve performance on the downstream task, but will take significantly longer to train (due to the additional backward passes over the pretrained model parameters). Additionally, more care needs to be taken when selecting hyperparameters when trainable=true to prevent [catastrophic forgettng](https://en.wikipedia.org/wiki/Catastrophic_interference), whereby the model forgets all of the valuable information it learned during pretraining." expected_impact: 3 literature_references: - http://d2l.ai/chapter_computer-vision/fine-tuning.html" related_parameters: - use_pretrained, pretrained_model, saved_weights_in_checkpoint suggested_values: - false suggested_values_reasoning: Freezing the weights (i.e. `trainable = False`) is only worth trying if you are loading in pretrained weights. In that case, check to see if your model is overfitting. If so, freezing the weights (and therefore reducing model complexity) may be beneficial. ui_display_name: Trainable use_pretrained: default_value_reasoning: By default, the model is initialized as a pretrained model. description_implications: Pretrained models have typically already learned features that are difficult to learn from scratch. They are particularly beneficial when training on small amounts of data. expected_impact: 3 literature_references: - https://machinelearningmastery.com/transfer-learning-for-deep-learning/ related_parameters: - trainable, pretrained_model_name, pretrained_model_name_or_path, pretrained_kwargs suggested_values: - false suggested_values_reasoning: If you have a large amount of data and/or you have data that differs from the typical distribution, then it might be worth training the model from scratch. ui_display_name: Use Pretrained reduce_output: default_value_reasoning: Sums the tensors along the sequence dimension. description_implications: "\"last\", \"sum\", \"mean\", and \"max\" are the\ \ fastest and most memory-efficient operations\u2013 they result in tensors\ \ that are the same-size as a single item in the input sequence. However,\ \ these are simple aggregation operations, therefore some information\ \ may be lost. \n\n\"concat\" concatenates each tensor together, creating\ \ a `(sequence length)*(tensor size)`-element tensor. \"concat\" preserves\ \ this information, but can be very memory-intensive and should only be\ \ applied if the sequence length and/or tensor size is small. \n\n\"attention\"\ \ takes a weighted sum of the items in the sequence, where the weights\ \ for each item in the sequence are determined by the model on-the-fly\ \ based on the features of the item itself. This is both slower and and\ \ more memory-intensive than the other operations; however, it can also\ \ provide a richer \"global\" representation of the sequence." expected_impact: 1 related_parameters: - max_sequence_length suggested_values: '"attention". This and the default covers 95% of use cases.' suggested_values_reasoning: If you would like better performance and are not compute/memory-constrained, attention-based reduction can potentially provide a richer global representation than the default. ui_display_name: Sequence Reducer ALBERT: type: short_description: Similar to BERT with lower memory footprint and faster training. long_description: The `albert` encoder loads a pretrained [ALBERT](https://arxiv.org/abs/1909.11942) (default `albert-base-v2`) model using the Hugging Face transformers package. Albert is similar to BERT, with significantly lower memory usage and somewhat faster training time:. compute_tier: 2 attention_probs_dropout_prob: default_value_reasoning: Dropout can cause training to become less stable. Consider start with a dropout-free baseline, and add dropout gradually in subsequent experiments. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 1 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html related_parameters: - hidden_dropout_prob, classifier_dropout_prob suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: attention_probs_dropout_prob bos_token_id: default_value_reasoning: Default value used in pre-trained HF encoder. ui_display_name: Beginning-of-Sentence Token Id expected_impact: 1 classifier_dropout_prob: default_value_reasoning: Huggingface default. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 1 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html related_parameters: - hidden_dropout_prob, attention_probs_dropout_prob suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: classifier_dropout_prob embedding_size: default_value_reasoning: Not too big, not too small. description_implications: 'An embedding is a relatively low-dimensional space that is used to translate high-dimensional vectors like words, which can have a large vocbulary size. Ideally, after an embedding is trained, it captures some of the semantics of the input by placing semantically similar inputs close together in the embedding space. In most cases, the embedding size is chosen empirically, by trial and error. From https://www.amazon.com/dp/1098115783, "one rule of thumb is to use the fourth root of the total number of unique categorical elements while another is that the embedding dimension should be approximately 1.6 times the square root of the number of unique elements in the category, and no less than 600." Increasing the embedding size may cause the model to train more slowly, but the higher dimensionality can also improve overall quality.' expected_impact: 1 literature_references: - https://developers.google.com/machine-learning/crash-course/embeddings/video-lecture suggested_values: 1.6 * sqrt(vocab_size) suggested_values_reasoning: Rule of thumb suggested by a deep learning textbook. Try models with smaller or larger embedding sizes to observe relative impact. ui_display_name: Embedding Size eos_token_id: default_value_reasoning: Default value used in pre-trained HF encoder. ui_display_name: End-of-Sentence Token Id expected_impact: 1 hidden_act: default_value_reasoning: Taken from huggingface. description_implications: Changing this activation function will only affect the feed-forward layers of the transformer. example_value: - relu expected_impact: 1 literature_references: - "[Hugging face docs for ALBERT config](https://huggingface.co/docs/transformers/model_doc/albert#transformers.AlbertConfig.hidden_act)\n\ \r\n[Relevant StackOverflow discussion](https://ai.stackexchange.com/questions/30341/why-does-a-transformer-not-use-an-activation-function-following-the-multi-head-a)" suggested_values: gelu suggested_values_reasoning: Taken from huggingface defaults. ui_display_name: Hidden Layer Activation hidden_dropout_prob: default_value_reasoning: Dropout can cause training to become less stable. Consider start with a dropout-free baseline, and add dropout gradually in subsequent experiments. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 1 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html related_parameters: - "attention_probs_dropout_prob, classifier_dropout_prob" suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: hidden_dropout_prob hidden_size: default_value_reasoning: Huggingface default. description_implications: Increasing the hidden size makes the model larger and slower to train, increases the model's capacity to capture more complexity. It also increases the chance of overfitting. expected_impact: 1 suggested_values: 10 - 2048 suggested_values_reasoning: Increasing the hidden size makes sense if the model is underfitting. It's useful to train both smaller and larger models to see how model capacity affects performance. This should only be explored after the architecture of the model has been settled. ui_display_name: Hidden Size initializer_range: description_implications: There is an ideal value for this variable that doesn't lead to the outputs of these matrices to vanish or explode example_value: - 0.02 expected_impact: 1 other_information: Must be greater than 0 related_parameters: - weights_initializer suggested_values: 0.01-0.05 suggested_values_reasoning: Large values will likely lead to very large outputs. Small values will lead to vanishing outputs. ui_display_name: null inner_group_num: ui_display_name: null expected_impact: 1 intermediate_size: ui_display_name: null expected_impact: 1 layer_norm_eps: ui_display_name: null expected_impact: 1 max_position_embeddings: default_value_reasoning: Taken from huggingface. description_implications: The size of the position embeddings table. This typically coincides with the maximum sequence length this model might ever be used with. Typically set this to something large just in case (e.g. 512, 1024, 2048). expected_impact: 1 suggested_values: 512 suggested_values_reasoning: Out of the box value based on published literature. Try models with smaller or larger embedding sizes to observe relative impact. ui_display_name: Max Position Embeddings max_sequence_length: default_value_reasoning: Sets the maximum sequence length of the expected inputs, so input/output shapes are computed accurately. internal_only: true ui_display_name: null num_attention_heads: ui_display_name: null expected_impact: 1 num_hidden_groups: ui_display_name: null expected_impact: 1 num_hidden_layers: ui_display_name: null expected_impact: 1 pad_token_id: ui_display_name: null expected_impact: 1 position_embedding_type: ui_display_name: null expected_impact: 1 pretrained_kwargs: default_value_reasoning: These arguments typically don't need to be specified. expected_impact: 1 related_parameters: - pretrained_model_name_or_path suggested_values: Default ui_display_name: null pretrained_model_name_or_path: default_value_reasoning: The default model is the canonical model for this model architecture, and is therefore a good starting point for most use cases. description_implications: "There are two factors to consider when choosing\ \ a pre-trained model: (1) size, and (2) task similarity. \n\nThe larger\ \ the model, the more subtle its comprehension of inputs can become. However,\ \ larger models are also more compute and memory-intensive to train.\n\ \nModels pretrained on highly-related source tasks are more likely to\ \ be successful on the target task. Consider searching the HuggingFace\ \ model repository for models trained on similar tasks." expected_impact: 2 literature_references: - https://arxiv.org/abs/1909.11942 related_parameters: - use_pretrained, trainable, pretrained_kwargs suggested_values: albert-large-v2, albert-base-chinese suggested_values_reasoning: "If you would like better performance and are not compute/memory-constrained, increasing model capacity can potentially provide a richer representation than the default. The suggested value upsizes the model while maintaining the same model architecture. Language models trained on general corpora typically generalize well. Consider deviating from the default only if the text in the dataset originates from another domain (e.g. languages other than English)." ui_display_name: Pretrained model reduce_output: ui_display_name: null expected_impact: 1 saved_weights_in_checkpoint: default_value_reasoning: The weights of the encoder are not necessarily saved in the checkpoint. The user has to save them first. description_implications: The memory footprint for some of these encoders can be large. internal_only: true related_parameters: - skip_save_model suggested_values: - false suggested_values_reasoning: Some of these encoders are large, so it might be better to load them as needed, especially if 1. they're not used frequently 2. the user doesn't have a lot of storage. ui_display_name: null trainable: expected_impact: 3 ui_display_name: null type_vocab_size: ui_display_name: null expected_impact: 1 use_pretrained: default_value_reasoning: By default, the model is initialized as a pretrained model. description_implications: Pretrained models have typically already learned features that are difficult to learn from scratch. They are particularly beneficial when training on small amounts of data. expected_impact: 3 literature_references: - https://machinelearningmastery.com/transfer-learning-for-deep-learning/ related_parameters: - trainable, pretrained_model_name, pretrained_model_name_or_path, pretrained_kwargs suggested_values: - false suggested_values_reasoning: If you have a large amount of data and/or you have data that differs from the typical distribution, then it might be worth training the model from scratch. ui_display_name: Use Pretrained vocab: default_value_reasoning: Computed and passed along internally according to preprocessing settings. example_value: - a - b - c internal_only: true ui_display_name: Not Displayed vocab_size: internal_only: true ui_display_name: Not displayed AutoTransformer: type: short_description: Automatically retrieves the architecture from the provided model name/path. long_description: The `auto_transformer` encoder automatically instantiates the model architecture for the specified `pretrained_model_name_or_path`. Unlike the other HF encoders, `auto_transformer` does not provide a default value for `pretrained_model_name_or_path`, this is its only mandatory parameter. See the Hugging Face [AutoModels documentation](https://huggingface.co/docs/transformers/model_doc/auto) for more details. literature_references: - https://huggingface.co/docs/transformers/model_doc/auto compute_tier: 2 max_sequence_length: default_value_reasoning: Sets the maximum sequence length of the expected inputs, so input/output shapes are computed accurately. internal_only: true ui_display_name: null pretrained_kwargs: ui_display_name: null expected_impact: 1 pretrained_model_name_or_path: ui_display_name: null expected_impact: 3 reduce_output: ui_display_name: null expected_impact: 1 trainable: expected_impact: 3 ui_display_name: null vocab: default_value_reasoning: Computed and passed along internally according to preprocessing settings. example_value: - a - b - c internal_only: true ui_display_name: Not Displayed vocab_size: internal_only: true ui_display_name: Not displayed BERT: type: short_description: Bidirectional transformer great for language modeling. long_description: The bert encoder loads a pretrained BERT (default bert-base-uncased) model using the Hugging Face transformers package. BERT is a bidirectional transformer pretrained using a combination of masked language modeling objective and next sentence prediction on a large corpus comprising the Toronto Book Corpus and Wikipedia. literature_references: - https://arxiv.org/abs/1810.04805 compute_tier: 2 attention_probs_dropout_prob: default_value_reasoning: Huggingface default. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 2 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html related_parameters: - hidden_dropout_prob, classifier_dropout suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: attention_probs_dropout_prob classifier_dropout: default_value_reasoning: Huggingface default. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 2 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html related_parameters: - hidden_dropout_prob, attention_probs_dropout_prob suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: classifier_dropout gradient_checkpointing: ui_display_name: null expected_impact: 1 hidden_act: default_value_reasoning: Taken from huggingface. description_implications: Changing this activation function will only affect the feed-forward layers of the transformer. example_value: - relu expected_impact: 1 literature_references: - "[Huggingface docs for BERT config](https://huggingface.co/docs/transformers/model_doc/bert#transformers.BertConfig.hidden_act)\n\ \r\n[Relevant StackOverflow discussion](https://ai.stackexchange.com/questions/30341/why-does-a-transformer-not-use-an-activation-function-following-the-multi-head-a)" suggested_values: gelu suggested_values_reasoning: Taken from huggingface defaults. ui_display_name: Hidden Layer Activation hidden_dropout_prob: default_value_reasoning: Huggingface default. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 2 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html related_parameters: - attention_probs_dropout_prob, classifier_dropout suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: hidden_dropout_prob hidden_size: default_value_reasoning: Huggingface default. description_implications: Increasing the hidden size makes the model larger and slower to train, increases the model's capacity to capture more complexity. It also increases the chance of overfitting. expected_impact: 1 suggested_values: 10 - 2048 suggested_values_reasoning: Increasing the hidden size makes sense if the model is underfitting. It's useful to train both smaller and larger models to see how model capacity affects performance. This should only be explored after the architecture of the model has been settled. ui_display_name: Hidden Size initializer_range: description_implications: There is an ideal value for this variable that doesn't lead to the outputs of these matrices to vanish or explode example_value: - 0.02 expected_impact: 1 other_information: Must be greater than 0 related_parameters: - weights_initializer suggested_values: 0.01-0.05 suggested_values_reasoning: Large values will likely lead to very large outputs. Small values will lead to vanishing outputs. ui_display_name: null intermediate_size: ui_display_name: null expected_impact: 1 layer_norm_eps: ui_display_name: null expected_impact: 1 max_position_embeddings: default_value_reasoning: Taken from huggingface. description_implications: The size of the position embeddings table. This typically coincides with the maximum sequence length this model might ever be used with. Typically set this to something large just in case (e.g. 512, 1024, 2048). expected_impact: 2 suggested_values: 512 suggested_values_reasoning: Out of the box value based on published literature. Try models with smaller or larger embedding sizes to observe relative impact. ui_display_name: Max Position Embeddings max_sequence_length: default_value_reasoning: Sets the maximum sequence length of the expected inputs, so input/output shapes are computed accurately. internal_only: true ui_display_name: null num_attention_heads: ui_display_name: null expected_impact: 1 num_hidden_layers: ui_display_name: null expected_impact: 1 pad_token_id: ui_display_name: null expected_impact: 1 position_embedding_type: ui_display_name: null expected_impact: 1 pretrained_kwargs: ui_display_name: null expected_impact: 1 pretrained_model_name_or_path: ui_display_name: null expected_impact: 2 reduce_output: ui_display_name: null expected_impact: 1 saved_weights_in_checkpoint: default_value_reasoning: The weights of the encoder are not necessarily saved in the checkpoint. The user has to save them first. description_implications: The memory footprint for some of these encoders can be large. internal_only: true related_parameters: - skip_save_model suggested_values: - false suggested_values_reasoning: Some of these encoders are large, so it might be better to load them as needed, especially if 1. they're not used frequently 2. the user doesn't have a lot of storage. ui_display_name: null trainable: expected_impact: 3 ui_display_name: null type_vocab_size: ui_display_name: null expected_impact: 1 use_pretrained: default_value_reasoning: By default, the model is initialized as a pretrained model. description_implications: Pretrained models have typically already learned features that are difficult to learn from scratch. They are particularly beneficial when training on small amounts of data. expected_impact: 3 literature_references: - https://machinelearningmastery.com/transfer-learning-for-deep-learning/ related_parameters: - trainable, pretrained_model_name, pretrained_model_name_or_path, pretrained_kwargs suggested_values: - false suggested_values_reasoning: If you have a large amount of data and/or you have data that differs from the typical distribution, then it might be worth training the model from scratch. ui_display_name: Use Pretrained vocab: default_value_reasoning: Computed and passed along internally according to preprocessing settings. example_value: - a - b - c internal_only: true ui_display_name: Not Displayed vocab_size: internal_only: true ui_display_name: Not displayed BagEmbedWeighted: type: short_description: Transforms feature to vector, maps to sparse or dense embeddings, then aggregates. long_description: The embed weighted encoder first transforms the element frequency vector to sparse integer lists, which are then mapped to either dense or sparse embeddings (one-hot encodings). Lastly, embeddings are aggregated as a weighted sum where each embedding is multiplied by its respective element's frequency. Inputs are of size b while outputs are of size b x h where b is the batch size and h is the dimensionality of the embeddings. activation: default_value_reasoning: The Rectified Linear Units (ReLU) function is the standard activation function used for adding non-linearity. It is simple, fast, and empirically works well (https://arxiv.org/abs/1803.08375). description_implications: Changing the activation functions has an impact on the computational load of the model and might require further hypterparameter tuning expected_impact: 2 suggested_values: The default value will work well in the majority of the cases ui_display_name: Activation bias_initializer: default_value_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. description_implications: It's rare to see any performance gains from choosing a different bias initialization. Some practitioners like to use a small constant value such as 0.01 for all biases to ensure that all ReLU units are activated in the beginning and have some effect on the gradient. However, it's still an open question as to whether this provides consistent improvement. expected_impact: 1 literature_references: - https://cs231n.github.io/neural-networks-2/ related_parameters: - weights_initializer suggested_values: zeros suggested_values_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. For ReLU non-linearities, some people like to use small constant value such as 0.01 for all biases because this ensures that all ReLU units fire in the beginning and therefore obtain and propagate some gradient. However, it is not clear if this provides a consistent improvement (in fact some results seem to indicate that this performs worse) and it is more common to simply use 0 bias initialization. ui_display_name: Bias Initializer dropout: default_value_reasoning: Dropout can cause training to become less stable. Consider start with a dropout-free baseline, and add dropout gradually in subsequent experiments. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 3 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: Dropout embedding_size: default_value_reasoning: Not too big, not too small. description_implications: 'An embedding is a relatively low-dimensional space that is used to translate high-dimensional vectors like words, which can have a large vocbulary size. Ideally, after an embedding is trained, it captures some of the semantics of the input by placing semantically similar inputs close together in the embedding space. In most cases, the embedding size is chosen empirically, by trial and error. From https://www.amazon.com/dp/1098115783, "one rule of thumb is to use the fourth root of the total number of unique categorical elements while another is that the embedding dimension should be approximately 1.6 times the square root of the number of unique elements in the category, and no less than 600." Increasing the embedding size may cause the model to train more slowly, but the higher dimensionality can also improve overall quality.' expected_impact: 3 literature_references: - https://developers.google.com/machine-learning/crash-course/embeddings/video-lecture suggested_values: 1.6 * sqrt(vocab_size) suggested_values_reasoning: Rule of thumb suggested by a deep learning textbook. Try models with smaller or larger embedding sizes to observe relative impact. ui_display_name: Embedding Size embeddings_on_cpu: default_value_reasoning: By default embeddings matrices are stored on GPU memory if a GPU is used, as it allows for faster access. description_implications: By default embeddings matrices are stored on GPU memory if a GPU is used, as it allows for faster access. However, in some cases when the vocabulary size is very large, the full embedding matrix may be really big and unwieldy to have in GPU memory. This parameter forces the placement of the embedding matrix in regular memory and the CPU is used to access them. This may slow down training due to additional data transfer between CPU and GPU memory, but can lead to healthier GPU memory resource usage. expected_impact: 1 suggested_values: - false suggested_values_reasoning: If GPU memory is not a constraint, having embeddings stored and accessed within the GPU is faster. ui_display_name: Embeddings on CPU embeddings_trainable: default_value_reasoning: If trained from scratch, embedding vectors are typically learned alongside the rest of the model. description_implications: Typically this value is only set to False if pre-trained embeddings are uploaded. Even then, it is reasonable to leave it as True in order to fine-tune the embeddings. expected_impact: 1 related_parameters: - embedding_size, representation, pretrained_embeddings ui_display_name: (under Embeddings header) Trainable? fc_layers: default_value_reasoning: By default the stack is built by using num_fc_layers, output_size, use_bias, weights_initializer, bias_initializer, norm, norm_params, activation, dropout. When a list of dictionaries is provided, the stack is built following the parameters of each dict for building each layer. description_implications: The more layers that are specified the deeper and higher capacity the model will be. This makes it possible to potentially achieve better performance when a big anough amount of data is provided, but also makes the model more computationally expensive and potentially more prone to overfitting. example_value: - dropout: 0.1 output_size: 128 - norm: layer output_size: 64 expected_impact: 1 related_parameters: - output_size - use_bias - weights_initializer - bias_initializer - norm - norm_params - activation - dropout suggested_values_reasoning: It is easier to define a stack of fully connected layers by just specifying num_fc_layers, output_size and the other individual parameters. It will create a stack of layers with identical properties. Use this parameter only if you need a fine grained level of control of each individual layer in the stack. ui_display_name: Fully Connected Layers force_embedding_size: default_value_reasoning: It is not often the case that the user has a strict need for using an embedding size that should be larger than the vocabulary size. description_implications: Should only be True if the user has a strict need for using an embedding size that should be larger than the vocabulary size. For example, there may be size requirements across multiple features imposed by downstream modules like the ComparatorCombiner. expected_impact: 1 related_parameters: - embedding_size suggested_values: - false suggested_values_reasoning: True for advanced usage only. ui_display_name: Force Embedding Size norm: default_value_reasoning: While batch normalization and layer normalization usually lead to improvements, it can be useful to start with fewer bells and whistles. description_implications: Normalization helps stabilize the learning process and can have a regularizing effect that can help with generalization. It's often suggested that with normalization, you can use a higher learning rate. example_value: - batch expected_impact: 2 literature_references: - https://machinelearningmastery.com/batch-normalization-for-training-of-deep-neural-networks/ related_parameters: - norm_params suggested_values: '"batch" or "layer"' suggested_values_reasoning: Normalization tries to solve "internal covariate shift" that comes from the changing distributions of the inputs to layers deep in the network when weights are updated. For example, batch normalization standardizes the inputs to a layer for each mini-batch. Try out different normalizations to see if that helps with training stability ui_display_name: Normalization Type norm_params: default_value_reasoning: The default parameters that come with Torch's implementation of these normalization types are a trusted starting point. description_implications: There are a variety of ways a certain set of parameters specificed could influence performance here. Broadly speaking the different values passed in here allow for different levels of smoothness to be observed in the learning curves. Since setting this parameters depends on the type of `norm` set, see [BatchNorm2d](https://pytorch.org/docs/stable/generated/torch.nn.BatchNorm2d.html) for more information on the parameters to set for batch normalization, and see [LayerNorm](https://pytorch.org/docs/stable/generated/torch.nn.LayerNorm.html) for more information on the parameters to set for layer normalization. example_value: - affine: false momentum: 0.2 num_features: 100 expected_impact: 1 literature_references: - "For BatchNorm2d: https://arxiv.org/abs/1502.03167 For LayerNorm: https://arxiv.org/abs/1607.06450" related_parameters: - "`norm`" suggested_values: Depends on the type of `norm` set. suggested_values_reasoning: "NO" ui_display_name: Normalization Parameters num_fc_layers: default_value_reasoning: The encoder already has learnable parameters.Sometimes the default is 1 for modules where the FC stack is used for shape management, or the only source of learnable parameters. description_implications: Increasing num_fc_layers will increase the capacity of the model. The model will be slower to train, and there's a higher risk of overfitting. example_value: - 1 expected_impact: 1 other_information: Not all modules that have fc_layers also have an accompanying num_fc_layers parameter. Where both are present, fc_layers takes precedent over num_fc_layers. Specifying num_fc_layers alone uses fully connected layers that are configured by the defaults in FCStack. related_parameters: - fc_layers suggested_values: 0-1 suggested_values_reasoning: The full model likely contains many learnable parameters. Consider starting with very few, or without any additional fully connected layers and add them if you observe evidence of limited model capacity. Sometimes the default is 1 for modules where the FC stack is used for shape management, or the only source of learnable parameters. ui_display_name: Number of Fully Connected Layers output_size: default_value_reasoning: A modest value, not too small, not too large. description_implications: If there are fully connected layers in this module, increasing the output size of each fully connected layer will increase the capacity of the model. However, the model may be slower to train, and there's a higher risk of overfitting. If it seems like the model could use even more capacity, consider increasing the number of fully connected layers, or explore other architectures. expected_impact: 3 other_information: If num_fc_layers=0 and fc_layers=None, and there are no fully connected layers defined on the module, then this parameter may have no effect on the module's final output shape. related_parameters: - num_fc_layers, fc_layers suggested_values: 10 - 1024 suggested_values_reasoning: Increasing the output size increases the capacity of the model. If this seems to have a positive effect, then it could be worth increasing the number of layers, or trying a different architecture with a larger capacity. ui_display_name: Output Size pretrained_embeddings: default_value_reasoning: Embeddings are commonly trained from scratch, or incorporated as part of a pre-trained model package. description_implications: If pretrained embeddings are specified, then the model may have a head start in its representation of various input entities. example_value: - ~/Downloads/glove.6B.100d.txt expected_impact: 0 related_parameters: - embedding_size, embeddings_trainable ui_display_name: Pretrained embeddings path representation: default_value_reasoning: Trainable, randomly initialized embedding vectors often lead to more subtle representations of input entities than one-hot vectors. description_implications: If set to sparse, the representations for input entities are fixed as one-hot vectors. This leads to less flexible representations for input entities, but could lead to faster training since there are less learnable parameters. expected_impact: 1 other_information: "" related_parameters: - embedding_size, embeddings_trainable, pretrained_embeddings ui_display_name: Representation approach use_bias: default_value_reasoning: "Bias terms may improve model accuracy, and don't have much impact in terms of memory or training speed. For most models it is reasonable to use bias terms. Batch Normalization, however, adds a trainable shift parameter which is added to the activation. When Batch Normalization is used in a layer, bias terms are redundant and may be removed." description_implications: Bias terms may improve model accuracy, and don't have much impact in terms of memory or training speed. For most models it is reasonable to leave this parameter set to True. example_value: - true expected_impact: 1 other_information: If fc_layers is not specified, or use_bias is not specified for individual layers, the value of use_bias will be used as the default for all layers. related_parameters: - bias_initializer, fc_layers suggested_values: - true ui_display_name: Use Bias vocab: default_value_reasoning: Computed and passed along internally according to preprocessing settings. example_value: - a - b - c internal_only: true ui_display_name: Not Displayed weights_initializer: default_value_reasoning: Taken from published [literature](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf). description_implications: The method you choose to initialize layer weights during training can have a big impact on performance as well as the reproducibility of your final model between runs. As an example, if you were to randomly initialize weights you would risk non-reproducibility (and possibly general training performance), but sticking with constant values for initialization might significantly increase the time needed for model convergence. Generally, choosing one of the probabilistic approaches strikes a balance between the two extremes, and the literature kicked off by the landmark [*Xavier et al.* paper](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf) provides a few good options. See this nice discussion from [Weights and Biases](https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster.) for more information. expected_impact: 1 literature_references: - "Weights and Biases blog post: https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster. Xavier et al. paper: http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf" suggested_values: xavier_uniform suggested_values_reasoning: Changing the weights initialization scheme is something to consider if a model is having trouble with convergence, or otherwise it is something to experiment with after other factors are considered. The default choice (`xavier_uniform`) is a suitable starting point for most tasks. ui_display_name: Layer Weights Initializer CTRL: type: short_description: Language model trained to condition on control codes that govern style, content and task-specific behavior. long_description: The `ctrl` encoder loads a pretrained [CTRL](https://arxiv.org/abs/1909.05858) (default `ctrl`) model using the Hugging Face transformers package. CTRL is a conditional transformer language model trained to condition on control codes that govern style, content, and task-specific behavior. literature_references: - https://arxiv.org/abs/1909.05858 compute_tier: 2 attn_pdrop: ui_display_name: null dff: ui_display_name: null embd_pdrop: ui_display_name: null initializer_range: description_implications: There is an ideal value for this variable that doesn't lead to the outputs of these matrices to vanish or explode example_value: - 0.02 expected_impact: 1 other_information: Must be greater than 0 related_parameters: - weights_initializer suggested_values: 0.01-0.05 suggested_values_reasoning: Large values will likely lead to very large outputs. Small values will lead to vanishing outputs. ui_display_name: null layer_norm_epsilon: ui_display_name: null max_sequence_length: default_value_reasoning: Sets the maximum sequence length of the expected inputs, so input/output shapes are computed accurately. internal_only: true ui_display_name: null n_ctx: ui_display_name: null n_embd: ui_display_name: null n_head: ui_display_name: null n_layer: ui_display_name: null n_positions: ui_display_name: null pretrained_kwargs: ui_display_name: null pretrained_model_name_or_path: ui_display_name: null expected_impact: 2 reduce_output: ui_display_name: null expected_impact: 1 resid_pdrop: ui_display_name: null saved_weights_in_checkpoint: default_value_reasoning: The weights of the encoder are not necessarily saved in the checkpoint. The user has to save them first. description_implications: The memory footprint for some of these encoders can be large. internal_only: true related_parameters: - skip_save_model suggested_values: - false suggested_values_reasoning: Some of these encoders are large, so it might be better to load them as needed, especially if 1. they're not used frequently 2. the user doesn't have a lot of storage. ui_display_name: null trainable: expected_impact: 3 ui_display_name: null use_pretrained: default_value_reasoning: By default, the model is initialized as a pretrained model. description_implications: Pretrained models have typically already learned features that are difficult to learn from scratch. They are particularly beneficial when training on small amounts of data. expected_impact: 3 literature_references: - https://machinelearningmastery.com/transfer-learning-for-deep-learning/ related_parameters: - trainable, pretrained_model_name, pretrained_model_name_or_path, pretrained_kwargs suggested_values: - false suggested_values_reasoning: If you have a large amount of data and/or you have data that differs from the typical distribution, then it might be worth training the model from scratch. ui_display_name: Use Pretrained vocab: default_value_reasoning: Computed and passed along internally according to preprocessing settings. example_value: - a - b - c internal_only: true ui_display_name: Not Displayed vocab_size: internal_only: true ui_display_name: Not displayed CamemBERT: type: short_description: Language model trained on large French text corpus. long_description: The `camembert` encoder loads a pretrained [CamemBERT](https://arxiv.org/abs/1911.03894) (default `jplu/tf-camembert-base`) model using the Hugging Face transformers package. CamemBERT is pre-trained on a large French language web-crawled text corpus. literature_references: - https://arxiv.org/abs/1911.03894 compute_tier: 2 attention_probs_dropout_prob: default_value_reasoning: Huggingface default. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 2 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html related_parameters: - classifier_dropout, hidden_dropout_prob suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: attention_probs_dropout_prob classifier_dropout: default_value_reasoning: Huggingface default. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 2 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html related_parameters: - attention_probs_dropout_prob, hidden_dropout_prob suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: classifier_dropout gradient_checkpointing: ui_display_name: null hidden_act: default_value_reasoning: Taken from huggingface. description_implications: Changing this activation function will only affect the feed-forward layers of the transformer. example_value: - relu expected_impact: 1 literature_references: - "[Relevant StackOverflow discussion](https://ai.stackexchange.com/questions/30341/why-does-a-transformer-not-use-an-activation-function-following-the-multi-head-a)" suggested_values: gelu suggested_values_reasoning: Taken from huggingface defaults. ui_display_name: Hidden Layer Activation hidden_dropout_prob: default_value_reasoning: Huggingface default. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 2 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html related_parameters: - "attention_probs_dropout_prob, \nclassifier_dropout" suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: hidden_dropout_prob hidden_size: default_value_reasoning: Huggingface default. description_implications: Increasing the hidden size makes the model larger and slower to train, increases the model's capacity to capture more complexity. It also increases the chance of overfitting. expected_impact: 1 suggested_values: 10 - 2048 suggested_values_reasoning: Increasing the hidden size makes sense if the model is underfitting. It's useful to train both smaller and larger models to see how model capacity affects performance. This should only be explored after the architecture of the model has been settled. ui_display_name: Hidden Size initializer_range: description_implications: There is an ideal value for this variable that doesn't lead to the outputs of these matrices to vanish or explode example_value: - 0.02 expected_impact: 1 other_information: Must be greater than 0 related_parameters: - weights_initializer suggested_values: 0.01-0.05 suggested_values_reasoning: Large values will likely lead to very large outputs. Small values will lead to vanishing outputs. ui_display_name: null intermediate_size: ui_display_name: null layer_norm_eps: ui_display_name: null max_position_embeddings: default_value_reasoning: Taken from huggingface. description_implications: The size of the position embeddings table. This typically coincides with the maximum sequence length this model might ever be used with. Typically set this to something large just in case (e.g. 512, 1024, 2048). expected_impact: 2 suggested_values: 512 suggested_values_reasoning: Out of the box value based on published literature. Try models with smaller or larger embedding sizes to observe relative impact. ui_display_name: Max Position Embeddings max_sequence_length: default_value_reasoning: Sets the maximum sequence length of the expected inputs, so input/output shapes are computed accurately. internal_only: true ui_display_name: null num_attention_heads: ui_display_name: null num_hidden_layers: ui_display_name: null pad_token_id: ui_display_name: null position_embedding_type: ui_display_name: null pretrained_kwargs: ui_display_name: null pretrained_model_name_or_path: ui_display_name: null expected_impact: 2 reduce_output: ui_display_name: null expected_impact: 1 saved_weights_in_checkpoint: default_value_reasoning: The weights of the encoder are not necessarily saved in the checkpoint. The user has to save them first. description_implications: The memory footprint for some of these encoders can be large. internal_only: true related_parameters: - skip_save_model suggested_values: - false suggested_values_reasoning: Some of these encoders are large, so it might be better to load them as needed, especially if 1. they're not used frequently 2. the user doesn't have a lot of storage. ui_display_name: null trainable: expected_impact: 3 ui_display_name: null type_vocab_size: ui_display_name: null use_pretrained: default_value_reasoning: By default, the model is initialized as a pretrained model. description_implications: Pretrained models have typically already learned features that are difficult to learn from scratch. They are particularly beneficial when training on small amounts of data. expected_impact: 3 literature_references: - https://machinelearningmastery.com/transfer-learning-for-deep-learning/ related_parameters: - trainable, pretrained_model_name, pretrained_model_name_or_path, pretrained_kwargs suggested_values: - false suggested_values_reasoning: If you have a large amount of data and/or you have data that differs from the typical distribution, then it might be worth training the model from scratch. ui_display_name: Use Pretrained vocab: default_value_reasoning: Computed and passed along internally according to preprocessing settings. example_value: - a - b - c internal_only: true ui_display_name: Not Displayed vocab_size: internal_only: true ui_display_name: Not displayed CategoricalEmbed: type: short_description: Maps the categorical feature to a dense embedding. long_description: The dense encoder maps to a dense embedding and is returned as outputs of size `b x h`, where `b` is the batch size and `h` is the dimensionality of the embeddings. dropout: default_value_reasoning: Dropout can cause training to become less stable. Consider start with a dropout-free baseline, and add dropout gradually in subsequent experiments. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 3 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: Dropout embedding_initializer: default_value_reasoning: According to https://arxiv.org/abs/1711.09160, choice of embedding initialization is not important as long as the variance is kept reasonably low. description_implications: According to https://arxiv.org/abs/1711.09160, choice of embedding initialization is not important as long as the variance is kept reasonably low. example_value: - kaiming expected_impact: 1 literature_references: - https://arxiv.org/abs/1711.09160 suggested_values: kaiming suggested_values_reasoning: https://discuss.huggingface.co/t/state-of-the-art-technique-for-initializing-embedding-matrix/326 ui_display_name: Embedding Initialization embedding_size: default_value_reasoning: Not too big, not too small. description_implications: 'An embedding is a relatively low-dimensional space that is used to translate high-dimensional vectors like words, which can have a large vocbulary size. Ideally, after an embedding is trained, it captures some of the semantics of the input by placing semantically similar inputs close together in the embedding space. In most cases, the embedding size is chosen empirically, by trial and error. From https://www.amazon.com/dp/1098115783, "one rule of thumb is to use the fourth root of the total number of unique categorical elements while another is that the embedding dimension should be approximately 1.6 times the square root of the number of unique elements in the category, and no less than 600." Increasing the embedding size may cause the model to train more slowly, but the higher dimensionality can also improve overall quality.' expected_impact: 3 literature_references: - https://developers.google.com/machine-learning/crash-course/embeddings/video-lecture suggested_values: 1.6 * sqrt(vocab_size) suggested_values_reasoning: Rule of thumb suggested by a deep learning textbook. Try models with smaller or larger embedding sizes to observe relative impact. ui_display_name: Embedding Size embeddings_on_cpu: default_value_reasoning: By default embeddings matrices are stored on GPU memory if a GPU is used, as it allows for faster access. description_implications: By default embeddings matrices are stored on GPU memory if a GPU is used, as it allows for faster access. However, in some cases when the vocabulary size is very large, the full embedding matrix may be really big and unwieldy to have in GPU memory. This parameter forces the placement of the embedding matrix in regular memory and the CPU is used to access them. This may slow down training due to additional data transfer between CPU and GPU memory, but can lead to healthier GPU memory resource usage. expected_impact: 1 suggested_values: - false suggested_values_reasoning: If GPU memory is not a constraint, having embeddings stored and accessed within the GPU is faster. ui_display_name: Embeddings on CPU embeddings_trainable: ui_display_name: null expected_impact: 1 pretrained_embeddings: ui_display_name: null expected_impact: 0 vocab: default_value_reasoning: Computed and passed along internally according to preprocessing settings. example_value: - a - b - c internal_only: true ui_display_name: Not Displayed CategoricalSparse: type: short_description: Maps the categorical feature to a sparse embedding. long_description: The sparse encoder maps to a sparse embedding (one-hot encodings) and is returned as outputs of size `b x h`, where `b` is the batch size and `h` is the dimensionality of the embeddings. dropout: default_value_reasoning: Dropout can cause training to become less stable. Consider start with a dropout-free baseline, and add dropout gradually in subsequent experiments. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 3 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: Dropout embedding_initializer: default_value_reasoning: According to https://arxiv.org/abs/1711.09160, choice of embedding initialization is not important as long as the variance is kept reasonably low. description_implications: According to https://arxiv.org/abs/1711.09160, choice of embedding initialization is not important as long as the variance is kept reasonably low. example_value: - kaiming expected_impact: 1 literature_references: - https://arxiv.org/abs/1711.09161 suggested_values: kaiming suggested_values_reasoning: https://discuss.huggingface.co/t/state-of-the-art-technique-for-initializing-embedding-matrix/327 ui_display_name: Embedding Initialization embedding_size: default_value_reasoning: Not too big, not too small. description_implications: 'An embedding is a relatively low-dimensional space that is used to translate high-dimensional vectors like words, which can have a large vocbulary size. Ideally, after an embedding is trained, it captures some of the semantics of the input by placing semantically similar inputs close together in the embedding space. In most cases, the embedding size is chosen empirically, by trial and error. From https://www.amazon.com/dp/1098115783, "one rule of thumb is to use the fourth root of the total number of unique categorical elements while another is that the embedding dimension should be approximately 1.6 times the square root of the number of unique elements in the category, and no less than 600." Increasing the embedding size may cause the model to train more slowly, but the higher dimensionality can also improve overall quality.' expected_impact: 3 literature_references: - https://developers.google.com/machine-learning/crash-course/embeddings/video-lecture suggested_values: 1.6 * sqrt(vocab_size) suggested_values_reasoning: Rule of thumb suggested by a deep learning textbook. Try models with smaller or larger embedding sizes to observe relative impact. ui_display_name: Embedding Size embeddings_on_cpu: default_value_reasoning: By default embeddings matrices are stored on GPU memory if a GPU is used, as it allows for faster access. description_implications: By default embeddings matrices are stored on GPU memory if a GPU is used, as it allows for faster access. However, in some cases when the vocabulary size is very large, the full embedding matrix may be really big and unwieldy to have in GPU memory. This parameter forces the placement of the embedding matrix in regular memory and the CPU is used to access them. This may slow down training due to additional data transfer between CPU and GPU memory, but can lead to healthier GPU memory resource usage. expected_impact: 1 suggested_values: - false suggested_values_reasoning: If GPU memory is not a constraint, having embeddings stored and accessed within the GPU is faster. ui_display_name: Embeddings on CPU embeddings_trainable: ui_display_name: null expected_impact: 1 pretrained_embeddings: ui_display_name: null expected_impact: 0 vocab: default_value_reasoning: Computed and passed along internally according to preprocessing settings. example_value: - a - b - c internal_only: true ui_display_name: Not Displayed DateEmbed: type: short_description: Embeds the date elements passes them through fully connected layers. long_description: The Embed encoder passes the year through a fully connected layer of one neuron and embeds all other elements for the date, concatenates them and passes the concatenated representation through fully connected layers. activation: default_value_reasoning: The Rectified Linear Units (ReLU) function is the standard activation function used for adding non-linearity. It is simple, fast, and empirically works well (https://arxiv.org/abs/1803.08375). description_implications: Changing the activation functions has an impact on the computational load of the model and might require further hypterparameter tuning expected_impact: 2 suggested_values: The default value will work well in the majority of the cases ui_display_name: Activation bias_initializer: default_value_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. description_implications: It's rare to see any performance gains from choosing a different bias initialization. Some practitioners like to use a small constant value such as 0.01 for all biases to ensure that all ReLU units are activated in the beginning and have some effect on the gradient. However, it's still an open question as to whether this provides consistent improvement. expected_impact: 1 literature_references: - https://cs231n.github.io/neural-networks-2/ related_parameters: - weights_initializer suggested_values: zeros suggested_values_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. For ReLU non-linearities, some people like to use small constant value such as 0.01 for all biases because this ensures that all ReLU units fire in the beginning and therefore obtain and propagate some gradient. However, it is not clear if this provides a consistent improvement (in fact some results seem to indicate that this performs worse) and it is more common to simply use 0 bias initialization. ui_display_name: Bias Initializer dropout: default_value_reasoning: Dropout can cause training to become less stable. Consider start with a dropout-free baseline, and add dropout gradually in subsequent experiments. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 3 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: Dropout embedding_size: default_value_reasoning: Not too big, not too small. description_implications: 'An embedding is a relatively low-dimensional space that is used to translate high-dimensional vectors like words, which can have a large vocbulary size. Ideally, after an embedding is trained, it captures some of the semantics of the input by placing semantically similar inputs close together in the embedding space. In most cases, the embedding size is chosen empirically, by trial and error. From https://www.amazon.com/dp/1098115783, "one rule of thumb is to use the fourth root of the total number of unique categorical elements while another is that the embedding dimension should be approximately 1.6 times the square root of the number of unique elements in the category, and no less than 600." Increasing the embedding size may cause the model to train more slowly, but the higher dimensionality can also improve overall quality.' expected_impact: 3 literature_references: - https://developers.google.com/machine-learning/crash-course/embeddings/video-lecture suggested_values: 1.6 * sqrt(vocab_size) suggested_values_reasoning: Rule of thumb suggested by a deep learning textbook. Try models with smaller or larger embedding sizes to observe relative impact. ui_display_name: Embedding Size embeddings_on_cpu: default_value_reasoning: By default embeddings matrices are stored on GPU memory if a GPU is used, as it allows for faster access. description_implications: By default embeddings matrices are stored on GPU memory if a GPU is used, as it allows for faster access. However, in some cases when the vocabulary size is very large, the full embedding matrix may be really big and unwieldy to have in GPU memory. This parameter forces the placement of the embedding matrix in regular memory and the CPU is used to access them. This may slow down training due to additional data transfer between CPU and GPU memory, but can lead to healthier GPU memory resource usage. expected_impact: 1 suggested_values: - false suggested_values_reasoning: If GPU memory is not a constraint, having embeddings stored and accessed within the GPU is faster. ui_display_name: Embeddings on CPU fc_layers: default_value_reasoning: By default the stack is built by using num_fc_layers, output_size, use_bias, weights_initializer, bias_initializer, norm, norm_params, activation, dropout. When a list of dictionaries is provided, the stack is built following the parameters of each dict for building each layer. description_implications: The more layers that are specified the deeper and higher capacity the model will be. This makes it possible to potentially achieve better performance when a big anough amount of data is provided, but also makes the model more computationally expensive and potentially more prone to overfitting. example_value: - dropout: 0.1 output_size: 128 - norm: layer output_size: 64 expected_impact: 1 related_parameters: - output_size - use_bias - weights_initializer - bias_initializer - norm - norm_params - activation - dropout suggested_values_reasoning: It is easier to define a stack of fully connected layers by just specifying num_fc_layers, output_size and the other individual parameters. It will create a stack of layers with identical properties. Use this parameter only if you need a fine grained level of control of each individual layer in the stack. ui_display_name: Fully Connected Layers norm: default_value_reasoning: While batch normalization and layer normalization usually lead to improvements, it can be useful to start with fewer bells and whistles. description_implications: Normalization helps stabilize the learning process and can have a regularizing effect that can help with generalization. It's often suggested that with normalization, you can use a higher learning rate. example_value: - batch expected_impact: 2 literature_references: - https://machinelearningmastery.com/batch-normalization-for-training-of-deep-neural-networks/ related_parameters: - norm_params suggested_values: '"batch" or "layer"' suggested_values_reasoning: Normalization tries to solve "internal covariate shift" that comes from the changing distributions of the inputs to layers deep in the network when weights are updated. For example, batch normalization standardizes the inputs to a layer for each mini-batch. Try out different normalizations to see if that helps with training stability ui_display_name: Normalization Type norm_params: default_value_reasoning: The default parameters that come with Torch's implementation of these normalization types are a trusted starting point. description_implications: There are a variety of ways a certain set of parameters specificed could influence performance here. Broadly speaking the different values passed in here allow for different levels of smoothness to be observed in the learning curves. Since setting this parameters depends on the type of `norm` set, see [BatchNorm2d](https://pytorch.org/docs/stable/generated/torch.nn.BatchNorm2d.html) for more information on the parameters to set for batch normalization, and see [LayerNorm](https://pytorch.org/docs/stable/generated/torch.nn.LayerNorm.html) for more information on the parameters to set for layer normalization. example_value: - affine: false momentum: 0.2 num_features: 100 expected_impact: 1 literature_references: - "For BatchNorm2d: https://arxiv.org/abs/1502.03167 For LayerNorm: https://arxiv.org/abs/1607.06450" related_parameters: - "`norm`" suggested_values: Depends on the type of `norm` set. suggested_values_reasoning: "NO" ui_display_name: Normalization Parameters num_fc_layers: default_value_reasoning: The encoder already has learnable parameters.Sometimes the default is 1 for modules where the FC stack is used for shape management, or the only source of learnable parameters. description_implications: Increasing num_fc_layers will increase the capacity of the model. The model will be slower to train, and there's a higher risk of overfitting. example_value: - 1 expected_impact: 1 other_information: Not all modules that have fc_layers also have an accompanying num_fc_layers parameter. Where both are present, fc_layers takes precedent over num_fc_layers. Specifying num_fc_layers alone uses fully connected layers that are configured by the defaults in FCStack. related_parameters: - fc_layers suggested_values: 0-1 suggested_values_reasoning: The full model likely contains many learnable parameters. Consider starting with very few, or without any additional fully connected layers and add them if you observe evidence of limited model capacity. Sometimes the default is 1 for modules where the FC stack is used for shape management, or the only source of learnable parameters. ui_display_name: Number of Fully Connected Layers output_size: default_value_reasoning: A modest value, not too small, not too large. description_implications: If there are fully connected layers in this module, increasing the output size of each fully connected layer will increase the capacity of the model. However, the model may be slower to train, and there's a higher risk of overfitting. If it seems like the model could use even more capacity, consider increasing the number of fully connected layers, or explore other architectures. expected_impact: 3 other_information: If num_fc_layers=0 and fc_layers=None, and there are no fully connected layers defined on the module, then this parameter may have no effect on the module's final output shape. related_parameters: - num_fc_layers, fc_layers suggested_values: 10 - 1024 suggested_values_reasoning: Increasing the output size increases the capacity of the model. If this seems to have a positive effect, then it could be worth increasing the number of layers, or trying a different architecture with a larger capacity. ui_display_name: Output Size use_bias: default_value_reasoning: "Bias terms may improve model accuracy, and don't have much impact in terms of memory or training speed. For most models it is reasonable to use bias terms. Batch Normalization, however, adds a trainable shift parameter which is added to the activation. When Batch Normalization is used in a layer, bias terms are redundant and may be removed." description_implications: Bias terms may improve model accuracy, and don't have much impact in terms of memory or training speed. For most models it is reasonable to leave this parameter set to True. example_value: - true expected_impact: 1 other_information: If fc_layers is not specified, or use_bias is not specified for individual layers, the value of use_bias will be used as the default for all layers. related_parameters: - bias_initializer, fc_layers suggested_values: - true ui_display_name: Use Bias weights_initializer: default_value_reasoning: Taken from [this paper](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf). description_implications: The method you choose to initialize layer weights during training can have a big impact on performance as well as the reproducibility of your final model between runs. As an example, if you were to randomly initialize weights you would risk non-reproducibility (and possibly general training performance), but sticking with constant values for initialization might significantly increase the time needed for model convergence. Generally, choosing one of the probabilistic approaches strikes a balance between the two extremes, and the literature kicked off by the landmark [*Xavier et al.* paper](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf) provides a few good options. See this nice discussion from [Weights and Biases](https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster.) for more information. expected_impact: 1 literature_references: - "Weights and Biases blog post: https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster. Xavier et al. paper: http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf" suggested_values: xavier_uniform suggested_values_reasoning: Changing the weights initialization scheme is something to consider if a model is having trouble with convergence, or otherwise it is something to experiment with after other factors are considered. The default choice (`xavier_uniform`) is a suitable starting point for most tasks. ui_display_name: Layer Weights Initializer DateWave: type: short_description: Embeds the date elements by taking the cosine of their value before passing through fully connected layers. long_description: The Wave encoder passes the year through a fully connected layer of one neuron and represents all other elements for the date by taking the cosine of their value with a different period (12 for months, 31 for days, etc.), concatenates them and passes the concatenated representation through fully connected layers. activation: default_value_reasoning: The Rectified Linear Units (ReLU) function is the standard activation function used for adding non-linearity. It is simple, fast, and empirically works well (https://arxiv.org/abs/1803.08375). description_implications: Changing the activation functions has an impact on the computational load of the model and might require further hypterparameter tuning expected_impact: 2 suggested_values: The default value will work well in the majority of the cases ui_display_name: Activation bias_initializer: default_value_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. description_implications: It's rare to see any performance gains from choosing a different bias initialization. Some practitioners like to use a small constant value such as 0.01 for all biases to ensure that all ReLU units are activated in the beginning and have some effect on the gradient. However, it's still an open question as to whether this provides consistent improvement. expected_impact: 1 literature_references: - https://cs231n.github.io/neural-networks-2/ related_parameters: - weights_initializer suggested_values: zeros suggested_values_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. For ReLU non-linearities, some people like to use small constant value such as 0.01 for all biases because this ensures that all ReLU units fire in the beginning and therefore obtain and propagate some gradient. However, it is not clear if this provides a consistent improvement (in fact some results seem to indicate that this performs worse) and it is more common to simply use 0 bias initialization. ui_display_name: Bias Initializer dropout: default_value_reasoning: Dropout can cause training to become less stable. Consider start with a dropout-free baseline, and add dropout gradually in subsequent experiments. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 3 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: Dropout fc_layers: default_value_reasoning: By default the stack is built by using num_fc_layers, output_size, use_bias, weights_initializer, bias_initializer, norm, norm_params, activation, dropout. When a list of dictionaries is provided, the stack is built following the parameters of each dict for building each layer. description_implications: The more layers that are specified the deeper and higher capacity the model will be. This makes it possible to potentially achieve better performance when a big anough amount of data is provided, but also makes the model more computationally expensive and potentially more prone to overfitting. example_value: - dropout: 0.1 output_size: 128 - norm: layer output_size: 64 expected_impact: 1 related_parameters: - output_size - use_bias - weights_initializer - bias_initializer - norm - norm_params - activation - dropout suggested_values_reasoning: It is easier to define a stack of fully connected layers by just specifying num_fc_layers, output_size and the other individual parameters. It will create a stack of layers with identical properties. Use this parameter only if you need a fine grained level of control of each individual layer in the stack. ui_display_name: Fully Connected Layers norm: default_value_reasoning: While batch normalization and layer normalization usually lead to improvements, it can be useful to start with fewer bells and whistles. description_implications: Normalization helps stabilize the learning process and can have a regularizing effect that can help with generalization. It's often suggested that with normalization, you can use a higher learning rate. example_value: - batch expected_impact: 2 literature_references: - https://machinelearningmastery.com/batch-normalization-for-training-of-deep-neural-networks/ related_parameters: - norm_params suggested_values: '"batch" or "layer"' suggested_values_reasoning: Normalization tries to solve "internal covariate shift" that comes from the changing distributions of the inputs to layers deep in the network when weights are updated. For example, batch normalization standardizes the inputs to a layer for each mini-batch. Try out different normalizations to see if that helps with training stability ui_display_name: Normalization Type norm_params: default_value_reasoning: The default parameters that come with Torch's implementation of these normalization types are a trusted starting point. description_implications: There are a variety of ways a certain set of parameters specificed could influence performance here. Broadly speaking the different values passed in here allow for different levels of smoothness to be observed in the learning curves. Since setting this parameters depends on the type of `norm` set, see [BatchNorm2d](https://pytorch.org/docs/stable/generated/torch.nn.BatchNorm2d.html) for more information on the parameters to set for batch normalization, and see [LayerNorm](https://pytorch.org/docs/stable/generated/torch.nn.LayerNorm.html) for more information on the parameters to set for layer normalization. example_value: - affine: false momentum: 0.2 num_features: 100 expected_impact: 1 literature_references: - "For BatchNorm2d: https://arxiv.org/abs/1502.03167 For LayerNorm: https://arxiv.org/abs/1607.06450" related_parameters: - "`norm`" suggested_values: Depends on the type of `norm` set. suggested_values_reasoning: "NO" ui_display_name: Normalization Parameters num_fc_layers: default_value_reasoning: The encoder already has learnable parameters.Sometimes the default is 1 for modules where the FC stack is used for shape management, or the only source of learnable parameters. description_implications: Increasing num_fc_layers will increase the capacity of the model. The model will be slower to train, and there's a higher risk of overfitting. example_value: - 1 expected_impact: 1 other_information: Not all modules that have fc_layers also have an accompanying num_fc_layers parameter. Where both are present, fc_layers takes precedent over num_fc_layers. Specifying num_fc_layers alone uses fully connected layers that are configured by the defaults in FCStack. related_parameters: - fc_layers suggested_values: 0-1 suggested_values_reasoning: The full model likely contains many learnable parameters. Consider starting with very few, or without any additional fully connected layers and add them if you observe evidence of limited model capacity. Sometimes the default is 1 for modules where the FC stack is used for shape management, or the only source of learnable parameters. ui_display_name: Number of Fully Connected Layers output_size: default_value_reasoning: A modest value, not too small, not too large. description_implications: If there are fully connected layers in this module, increasing the output size of each fully connected layer will increase the capacity of the model. However, the model may be slower to train, and there's a higher risk of overfitting. If it seems like the model could use even more capacity, consider increasing the number of fully connected layers, or explore other architectures. expected_impact: 3 other_information: If num_fc_layers=0 and fc_layers=None, and there are no fully connected layers defined on the module, then this parameter may have no effect on the module's final output shape. related_parameters: - num_fc_layers, fc_layers suggested_values: 10 - 1024 suggested_values_reasoning: Increasing the output size increases the capacity of the model. If this seems to have a positive effect, then it could be worth increasing the number of layers, or trying a different architecture with a larger capacity. ui_display_name: Output Size use_bias: default_value_reasoning: "Bias terms may improve model accuracy, and don't have much impact in terms of memory or training speed. For most models it is reasonable to use bias terms. Batch Normalization, however, adds a trainable shift parameter which is added to the activation. When Batch Normalization is used in a layer, bias terms are redundant and may be removed." description_implications: Bias terms may improve model accuracy, and don't have much impact in terms of memory or training speed. For most models it is reasonable to leave this parameter set to True. example_value: - true expected_impact: 1 other_information: If fc_layers is not specified, or use_bias is not specified for individual layers, the value of use_bias will be used as the default for all layers. related_parameters: - bias_initializer, fc_layers suggested_values: - true ui_display_name: Use Bias weights_initializer: default_value_reasoning: Taken from [this paper](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf). description_implications: The method you choose to initialize layer weights during training can have a big impact on performance as well as the reproducibility of your final model between runs. As an example, if you were to randomly initialize weights you would risk non-reproducibility (and possibly general training performance), but sticking with constant values for initialization might significantly increase the time needed for model convergence. Generally, choosing one of the probabilistic approaches strikes a balance between the two extremes, and the literature kicked off by the landmark [*Xavier et al.* paper](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf) provides a few good options. See this nice discussion from [Weights and Biases](https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster.) for more information. expected_impact: 1 literature_references: - "Weights and Biases blog post: https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster. Xavier et al. paper: http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf" suggested_values: xavier_uniform suggested_values_reasoning: Changing the weights initialization scheme is something to consider if a model is having trouble with convergence, or otherwise it is something to experiment with after other factors are considered. The default choice (`xavier_uniform`) is a suitable starting point for most tasks. ui_display_name: Layer Weights Initializer DenseEncoder: type: short_description: Passes the raw numerical values through fully connected layers. long_description: The dense encoder passes the raw numerical values through fully connected layers. In this case the inputs of size `b` are transformed to size `b x h`. activation: default_value_reasoning: The Rectified Linear Units (ReLU) function is the standard activation function used for adding non-linearity. It is simple, fast, and empirically works well (https://arxiv.org/abs/1803.08375). description_implications: Changing the activation functions has an impact on the computational load of the model and might require further hypterparameter tuning expected_impact: 2 suggested_values: The default value will work well in the majority of the cases ui_display_name: Activation bias_initializer: default_value_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. description_implications: It's rare to see any performance gains from choosing a different bias initialization. Some practitioners like to use a small constant value such as 0.01 for all biases to ensure that all ReLU units are activated in the beginning and have some effect on the gradient. However, it's still an open question as to whether this provides consistent improvement. expected_impact: 1 literature_references: - https://cs231n.github.io/neural-networks-2/ related_parameters: - weights_initializer suggested_values: zeros suggested_values_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. For ReLU non-linearities, some people like to use small constant value such as 0.01 for all biases because this ensures that all ReLU units fire in the beginning and therefore obtain and propagate some gradient. However, it is not clear if this provides a consistent improvement (in fact some results seem to indicate that this performs worse) and it is more common to simply use 0 bias initialization. ui_display_name: Bias Initializer dropout: default_value_reasoning: Dropout can cause training to become less stable. Consider start with a dropout-free baseline, and add dropout gradually in subsequent experiments. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 3 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: Dropout input_size: internal_only: true other_information: Internal Only related_parameters: - "No" ui_display_name: Not Displayed fc_layers: ui_display_name: null expected_impact: 1 norm: default_value_reasoning: While batch normalization and layer normalization usually lead to improvements, it can be useful to start with fewer bells and whistles. description_implications: Normalization helps stabilize the learning process and can have a regularizing effect that can help with generalization. It's often suggested that with normalization, you can use a higher learning rate. example_value: - batch expected_impact: 2 literature_references: - https://machinelearningmastery.com/batch-normalization-for-training-of-deep-neural-networks/ related_parameters: - norm_params suggested_values: '"batch" or "layer"' suggested_values_reasoning: Normalization tries to solve "internal covariate shift" that comes from the changing distributions of the inputs to layers deep in the network when weights are updated. For example, batch normalization standardizes the inputs to a layer for each mini-batch. Try out different normalizations to see if that helps with training stability ui_display_name: Normalization Type norm_params: default_value_reasoning: The default parameters that come with Torch's implementation of these normalization types are a trusted starting point. description_implications: There are a variety of ways a certain set of parameters specificed could influence performance here. Broadly speaking the different values passed in here allow for different levels of smoothness to be observed in the learning curves. Since setting this parameters depends on the type of `norm` set, see [BatchNorm2d](https://pytorch.org/docs/stable/generated/torch.nn.BatchNorm2d.html) for more information on the parameters to set for batch normalization, and see [LayerNorm](https://pytorch.org/docs/stable/generated/torch.nn.LayerNorm.html) for more information on the parameters to set for layer normalization. example_value: - affine: false momentum: 0.2 num_features: 100 expected_impact: 1 literature_references: - "For BatchNorm2d: https://arxiv.org/abs/1502.03167 For LayerNorm: https://arxiv.org/abs/1607.06450" related_parameters: - "`norm`" suggested_values: Depends on the type of `norm` set. suggested_values_reasoning: "NO" ui_display_name: Normalization Parameters num_layers: default_value_reasoning: The ideal number of layers depends on the data. For many data types, one layer is sufficient. description_implications: "Increasing the number of layers may improve model performance by allowing the model to synthesize learned features derived from the original input. If the input is simple, ex. a category with a few options, increasing the number of layers has no benefit. For more complex inputs, additional layers add more 'processing power' to extract useful information from the input. However, more layers will increase training time and may reduce accuracy due to overfitting." example_value: - 1 expected_impact: 3 other_information: If you have multiple input features, varying the number of layers in the combiner or output feature decoder will have more impact. related_parameters: - layers suggested_values: 1-3 suggested_values_reasoning: Increasing the number of layers may improve encoder performance. However, more layers will increase training time and may cause overfitting. Small numbers of layers usually work best. ui_display_name: Number of Layers output_size: default_value_reasoning: A modest value, not too small, not too large. description_implications: If there are fully connected layers in this module, increasing the output size of each fully connected layer will increase the capacity of the model. However, the model may be slower to train, and there's a higher risk of overfitting. If it seems like the model could use even more capacity, consider increasing the number of fully connected layers, or explore other architectures. expected_impact: 3 other_information: If num_fc_layers=0 and fc_layers=None, and there are no fully connected layers defined on the module, then this parameter may have no effect on the module's final output shape. related_parameters: - num_fc_layers, fc_layers suggested_values: 10 - 1024 suggested_values_reasoning: Increasing the output size increases the capacity of the model. If this seems to have a positive effect, then it could be worth increasing the number of layers, or trying a different architecture with a larger capacity. ui_display_name: Output Size use_bias: ui_display_name: null expected_impact: 1 weights_initializer: default_value_reasoning: Taken from [this paper](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf). description_implications: The method you choose to initialize layer weights during training can have a big impact on performance as well as the reproducibility of your final model between runs. As an example, if you were to randomly initialize weights you would risk non-reproducibility (and possibly general training performance), but sticking with constant values for initialization might significantly increase the time needed for model convergence. Generally, choosing one of the probabilistic approaches strikes a balance between the two extremes, and the literature kicked off by the landmark [*Xavier et al.* paper](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf) provides a few good options. See this nice discussion from [Weights and Biases](https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster.) for more information. expected_impact: 1 literature_references: - "Weights and Biases blog post: https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster. Xavier et al. paper: http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf" suggested_values: xavier_uniform suggested_values_reasoning: Changing the weights initialization scheme is something to consider if a model is having trouble with convergence, or otherwise it is something to experiment with after other factors are considered. The default choice (`xavier_uniform`) is a suitable starting point for most tasks. ui_display_name: Layer Weights Initializer DistilBERT: type: short_description: A distilled version of BERT base that is 40% smaller and 60% faster with 95% of performance preserved. long_description: The `distilbert` encoder loads a pretrained [DistilBERT](https://medium.com/huggingface/distilbert-8cf3380435b5) (default `distilbert-base-uncased`) model using the Hugging Face transformers package. DistilBERT is a small, fast, cheap and light Transformer model trained by distilling BERT base. It has 40% less parameters than bert-base-uncased, runs 60% faster while preserving over 95% of BERT’s performances as measured on the GLUE language understanding benchmark. compute_tier: 2 activation: default_value_reasoning: This is the default activation function used in the Distillbert huggingface implementation description_implications: Changing the activation functions has an impact on the computational load of the model and might require further hypterparameter tuning expected_impact: 2 suggested_values: The default value will work well in the majority of the cases ui_display_name: Activation attention_dropout: default_value_reasoning: Huggingface default. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 2 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html related_parameters: - dropout, qa_dropout, seq_classif_dropout suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: attention_dropout dim: ui_display_name: null dropout: default_value_reasoning: Huggingface default. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 2 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html related_parameters: - "attention_dropout, qa_dropout, seq_classif_dropout" suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: dropout hidden_dim: ui_display_name: null initializer_range: description_implications: There is an ideal value for this variable that doesn't lead to the outputs of these matrices to vanish or explode example_value: - 0.02 expected_impact: 1 other_information: Must be greater than 0 related_parameters: - weights_initializer suggested_values: 0.01-0.05 suggested_values_reasoning: Large values will likely lead to very large outputs. Small values will lead to vanishing outputs. ui_display_name: null max_position_embeddings: default_value_reasoning: Taken from huggingface. description_implications: The size of the position embeddings table. This typically coincides with the maximum sequence length this model might ever be used with. Typically set this to something large just in case (e.g. 512, 1024, 2048). expected_impact: 2 suggested_values: 512 suggested_values_reasoning: Out of the box value based on published literature. Try models with smaller or larger embedding sizes to observe relative impact. ui_display_name: Max Position Embeddings max_sequence_length: default_value_reasoning: Sets the maximum sequence length of the expected inputs, so input/output shapes are computed accurately. internal_only: true ui_display_name: null n_heads: ui_display_name: null n_layers: ui_display_name: null pretrained_kwargs: ui_display_name: null pretrained_model_name_or_path: ui_display_name: null expected_impact: 2 qa_dropout: default_value_reasoning: Huggingface default. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 1 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html related_parameters: - dropout, attention_dropout, seq_classif_dropout suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: qa_dropout reduce_output: ui_display_name: null expected_impact: 1 saved_weights_in_checkpoint: default_value_reasoning: The weights of the encoder are not necessarily saved in the checkpoint. The user has to save them first. description_implications: The memory footprint for some of these encoders can be large. internal_only: true related_parameters: - skip_save_model suggested_values: - false suggested_values_reasoning: Some of these encoders are large, so it might be better to load them as needed, especially if 1. they're not used frequently 2. the user doesn't have a lot of storage. ui_display_name: null seq_classif_dropout: default_value_reasoning: Huggingface default. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 1 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html related_parameters: - "dropout, attention_dropout, qa_dropout" suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: seq_classif_dropout sinusoidal_pos_embds: ui_display_name: null trainable: expected_impact: 3 ui_display_name: null use_pretrained: default_value_reasoning: By default, the model is initialized as a pretrained model. description_implications: Pretrained models have typically already learned features that are difficult to learn from scratch. They are particularly beneficial when training on small amounts of data. expected_impact: 3 literature_references: - https://machinelearningmastery.com/transfer-learning-for-deep-learning/ related_parameters: - trainable, pretrained_model_name, pretrained_model_name_or_path, pretrained_kwargs suggested_values: - false suggested_values_reasoning: If you have a large amount of data and/or you have data that differs from the typical distribution, then it might be worth training the model from scratch. ui_display_name: Use Pretrained vocab: default_value_reasoning: Computed and passed along internally according to preprocessing settings. example_value: - a - b - c internal_only: true ui_display_name: Not Displayed vocab_size: internal_only: true ui_display_name: Not displayed ELECTRA: type: short_description: Transformer encoder that can be used to encode a sequence of tokens with little compute long_description: The `electra`` encoder loads a pretrained [ELECTRA](https://openreview.net/pdf?id=r1xMH1BtvB) model using the Hugging Face transformers package. ELECTRA is a new pretraining approach which trains two transformer models the generator and the discriminator. The generator’s role is to replace tokens in a sequence, and is therefore trained as a masked language model. The discriminator, which is the model we’re interested in, tries to identify which tokens were replaced by the generator in the sequence. literature_references: - https://openreview.net/pdf?id=r1xMH1BtvB compute_tier: 2 attention_probs_dropout_prob: default_value_reasoning: Huggingface default. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 2 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html related_parameters: - hidden_dropout_prob, classifier_dropout suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: attention_probs_dropout_prob classifier_dropout: default_value_reasoning: Huggingface default. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 2 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html related_parameters: - hidden_dropout_prob, attention_probs_dropout_prob suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: classifier_dropout embedding_size: default_value_reasoning: Not too big, not too small. description_implications: 'An embedding is a relatively low-dimensional space that is used to translate high-dimensional vectors like words, which can have a large vocbulary size. Ideally, after an embedding is trained, it captures some of the semantics of the input by placing semantically similar inputs close together in the embedding space. In most cases, the embedding size is chosen empirically, by trial and error. From https://www.amazon.com/dp/1098115783, "one rule of thumb is to use the fourth root of the total number of unique categorical elements while another is that the embedding dimension should be approximately 1.6 times the square root of the number of unique elements in the category, and no less than 600." Increasing the embedding size may cause the model to train more slowly, but the higher dimensionality can also improve overall quality.' expected_impact: 1 literature_references: - https://developers.google.com/machine-learning/crash-course/embeddings/video-lecture suggested_values: 1.6 * sqrt(vocab_size) suggested_values_reasoning: Rule of thumb suggested by a deep learning textbook. Try models with smaller or larger embedding sizes to observe relative impact. ui_display_name: Embedding Size hidden_act: default_value_reasoning: Taken from huggingface. description_implications: Changing this activation function will only affect the feed-forward layers of the transformer. example_value: - relu expected_impact: 1 literature_references: - "[Huggingface docs for ELECTRA config](https://huggingface.co/docs/transformers/model_doc/electra#transformers.ElectraConfig.hidden_act) [Relevant StackOverflow discussion](https://ai.stackexchange.com/questions/30341/why-does-a-transformer-not-use-an-activation-function-following-the-multi-head-a)" suggested_values: gelu suggested_values_reasoning: Taken from huggingface defaults. ui_display_name: Hidden Layer Activation hidden_dropout_prob: default_value_reasoning: Huggingface default. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 2 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html related_parameters: - "attention_probs_dropout_prob, classifier_dropout" suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: hidden_dropout_prob hidden_size: default_value_reasoning: Huggingface default. description_implications: Increasing the hidden size makes the model larger and slower to train, increases the model's capacity to capture more complexity. It also increases the chance of overfitting. expected_impact: 1 suggested_values: 10 - 2048 suggested_values_reasoning: Increasing the hidden size makes sense if the model is underfitting. It's useful to train both smaller and larger models to see how model capacity affects performance. This should only be explored after the architecture of the model has been settled. ui_display_name: Hidden Size initializer_range: description_implications: There is an ideal value for this variable that doesn't lead to the outputs of these matrices to vanish or explode example_value: - 0.02 expected_impact: 1 other_information: Must be greater than 0 related_parameters: - weights_initializer suggested_values: 0.01-0.05 suggested_values_reasoning: Large values will likely lead to very large outputs. Small values will lead to vanishing outputs. ui_display_name: null intermediate_size: ui_display_name: null layer_norm_eps: ui_display_name: null max_position_embeddings: default_value_reasoning: Taken from huggingface. description_implications: The size of the position embeddings table. This typically coincides with the maximum sequence length this model might ever be used with. Typically set this to something large just in case (e.g. 512, 1024, 2048). expected_impact: 2 suggested_values: 512 suggested_values_reasoning: Out of the box value based on published literature. Try models with smaller or larger embedding sizes to observe relative impact. ui_display_name: Max Position Embeddings max_sequence_length: default_value_reasoning: Sets the maximum sequence length of the expected inputs, so input/output shapes are computed accurately. internal_only: true ui_display_name: null num_attention_heads: ui_display_name: null num_hidden_layers: ui_display_name: null position_embedding_type: ui_display_name: null pretrained_kwargs: ui_display_name: null pretrained_model_name_or_path: ui_display_name: null expected_impact: 2 reduce_output: ui_display_name: null expected_impact: 1 saved_weights_in_checkpoint: default_value_reasoning: The weights of the encoder are not necessarily saved in the checkpoint. The user has to save them first. description_implications: The memory footprint for some of these encoders can be large. internal_only: true related_parameters: - skip_save_model suggested_values: - false suggested_values_reasoning: Some of these encoders are large, so it might be better to load them as needed, especially if 1. they're not used frequently 2. the user doesn't have a lot of storage. ui_display_name: null trainable: expected_impact: 3 ui_display_name: null type_vocab_size: ui_display_name: null use_pretrained: default_value_reasoning: By default, the model is initialized as a pretrained model. description_implications: Pretrained models have typically already learned features that are difficult to learn from scratch. They are particularly beneficial when training on small amounts of data. expected_impact: 3 literature_references: - https://machinelearningmastery.com/transfer-learning-for-deep-learning/ related_parameters: - trainable, pretrained_model_name, pretrained_model_name_or_path, pretrained_kwargs suggested_values: - false suggested_values_reasoning: If you have a large amount of data and/or you have data that differs from the typical distribution, then it might be worth training the model from scratch. ui_display_name: Use Pretrained vocab: default_value_reasoning: Computed and passed along internally according to preprocessing settings. example_value: - a - b - c internal_only: true ui_display_name: Not Displayed vocab_size: internal_only: true ui_display_name: Not displayed FlauBERT: type: short_description: Language model with BERT related architecture trained on large French text corpus. long_description: The `flaubert`` encoder loads a pretrained [FlauBERT](https://arxiv.org/abs/1912.05372) (default `jplu/tf-flaubert-base-uncased``) model using the Hugging Face transformers package. FlauBERT has an architecture similar to BERT and is pre-trained on a large French language corpus. literature_references: - https://arxiv.org/abs/1912.05372 compute_tier: 2 asm: ui_display_name: null expected_impact: 1 attention_dropout: default_value_reasoning: Huggingface default. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 1 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html related_parameters: - dropout suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: attention_dropout bos_index: ui_display_name: null expected_impact: 1 causal: ui_display_name: null expected_impact: 1 dropout: default_value_reasoning: Huggingface default. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 2 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html related_parameters: - attention_dropout suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: dropout emb_dim: ui_display_name: null expected_impact: 1 embed_init_std: ui_display_name: null expected_impact: 1 eos_index: ui_display_name: null expected_impact: 1 gelu_activation: ui_display_name: null expected_impact: 1 init_std: ui_display_name: null expected_impact: 1 is_encoder: ui_display_name: null expected_impact: 1 lang_id: ui_display_name: null expected_impact: 1 layer_norm_eps: ui_display_name: null expected_impact: 1 layerdrop: ui_display_name: null expected_impact: 1 mask_index: ui_display_name: null expected_impact: 1 mask_token_id: default_value_reasoning: Default value used in pre-trained HF encoder. ui_display_name: Mask Token ID expected_impact: 1 max_position_embeddings: default_value_reasoning: Taken from huggingface. description_implications: The size of the position embeddings table. This typically coincides with the maximum sequence length this model might ever be used with. Typically set this to something large just in case (e.g. 512, 1024, 2048). expected_impact: 1 suggested_values: 512 suggested_values_reasoning: Out of the box value based on published literature. Try models with smaller or larger embedding sizes to observe relative impact. ui_display_name: Max Position Embeddings max_sequence_length: default_value_reasoning: Sets the maximum sequence length of the expected inputs, so input/output shapes are computed accurately. internal_only: true ui_display_name: null n_heads: ui_display_name: null expected_impact: 1 n_langs: default_value_reasoning: Default value used in pre-trained HF encoder. expected_impact: 1 ui_display_name: Number of Languages n_layers: ui_display_name: null expected_impact: 1 pad_index: ui_display_name: null expected_impact: 1 pre_norm: ui_display_name: null expected_impact: 1 pretrained_kwargs: ui_display_name: null expected_impact: 1 pretrained_model_name_or_path: ui_display_name: null expected_impact: 2 reduce_output: ui_display_name: null expected_impact: 1 saved_weights_in_checkpoint: default_value_reasoning: The weights of the encoder are not necessarily saved in the checkpoint. The user has to save them first. description_implications: The memory footprint for some of these encoders can be large. internal_only: true related_parameters: - skip_save_model suggested_values: - false suggested_values_reasoning: Some of these encoders are large, so it might be better to load them as needed, especially if 1. they're not used frequently 2. the user doesn't have a lot of storage. ui_display_name: null sinusoidal_embeddings: ui_display_name: null expected_impact: 1 trainable: ui_display_name: null expected_impact: 3 unk_index: ui_display_name: null expected_impact: 1 use_lang_emb: ui_display_name: null expected_impact: 1 use_pretrained: default_value_reasoning: By default, the model is initialized as a pretrained model. description_implications: Pretrained models have typically already learned features that are difficult to learn from scratch. They are particularly beneficial when training on small amounts of data. expected_impact: 3 literature_references: - https://machinelearningmastery.com/transfer-learning-for-deep-learning/ related_parameters: - trainable, pretrained_model_name, pretrained_model_name_or_path, pretrained_kwargs suggested_values: - false suggested_values_reasoning: If you have a large amount of data and/or you have data that differs from the typical distribution, then it might be worth training the model from scratch. ui_display_name: Use Pretrained vocab: default_value_reasoning: Computed and passed along internally according to preprocessing settings. example_value: - a - b - c internal_only: true ui_display_name: Not Displayed vocab_size: internal_only: true ui_display_name: Not displayed GPT2: type: short_description: GPT-2 is a pre-trained language model used for NLP tasks like generation, summarization, and translation. long_description: The `gpt2` encoder loads a pretrained [GPT-2](https://cdn.openai.com/better-language-models/language_models_are_unsupervised_multitask_learners.pdf) (default `gpt2`) model using the Hugging Face transformers package. GPT-2 is a causal (unidirectional) transformer pretrained using language modeling on a very large corpus of ~40 GB of text data. literature_references: - https://cdn.openai.com/better-language-models/language_models_are_unsupervised_multitask_learners.pdf compute_tier: 3 activation_function: ui_display_name: null attn_pdrop: ui_display_name: null embd_pdrop: ui_display_name: null initializer_range: description_implications: There is an ideal value for this variable that doesn't lead to the outputs of these matrices to vanish or explode example_value: - 0.02 expected_impact: 1 other_information: Must be greater than 0 related_parameters: - weights_initializer suggested_values: 0.01-0.05 suggested_values_reasoning: Large values will likely lead to very large outputs. Small values will lead to vanishing outputs. ui_display_name: null layer_norm_epsilon: ui_display_name: null max_sequence_length: default_value_reasoning: Sets the maximum sequence length of the expected inputs, so input/output shapes are computed accurately. internal_only: true ui_display_name: null n_ctx: ui_display_name: null n_embd: ui_display_name: null n_head: ui_display_name: null n_inner: ui_display_name: null n_layer: ui_display_name: null n_positions: ui_display_name: null pretrained_kwargs: ui_display_name: null pretrained_model_name_or_path: ui_display_name: null expected_impact: 2 reduce_output: ui_display_name: null expected_impact: 1 resid_pdrop: ui_display_name: null scale_attn_weights: ui_display_name: null trainable: expected_impact: 3 ui_display_name: null use_pretrained: default_value_reasoning: By default, the model is initialized as a pretrained model. description_implications: Pretrained models have typically already learned features that are difficult to learn from scratch. They are particularly beneficial when training on small amounts of data. expected_impact: 3 literature_references: - https://machinelearningmastery.com/transfer-learning-for-deep-learning/ related_parameters: - trainable, pretrained_model_name, pretrained_model_name_or_path, pretrained_kwargs suggested_values: - false suggested_values_reasoning: If you have a large amount of data and/or you have data that differs from the typical distribution, then it might be worth training the model from scratch. ui_display_name: Use Pretrained vocab: default_value_reasoning: Computed and passed along internally according to preprocessing settings. example_value: - a - b - c internal_only: true ui_display_name: Not Displayed vocab_size: internal_only: true ui_display_name: Not displayed GPT: type: short_description: GPT is a pre-trained language model used for NLP tasks like generation, summarization, and translation. long_description: The `gpt` encoder loads a pretrained [GPT](https://s3-us-west-2.amazonaws.com/openai-assets/research-covers/language-unsupervised/language_understanding_paper.pdf) (default `openai-gpt`) model using the Hugging Face transformers package. GPT is a causal (unidirectional) transformer pre-trained using language modeling on a large corpus with long range dependencies, the Toronto Book Corpus. literature_references: - https://s3-us-west-2.amazonaws.com/openai-assets/research-covers/language-unsupervised/language_understanding_paper.pdf compute_tier: 2 afn: ui_display_name: null attn_pdrop: ui_display_name: null embd_pdrop: ui_display_name: null initializer_range: description_implications: There is an ideal value for this variable that doesn't lead to the outputs of these matrices to vanish or explode example_value: - 0.02 expected_impact: 1 other_information: Must be greater than 0 related_parameters: - weights_initializer suggested_values: 0.01-0.05 suggested_values_reasoning: Large values will likely lead to very large outputs. Small values will lead to vanishing outputs. ui_display_name: null layer_norm_epsilon: ui_display_name: null max_sequence_length: default_value_reasoning: Sets the maximum sequence length of the expected inputs, so input/output shapes are computed accurately. internal_only: true ui_display_name: null n_ctx: ui_display_name: null n_embd: ui_display_name: null n_head: ui_display_name: null n_layer: ui_display_name: null n_positions: ui_display_name: null pretrained_kwargs: ui_display_name: null pretrained_model_name_or_path: ui_display_name: null expected_impact: 2 reduce_output: ui_display_name: null expected_impact: 1 resid_pdrop: ui_display_name: null saved_weights_in_checkpoint: default_value_reasoning: The weights of the encoder are not necessarily saved in the checkpoint. The user has to save them first. description_implications: The memory footprint for some of these encoders can be large. internal_only: true related_parameters: - skip_save_model suggested_values: - false suggested_values_reasoning: Some of these encoders are large, so it might be better to load them as needed, especially if 1. they're not used frequently 2. the user doesn't have a lot of storage. ui_display_name: null trainable: expected_impact: 3 ui_display_name: null use_pretrained: default_value_reasoning: By default, the model is initialized as a pretrained model. description_implications: Pretrained models have typically already learned features that are difficult to learn from scratch. They are particularly beneficial when training on small amounts of data. expected_impact: 3 literature_references: - https://machinelearningmastery.com/transfer-learning-for-deep-learning/ related_parameters: - trainable, pretrained_model_name, pretrained_model_name_or_path, pretrained_kwargs suggested_values: - false suggested_values_reasoning: If you have a large amount of data and/or you have data that differs from the typical distribution, then it might be worth training the model from scratch. ui_display_name: Use Pretrained vocab: default_value_reasoning: Computed and passed along internally according to preprocessing settings. example_value: - a - b - c internal_only: true ui_display_name: Not Displayed vocab_size: internal_only: true ui_display_name: Not displayed H3Embed: type: short_description: Encodes each H3 component with embeddings then takes a sum and passes them through fully connected layers. long_description: The Embed encoder encodes each component of the H3 representation (mode, edge, resolution, base cell and children cells) with embeddings. Children cells with value 0 will be masked out. After the embedding, all embeddings are summed and optionally passed through a stack of fully connected layers. activation: default_value_reasoning: The Rectified Linear Units (ReLU) function is the standard activation function used for adding non-linearity. It is simple, fast, and empirically works well (https://arxiv.org/abs/1803.08375). description_implications: Changing the activation functions has an impact on the computational load of the model and might require further hypterparameter tuning expected_impact: 2 suggested_values: The default value will work well in the majority of the cases ui_display_name: Activation bias_initializer: default_value_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. description_implications: It's rare to see any performance gains from choosing a different bias initialization. Some practitioners like to use a small constant value such as 0.01 for all biases to ensure that all ReLU units are activated in the beginning and have some effect on the gradient. However, it's still an open question as to whether this provides consistent improvement. expected_impact: 1 literature_references: - https://cs231n.github.io/neural-networks-2/ related_parameters: - weights_initializer suggested_values: zeros suggested_values_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. For ReLU non-linearities, some people like to use small constant value such as 0.01 for all biases because this ensures that all ReLU units fire in the beginning and therefore obtain and propagate some gradient. However, it is not clear if this provides a consistent improvement (in fact some results seem to indicate that this performs worse) and it is more common to simply use 0 bias initialization. ui_display_name: Bias Initializer dropout: default_value_reasoning: Dropout can cause training to become less stable. Consider start with a dropout-free baseline, and add dropout gradually in subsequent experiments. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 3 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: Dropout embedding_size: default_value_reasoning: Not too big, not too small. description_implications: 'An embedding is a relatively low-dimensional space that is used to translate high-dimensional vectors like words, which can have a large vocbulary size. Ideally, after an embedding is trained, it captures some of the semantics of the input by placing semantically similar inputs close together in the embedding space. In most cases, the embedding size is chosen empirically, by trial and error. From https://www.amazon.com/dp/1098115783, "one rule of thumb is to use the fourth root of the total number of unique categorical elements while another is that the embedding dimension should be approximately 1.6 times the square root of the number of unique elements in the category, and no less than 600." Increasing the embedding size may cause the model to train more slowly, but the higher dimensionality can also improve overall quality.' expected_impact: 3 literature_references: - https://developers.google.com/machine-learning/crash-course/embeddings/video-lecture suggested_values: 1.6 * sqrt(vocab_size) suggested_values_reasoning: Rule of thumb suggested by a deep learning textbook. Try models with smaller or larger embedding sizes to observe relative impact. ui_display_name: Embedding Size embeddings_on_cpu: default_value_reasoning: By default embeddings matrices are stored on GPU memory if a GPU is used, as it allows for faster access. description_implications: By default embeddings matrices are stored on GPU memory if a GPU is used, as it allows for faster access. However, in some cases when the vocabulary size is very large, the full embedding matrix may be really big and unwieldy to have in GPU memory. This parameter forces the placement of the embedding matrix in regular memory and the CPU is used to access them. This may slow down training due to additional data transfer between CPU and GPU memory, but can lead to healthier GPU memory resource usage. expected_impact: 1 suggested_values: - false suggested_values_reasoning: If GPU memory is not a constraint, having embeddings stored and accessed within the GPU is faster. ui_display_name: Embeddings on CPU fc_layers: default_value_reasoning: By default the stack is built by using num_fc_layers, output_size, use_bias, weights_initializer, bias_initializer, norm, norm_params, activation, dropout. When a list of dictionaries is provided, the stack is built following the parameters of each dict for building each layer. description_implications: The more layers that are specified the deeper and higher capacity the model will be. This makes it possible to potentially achieve better performance when a big anough amount of data is provided, but also makes the model more computationally expensive and potentially more prone to overfitting. example_value: - dropout: 0.1 output_size: 128 - norm: layer output_size: 64 expected_impact: 1 related_parameters: - output_size - use_bias - weights_initializer - bias_initializer - norm - norm_params - activation - dropout suggested_values_reasoning: It is easier to define a stack of fully connected layers by just specifying num_fc_layers, output_size and the other individual parameters. It will create a stack of layers with identical properties. Use this parameter only if you need a fine grained level of control of each individual layer in the stack. ui_display_name: Fully Connected Layers norm: default_value_reasoning: While batch normalization and layer normalization usually lead to improvements, it can be useful to start with fewer bells and whistles. description_implications: Normalization helps stabilize the learning process and can have a regularizing effect that can help with generalization. It's often suggested that with normalization, you can use a higher learning rate. example_value: - batch expected_impact: 2 literature_references: - https://machinelearningmastery.com/batch-normalization-for-training-of-deep-neural-networks/ related_parameters: - norm_params suggested_values: '"batch" or "layer"' suggested_values_reasoning: Normalization tries to solve "internal covariate shift" that comes from the changing distributions of the inputs to layers deep in the network when weights are updated. For example, batch normalization standardizes the inputs to a layer for each mini-batch. Try out different normalizations to see if that helps with training stability ui_display_name: Normalization Type norm_params: default_value_reasoning: The default parameters that come with Torch's implementation of these normalization types are a trusted starting point. description_implications: There are a variety of ways a certain set of parameters specificed could influence performance here. Broadly speaking the different values passed in here allow for different levels of smoothness to be observed in the learning curves. Since setting this parameters depends on the type of `norm` set, see [BatchNorm2d](https://pytorch.org/docs/stable/generated/torch.nn.BatchNorm2d.html) for more information on the parameters to set for batch normalization, and see [LayerNorm](https://pytorch.org/docs/stable/generated/torch.nn.LayerNorm.html) for more information on the parameters to set for layer normalization. example_value: - affine: false momentum: 0.2 num_features: 100 expected_impact: 1 literature_references: - "For BatchNorm2d: https://arxiv.org/abs/1502.03167 For LayerNorm: https://arxiv.org/abs/1607.06450" related_parameters: - "`norm`" suggested_values: Depends on the type of `norm` set. suggested_values_reasoning: "NO" ui_display_name: Normalization Parameters num_fc_layers: default_value_reasoning: The encoder already has learnable parameters.Sometimes the default is 1 for modules where the FC stack is used for shape management, or the only source of learnable parameters. description_implications: Increasing num_fc_layers will increase the capacity of the model. The model will be slower to train, and there's a higher risk of overfitting. example_value: - 1 expected_impact: 1 other_information: Not all modules that have fc_layers also have an accompanying num_fc_layers parameter. Where both are present, fc_layers takes precedent over num_fc_layers. Specifying num_fc_layers alone uses fully connected layers that are configured by the defaults in FCStack. related_parameters: - fc_layers suggested_values: 0-1 suggested_values_reasoning: The full model likely contains many learnable parameters. Consider starting with very few, or without any additional fully connected layers and add them if you observe evidence of limited model capacity. Sometimes the default is 1 for modules where the FC stack is used for shape management, or the only source of learnable parameters. ui_display_name: Number of Fully Connected Layers output_size: default_value_reasoning: A modest value, not too small, not too large. description_implications: If there are fully connected layers in this module, increasing the output size of each fully connected layer will increase the capacity of the model. However, the model may be slower to train, and there's a higher risk of overfitting. If it seems like the model could use even more capacity, consider increasing the number of fully connected layers, or explore other architectures. expected_impact: 3 other_information: If num_fc_layers=0 and fc_layers=None, and there are no fully connected layers defined on the module, then this parameter may have no effect on the module's final output shape. related_parameters: - num_fc_layers, fc_layers suggested_values: 10 - 1024 suggested_values_reasoning: Increasing the output size increases the capacity of the model. If this seems to have a positive effect, then it could be worth increasing the number of layers, or trying a different architecture with a larger capacity. ui_display_name: Output Size reduce_output: default_value_reasoning: Sums the tensors along the sequence dimension. description_implications: "\"last\", \"sum\", \"mean\", and \"max\" are the\ \ fastest and most memory-efficient operations\u2013 they result in tensors\ \ that are the same-size as a single item in the input sequence. However,\ \ these are simple aggregation operations, therefore some information\ \ may be lost. \n\n\"concat\" concatenates each tensor together, creating\ \ a `(sequence length)*(tensor size)`-element tensor. \"concat\" preserves\ \ this information, but can be very memory-intensive and should only be\ \ applied if the sequence length and/or tensor size is small. \n\n\"attention\"\ \ takes a weighted sum of the items in the sequence, where the weights\ \ for each item in the sequence are determined by the model on-the-fly\ \ based on the features of the item itself. This is both slower and and\ \ more memory-intensive than the other operations; however, it can also\ \ provide a richer \"global\" representation of the sequence." expected_impact: 1 related_parameters: - max_sequence_length suggested_values: '"attention". This and the default covers 95% of use cases.' suggested_values_reasoning: If you would like better performance and are not compute/memory-constrained, attention-based reduction can potentially provide a richer global representation than the default. ui_display_name: Sequence Reducer use_bias: ui_display_name: null expected_impact: 1 weights_initializer: default_value_reasoning: Taken from [this paper](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf). description_implications: The method you choose to initialize layer weights during training can have a big impact on performance as well as the reproducibility of your final model between runs. As an example, if you were to randomly initialize weights you would risk non-reproducibility (and possibly general training performance), but sticking with constant values for initialization might significantly increase the time needed for model convergence. Generally, choosing one of the probabilistic approaches strikes a balance between the two extremes, and the literature kicked off by the landmark [*Xavier et al.* paper](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf) provides a few good options. See this nice discussion from [Weights and Biases](https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster.) for more information. expected_impact: 1 literature_references: - "Weights and Biases blog post: https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster. Xavier et al. paper: http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf" suggested_values: xavier_uniform suggested_values_reasoning: Changing the weights initialization scheme is something to consider if a model is having trouble with convergence, or otherwise it is something to experiment with after other factors are considered. The default choice (`xavier_uniform`) is a suitable starting point for most tasks. ui_display_name: Layer Weights Initializer H3RNN: type: short_description: Encodes each H3 component with embeddings then passes them through an RNN encoder. long_description: The RNN encoder encodes each component of the H3 representation (mode, edge, resolution, base cell and children cells) with embeddings. Children cells with value 0 will be masked out. After the embedding, all embeddings are passed through an RNN encoder. The intuition behind this is that, starting from the base cell, the sequence of children cells can be seen as a sequence encoding the path in the tree of all H3 hexes. activation: ui_display_name: null expected_impact: 1 bias_initializer: default_value_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. description_implications: It's rare to see any performance gains from choosing a different bias initialization. Some practitioners like to use a small constant value such as 0.01 for all biases to ensure that all ReLU units are activated in the beginning and have some effect on the gradient. However, it's still an open question as to whether this provides consistent improvement. expected_impact: 2 literature_references: - https://cs231n.github.io/neural-networks-2/ related_parameters: - weights_initializer suggested_values: zeros suggested_values_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. For ReLU non-linearities, some people like to use small constant value such as 0.01 for all biases because this ensures that all ReLU units fire in the beginning and therefore obtain and propagate some gradient. However, it is not clear if this provides a consistent improvement (in fact some results seem to indicate that this performs worse) and it is more common to simply use 0 bias initialization. ui_display_name: Bias Initializer bidirectional: default_value_reasoning: For short sequences, it is reasonable to use a vanilla RNN. description_implications: Setting bidirectional to True may increase the compute and memory requirements of the model, but may also increase model performance on long sequences. expected_impact: 0 literature_references: - https://devopedia.org/bidirectional-rnn#:~:text=RNN%20has%20the%20limitation%20that,forward%20and%20reverse%20time%20order. related_parameters: - cell_type, activation, recurrent_activation, use_bias suggested_values: - true suggested_values_reasoning: "RNNs can sometimes suffer from catastrophic forgetting (source: https://en.wikipedia.org/wiki/Catastrophic_interference ) on long sequences. Allowing the RNN to read from both the beginning and end of the sequence can improve its representation at each timestep." ui_display_name: Bidirectional cell_type: default_value_reasoning: The LSTM cell has proven to be the most performant of the three cells. description_implications: "There are two reasons to consider other cell types: (1) compute costs and (2) catastrophic forgetting (source: https://en.wikipedia.org/wiki/Catastrophic_interference ). RNNs have marginally less compute costs, but are prone to catastrophic forgetting." expected_impact: 3 related_parameters: - "bidirectional activation recurrent_activation use_bias" ui_display_name: Cell Type dropout: default_value_reasoning: Dropout can cause training to become less stable. Consider start with a dropout-free baseline, and add dropout gradually in subsequent experiments. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 3 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html related_parameters: - recurrent_dropout suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: Dropout embedding_size: default_value_reasoning: Not too big, not too small. description_implications: 'An embedding is a relatively low-dimensional space that is used to translate high-dimensional vectors like words, which can have a large vocbulary size. Ideally, after an embedding is trained, it captures some of the semantics of the input by placing semantically similar inputs close together in the embedding space. In most cases, the embedding size is chosen empirically, by trial and error. From https://www.amazon.com/dp/1098115783, "one rule of thumb is to use the fourth root of the total number of unique categorical elements while another is that the embedding dimension should be approximately 1.6 times the square root of the number of unique elements in the category, and no less than 600." Increasing the embedding size may cause the model to train more slowly, but the higher dimensionality can also improve overall quality.' expected_impact: 3 literature_references: - https://developers.google.com/machine-learning/crash-course/embeddings/video-lecture suggested_values: 1.6 * sqrt(vocab_size) suggested_values_reasoning: Rule of thumb suggested by a deep learning textbook. Try models with smaller or larger embedding sizes to observe relative impact. ui_display_name: Embedding Size embeddings_on_cpu: default_value_reasoning: By default embeddings matrices are stored on GPU memory if a GPU is used, as it allows for faster access. description_implications: By default embeddings matrices are stored on GPU memory if a GPU is used, as it allows for faster access. However, in some cases when the vocabulary size is very large, the full embedding matrix may be really big and unwieldy to have in GPU memory. This parameter forces the placement of the embedding matrix in regular memory and the CPU is used to access them. This may slow down training due to additional data transfer between CPU and GPU memory, but can lead to healthier GPU memory resource usage. expected_impact: 1 suggested_values: - false suggested_values_reasoning: If GPU memory is not a constraint, having embeddings stored and accessed within the GPU is faster. ui_display_name: Embeddings on CPU hidden_size: default_value_reasoning: H3 values numbers, so a small RNN dimensionality is likely sufficient. description_implications: Increasing the hidden size makes the model larger and slower to train, increases the model's capacity to capture more complexity. It also increases the chance of overfitting. expected_impact: 2 suggested_values: 10 - 2048 suggested_values_reasoning: Increasing the hidden size makes sense if the model is underfitting. It's useful to train both smaller and larger models to see how model capacity affects performance. This should only be explored after the architecture of the model has been settled. ui_display_name: Hidden Size num_layers: default_value_reasoning: The ideal number of layers depends on the data. For many data types, one layer is sufficient. description_implications: Increasing the number of layers may improve model performance for longer sequences or more complex tasks. example_value: - 1 expected_impact: 3 other_information: If you have multiple input features, varying the number of layers in the combiner or output feature decoder will have more impact. related_parameters: - layers suggested_values: 1-3 suggested_values_reasoning: Increasing the number of layers may improve encoder performance. However, more layers will increase training time and may cause overfitting. Small numbers of layers usually work best. ui_display_name: Number of Recurrent Layers recurrent_activation: default_value_reasoning: sigmoid' is commonly used expected_impact: 1 other_information: I don't think that this parameter is used anywhere in the code base. It's being passed down but not used in the actual RNN forwarding functions. suggested_values: sigmoid, ReLu, tanh ui_display_name: null recurrent_dropout: default_value_reasoning: Dropout can cause training to become less stable. Consider start with a dropout-free baseline, and add dropout gradually in subsequent experiments. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 2 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html related_parameters: - dropout suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: Recurrent Dropout recurrent_initializer: ui_display_name: null expected_impact: 1 reduce_output: ui_display_name: null expected_impact: 1 unit_forget_bias: ui_display_name: null expected_impact: 1 use_bias: ui_display_name: null weights_initializer: default_value_reasoning: Taken from [this paper](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf). description_implications: The method you choose to initialize layer weights during training can have a big impact on performance as well as the reproducibility of your final model between runs. As an example, if you were to randomly initialize weights you would risk non-reproducibility (and possibly general training performance), but sticking with constant values for initialization might significantly increase the time needed for model convergence. Generally, choosing one of the probabilistic approaches strikes a balance between the two extremes, and the literature kicked off by the landmark [*Xavier et al.* paper](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf) provides a few good options. See this nice discussion from [Weights and Biases](https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster.) for more information. expected_impact: 1 literature_references: - "Weights and Biases blog post: https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster. Xavier et al. paper: http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf" suggested_values: xavier_uniform suggested_values_reasoning: Changing the weights initialization scheme is something to consider if a model is having trouble with convergence, or otherwise it is something to experiment with after other factors are considered. The default choice (`xavier_uniform`) is a suitable starting point for most tasks. ui_display_name: Layer Weights Initializer H3WeightedSum: type: short_description: Encodes each H3 component with embeddings then takes a weighted sum. long_description: The Weighted Sum encoder encodes each component of the H3 representation (mode, edge, resolution, base cell and children cells) with embeddings. Children cells with value 0 will be masked out. After the embedding, all embeddings are summed with a weighted sum (with learned weights) and optionally passed through a stack of fully connected layers. activation: default_value_reasoning: The Rectified Linear Units (ReLU) function is the standard activation function used for adding non-linearity. It is simple, fast, and empirically works well (https://arxiv.org/abs/1803.08375). description_implications: Changing the activation functions has an impact on the computational load of the model and might require further hypterparameter tuning expected_impact: 2 suggested_values: The default value will work well in the majority of the cases ui_display_name: Activation bias_initializer: default_value_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. description_implications: It's rare to see any performance gains from choosing a different bias initialization. Some practitioners like to use a small constant value such as 0.01 for all biases to ensure that all ReLU units are activated in the beginning and have some effect on the gradient. However, it's still an open question as to whether this provides consistent improvement. expected_impact: 1 literature_references: - https://cs231n.github.io/neural-networks-2/ related_parameters: - weights_initializer suggested_values: zeros suggested_values_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. For ReLU non-linearities, some people like to use small constant value such as 0.01 for all biases because this ensures that all ReLU units fire in the beginning and therefore obtain and propagate some gradient. However, it is not clear if this provides a consistent improvement (in fact some results seem to indicate that this performs worse) and it is more common to simply use 0 bias initialization. ui_display_name: Bias Initializer dropout: default_value_reasoning: Dropout can cause training to become less stable. Consider start with a dropout-free baseline, and add dropout gradually in subsequent experiments. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 3 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: Dropout embedding_size: default_value_reasoning: Not too big, not too small. description_implications: 'An embedding is a relatively low-dimensional space that is used to translate high-dimensional vectors like words, which can have a large vocbulary size. Ideally, after an embedding is trained, it captures some of the semantics of the input by placing semantically similar inputs close together in the embedding space. In most cases, the embedding size is chosen empirically, by trial and error. From https://www.amazon.com/dp/1098115783, "one rule of thumb is to use the fourth root of the total number of unique categorical elements while another is that the embedding dimension should be approximately 1.6 times the square root of the number of unique elements in the category, and no less than 600." Increasing the embedding size may cause the model to train more slowly, but the higher dimensionality can also improve overall quality.' expected_impact: 3 literature_references: - https://developers.google.com/machine-learning/crash-course/embeddings/video-lecture suggested_values: 1.6 * sqrt(vocab_size) suggested_values_reasoning: Rule of thumb suggested by a deep learning textbook. Try models with smaller or larger embedding sizes to observe relative impact. ui_display_name: Embedding Size embeddings_on_cpu: default_value_reasoning: By default embeddings matrices are stored on GPU memory if a GPU is used, as it allows for faster access. description_implications: By default embeddings matrices are stored on GPU memory if a GPU is used, as it allows for faster access. However, in some cases when the vocabulary size is very large, the full embedding matrix may be really big and unwieldy to have in GPU memory. This parameter forces the placement of the embedding matrix in regular memory and the CPU is used to access them. This may slow down training due to additional data transfer between CPU and GPU memory, but can lead to healthier GPU memory resource usage. expected_impact: 1 suggested_values: - false suggested_values_reasoning: If GPU memory is not a constraint, having embeddings stored and accessed within the GPU is faster. ui_display_name: Embeddings on CPU fc_layers: default_value_reasoning: By default the stack is built by using num_fc_layers, output_size, use_bias, weights_initializer, bias_initializer, norm, norm_params, activation, dropout. When a list of dictionaries is provided, the stack is built following the parameters of each dict for building each layer. description_implications: The more layers that are specified the deeper and higher capacity the model will be. This makes it possible to potentially achieve better performance when a big anough amount of data is provided, but also makes the model more computationally expensive and potentially more prone to overfitting. example_value: - dropout: 0.1 output_size: 128 - norm: layer output_size: 64 expected_impact: 1 related_parameters: - output_size - use_bias - weights_initializer - bias_initializer - norm - norm_params - activation - dropout suggested_values_reasoning: It is easier to define a stack of fully connected layers by just specifying num_fc_layers, output_size and the other individual parameters. It will create a stack of layers with identical properties. Use this parameter only if you need a fine grained level of control of each individual layer in the stack. ui_display_name: Fully Connected Layers norm: default_value_reasoning: While batch normalization and layer normalization usually lead to improvements, it can be useful to start with fewer bells and whistles. description_implications: Normalization helps stabilize the learning process and can have a regularizing effect that can help with generalization. It's often suggested that with normalization, you can use a higher learning rate. example_value: - batch expected_impact: 2 literature_references: - https://machinelearningmastery.com/batch-normalization-for-training-of-deep-neural-networks/ related_parameters: - norm_params suggested_values: '"batch" or "layer"' suggested_values_reasoning: Normalization tries to solve "internal covariate shift" that comes from the changing distributions of the inputs to layers deep in the network when weights are updated. For example, batch normalization standardizes the inputs to a layer for each mini-batch. Try out different normalizations to see if that helps with training stability ui_display_name: Normalization Type norm_params: default_value_reasoning: The default parameters that come with Torch's implementation of these normalization types are a trusted starting point. description_implications: There are a variety of ways a certain set of parameters specificed could influence performance here. Broadly speaking the different values passed in here allow for different levels of smoothness to be observed in the learning curves. Since setting this parameters depends on the type of `norm` set, see [BatchNorm2d](https://pytorch.org/docs/stable/generated/torch.nn.BatchNorm2d.html) for more information on the parameters to set for batch normalization, and see [LayerNorm](https://pytorch.org/docs/stable/generated/torch.nn.LayerNorm.html) for more information on the parameters to set for layer normalization. example_value: - affine: false momentum: 0.2 num_features: 100 expected_impact: 1 literature_references: - "For BatchNorm2d: https://arxiv.org/abs/1502.03167 For LayerNorm: https://arxiv.org/abs/1607.06450" related_parameters: - "`norm`" suggested_values: Depends on the type of `norm` set. suggested_values_reasoning: "NO" ui_display_name: Normalization Parameters num_fc_layers: default_value_reasoning: The encoder already has learnable parameters.Sometimes the default is 1 for modules where the FC stack is used for shape management, or the only source of learnable parameters. description_implications: Increasing num_fc_layers will increase the capacity of the model. The model will be slower to train, and there's a higher risk of overfitting. example_value: - 1 expected_impact: 1 other_information: Not all modules that have fc_layers also have an accompanying num_fc_layers parameter. Where both are present, fc_layers takes precedent over num_fc_layers. Specifying num_fc_layers alone uses fully connected layers that are configured by the defaults in FCStack. related_parameters: - fc_layers suggested_values: 0-1 suggested_values_reasoning: The full model likely contains many learnable parameters. Consider starting with very few, or without any additional fully connected layers and add them if you observe evidence of limited model capacity. Sometimes the default is 1 for modules where the FC stack is used for shape management, or the only source of learnable parameters. ui_display_name: Number of Fully Connected Layers output_size: default_value_reasoning: A modest value, not too small, not too large. description_implications: If there are fully connected layers in this module, increasing the output size of each fully connected layer will increase the capacity of the model. However, the model may be slower to train, and there's a higher risk of overfitting. If it seems like the model could use even more capacity, consider increasing the number of fully connected layers, or explore other architectures. expected_impact: 3 other_information: If num_fc_layers=0 and fc_layers=None, and there are no fully connected layers defined on the module, then this parameter may have no effect on the module's final output shape. related_parameters: - num_fc_layers, fc_layers suggested_values: 10 - 1024 suggested_values_reasoning: Increasing the output size increases the capacity of the model. If this seems to have a positive effect, then it could be worth increasing the number of layers, or trying a different architecture with a larger capacity. ui_display_name: Output Size should_softmax: ui_display_name: null expected_impact: 1 use_bias: ui_display_name: null expected_impact: 1 weights_initializer: default_value_reasoning: Taken from [this paper](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf). description_implications: The method you choose to initialize layer weights during training can have a big impact on performance as well as the reproducibility of your final model between runs. As an example, if you were to randomly initialize weights you would risk non-reproducibility (and possibly general training performance), but sticking with constant values for initialization might significantly increase the time needed for model convergence. Generally, choosing one of the probabilistic approaches strikes a balance between the two extremes, and the literature kicked off by the landmark [*Xavier et al.* paper](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf) provides a few good options. See this nice discussion from [Weights and Biases](https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster.) for more information. expected_impact: 1 literature_references: - "Weights and Biases blog post: https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster. Xavier et al. paper: http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf" suggested_values: xavier_uniform suggested_values_reasoning: Changing the weights initialization scheme is something to consider if a model is having trouble with convergence, or otherwise it is something to experiment with after other factors are considered. The default choice (`xavier_uniform`) is a suitable starting point for most tasks. ui_display_name: Layer Weights Initializer Longformer: type: short_description: Transformer optimized for longer text inputs. long_description: The `longformer` encoder loads a pretrained [Longformer](https://arxiv.org/pdf/2004.05150.pdf) (default `allenai/longformer-base-4096`) model using the Hugging Face transformers package. Longformer is a good choice for longer text, as it supports sequences up to 4096 tokens long. literature_references: - https://arxiv.org/pdf/2004.05150.pdf compute_tier: 2 attention_window: ui_display_name: null max_sequence_length: default_value_reasoning: Sets the maximum sequence length of the expected inputs, so input/output shapes are computed accurately. internal_only: true ui_display_name: null num_tokens: ui_display_name: null max_position_embeddings: default_value_reasoning: Taken from huggingface. description_implications: "An embedding is a relatively low-dimensional space that is used to translate high-dimensional vectors like words or positions, which can have a large vocbulary size. Ideally, after an embedding is trained, it captures some of the semantics of the input by placing semantically similar inputs close together in the embedding space. Increasing the embedding size may cause the model to train more slowly, but the higher dimensionality can also improve overall quality." expected_impact: 2 suggested_values: 512 suggested_values_reasoning: Out of the box value based on published literature. Try models with smaller or larger embedding sizes to observe relative impact. ui_display_name: Max Position Embeddings type_vocab_size: ui_display_name: null pretrained_kwargs: ui_display_name: null pretrained_model_name_or_path: ui_display_name: null expected_impact: 2 reduce_output: ui_display_name: null expected_impact: 1 saved_weights_in_checkpoint: default_value_reasoning: The weights of the encoder are not necessarily saved in the checkpoint. The user has to save them first. description_implications: The memory footprint for some of these encoders can be large. internal_only: true related_parameters: - skip_save_model suggested_values: - false suggested_values_reasoning: Some of these encoders are large, so it might be better to load them as needed, especially if 1. they're not used frequently 2. the user doesn't have a lot of storage. ui_display_name: null sep_token_id: ui_display_name: null trainable: expected_impact: 3 ui_display_name: null use_pretrained: default_value_reasoning: By default, the model is initialized as a pretrained model. description_implications: Pretrained models have typically already learned features that are difficult to learn from scratch. They are particularly beneficial when training on small amounts of data. expected_impact: 3 literature_references: - https://machinelearningmastery.com/transfer-learning-for-deep-learning/ related_parameters: - trainable, pretrained_model_name, pretrained_model_name_or_path, pretrained_kwargs suggested_values: - false suggested_values_reasoning: If you have a large amount of data and/or you have data that differs from the typical distribution, then it might be worth training the model from scratch. ui_display_name: Use Pretrained vocab: default_value_reasoning: Computed and passed along internally according to preprocessing settings. example_value: - a - b - c internal_only: true ui_display_name: Not Displayed vocab_size: internal_only: true ui_display_name: Not displayed MLPMixer: type: short_description: Image encoder which applies fully connected layers to different patches of the image. long_description: MLP-Mixer divides the image into equal-sized patches, applying fully connected layers to each patch to compute per-patch representations (tokens) and combining the representations with fully-connected mixer layers. compute_tier: 1 avg_pool: ui_display_name: null channel_dim: ui_display_name: null dropout: default_value_reasoning: Dropout can cause training to become less stable. Consider start with a dropout-free baseline, and add dropout gradually in subsequent experiments. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 3 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: Dropout embed_size: ui_display_name: null height: internal_only: true ui_display_name: null num_channels: ui_display_name: null num_layers: default_value_reasoning: The ideal number of layers depends on the size and complexity of the input images. The default value is used in the paper and tested on several image datasets. description_implications: Increasing the number of layers may improve model performance for larger images or more complex image tasks. example_value: - 8 expected_impact: 3 literature_references: - "MLP-Mixer: An all-MLP Architecture for Vision https://arxiv.org/abs/2105.01601" suggested_values: 4 - 32 suggested_values_reasoning: Values from 8 - 32 are tested in the paper. It is possible that fewer layers will be sufficient for some tasks. ui_display_name: Number of Layers patch_size: default_value_reasoning: Taken from MLP-Mixer paper. description_implications: "The implications of the image patch size for this\ \ layer depend on other factors, such as the true resolution of the incoming\ \ image dataset. If the patch size is kept consistent but a higher resolution\ \ image is used as input, then the resulting chunked sequence of tokens\ \ will be longer than it would have been if the input resolution was lower.\ \ \n\nThe original MLP-Mixer paper also notes that there is a tradeoff\ \ with respect to the projection units learned by a model. In their findings,\ \ a 32x32 patch size model learned very structured low frequency projection\ \ units, while the equivalent 16x16 model learned high frequencies and\ \ showed no clear structure." expected_impact: 2 literature_references: - "[MLP Mixer paper](https://arxiv.org/pdf/2105.01601.pdf)" suggested_values: - 16 - 32 suggested_values_reasoning: 16 and 32 are the values used in the original MLP Mixer paper ui_display_name: Patch Size token_size: ui_display_name: null width: internal_only: true ui_display_name: null MT5: type: short_description: MT5 is a multilingual variant of T5 useful for multilingual NLP use cases. long_description: The `mt5` encoder loads a pretrained [MT5](https://arxiv.org/abs/2010.11934) (default `google/mt5-base`) model using the Hugging Face transformers package. MT5 is a multilingual variant of T5 trained on a dataset of 101 languages. compute_tier: 2 d_ff: default_value_reasoning: Default value matches the pre-trained encoder. description_implications: If using a pre-trained encoder, this parameter will be automatically derived from the pre-trained model. expected_impact: 1 ui_display_name: Dimensionality of Feed-Forward Layer d_kv: ui_display_name: null d_model: ui_display_name: null decoder_start_token_id: ui_display_name: null dropout_rate: default_value_reasoning: Huggingface default. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 3 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: dropout_rate eos_token_id: default_value_reasoning: Default value used in pre-trained HF encoder. ui_display_name: End-of-Sentence Token Id feed_forward_proj: ui_display_name: null initializer_factor: ui_display_name: null is_encoder_decoder: ui_display_name: null layer_norm_epsilon: ui_display_name: null max_sequence_length: default_value_reasoning: Sets the maximum sequence length of the expected inputs, so input/output shapes are computed accurately. internal_only: true ui_display_name: null num_decoder_layers: ui_display_name: null num_heads: ui_display_name: null num_layers: default_value_reasoning: The default value matches the number of layers in the default pretrained encoder. description_implications: "The ideal number of transformer layers depends on the length and complexity of input sequences, as well as the task. If using a pre-trained encoder, this parameter will be automatically derived from the pre-trained model." example_value: - 8 expected_impact: 3 related_parameters: - pretrained_model_or_path suggested_values: 1 - 12 suggested_values_reasoning: Increasing the number of layers may improve encoder performance. However, more layers will increase training time and may cause overfitting. Small numbers of layers usually work best. ui_display_name: Number of Transformer Layers pad_token_id: ui_display_name: null pretrained_kwargs: ui_display_name: null pretrained_model_name_or_path: ui_display_name: null expected_impact: 2 reduce_output: ui_display_name: null expected_impact: 1 relative_attention_num_buckets: ui_display_name: null saved_weights_in_checkpoint: default_value_reasoning: The weights of the encoder are not necessarily saved in the checkpoint. The user has to save them first. description_implications: The memory footprint for some of these encoders can be large. internal_only: true related_parameters: - skip_save_model suggested_values: - false suggested_values_reasoning: Some of these encoders are large, so it might be better to load them as needed, especially if 1. they're not used frequently 2. the user doesn't have a lot of storage. ui_display_name: null tie_word_embeddings: default_value_reasoning: Keeping the word embeddings separate ensures maximum modeling flexibility. description_implications: The main tradeoff between True and False values is in compute costs and model flexibility. If set to False, the model will require more memory, but may be more flexible. If set to True, the opposite is true. example_value: - true expected_impact: 2 suggested_values: - true suggested_values_reasoning: "If set to True, then the word embeddings will be shared between the encoder and decoder. There are two main reasons to set this value to True: (1) saving compute resources. Word embedding tables can be very large and using a single table between the encoder and decoder can cut one's memory usage in half. (2) If the domain of the generated text is highly similar to the input text. For example, if training a Question and Answering (QA) text model, where both the questions and answers are in the same language, the word embeddings used by the encoder are likely usable by the decoder and vice-versa. On the other hand, if training a translation model between two languages, the word embeddings are not likely to be shareable by both model components." ui_display_name: null tokenizer_class: ui_display_name: null trainable: expected_impact: 3 ui_display_name: null use_cache: ui_display_name: null use_pretrained: default_value_reasoning: By default, the model is initialized as a pretrained model. description_implications: Pretrained models have typically already learned features that are difficult to learn from scratch. They are particularly beneficial when training on small amounts of data. expected_impact: 3 literature_references: - https://machinelearningmastery.com/transfer-learning-for-deep-learning/ related_parameters: - trainable, pretrained_model_name, pretrained_model_name_or_path, pretrained_kwargs suggested_values: - false suggested_values_reasoning: If you have a large amount of data and/or you have data that differs from the typical distribution, then it might be worth training the model from scratch. ui_display_name: Use Pretrained vocab: default_value_reasoning: Computed and passed along internally according to preprocessing settings. example_value: - a - b - c internal_only: true ui_display_name: Not Displayed vocab_size: internal_only: true ui_display_name: Not displayed ParallelCNN: type: short_description: Default option for processing sequence, audio, and text data types. long_description: The Parallel CNN works by first mapping the input integer sequence b x s (where b is the batch size and s is the length of the sequence) into a sequence of embeddings, then it passes the embedding through a number of parallel 1d convolutional layers with different filter size (by default 4 layers with filter size 2, 3, 4 and 5), followed by max pooling and concatenation. This single vector concatenating the outputs of the parallel convolutional layers is then passed through a stack of fully connected layers and returned as a b x h tensor where h is the output size of the last fully connected layer. compute_tier: 1 activation: default_value_reasoning: The Rectified Linear Units (ReLU) function is the standard activation function used for adding non-linearity. It is simple, fast, and empirically works well (https://arxiv.org/abs/1803.08375). description_implications: Changing the activation functions has an impact on the computational load of the model and might require further hypterparameter tuning expected_impact: 2 suggested_values: The default value will work well in the majority of the cases ui_display_name: Activation bias_initializer: default_value_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. description_implications: It's rare to see any performance gains from choosing a different bias initialization. Some practitioners like to use a small constant value such as 0.01 for all biases to ensure that all ReLU units are activated in the beginning and have some effect on the gradient. However, it's still an open question as to whether this provides consistent improvement. expected_impact: 1 literature_references: - https://cs231n.github.io/neural-networks-2/ related_parameters: - weights_initializer suggested_values: zeros suggested_values_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. For ReLU non-linearities, some people like to use small constant value such as 0.01 for all biases because this ensures that all ReLU units fire in the beginning and therefore obtain and propagate some gradient. However, it is not clear if this provides a consistent improvement (in fact some results seem to indicate that this performs worse) and it is more common to simply use 0 bias initialization. ui_display_name: Bias Initializer dropout: default_value_reasoning: Dropout can cause training to become less stable. Consider start with a dropout-free baseline, and add dropout gradually in subsequent experiments. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 3 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: Dropout embedding_size: default_value_reasoning: Not too big, not too small. description_implications: 'An embedding is a relatively low-dimensional space that is used to translate high-dimensional vectors like words, which can have a large vocbulary size. Ideally, after an embedding is trained, it captures some of the semantics of the input by placing semantically similar inputs close together in the embedding space. In most cases, the embedding size is chosen empirically, by trial and error. From https://www.amazon.com/dp/1098115783, "one rule of thumb is to use the fourth root of the total number of unique categorical elements while another is that the embedding dimension should be approximately 1.6 times the square root of the number of unique elements in the category, and no less than 600." Increasing the embedding size may cause the model to train more slowly, but the higher dimensionality can also improve overall quality.' expected_impact: 3 literature_references: - https://developers.google.com/machine-learning/crash-course/embeddings/video-lecture suggested_values: 1.6 * sqrt(vocab_size) suggested_values_reasoning: Rule of thumb suggested by a deep learning textbook. Try models with smaller or larger embedding sizes to observe relative impact. ui_display_name: Embedding Size embeddings_on_cpu: default_value_reasoning: By default embeddings matrices are stored on GPU memory if a GPU is used, as it allows for faster access. description_implications: By default embeddings matrices are stored on GPU memory if a GPU is used, as it allows for faster access. However, in some cases when the vocabulary size is very large, the full embedding matrix may be really big and unwieldy to have in GPU memory. This parameter forces the placement of the embedding matrix in regular memory and the CPU is used to access them. This may slow down training due to additional data transfer between CPU and GPU memory, but can lead to healthier GPU memory resource usage. expected_impact: 1 suggested_values: - false suggested_values_reasoning: If GPU memory is not a constraint, having embeddings stored and accessed within the GPU is faster. ui_display_name: Embeddings on CPU embeddings_trainable: ui_display_name: null expected_impact: 1 fc_layers: default_value_reasoning: By default the stack is built by using num_fc_layers, output_size, use_bias, weights_initializer, bias_initializer, norm, norm_params, activation, dropout. When a list of dictionaries is provided, the stack is built following the parameters of each dict for building each layer. description_implications: The more layers that are specified the deeper and higher capacity the model will be. This makes it possible to potentially achieve better performance when a big anough amount of data is provided, but also makes the model more computationally expensive and potentially more prone to overfitting. example_value: - dropout: 0.1 output_size: 128 - norm: layer output_size: 64 expected_impact: 1 related_parameters: - output_size - use_bias - weights_initializer - bias_initializer - norm - norm_params - activation - dropout suggested_values_reasoning: It is easier to define a stack of fully connected layers by just specifying num_fc_layers, output_size and the other individual parameters. It will create a stack of layers with identical properties. Use this parameter only if you need a fine grained level of control of each individual layer in the stack. ui_display_name: Fully Connected Layers filter_size: ui_display_name: null expected_impact: 2 max_sequence_length: default_value_reasoning: Sets the maximum sequence length of the expected inputs, so input/output shapes are computed accurately. internal_only: true ui_display_name: null norm: default_value_reasoning: While batch normalization and layer normalization usually lead to improvements, it can be useful to start with fewer bells and whistles. description_implications: Normalization helps stabilize the learning process and can have a regularizing effect that can help with generalization. It's often suggested that with normalization, you can use a higher learning rate. example_value: - batch expected_impact: 2 literature_references: - https://machinelearningmastery.com/batch-normalization-for-training-of-deep-neural-networks/ related_parameters: - norm_params suggested_values: '"batch" or "layer"' suggested_values_reasoning: Normalization tries to solve "internal covariate shift" that comes from the changing distributions of the inputs to layers deep in the network when weights are updated. For example, batch normalization standardizes the inputs to a layer for each mini-batch. Try out different normalizations to see if that helps with training stability ui_display_name: Normalization Type norm_params: default_value_reasoning: The default parameters that come with Torch's implementation of these normalization types are a trusted starting point. description_implications: There are a variety of ways a certain set of parameters specificed could influence performance here. Broadly speaking the different values passed in here allow for different levels of smoothness to be observed in the learning curves. Since setting this parameters depends on the type of `norm` set, see [BatchNorm2d](https://pytorch.org/docs/stable/generated/torch.nn.BatchNorm2d.html) for more information on the parameters to set for batch normalization, and see [LayerNorm](https://pytorch.org/docs/stable/generated/torch.nn.LayerNorm.html) for more information on the parameters to set for layer normalization. example_value: - affine: false momentum: 0.2 num_features: 100 expected_impact: 1 literature_references: - "For BatchNorm2d: https://arxiv.org/abs/1502.03167 For LayerNorm: https://arxiv.org/abs/1607.06450" related_parameters: - "`norm`" suggested_values: Depends on the type of `norm` set. suggested_values_reasoning: "NO" ui_display_name: Normalization Parameters num_fc_layers: default_value_reasoning: The encoder already has learnable parameters.Sometimes the default is 1 for modules where the FC stack is used for shape management, or the only source of learnable parameters. description_implications: Increasing num_fc_layers will increase the capacity of the model. The model will be slower to train, and there's a higher risk of overfitting. example_value: - 1 expected_impact: 1 other_information: Not all modules that have fc_layers also have an accompanying num_fc_layers parameter. Where both are present, fc_layers takes precedent over num_fc_layers. Specifying num_fc_layers alone uses fully connected layers that are configured by the defaults in FCStack. related_parameters: - fc_layers suggested_values: 0-1 suggested_values_reasoning: The full model likely contains many learnable parameters. Consider starting with very few, or without any additional fully connected layers and add them if you observe evidence of limited model capacity. Sometimes the default is 1 for modules where the FC stack is used for shape management, or the only source of learnable parameters. ui_display_name: Number of Fully Connected Layers output_size: default_value_reasoning: A modest value, not too small, not too large. description_implications: If there are fully connected layers in this module, increasing the output size of each fully connected layer will increase the capacity of the model. However, the model may be slower to train, and there's a higher risk of overfitting. If it seems like the model could use even more capacity, consider increasing the number of fully connected layers, or explore other architectures. expected_impact: 3 other_information: If num_fc_layers=0 and fc_layers=None, and there are no fully connected layers defined on the module, then this parameter may have no effect on the module's final output shape. related_parameters: - num_fc_layers, fc_layers suggested_values: 10 - 1024 suggested_values_reasoning: Increasing the output size increases the capacity of the model. If this seems to have a positive effect, then it could be worth increasing the number of layers, or trying a different architecture with a larger capacity. ui_display_name: Output Size pool_function: ui_display_name: Pooling function expected_impact: 1 pool_size: ui_display_name: null expected_impact: 1 pretrained_embeddings: ui_display_name: null expected_impact: 0 reduce_output: ui_display_name: null expected_impact: 1 representation: ui_display_name: null expected_impact: 1 should_embed: internal_only: true ui_display_name: Not displayed use_bias: default_value_reasoning: "Bias terms may improve model accuracy, and don't have much impact in terms of memory or training speed. For most models it is reasonable to use bias terms. Batch Normalization, however, adds a trainable shift parameter which is added to the activation. When Batch Normalization is used in a layer, bias terms are redundant and may be removed." description_implications: Bias terms may improve model accuracy, and don't have much impact in terms of memory or training speed. For most models it is reasonable to leave this parameter set to True. example_value: - true expected_impact: 1 other_information: If fc_layers is not specified, or use_bias is not specified for individual layers, the value of use_bias will be used as the default for all layers. related_parameters: - bias_initializer, fc_layers suggested_values: - true ui_display_name: Use Bias vocab: default_value_reasoning: Computed and passed along internally according to preprocessing settings. example_value: - a - b - c internal_only: true ui_display_name: Not Displayed weights_initializer: default_value_reasoning: Taken from [this paper](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf). description_implications: The method you choose to initialize layer weights during training can have a big impact on performance as well as the reproducibility of your final model between runs. As an example, if you were to randomly initialize weights you would risk non-reproducibility (and possibly general training performance), but sticking with constant values for initialization might significantly increase the time needed for model convergence. Generally, choosing one of the probabilistic approaches strikes a balance between the two extremes, and the literature kicked off by the landmark [*Xavier et al.* paper](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf) provides a few good options. See this nice discussion from [Weights and Biases](https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster.) for more information. expected_impact: 1 literature_references: - "Weights and Biases blog post: https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster. Xavier et al. paper: http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf" suggested_values: xavier_uniform suggested_values_reasoning: Changing the weights initialization scheme is something to consider if a model is having trouble with convergence, or otherwise it is something to experiment with after other factors are considered. The default choice (`xavier_uniform`) is a suitable starting point for most tasks. ui_display_name: Layer Weights Initializer PassthroughEncoder: type: short_description: Passes the raw input through to the combiner. long_description: The passthrough encoder simply returns the raw numerical values coming from the input placeholders as outputs. Inputs are of size `b` while outputs are of size `b x 1` where `b` is the batch size. input_size: internal_only: true other_information: Internal Only related_parameters: - "No" ui_display_name: Not Displayed BinaryPassthroughEncoder: type: short_description: Passes the raw input through to the combiner. long_description: The passthrough encoder simply returns the raw numerical values coming from the input placeholders as outputs. Inputs are of size `b` while outputs are of size `b x 1` where `b` is the batch size. input_size: internal_only: true other_information: Internal Only related_parameters: - "No" ui_display_name: Not Displayed CategoricalPassthroughEncoder: type: short_description: Passes the raw input through to the combiner. long_description: The passthrough encoder simply returns the raw numerical values coming from the input placeholders as outputs. Inputs are of size `b` while outputs are of size `b x 1` where `b` is the batch size. input_size: internal_only: true other_information: Internal Only related_parameters: - "No" ui_display_name: Not Displayed ResNet: type: short_description: Residual network achieving very high performance on computer vision tasks. long_description: ResNet - short for residual network is part of a family of extremely deep architectures showing compelling accuracy and nice convergence behaviors for computer vision applications. It is a type of CNN architecture designed to support hundreds or thousands of convolutional layers. compute_tier: 2 activation: default_value_reasoning: The Rectified Linear Units (ReLU) function is the standard activation function used for adding non-linearity. It is simple, fast, and empirically works well (https://arxiv.org/abs/1803.08375). description_implications: Changing the activation functions has an impact on the computational load of the model and might require further hypterparameter tuning expected_impact: 2 suggested_values: The default value will work well in the majority of the cases ui_display_name: Activation batch_norm_epsilon: ui_display_name: null batch_norm_momentum: ui_display_name: null bias_initializer: default_value_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. description_implications: It's rare to see any performance gains from choosing a different bias initialization. Some practitioners like to use a small constant value such as 0.01 for all biases to ensure that all ReLU units are activated in the beginning and have some effect on the gradient. However, it's still an open question as to whether this provides consistent improvement. expected_impact: 1 literature_references: - https://cs231n.github.io/neural-networks-2/ related_parameters: - weights_initializer suggested_values: zeros suggested_values_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. For ReLU non-linearities, some people like to use small constant value such as 0.01 for all biases because this ensures that all ReLU units fire in the beginning and therefore obtain and propagate some gradient. However, it is not clear if this provides a consistent improvement (in fact some results seem to indicate that this performs worse) and it is more common to simply use 0 bias initialization. ui_display_name: Bias Initializer conv_stride: ui_display_name: null dropout: default_value_reasoning: Dropout can cause training to become less stable. Consider start with a dropout-free baseline, and add dropout gradually in subsequent experiments. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 3 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: Dropout fc_layers: default_value_reasoning: By default the stack is built by using num_fc_layers, output_size, use_bias, weights_initializer, bias_initializer, norm, norm_params, activation, dropout. When a list of dictionaries is provided, the stack is built following the parameters of each dict for building each layer. description_implications: The more layers that are specified the deeper and higher capacity the model will be. This makes it possible to potentially achieve better performance when a big anough amount of data is provided, but also makes the model more computationally expensive and potentially more prone to overfitting. example_value: - dropout: 0.1 output_size: 128 - norm: layer output_size: 64 expected_impact: 1 related_parameters: - output_size - use_bias - weights_initializer - bias_initializer - norm - norm_params - activation - dropout suggested_values_reasoning: It is easier to define a stack of fully connected layers by just specifying num_fc_layers, output_size and the other individual parameters. It will create a stack of layers with identical properties. Use this parameter only if you need a fine grained level of control of each individual layer in the stack. ui_display_name: Fully Connected Layers first_pool_kernel_size: ui_display_name: null expected_impact: 1 first_pool_stride: ui_display_name: null expected_impact: 1 height: internal_only: true ui_display_name: null kernel_size: ui_display_name: null norm: default_value_reasoning: While batch normalization and layer normalization usually lead to improvements, it can be useful to start with fewer bells and whistles. description_implications: Normalization helps stabilize the learning process and can have a regularizing effect that can help with generalization. It's often suggested that with normalization, you can use a higher learning rate. example_value: - batch expected_impact: 2 literature_references: - https://machinelearningmastery.com/batch-normalization-for-training-of-deep-neural-networks/ related_parameters: - norm_params suggested_values: '"batch" or "layer"' suggested_values_reasoning: Normalization tries to solve "internal covariate shift" that comes from the changing distributions of the inputs to layers deep in the network when weights are updated. For example, batch normalization standardizes the inputs to a layer for each mini-batch. Try out different normalizations to see if that helps with training stability ui_display_name: Normalization Type norm_params: default_value_reasoning: The default parameters that come with Torch's implementation of these normalization types are a trusted starting point. description_implications: There are a variety of ways a certain set of parameters specificed could influence performance here. Broadly speaking the different values passed in here allow for different levels of smoothness to be observed in the learning curves. Since setting this parameters depends on the type of `norm` set, see [BatchNorm2d](https://pytorch.org/docs/stable/generated/torch.nn.BatchNorm2d.html) for more information on the parameters to set for batch normalization, and see [LayerNorm](https://pytorch.org/docs/stable/generated/torch.nn.LayerNorm.html) for more information on the parameters to set for layer normalization. example_value: - affine: false momentum: 0.2 num_features: 100 expected_impact: 1 literature_references: - "For BatchNorm2d: https://arxiv.org/abs/1502.03167 For LayerNorm: https://arxiv.org/abs/1607.06450" related_parameters: - "`norm`" suggested_values: Depends on the type of `norm` set. suggested_values_reasoning: "NO" ui_display_name: Normalization Parameters num_channels: ui_display_name: null num_fc_layers: default_value_reasoning: The encoder already has learnable parameters.Sometimes the default is 1 for modules where the FC stack is used for shape management, or the only source of learnable parameters. description_implications: Increasing num_fc_layers will increase the capacity of the model. The model will be slower to train, and there's a higher risk of overfitting. example_value: - 1 expected_impact: 1 other_information: Not all modules that have fc_layers also have an accompanying num_fc_layers parameter. Where both are present, fc_layers takes precedent over num_fc_layers. Specifying num_fc_layers alone uses fully connected layers that are configured by the defaults in FCStack. related_parameters: - fc_layers suggested_values: 0-1 suggested_values_reasoning: The full model likely contains many learnable parameters. Consider starting with very few, or without any additional fully connected layers and add them if you observe evidence of limited model capacity. Sometimes the default is 1 for modules where the FC stack is used for shape management, or the only source of learnable parameters. ui_display_name: Number of Fully Connected Layers out_channels: ui_display_name: null output_size: default_value_reasoning: A modest value, not too small, not too large. description_implications: If there are fully connected layers in this module, increasing the output size of each fully connected layer will increase the capacity of the model. However, the model may be slower to train, and there's a higher risk of overfitting. If it seems like the model could use even more capacity, consider increasing the number of fully connected layers, or explore other architectures. expected_impact: 3 other_information: If num_fc_layers=0 and fc_layers=None, and there are no fully connected layers defined on the module, then this parameter may have no effect on the module's final output shape. related_parameters: - num_fc_layers, fc_layers suggested_values: 10 - 1024 suggested_values_reasoning: Increasing the output size increases the capacity of the model. If this seems to have a positive effect, then it could be worth increasing the number of layers, or trying a different architecture with a larger capacity. ui_display_name: Output Size resnet_size: ui_display_name: null use_bias: ui_display_name: null expected_impact: 1 weights_initializer: default_value_reasoning: Taken from [this paper](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf). description_implications: The method you choose to initialize layer weights during training can have a big impact on performance as well as the reproducibility of your final model between runs. As an example, if you were to randomly initialize weights you would risk non-reproducibility (and possibly general training performance), but sticking with constant values for initialization might significantly increase the time needed for model convergence. Generally, choosing one of the probabilistic approaches strikes a balance between the two extremes, and the literature kicked off by the landmark [*Xavier et al.* paper](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf) provides a few good options. See this nice discussion from [Weights and Biases](https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster.) for more information. expected_impact: 1 literature_references: - "Weights and Biases blog post: https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster. Xavier et al. paper: http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf" suggested_values: xavier_uniform suggested_values_reasoning: Changing the weights initialization scheme is something to consider if a model is having trouble with convergence, or otherwise it is something to experiment with after other factors are considered. The default choice (`xavier_uniform`) is a suitable starting point for most tasks. ui_display_name: Layer Weights Initializer width: internal_only: true ui_display_name: null DeBERTa: type: short_description: Improved version of BERT and RoBERTa, achieving good baseline performance on many tasks. long_description: The [DeBERTa](https://arxiv.org/abs/2006.03654) encoder improves the BERT and RoBERTa models using disentangled attention and enhanced mask decoder. With those two improvements, DeBERTa out performs RoBERTa on a majority of NLU tasks with 80GB training data. In [DeBERTa V3](https://arxiv.org/abs/2111.09543), the authors further improved the efficiency of DeBERTa using ELECTRA-Style pre-training with Gradient Disentangled Embedding Sharing. Compared to DeBERTa, the V3 version significantly improves the model performance on downstream tasks. compute_tier: 2 literature_references: - https://arxiv.org/abs/2006.03654 - https://arxiv.org/abs/2111.09543 pretrained_model_name_or_path: default_value_reasoning: The default model was selected based on the benchmarking work done by IBM's [model recycling](https://ibm.github.io/model-recycling/microsoft_deberta-v3-base_table.html) project. In that study, the selected model ranked first among all variants of the `microsoft/deberta-v3-base` architecture on an evaluation across 36 different datasets. description_implications: Considerations when selecting a pretrained model version include number of parameters (how long the model will take to fine-tuning / perform inference), general model performance on various benchmarks, and specific model performance on the task you wish to fine-tune it on. expected_impact: 2 related_parameters: - use_pretrained, trainable, pretrained_kwargs ui_display_name: Pretrained model RoBERTa: type: short_description: BERT based model that has higher accuracy and is easier parallelize due to larger mini-batches. long_description: The `roberta` encoder loads a pretrained [RoBERTa](https://arxiv.org/abs/1907.11692) (default `roberta-base`) model using the Hugging Face transformers package. Replication of BERT pretraining which may match or exceed the performance of BERT. RoBERTa builds on BERT and modifies key hyperparameters, removing the next-sentence pretraining objective and training with much larger mini-batches and learning rates. literature_references: - https://arxiv.org/abs/1907.11692 compute_tier: 2 bos_token_id: default_value_reasoning: Default value used in pre-trained HF encoder. ui_display_name: Beginning-of-Sentence Token Id eos_token_id: default_value_reasoning: example_value: - Default value used in pre-trained HF encoder. expected_impact: 1 ui_display_name: null max_sequence_length: default_value_reasoning: Sets the maximum sequence length of the expected inputs, so input/output shapes are computed accurately. internal_only: true ui_display_name: null pad_token_id: ui_display_name: null pretrained_kwargs: ui_display_name: null pretrained_model_name_or_path: ui_display_name: null expected_impact: 2 reduce_output: ui_display_name: null expected_impact: 1 saved_weights_in_checkpoint: default_value_reasoning: The weights of the encoder are not necessarily saved in the checkpoint. The user has to save them first. description_implications: The memory footprint for some of these encoders can be large. internal_only: true related_parameters: - skip_save_model suggested_values: - false suggested_values_reasoning: Some of these encoders are large, so it might be better to load them as needed, especially if 1. they're not used frequently 2. the user doesn't have a lot of storage. ui_display_name: null trainable: expected_impact: 3 ui_display_name: null use_pretrained: default_value_reasoning: By default, the model is initialized as a pretrained model. description_implications: Pretrained models have typically already learned features that are difficult to learn from scratch. They are particularly beneficial when training on small amounts of data. expected_impact: 3 literature_references: - https://machinelearningmastery.com/transfer-learning-for-deep-learning/ related_parameters: - trainable, pretrained_model_name, pretrained_model_name_or_path, pretrained_kwargs suggested_values: - false suggested_values_reasoning: If you have a large amount of data and/or you have data that differs from the typical distribution, then it might be worth training the model from scratch. ui_display_name: Use Pretrained vocab: default_value_reasoning: Computed and passed along internally according to preprocessing settings. example_value: - a - b - c internal_only: true ui_display_name: Not Displayed vocab_size: internal_only: true ui_display_name: Not displayed SequenceEmbed: type: short_description: Maps each element of the sequence to an embedding. long_description: The embed encoder simply maps each integer in the sequence to an embedding, creating a `b x s x h` tensor where `b` is the batch size, `s` is the length of the sequence and `h` is the embedding size. The tensor is reduced along the `s` dimension to obtain a single vector of size `h` for each element of the batch. dropout: default_value_reasoning: Dropout can cause training to become less stable. Consider start with a dropout-free baseline, and add dropout gradually in subsequent experiments. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 3 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: Dropout embedding_size: default_value_reasoning: Not too big, not too small. description_implications: 'An embedding is a relatively low-dimensional space that is used to translate high-dimensional vectors like words, which can have a large vocbulary size. Ideally, after an embedding is trained, it captures some of the semantics of the input by placing semantically similar inputs close together in the embedding space. In most cases, the embedding size is chosen empirically, by trial and error. From https://www.amazon.com/dp/1098115783, "one rule of thumb is to use the fourth root of the total number of unique categorical elements while another is that the embedding dimension should be approximately 1.6 times the square root of the number of unique elements in the category, and no less than 600." Increasing the embedding size may cause the model to train more slowly, but the higher dimensionality can also improve overall quality.' expected_impact: 3 literature_references: - https://developers.google.com/machine-learning/crash-course/embeddings/video-lecture suggested_values: 1.6 * sqrt(vocab_size) suggested_values_reasoning: Rule of thumb suggested by a deep learning textbook. Try models with smaller or larger embedding sizes to observe relative impact. ui_display_name: Embedding Size embeddings_on_cpu: default_value_reasoning: By default embeddings matrices are stored on GPU memory if a GPU is used, as it allows for faster access. description_implications: By default embeddings matrices are stored on GPU memory if a GPU is used, as it allows for faster access. However, in some cases when the vocabulary size is very large, the full embedding matrix may be really big and unwieldy to have in GPU memory. This parameter forces the placement of the embedding matrix in regular memory and the CPU is used to access them. This may slow down training due to additional data transfer between CPU and GPU memory, but can lead to healthier GPU memory resource usage. expected_impact: 1 suggested_values: - false suggested_values_reasoning: If GPU memory is not a constraint, having embeddings stored and accessed within the GPU is faster. ui_display_name: Embeddings on CPU embeddings_trainable: ui_display_name: null expected_impact: 1 max_sequence_length: default_value_reasoning: Sets the maximum sequence length of the expected inputs, so input/output shapes are computed accurately. internal_only: true ui_display_name: null pretrained_embeddings: ui_display_name: null expected_impact: 0 reduce_output: ui_display_name: null expected_impact: 1 representation: ui_display_name: null expected_impact: 1 vocab: default_value_reasoning: Computed and passed along internally according to preprocessing settings. example_value: - a - b - c internal_only: true ui_display_name: Not Displayed weights_initializer: default_value_reasoning: Taken from [this paper](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf). description_implications: The method you choose to initialize layer weights during training can have a big impact on performance as well as the reproducibility of your final model between runs. As an example, if you were to randomly initialize weights you would risk non-reproducibility (and possibly general training performance), but sticking with constant values for initialization might significantly increase the time needed for model convergence. Generally, choosing one of the probabilistic approaches strikes a balance between the two extremes, and the literature kicked off by the landmark [*Xavier et al.* paper](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf) provides a few good options. See this nice discussion from [Weights and Biases](https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster.) for more information. expected_impact: 1 literature_references: - "Weights and Biases blog post: https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster. Xavier et al. paper: http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf" suggested_values: xavier_uniform suggested_values_reasoning: Changing the weights initialization scheme is something to consider if a model is having trouble with convergence, or otherwise it is something to experiment with after other factors are considered. The default choice (`xavier_uniform`) is a suitable starting point for most tasks. ui_display_name: Layer Weights Initializer SequencePassthrough: type: short_description: Transforms sequence values to a floats then reduces to obtain a vector for each element. long_description: The passthrough encoder simply transforms each input value into a float value and adds a dimension to the input tensor, creating a b x s x 1 tensor where b is the batch size and s is the length of the sequence. The tensor is reduced along the s dimension to obtain a single vector of size h for each element of the batch. encoding_size: default_value_reasoning: The default `reduce_output` method does not use this parameter, so by default this parameter is not set. description_implications: This parameter must be equal to the size of the input. Otherwise, an error will occur. example_value: - 128 expected_impact: 1 related_parameters: - reduce_output suggested_values_reasoning: NONE ui_display_name: null max_sequence_length: default_value_reasoning: Sets the maximum sequence length of the expected inputs, so input/output shapes are computed accurately. internal_only: true ui_display_name: null reduce_output: ui_display_name: null expected_impact: 1 SetSparseEncoder: type: short_description: Maps raw values to sparse integer lists, then maps to dense/sparse embeddings, then reduces to final vector. long_description: The Embed encoder takes the raw binary values coming from the input placeholders and transforms them to sparse integer lists, then they are mapped to either dense or sparse embeddings (one-hot encodings), finally they are reduced on the sequence dimension and returned as an aggregated embedding vector. Inputs are of size b while outputs are of size b x h where b is the batch size and h is the dimensionality of the embeddings. activation: default_value_reasoning: The Rectified Linear Units (ReLU) function is the standard activation function used for adding non-linearity. It is simple, fast, and empirically works well (https://arxiv.org/abs/1803.08375). description_implications: Changing the activation functions has an impact on the computational load of the model and might require further hypterparameter tuning expected_impact: 2 suggested_values: The default value will work well in the majority of the cases ui_display_name: Activation bias_initializer: default_value_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. description_implications: It's rare to see any performance gains from choosing a different bias initialization. Some practitioners like to use a small constant value such as 0.01 for all biases to ensure that all ReLU units are activated in the beginning and have some effect on the gradient. However, it's still an open question as to whether this provides consistent improvement. expected_impact: 1 literature_references: - https://cs231n.github.io/neural-networks-2/ related_parameters: - weights_initializer suggested_values: zeros suggested_values_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. For ReLU non-linearities, some people like to use small constant value such as 0.01 for all biases because this ensures that all ReLU units fire in the beginning and therefore obtain and propagate some gradient. However, it is not clear if this provides a consistent improvement (in fact some results seem to indicate that this performs worse) and it is more common to simply use 0 bias initialization. ui_display_name: Bias Initializer dropout: default_value_reasoning: Dropout can cause training to become less stable. Consider start with a dropout-free baseline, and add dropout gradually in subsequent experiments. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 3 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: Dropout embedding_size: default_value_reasoning: Not too big, not too small. description_implications: 'An embedding is a relatively low-dimensional space that is used to translate high-dimensional vectors like words, which can have a large vocbulary size. Ideally, after an embedding is trained, it captures some of the semantics of the input by placing semantically similar inputs close together in the embedding space. In most cases, the embedding size is chosen empirically, by trial and error. From https://www.amazon.com/dp/1098115783, "one rule of thumb is to use the fourth root of the total number of unique categorical elements while another is that the embedding dimension should be approximately 1.6 times the square root of the number of unique elements in the category, and no less than 600." Increasing the embedding size may cause the model to train more slowly, but the higher dimensionality can also improve overall quality.' expected_impact: 3 literature_references: - https://developers.google.com/machine-learning/crash-course/embeddings/video-lecture suggested_values: 1.6 * sqrt(vocab_size) suggested_values_reasoning: Rule of thumb suggested by a deep learning textbook. Try models with smaller or larger embedding sizes to observe relative impact. ui_display_name: Embedding Size embeddings_on_cpu: default_value_reasoning: By default embeddings matrices are stored on GPU memory if a GPU is used, as it allows for faster access. description_implications: By default embeddings matrices are stored on GPU memory if a GPU is used, as it allows for faster access. However, in some cases when the vocabulary size is very large, the full embedding matrix may be really big and unwieldy to have in GPU memory. This parameter forces the placement of the embedding matrix in regular memory and the CPU is used to access them. This may slow down training due to additional data transfer between CPU and GPU memory, but can lead to healthier GPU memory resource usage. expected_impact: 1 suggested_values: - false suggested_values_reasoning: If GPU memory is not a constraint, having embeddings stored and accessed within the GPU is faster. ui_display_name: Embeddings on CPU embeddings_trainable: ui_display_name: null expected_impact: 1 fc_layers: default_value_reasoning: By default the stack is built by using num_fc_layers, output_size, use_bias, weights_initializer, bias_initializer, norm, norm_params, activation, dropout. When a list of dictionaries is provided, the stack is built following the parameters of each dict for building each layer. description_implications: The more layers that are specified the deeper and higher capacity the model will be. This makes it possible to potentially achieve better performance when a big anough amount of data is provided, but also makes the model more computationally expensive and potentially more prone to overfitting. example_value: - dropout: 0.1 output_size: 128 - norm: layer output_size: 64 expected_impact: 1 related_parameters: - output_size - use_bias - weights_initializer - bias_initializer - norm - norm_params - activation - dropout suggested_values_reasoning: It is easier to define a stack of fully connected layers by just specifying num_fc_layers, output_size and the other individual parameters. It will create a stack of layers with identical properties. Use this parameter only if you need a fine grained level of control of each individual layer in the stack. ui_display_name: Fully Connected Layers norm: default_value_reasoning: While batch normalization and layer normalization usually lead to improvements, it can be useful to start with fewer bells and whistles. description_implications: Normalization helps stabilize the learning process and can have a regularizing effect that can help with generalization. It's often suggested that with normalization, you can use a higher learning rate. example_value: - batch expected_impact: 2 literature_references: - https://machinelearningmastery.com/batch-normalization-for-training-of-deep-neural-networks/ related_parameters: - norm_params suggested_values: '"batch" or "layer"' suggested_values_reasoning: Normalization tries to solve "internal covariate shift" that comes from the changing distributions of the inputs to layers deep in the network when weights are updated. For example, batch normalization standardizes the inputs to a layer for each mini-batch. Try out different normalizations to see if that helps with training stability ui_display_name: Normalization Type norm_params: default_value_reasoning: The default parameters that come with Torch's implementation of these normalization types are a trusted starting point. description_implications: There are a variety of ways a certain set of parameters specificed could influence performance here. Broadly speaking the different values passed in here allow for different levels of smoothness to be observed in the learning curves. Since setting this parameters depends on the type of `norm` set, see [BatchNorm2d](https://pytorch.org/docs/stable/generated/torch.nn.BatchNorm2d.html) for more information on the parameters to set for batch normalization, and see [LayerNorm](https://pytorch.org/docs/stable/generated/torch.nn.LayerNorm.html) for more information on the parameters to set for layer normalization. example_value: - affine: false momentum: 0.2 num_features: 100 expected_impact: 1 literature_references: - "For BatchNorm2d: https://arxiv.org/abs/1502.03167 For LayerNorm: https://arxiv.org/abs/1607.06450" related_parameters: - "`norm`" suggested_values: Depends on the type of `norm` set. suggested_values_reasoning: "NO" ui_display_name: Normalization Parameters num_fc_layers: default_value_reasoning: The encoder already has learnable parameters.Sometimes the default is 1 for modules where the FC stack is used for shape management, or the only source of learnable parameters. description_implications: Increasing num_fc_layers will increase the capacity of the model. The model will be slower to train, and there's a higher risk of overfitting. example_value: - 1 expected_impact: 1 other_information: Not all modules that have fc_layers also have an accompanying num_fc_layers parameter. Where both are present, fc_layers takes precedent over num_fc_layers. Specifying num_fc_layers alone uses fully connected layers that are configured by the defaults in FCStack. related_parameters: - fc_layers suggested_values: 0-1 suggested_values_reasoning: The full model likely contains many learnable parameters. Consider starting with very few, or without any additional fully connected layers and add them if you observe evidence of limited model capacity. Sometimes the default is 1 for modules where the FC stack is used for shape management, or the only source of learnable parameters. ui_display_name: Number of Fully Connected Layers output_size: default_value_reasoning: A modest value, not too small, not too large. description_implications: If there are fully connected layers in this module, increasing the output size of each fully connected layer will increase the capacity of the model. However, the model may be slower to train, and there's a higher risk of overfitting. If it seems like the model could use even more capacity, consider increasing the number of fully connected layers, or explore other architectures. expected_impact: 3 other_information: If num_fc_layers=0 and fc_layers=None, and there are no fully connected layers defined on the module, then this parameter may have no effect on the module's final output shape. related_parameters: - num_fc_layers, fc_layers suggested_values: 10 - 1024 suggested_values_reasoning: Increasing the output size increases the capacity of the model. If this seems to have a positive effect, then it could be worth increasing the number of layers, or trying a different architecture with a larger capacity. ui_display_name: Output Size pretrained_embeddings: ui_display_name: null expected_impact: 0 representation: ui_display_name: null expected_impact: 1 use_bias: default_value_reasoning: "Bias terms may improve model accuracy, and don't have much impact in terms of memory or training speed. For most models it is reasonable to use bias terms. Batch Normalization, however, adds a trainable shift parameter which is added to the activation. When Batch Normalization is used in a layer, bias terms are redundant and may be removed." description_implications: Bias terms may improve model accuracy, and don't have much impact in terms of memory or training speed. For most models it is reasonable to leave this parameter set to True. example_value: - true expected_impact: 1 other_information: If fc_layers is not specified, or use_bias is not specified for individual layers, the value of use_bias will be used as the default for all layers. related_parameters: - bias_initializer, fc_layers suggested_values: - true ui_display_name: Use Bias vocab: default_value_reasoning: Computed and passed along internally according to preprocessing settings. example_value: - a - b - c internal_only: true ui_display_name: Not Displayed weights_initializer: default_value_reasoning: Taken from [this paper](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf). description_implications: The method you choose to initialize layer weights during training can have a big impact on performance as well as the reproducibility of your final model between runs. As an example, if you were to randomly initialize weights you would risk non-reproducibility (and possibly general training performance), but sticking with constant values for initialization might significantly increase the time needed for model convergence. Generally, choosing one of the probabilistic approaches strikes a balance between the two extremes, and the literature kicked off by the landmark [*Xavier et al.* paper](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf) provides a few good options. See this nice discussion from [Weights and Biases](https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster.) for more information. expected_impact: 1 literature_references: - "Weights and Biases blog post: https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster. Xavier et al. paper: http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf" suggested_values: xavier_uniform suggested_values_reasoning: Changing the weights initialization scheme is something to consider if a model is having trouble with convergence, or otherwise it is something to experiment with after other factors are considered. The default choice (`xavier_uniform`) is a suitable starting point for most tasks. ui_display_name: Layer Weights Initializer Stacked2DCNN: type: short_description: Stack of 2D convolutional layers followed by an optional stack of fully connected layers. long_description: Stack of 2D convolutional layers with optional normalization, dropout, and down-sampling pooling layers, followed by an optional stack of fully connected layers. compute_tier: 1 conv_activation: expected_impact: 1 ui_display_name: Convolutional Activation conv_bias: expected_impact: 1 ui_display_name: Convolutional Bias conv_dropout: default_value_reasoning: Dropout can cause training to become less stable. Consider start with a dropout-free baseline, and add dropout gradually in subsequent experiments. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 3 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html related_parameters: - "conv_dropout, fc_dropout" suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: Convolutional Dropout conv_norm: expected_impact: 2 ui_display_name: Convolutional Normalization conv_norm_params: expected_impact: 1 ui_display_name: Convolutional Normalization Parameters dilation: expected_impact: 1 ui_display_name: Dilation fc_activation: default_value_reasoning: The Rectified Linear Units (ReLU) function is the standard activation function used for adding non-linearity. It is simple, fast, and empirically works well (https://arxiv.org/abs/1803.08375). description_implications: Changing the activation functions has an impact on the computational load of the model and might require further hypterparameter tuning example_value: - relu expected_impact: 1 literature_references: - https://ml-cheatsheet.readthedocs.io/en/latest/activation_functions.html related_parameters: - activation, activation_function, conv_activation, recurrent_activation suggested_values: relu, alternatively leakyRelu or elu suggested_values_reasoning: The default value will work well in the majority of the cases ui_display_name: FC Activation fc_bias_initializer: default_value_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. description_implications: It's rare to see any performance gains from choosing a different bias initialization. Some practitioners like to use a small constant value such as 0.01 for all biases to ensure that all ReLU units are activated in the beginning and have some effect on the gradient. However, it's still an open question as to whether this provides consistent improvement. expected_impact: 1 literature_references: - https://cs231n.github.io/neural-networks-2/ related_parameters: - weights_initializer suggested_values: zeros suggested_values_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. For ReLU non-linearities, some people like to use small constant value such as 0.01 for all biases because this ensures that all ReLU units fire in the beginning and therefore obtain and propagate some gradient. However, it is not clear if this provides a consistent improvement (in fact some results seem to indicate that this performs worse) and it is more common to simply use 0 bias initialization. ui_display_name: Bias Initializer fc_dropout: default_value_reasoning: Dropout can cause training to become less stable. Consider start with a dropout-free baseline, and add dropout gradually in subsequent experiments. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 1 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html related_parameters: - "conv_dropout, fc_dropout" suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: FC Dropout fc_layers: default_value_reasoning: By default the stack is built by using num_fc_layers, output_size, use_bias, weights_initializer, bias_initializer, norm, norm_params, activation, dropout. When a list of dictionaries is provided, the stack is built following the parameters of each dict for building each layer. description_implications: The more layers that are specified the deeper and higher capacity the model will be. This makes it possible to potentially achieve better performance when a big anough amount of data is provided, but also makes the model more computationally expensive and potentially more prone to overfitting. example_value: - dropout: 0.1 output_size: 128 - norm: layer output_size: 64 expected_impact: 1 related_parameters: - output_size - use_bias - weights_initializer - bias_initializer - norm - norm_params - activation - dropout suggested_values_reasoning: It is easier to define a stack of fully connected layers by just specifying num_fc_layers, output_size and the other individual parameters. It will create a stack of layers with identical properties. Use this parameter only if you need a fine grained level of control of each individual layer in the stack. ui_display_name: Fully Connected Layers fc_norm: description_implications: Normalization helps stabilize the learning process and can have a regularizing effect that can help with generalization. It's often suggested that with normalization, you can use a higher learning rate. See Torch's documentation on batch normalization or for layer see Torch's documentation on layer normalization. expected_impact: 2 related_parameters: - fc_norm_params suggested_values: batch ui_display_name: Fully Connected Normalization fc_norm_params: description_implications: There are a variety of ways a certain set of parameters specificed could influence performance here. Broadly speaking the different values passed in here allow for different levels of smoothness to be observed in the learning curves. Since setting this parameters depends on the type of `norm` set, see [BatchNorm2d](https://pytorch.org/docs/stable/generated/torch.nn.BatchNorm2d.html) for more information on the parameters to set for batch normalization, and see [LayerNorm](https://pytorch.org/docs/stable/generated/torch.nn.LayerNorm.html) for more information on the parameters to set for layer normalization. expected_impact: 2 related_parameters: - fc_norm suggested_values: Depends on the type of `norm` set. ui_display_name: Fully Connected Normalization Parameters fc_use_bias: expected_impact: 1 ui_display_name: FC Use Bias fc_weights_initializer: expected_impact: 1 ui_display_name: FC Weights Initializer groups: expected_impact: 1 ui_display_name: Groups height: default_value_reasoning: Computed internally, automatically, based on image data preprocessing. internal_only: true ui_display_name: NOT DISPLAYED kernel_size: expected_impact: 1 ui_display_name: Kernel Size num_channels: default_value_reasoning: Computed internally, automatically, based on image data preprocessing. ui_display_name: NOT DISPLAYED num_fc_layers: default_value_reasoning: The encoder already has learnable parameters.Sometimes the default is 1 for modules where the FC stack is used for shape management, or the only source of learnable parameters. description_implications: Increasing num_fc_layers will increase the capacity of the model. The model will be slower to train, and there's a higher risk of overfitting. example_value: - 1 expected_impact: 1 other_information: Not all modules that have fc_layers also have an accompanying num_fc_layers parameter. Where both are present, fc_layers takes precedent over num_fc_layers. Specifying num_fc_layers alone uses fully connected layers that are configured by the defaults in FCStack. related_parameters: - fc_layers suggested_values: 0-1 suggested_values_reasoning: The full model likely contains many learnable parameters. Consider starting with very few, or without any additional fully connected layers and add them if you observe evidence of limited model capacity. Sometimes the default is 1 for modules where the FC stack is used for shape management, or the only source of learnable parameters. ui_display_name: Number of Fully Connected Layers out_channels: expected_impact: 2 ui_display_name: Number of Output Channels output_size: default_value_reasoning: A modest value, not too small, not too large. description_implications: If there are fully connected layers in this module, increasing the output size of each fully connected layer will increase the capacity of the model. However, the model may be slower to train, and there's a higher risk of overfitting. If it seems like the model could use even more capacity, consider increasing the number of fully connected layers, or explore other architectures. expected_impact: 3 other_information: If num_fc_layers=0 and fc_layers=None, and there are no fully connected layers defined on the module, then this parameter may have no effect on the module's final output shape. related_parameters: - num_fc_layers, fc_layers suggested_values: 10 - 1024 suggested_values_reasoning: Increasing the output size increases the capacity of the model. If this seems to have a positive effect, then it could be worth increasing the number of layers, or trying a different architecture with a larger capacity. ui_display_name: Output Size padding: default_value_reasoning: When padding is set to 'valid' like in the default case, no padding is added. As a default value putting in the raw image is the goal here. description_implications: By increasing the amount of padding, you can increase the accuracy of the image analysis for certain circumstances. example_value: - "'same'" expected_impact: 1 literature_references: - https://www.geeksforgeeks.org/cnn-introduction-to-padding/ related_parameters: - "padding_mode, resize method" suggested_values: "Same' padding if images are of different dimensions. \n\ Specific [h, w] entries can be valuable on a per dataset basis." suggested_values_reasoning: If your images already have padding, there is no need to add padding, so the default is fine. If your images come in different dimensions, then 'same' padding can help pad the images to standardized dimensions. For certain images, adding padding to the edges can help the CNN process the images better which can improve model performance. This depends on the images however. ui_display_name: Padding padding_mode: expected_impact: 1 ui_display_name: Padding Mode pool_dilation: expected_impact: 1 ui_display_name: Pool Dilation pool_kernel_size: expected_impact: 1 ui_display_name: Pool Kernel Size pool_padding: expected_impact: 1 ui_display_name: Pool Padding pool_stride: expected_impact: 1 ui_display_name: Pool Stride stride: expected_impact: 1 ui_display_name: Stride width: default_value_reasoning: Computed internally, automatically, based on image data preprocessing. internal_only: true ui_display_name: NOT DISPLAYED StackedCNN: type: short_description: Maps inputs to embeddings then passes them through a stack of 1d convolutional layers. long_description: The Stacked CNN works by first mapping the input integer sequence b x s (where b is the batch size and s is the length of the sequence) into a sequence of embeddings, then it passes the embedding through a stack of 1d convolutional layers with different filter size (by default 6 layers with filter size 7, 7, 3, 3, 3 and 3), followed by an optional final pool and by a flatten operation. This single flatten vector is then passed through a stack of fully connected layers and returned as a b x h tensor where h is the output size of the last fully connected layer. compute_tier: 1 activation: default_value_reasoning: The Rectified Linear Units (ReLU) function is the standard activation function used for adding non-linearity. It is simple, fast, and empirically works well (https://arxiv.org/abs/1803.08375). description_implications: Changing the activation functions has an impact on the computational load of the model and might require further hypterparameter tuning expected_impact: 2 suggested_values: The default value will work well in the majority of the cases ui_display_name: Activation bias_initializer: default_value_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. description_implications: It's rare to see any performance gains from choosing a different bias initialization. Some practitioners like to use a small constant value such as 0.01 for all biases to ensure that all ReLU units are activated in the beginning and have some effect on the gradient. However, it's still an open question as to whether this provides consistent improvement. expected_impact: 1 literature_references: - https://cs231n.github.io/neural-networks-2/ related_parameters: - weights_initializer suggested_values: zeros suggested_values_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. For ReLU non-linearities, some people like to use small constant value such as 0.01 for all biases because this ensures that all ReLU units fire in the beginning and therefore obtain and propagate some gradient. However, it is not clear if this provides a consistent improvement (in fact some results seem to indicate that this performs worse) and it is more common to simply use 0 bias initialization. ui_display_name: Bias Initializer dilation_rate: default_value_reasoning: The standard discrete convolution is the same as a 1-dilated convolution. description_implications: Higher dilation rates increase the effective size of the convolutional filter. Dilated convolution may improve performance if the data is very correlated locally and also contains long-term dependencies. example_value: - 2 expected_impact: 1 other_information: Dilated convolution is also known as atrous convolution. related_parameters: - filter_size suggested_values: 1-3 suggested_values_reasoning: The dilation rate is a factor which increases the spacing between elements of the convolutional filter ui_display_name: Dilation Rate dropout: default_value_reasoning: Dropout can cause training to become less stable. Consider start with a dropout-free baseline, and add dropout gradually in subsequent experiments. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 3 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: Dropout embedding_size: default_value_reasoning: Not too big, not too small. description_implications: 'An embedding is a relatively low-dimensional space that is used to translate high-dimensional vectors like words, which can have a large vocbulary size. Ideally, after an embedding is trained, it captures some of the semantics of the input by placing semantically similar inputs close together in the embedding space. In most cases, the embedding size is chosen empirically, by trial and error. From https://www.amazon.com/dp/1098115783, "one rule of thumb is to use the fourth root of the total number of unique categorical elements while another is that the embedding dimension should be approximately 1.6 times the square root of the number of unique elements in the category, and no less than 600." Increasing the embedding size may cause the model to train more slowly, but the higher dimensionality can also improve overall quality.' expected_impact: 3 literature_references: - https://developers.google.com/machine-learning/crash-course/embeddings/video-lecture suggested_values: 1.6 * sqrt(vocab_size) suggested_values_reasoning: Rule of thumb suggested by a deep learning textbook. Try models with smaller or larger embedding sizes to observe relative impact. ui_display_name: Embedding Size embeddings_on_cpu: default_value_reasoning: By default embeddings matrices are stored on GPU memory if a GPU is used, as it allows for faster access. description_implications: By default embeddings matrices are stored on GPU memory if a GPU is used, as it allows for faster access. However, in some cases when the vocabulary size is very large, the full embedding matrix may be really big and unwieldy to have in GPU memory. This parameter forces the placement of the embedding matrix in regular memory and the CPU is used to access them. This may slow down training due to additional data transfer between CPU and GPU memory, but can lead to healthier GPU memory resource usage. expected_impact: 1 suggested_values: - false suggested_values_reasoning: If GPU memory is not a constraint, having embeddings stored and accessed within the GPU is faster. ui_display_name: Embeddings on CPU embeddings_trainable: ui_display_name: null expected_impact: 1 fc_layers: default_value_reasoning: By default the stack is built by using num_fc_layers, output_size, use_bias, weights_initializer, bias_initializer, norm, norm_params, activation, dropout. When a list of dictionaries is provided, the stack is built following the parameters of each dict for building each layer. description_implications: The more layers that are specified the deeper and higher capacity the model will be. This makes it possible to potentially achieve better performance when a big anough amount of data is provided, but also makes the model more computationally expensive and potentially more prone to overfitting. example_value: - dropout: 0.1 output_size: 128 - norm: layer output_size: 64 expected_impact: 1 related_parameters: - output_size - use_bias - weights_initializer - bias_initializer - norm - norm_params - activation - dropout suggested_values_reasoning: It is easier to define a stack of fully connected layers by just specifying num_fc_layers, output_size and the other individual parameters. It will create a stack of layers with identical properties. Use this parameter only if you need a fine grained level of control of each individual layer in the stack. ui_display_name: Fully Connected Layers max_sequence_length: default_value_reasoning: Sets the maximum sequence length of the expected inputs, so input/output shapes are computed accurately. internal_only: true ui_display_name: null norm: default_value_reasoning: While batch normalization and layer normalization usually lead to improvements, it can be useful to start with fewer bells and whistles. description_implications: Normalization helps stabilize the learning process and can have a regularizing effect that can help with generalization. It's often suggested that with normalization, you can use a higher learning rate. example_value: - batch expected_impact: 2 literature_references: - https://machinelearningmastery.com/batch-normalization-for-training-of-deep-neural-networks/ related_parameters: - norm_params suggested_values: '"batch" or "layer"' suggested_values_reasoning: Normalization tries to solve "internal covariate shift" that comes from the changing distributions of the inputs to layers deep in the network when weights are updated. For example, batch normalization standardizes the inputs to a layer for each mini-batch. Try out different normalizations to see if that helps with training stability ui_display_name: Normalization Type norm_params: default_value_reasoning: The default parameters that come with Torch's implementation of these normalization types are a trusted starting point. description_implications: There are a variety of ways a certain set of parameters specificed could influence performance here. Broadly speaking the different values passed in here allow for different levels of smoothness to be observed in the learning curves. Since setting this parameters depends on the type of `norm` set, see [BatchNorm2d](https://pytorch.org/docs/stable/generated/torch.nn.BatchNorm2d.html) for more information on the parameters to set for batch normalization, and see [LayerNorm](https://pytorch.org/docs/stable/generated/torch.nn.LayerNorm.html) for more information on the parameters to set for layer normalization. example_value: - affine: false momentum: 0.2 num_features: 100 expected_impact: 1 literature_references: - "For BatchNorm2d: https://arxiv.org/abs/1502.03167 For LayerNorm: https://arxiv.org/abs/1607.06450" related_parameters: - "`norm`" suggested_values: Depends on the type of `norm` set. suggested_values_reasoning: "NO" ui_display_name: Normalization Parameters num_fc_layers: default_value_reasoning: The encoder already has learnable parameters.Sometimes the default is 1 for modules where the FC stack is used for shape management, or the only source of learnable parameters. description_implications: Increasing num_fc_layers will increase the capacity of the model. The model will be slower to train, and there's a higher risk of overfitting. example_value: - 1 expected_impact: 1 other_information: Not all modules that have fc_layers also have an accompanying num_fc_layers parameter. Where both are present, fc_layers takes precedent over num_fc_layers. Specifying num_fc_layers alone uses fully connected layers that are configured by the defaults in FCStack. related_parameters: - fc_layers suggested_values: 0-1 suggested_values_reasoning: The full model likely contains many learnable parameters. Consider starting with very few, or without any additional fully connected layers and add them if you observe evidence of limited model capacity. Sometimes the default is 1 for modules where the FC stack is used for shape management, or the only source of learnable parameters. ui_display_name: Number of Fully Connected Layers num_filters: ui_display_name: null output_size: default_value_reasoning: A modest value, not too small, not too large. description_implications: If there are fully connected layers in this module, increasing the output size of each fully connected layer will increase the capacity of the model. However, the model may be slower to train, and there's a higher risk of overfitting. If it seems like the model could use even more capacity, consider increasing the number of fully connected layers, or explore other architectures. expected_impact: 3 other_information: If num_fc_layers=0 and fc_layers=None, and there are no fully connected layers defined on the module, then this parameter may have no effect on the module's final output shape. related_parameters: - num_fc_layers, fc_layers suggested_values: 10 - 1024 suggested_values_reasoning: Increasing the output size increases the capacity of the model. If this seems to have a positive effect, then it could be worth increasing the number of layers, or trying a different architecture with a larger capacity. ui_display_name: Output Size padding: ui_display_name: null pool_function: ui_display_name: null expected_impact: 1 pool_padding: ui_display_name: null expected_impact: 1 pool_size: ui_display_name: null expected_impact: 1 pool_strides: ui_display_name: null expected_impact: 1 pretrained_embeddings: ui_display_name: null expected_impact: 0 reduce_output: ui_display_name: null expected_impact: 1 representation: ui_display_name: null expected_impact: 1 should_embed: internal_only: true ui_display_name: Not displayed strides: default_value_reasoning: In general, it makes sense to have a smaller stride that fits the input. Imagining the simple 2D image as our input, two pixels next to eachother are strongly correlated while pixels that are further apart will have a comparatively weaker correlation. Consequently, a higher stride may cause significant information loss. description_implications: Changing the stride of a convolutional layer is one form of downsampling (another being pooling). In the case of a large stride, significant amounts of information is thrown away as the filter convolves over its input. This should be usually avoided but may be desirable in cases in which the user has some deep knowledge of the filter or of the rest of the model architecture that makes it comfortable to allow a higher level compression in the output feature map of this layer. example_value: - 1 expected_impact: 2 literature_references: - "[d2l.ai blog post](http://d2l.ai/chapter_convolutional-neural-networks/padding-and-strides.html) [machinelearningmastery blogpost](https://machinelearningmastery.com/padding-and-stride-for-convolutional-neural-networks/) [crossvalidated discussion](https://stats.stackexchange.com/questions/296027/choosing-filter-size-strides-etc-in-a-cnn)" related_parameters: - pool_strides, default_strides, default_pool_strides, block_strides suggested_values: 1-2 suggested_values_reasoning: In general, points that are closer to eachother in the input feature space will be more strongly correlated to eachother, so it is a good idea to select a stride that captures these neighboring relationships. ui_display_name: Stride use_bias: ui_display_name: null expected_impact: 1 vocab: default_value_reasoning: Computed and passed along internally according to preprocessing settings. example_value: - a - b - c internal_only: true ui_display_name: Not Displayed weights_initializer: default_value_reasoning: Taken from [this paper](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf). description_implications: The method you choose to initialize layer weights during training can have a big impact on performance as well as the reproducibility of your final model between runs. As an example, if you were to randomly initialize weights you would risk non-reproducibility (and possibly general training performance), but sticking with constant values for initialization might significantly increase the time needed for model convergence. Generally, choosing one of the probabilistic approaches strikes a balance between the two extremes, and the literature kicked off by the landmark [*Xavier et al.* paper](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf) provides a few good options. See this nice discussion from [Weights and Biases](https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster.) for more information. expected_impact: 1 literature_references: - "Weights and Biases blog post: https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster. Xavier et al. paper: http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf" suggested_values: xavier_uniform suggested_values_reasoning: Changing the weights initialization scheme is something to consider if a model is having trouble with convergence, or otherwise it is something to experiment with after other factors are considered. The default choice (`xavier_uniform`) is a suitable starting point for most tasks. ui_display_name: Layer Weights Initializer StackedCNNRNN: type: short_description: Maps inputs to embeddings, passes them through convolutional layer stack, then recurrent layer stack. long_description: The cnnrnn encoder works by first mapping the input integer sequence b x s (where b is the batch size and s is the length of the sequence) into a sequence of embeddings, then it passes the embedding through a stack of convolutional layers (by default 2), that is followed by a stack of recurrent layers (by default 1), followed by a reduce operation that by default only returns the last output, but can perform other reduce functions. compute_tier: 1 activation: ui_display_name: null expected_impact: 2 bias_initializer: default_value_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. description_implications: It's rare to see any performance gains from choosing a different bias initialization. Some practitioners like to use a small constant value such as 0.01 for all biases to ensure that all ReLU units are activated in the beginning and have some effect on the gradient. However, it's still an open question as to whether this provides consistent improvement. expected_impact: 1 literature_references: - https://cs231n.github.io/neural-networks-2/ related_parameters: - weights_initializer suggested_values: zeros suggested_values_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. For ReLU non-linearities, some people like to use small constant value such as 0.01 for all biases because this ensures that all ReLU units fire in the beginning and therefore obtain and propagate some gradient. However, it is not clear if this provides a consistent improvement (in fact some results seem to indicate that this performs worse) and it is more common to simply use 0 bias initialization. ui_display_name: Bias Initializer bidirectional: ui_display_name: null expected_impact: 0 cell_type: ui_display_name: null expected_impact: 3 conv_activation: ui_display_name: null expected_impact: 1 conv_dropout: default_value_reasoning: Dropout can cause training to become less stable. Consider start with a dropout-free baseline, and add dropout gradually in subsequent experiments. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 3 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html related_parameters: - "conv_dropout, dropout, recurrent_dropout, fc_dropout" suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: Convolutional Dropout dilation_rate: default_value_reasoning: The standard discrete convolution is the same as a 1-dilated convolution. description_implications: Higher dilation rates increase the effective size of the convolutional filter. Dilated convolution may improve performance if the data is very correlated locally and also contains long-term dependencies. example_value: - 2 expected_impact: 1 other_information: Dilated convolution is also known as atrous convolution. related_parameters: - filter_size suggested_values: 1-3 suggested_values_reasoning: The dilation rate is a factor which increases the spacing between elements of the convolutional filter ui_display_name: Dilation Rate dropout: default_value_reasoning: Dropout can cause training to become less stable. Consider start with a dropout-free baseline, and add dropout gradually in subsequent experiments. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 3 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html related_parameters: - "conv_dropout, dropout, recurrent_dropout, fc_dropout" suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: Dropout embedding_size: default_value_reasoning: Not too big, not too small. description_implications: 'An embedding is a relatively low-dimensional space that is used to translate high-dimensional vectors like words, which can have a large vocbulary size. Ideally, after an embedding is trained, it captures some of the semantics of the input by placing semantically similar inputs close together in the embedding space. In most cases, the embedding size is chosen empirically, by trial and error. From https://www.amazon.com/dp/1098115783, "one rule of thumb is to use the fourth root of the total number of unique categorical elements while another is that the embedding dimension should be approximately 1.6 times the square root of the number of unique elements in the category, and no less than 600." Increasing the embedding size may cause the model to train more slowly, but the higher dimensionality can also improve overall quality.' expected_impact: 3 literature_references: - https://developers.google.com/machine-learning/crash-course/embeddings/video-lecture suggested_values: 1.6 * sqrt(vocab_size) suggested_values_reasoning: Rule of thumb suggested by a deep learning textbook. Try models with smaller or larger embedding sizes to observe relative impact. ui_display_name: Embedding Size embeddings_on_cpu: default_value_reasoning: By default embeddings matrices are stored on GPU memory if a GPU is used, as it allows for faster access. description_implications: By default embeddings matrices are stored on GPU memory if a GPU is used, as it allows for faster access. However, in some cases when the vocabulary size is very large, the full embedding matrix may be really big and unwieldy to have in GPU memory. This parameter forces the placement of the embedding matrix in regular memory and the CPU is used to access them. This may slow down training due to additional data transfer between CPU and GPU memory, but can lead to healthier GPU memory resource usage. expected_impact: 1 suggested_values: - false suggested_values_reasoning: If GPU memory is not a constraint, having embeddings stored and accessed within the GPU is faster. ui_display_name: Embeddings on CPU embeddings_trainable: ui_display_name: null expected_impact: 1 fc_activation: default_value_reasoning: The Rectified Linear Units (ReLU) function is the standard activation function used for adding non-linearity. It is simple, fast, and empirically works well (https://arxiv.org/abs/1803.08375). description_implications: Changing the activation functions has an impact on the computational load of the model and might require further hypterparameter tuning example_value: - relu expected_impact: 1 literature_references: - https://ml-cheatsheet.readthedocs.io/en/latest/activation_functions.html related_parameters: - activation, activation_function, conv_activation, recurrent_activation suggested_values: relu, alternatively leakyRelu or elu suggested_values_reasoning: The default value will work well in the majority of the cases ui_display_name: FC Activation fc_dropout: default_value_reasoning: Dropout can cause training to become less stable. Consider start with a dropout-free baseline, and add dropout gradually in subsequent experiments. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 1 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html related_parameters: - "conv_dropout, dropout, recurrent_dropout, fc_dropout" suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: FC Dropout fc_layers: default_value_reasoning: By default the stack is built by using num_fc_layers, output_size, use_bias, weights_initializer, bias_initializer, norm, norm_params, activation, dropout. When a list of dictionaries is provided, the stack is built following the parameters of each dict for building each layer. description_implications: The more layers that are specified the deeper and higher capacity the model will be. This makes it possible to potentially achieve better performance when a big anough amount of data is provided, but also makes the model more computationally expensive and potentially more prone to overfitting. example_value: - dropout: 0.1 output_size: 128 - norm: layer output_size: 64 expected_impact: 1 related_parameters: - output_size - use_bias - weights_initializer - bias_initializer - norm - norm_params - activation - dropout suggested_values_reasoning: It is easier to define a stack of fully connected layers by just specifying num_fc_layers, output_size and the other individual parameters. It will create a stack of layers with identical properties. Use this parameter only if you need a fine grained level of control of each individual layer in the stack. ui_display_name: Fully Connected Layers filter_size: ui_display_name: null expected_impact: 2 max_sequence_length: default_value_reasoning: Sets the maximum sequence length of the expected inputs, so input/output shapes are computed accurately. internal_only: true ui_display_name: null norm: default_value_reasoning: While batch normalization and layer normalization usually lead to improvements, it can be useful to start with fewer bells and whistles. description_implications: Normalization helps stabilize the learning process and can have a regularizing effect that can help with generalization. It's often suggested that with normalization, you can use a higher learning rate. example_value: - batch expected_impact: 2 literature_references: - https://machinelearningmastery.com/batch-normalization-for-training-of-deep-neural-networks/ related_parameters: - norm_params suggested_values: '"batch" or "layer"' suggested_values_reasoning: Normalization tries to solve "internal covariate shift" that comes from the changing distributions of the inputs to layers deep in the network when weights are updated. For example, batch normalization standardizes the inputs to a layer for each mini-batch. Try out different normalizations to see if that helps with training stability ui_display_name: Normalization Type norm_params: default_value_reasoning: The default parameters that come with Torch's implementation of these normalization types are a trusted starting point. description_implications: There are a variety of ways a certain set of parameters specificed could influence performance here. Broadly speaking the different values passed in here allow for different levels of smoothness to be observed in the learning curves. Since setting this parameters depends on the type of `norm` set, see [BatchNorm2d](https://pytorch.org/docs/stable/generated/torch.nn.BatchNorm2d.html) for more information on the parameters to set for batch normalization, and see [LayerNorm](https://pytorch.org/docs/stable/generated/torch.nn.LayerNorm.html) for more information on the parameters to set for layer normalization. example_value: - affine: false momentum: 0.2 num_features: 100 expected_impact: 1 literature_references: - "For BatchNorm2d: https://arxiv.org/abs/1502.03167 For LayerNorm: https://arxiv.org/abs/1607.06450" related_parameters: - "`norm`" suggested_values: Depends on the type of `norm` set. suggested_values_reasoning: "NO" ui_display_name: Normalization Parameters num_fc_layers: default_value_reasoning: The encoder already has learnable parameters.Sometimes the default is 1 for modules where the FC stack is used for shape management, or the only source of learnable parameters. description_implications: Increasing num_fc_layers will increase the capacity of the model. The model will be slower to train, and there's a higher risk of overfitting. example_value: - 1 expected_impact: 1 other_information: Not all modules that have fc_layers also have an accompanying num_fc_layers parameter. Where both are present, fc_layers takes precedent over num_fc_layers. Specifying num_fc_layers alone uses fully connected layers that are configured by the defaults in FCStack. related_parameters: - fc_layers suggested_values: 0-1 suggested_values_reasoning: The full model likely contains many learnable parameters. Consider starting with very few, or without any additional fully connected layers and add them if you observe evidence of limited model capacity. Sometimes the default is 1 for modules where the FC stack is used for shape management, or the only source of learnable parameters. ui_display_name: Number of Fully Connected Layers num_filters: ui_display_name: null num_rec_layers: ui_display_name: null output_size: default_value_reasoning: A modest value, not too small, not too large. description_implications: If there are fully connected layers in this module, increasing the output size of each fully connected layer will increase the capacity of the model. However, the model may be slower to train, and there's a higher risk of overfitting. If it seems like the model could use even more capacity, consider increasing the number of fully connected layers, or explore other architectures. expected_impact: 3 other_information: If num_fc_layers=0 and fc_layers=None, and there are no fully connected layers defined on the module, then this parameter may have no effect on the module's final output shape. related_parameters: - num_fc_layers, fc_layers suggested_values: 10 - 1024 suggested_values_reasoning: Increasing the output size increases the capacity of the model. If this seems to have a positive effect, then it could be worth increasing the number of layers, or trying a different architecture with a larger capacity. ui_display_name: Output Size padding: ui_display_name: null pool_function: ui_display_name: null expected_impact: 1 pool_padding: ui_display_name: null expected_impact: 1 pool_size: ui_display_name: null expected_impact: 1 pool_strides: ui_display_name: null expected_impact: 1 pretrained_embeddings: ui_display_name: null expected_impact: 0 recurrent_activation: default_value_reasoning: sigmoid' is commonly used expected_impact: 1 other_information: I don't think that this parameter is used anywhere in the code base. It's being passed down but not used in the actual RNN forwarding functions. suggested_values: sigmoid, ReLu, tanh ui_display_name: null recurrent_dropout: default_value_reasoning: Dropout can cause training to become less stable. Consider start with a dropout-free baseline, and add dropout gradually in subsequent experiments. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 2 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html related_parameters: - "conv_dropout, dropout, recurrent_dropout, fc_dropout" suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: Recurrent Dropout recurrent_initializer: ui_display_name: null expected_impact: 1 reduce_output: ui_display_name: null expected_impact: 1 representation: ui_display_name: null expected_impact: 1 should_embed: internal_only: true ui_display_name: Not displayed state_size: ui_display_name: null expected_impact: 3 strides: default_value_reasoning: In general, it makes sense to have a smaller stride that fits the input. Imagining the simple 2D image as our input, two pixels next to eachother are strongly correlated while pixels that are further apart will have a comparatively weaker correlation. Consequently, a higher stride may cause significant information loss. description_implications: Changing the stride of a convolutional layer is one form of downsampling (another being pooling). In the case of a large stride, significant amounts of information is thrown away as the filter convolves over its input. This should be usually avoided but may be desirable in cases in which the user has some deep knowledge of the filter or of the rest of the model architecture that makes it comfortable to allow a higher level compression in the output feature map of this layer. example_value: - 1 expected_impact: 2 literature_references: - "[d2l.ai blog post](http://d2l.ai/chapter_convolutional-neural-networks/padding-and-strides.html) [machinelearningmastery blogpost](https://machinelearningmastery.com/padding-and-stride-for-convolutional-neural-networks/) [crossvalidated discussion](https://stats.stackexchange.com/questions/296027/choosing-filter-size-strides-etc-in-a-cnn)" related_parameters: - pool_strides, default_strides, default_pool_strides, block_strides suggested_values: 1-2 suggested_values_reasoning: In general, points that are closer to eachother in the input feature space will be more strongly correlated to eachother, so it is a good idea to select a stride that captures these neighboring relationships. ui_display_name: Stride unit_forget_bias: ui_display_name: null expected_impact: 1 use_bias: default_value_reasoning: "Bias terms may improve model accuracy, and don't have much impact in terms of memory or training speed. For most models it is reasonable to use bias terms. Batch Normalization, however, adds a trainable shift parameter which is added to the activation. When Batch Normalization is used in a layer, bias terms are redundant and may be removed." description_implications: Bias terms may improve model accuracy, and don't have much impact in terms of memory or training speed. For most models it is reasonable to leave this parameter set to True. example_value: - true expected_impact: 1 other_information: If fc_layers is not specified, or use_bias is not specified for individual layers, the value of use_bias will be used as the default for all layers. related_parameters: - bias_initializer, fc_layers suggested_values: - true ui_display_name: Use Bias vocab: default_value_reasoning: Computed and passed along internally according to preprocessing settings. example_value: - a - b - c internal_only: true ui_display_name: Not Displayed weights_initializer: default_value_reasoning: Taken from [this paper](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf). description_implications: The method you choose to initialize layer weights during training can have a big impact on performance as well as the reproducibility of your final model between runs. As an example, if you were to randomly initialize weights you would risk non-reproducibility (and possibly general training performance), but sticking with constant values for initialization might significantly increase the time needed for model convergence. Generally, choosing one of the probabilistic approaches strikes a balance between the two extremes, and the literature kicked off by the landmark [*Xavier et al.* paper](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf) provides a few good options. See this nice discussion from [Weights and Biases](https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster.) for more information. expected_impact: 1 literature_references: - "Weights and Biases blog post: https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster. Xavier et al. paper: http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf" suggested_values: xavier_uniform suggested_values_reasoning: Changing the weights initialization scheme is something to consider if a model is having trouble with convergence, or otherwise it is something to experiment with after other factors are considered. The default choice (`xavier_uniform`) is a suitable starting point for most tasks. ui_display_name: Layer Weights Initializer StackedParallelCNN: type: short_description: Combination of Parallel CNN and Stacked CNN encoders utilizing a stack of parallel convolutional layers. long_description: The stacked parallel cnn encoder is a combination of the Parallel CNN and the Stacked CNN encoders where each layer of the stack is composed of parallel convolutional layers. It works by first mapping the input integer sequence b x s (where b is the batch size and s is the length of the sequence) into a sequence of embeddings, then it passes the embedding through a stack of several parallel 1d convolutional layers with different filter size, followed by an optional final pool and by a flatten operation. This single flattened vector is then passed through a stack of fully connected layers and returned as a b x h tensor where h is the output size of the last fully connected layer. compute_tier: 1 activation: default_value_reasoning: The Rectified Linear Units (ReLU) function is the standard activation function used for adding non-linearity. It is simple, fast, and empirically works well (https://arxiv.org/abs/1803.08375). description_implications: Changing the activation functions has an impact on the computational load of the model and might require further hypterparameter tuning expected_impact: 2 suggested_values: The default value will work well in the majority of the cases ui_display_name: Activation bias_initializer: default_value_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. description_implications: It's rare to see any performance gains from choosing a different bias initialization. Some practitioners like to use a small constant value such as 0.01 for all biases to ensure that all ReLU units are activated in the beginning and have some effect on the gradient. However, it's still an open question as to whether this provides consistent improvement. expected_impact: 1 literature_references: - https://cs231n.github.io/neural-networks-2/ related_parameters: - weights_initializer suggested_values: zeros suggested_values_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. For ReLU non-linearities, some people like to use small constant value such as 0.01 for all biases because this ensures that all ReLU units fire in the beginning and therefore obtain and propagate some gradient. However, it is not clear if this provides a consistent improvement (in fact some results seem to indicate that this performs worse) and it is more common to simply use 0 bias initialization. ui_display_name: Bias Initializer dropout: default_value_reasoning: Dropout can cause training to become less stable. Consider start with a dropout-free baseline, and add dropout gradually in subsequent experiments. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 3 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: Dropout embedding_size: default_value_reasoning: Not too big, not too small. description_implications: 'An embedding is a relatively low-dimensional space that is used to translate high-dimensional vectors like words, which can have a large vocbulary size. Ideally, after an embedding is trained, it captures some of the semantics of the input by placing semantically similar inputs close together in the embedding space. In most cases, the embedding size is chosen empirically, by trial and error. From https://www.amazon.com/dp/1098115783, "one rule of thumb is to use the fourth root of the total number of unique categorical elements while another is that the embedding dimension should be approximately 1.6 times the square root of the number of unique elements in the category, and no less than 600." Increasing the embedding size may cause the model to train more slowly, but the higher dimensionality can also improve overall quality.' expected_impact: 3 literature_references: - https://developers.google.com/machine-learning/crash-course/embeddings/video-lecture suggested_values: 1.6 * sqrt(vocab_size) suggested_values_reasoning: Rule of thumb suggested by a deep learning textbook. Try models with smaller or larger embedding sizes to observe relative impact. ui_display_name: Embedding Size embeddings_on_cpu: default_value_reasoning: By default embeddings matrices are stored on GPU memory if a GPU is used, as it allows for faster access. description_implications: By default embeddings matrices are stored on GPU memory if a GPU is used, as it allows for faster access. However, in some cases when the vocabulary size is very large, the full embedding matrix may be really big and unwieldy to have in GPU memory. This parameter forces the placement of the embedding matrix in regular memory and the CPU is used to access them. This may slow down training due to additional data transfer between CPU and GPU memory, but can lead to healthier GPU memory resource usage. expected_impact: 1 suggested_values: - false suggested_values_reasoning: If GPU memory is not a constraint, having embeddings stored and accessed within the GPU is faster. ui_display_name: Embeddings on CPU embeddings_trainable: ui_display_name: null expected_impact: 1 fc_layers: default_value_reasoning: By default the stack is built by using num_fc_layers, output_size, use_bias, weights_initializer, bias_initializer, norm, norm_params, activation, dropout. When a list of dictionaries is provided, the stack is built following the parameters of each dict for building each layer. description_implications: The more layers that are specified the deeper and higher capacity the model will be. This makes it possible to potentially achieve better performance when a big anough amount of data is provided, but also makes the model more computationally expensive and potentially more prone to overfitting. example_value: - dropout: 0.1 output_size: 128 - norm: layer output_size: 64 expected_impact: 1 related_parameters: - output_size - use_bias - weights_initializer - bias_initializer - norm - norm_params - activation - dropout suggested_values_reasoning: It is easier to define a stack of fully connected layers by just specifying num_fc_layers, output_size and the other individual parameters. It will create a stack of layers with identical properties. Use this parameter only if you need a fine grained level of control of each individual layer in the stack. ui_display_name: Fully Connected Layers filter_size: ui_display_name: null expected_impact: 2 max_sequence_length: default_value_reasoning: Sets the maximum sequence length of the expected inputs, so input/output shapes are computed accurately. internal_only: true ui_display_name: null norm: default_value_reasoning: While batch normalization and layer normalization usually lead to improvements, it can be useful to start with fewer bells and whistles. description_implications: Normalization helps stabilize the learning process and can have a regularizing effect that can help with generalization. It's often suggested that with normalization, you can use a higher learning rate. example_value: - batch expected_impact: 2 literature_references: - https://machinelearningmastery.com/batch-normalization-for-training-of-deep-neural-networks/ related_parameters: - norm_params suggested_values: '"batch" or "layer"' suggested_values_reasoning: Normalization tries to solve "internal covariate shift" that comes from the changing distributions of the inputs to layers deep in the network when weights are updated. For example, batch normalization standardizes the inputs to a layer for each mini-batch. Try out different normalizations to see if that helps with training stability ui_display_name: Normalization Type norm_params: default_value_reasoning: The default parameters that come with Torch's implementation of these normalization types are a trusted starting point. description_implications: There are a variety of ways a certain set of parameters specificed could influence performance here. Broadly speaking the different values passed in here allow for different levels of smoothness to be observed in the learning curves. Since setting this parameters depends on the type of `norm` set, see [BatchNorm2d](https://pytorch.org/docs/stable/generated/torch.nn.BatchNorm2d.html) for more information on the parameters to set for batch normalization, and see [LayerNorm](https://pytorch.org/docs/stable/generated/torch.nn.LayerNorm.html) for more information on the parameters to set for layer normalization. example_value: - affine: false momentum: 0.2 num_features: 100 expected_impact: 1 literature_references: - "For BatchNorm2d: https://arxiv.org/abs/1502.03167 For LayerNorm: https://arxiv.org/abs/1607.06450" related_parameters: - "`norm`" suggested_values: Depends on the type of `norm` set. suggested_values_reasoning: "NO" ui_display_name: Normalization Parameters num_fc_layers: default_value_reasoning: The encoder already has learnable parameters.Sometimes the default is 1 for modules where the FC stack is used for shape management, or the only source of learnable parameters. description_implications: Increasing num_fc_layers will increase the capacity of the model. The model will be slower to train, and there's a higher risk of overfitting. example_value: - 1 expected_impact: 1 other_information: Not all modules that have fc_layers also have an accompanying num_fc_layers parameter. Where both are present, fc_layers takes precedent over num_fc_layers. Specifying num_fc_layers alone uses fully connected layers that are configured by the defaults in FCStack. related_parameters: - fc_layers suggested_values: 0-1 suggested_values_reasoning: The full model likely contains many learnable parameters. Consider starting with very few, or without any additional fully connected layers and add them if you observe evidence of limited model capacity. Sometimes the default is 1 for modules where the FC stack is used for shape management, or the only source of learnable parameters. ui_display_name: Number of Fully Connected Layers num_filters: ui_display_name: null num_stacked_layers: description_implications: While superceded by `stacked_layers`, this can directly change the depth of the current stack of parallel convolutional layers. example_value: - 1 expected_impact: 1 related_parameters: - stacked_layers ui_display_name: Number of Stacked Layers output_size: default_value_reasoning: A modest value, not too small, not too large. description_implications: If there are fully connected layers in this module, increasing the output size of each fully connected layer will increase the capacity of the model. However, the model may be slower to train, and there's a higher risk of overfitting. If it seems like the model could use even more capacity, consider increasing the number of fully connected layers, or explore other architectures. expected_impact: 3 other_information: If num_fc_layers=0 and fc_layers=None, and there are no fully connected layers defined on the module, then this parameter may have no effect on the module's final output shape. related_parameters: - num_fc_layers, fc_layers suggested_values: 10 - 1024 suggested_values_reasoning: Increasing the output size increases the capacity of the model. If this seems to have a positive effect, then it could be worth increasing the number of layers, or trying a different architecture with a larger capacity. ui_display_name: Output Size pool_function: ui_display_name: null expected_impact: 1 pretrained_embeddings: ui_display_name: null expected_impact: 0 reduce_output: ui_display_name: null expected_impact: 1 representation: ui_display_name: null expected_impact: 1 should_embed: internal_only: true ui_display_name: Not displayed stacked_layers: ui_display_name: null use_bias: default_value_reasoning: "Bias terms may improve model accuracy, and don't have much impact in terms of memory or training speed. For most models it is reasonable to use bias terms. Batch Normalization, however, adds a trainable shift parameter which is added to the activation. When Batch Normalization is used in a layer, bias terms are redundant and may be removed." description_implications: Bias terms may improve model accuracy, and don't have much impact in terms of memory or training speed. For most models it is reasonable to leave this parameter set to True. example_value: - true expected_impact: 1 other_information: If fc_layers is not specified, or use_bias is not specified for individual layers, the value of use_bias will be used as the default for all layers. related_parameters: - bias_initializer, fc_layers suggested_values: - true ui_display_name: Use Bias vocab: default_value_reasoning: Computed and passed along internally according to preprocessing settings. example_value: - a - b - c internal_only: true ui_display_name: Not Displayed weights_initializer: default_value_reasoning: Taken from [this paper](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf). description_implications: The method you choose to initialize layer weights during training can have a big impact on performance as well as the reproducibility of your final model between runs. As an example, if you were to randomly initialize weights you would risk non-reproducibility (and possibly general training performance), but sticking with constant values for initialization might significantly increase the time needed for model convergence. Generally, choosing one of the probabilistic approaches strikes a balance between the two extremes, and the literature kicked off by the landmark [*Xavier et al.* paper](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf) provides a few good options. See this nice discussion from [Weights and Biases](https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster.) for more information. expected_impact: 1 literature_references: - "Weights and Biases blog post: https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster. Xavier et al. paper: http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf" suggested_values: xavier_uniform suggested_values_reasoning: Changing the weights initialization scheme is something to consider if a model is having trouble with convergence, or otherwise it is something to experiment with after other factors are considered. The default choice (`xavier_uniform`) is a suitable starting point for most tasks. ui_display_name: Layer Weights Initializer StackedRNN: type: short_description: Utilizes a stack of recurrent layers followed by a reduce operation. long_description: The rnn encoder works by first mapping the input integer sequence b x s (where b is the batch size and s is the length of the sequence) into a sequence of embeddings, then it passes the embedding through a stack of recurrent layers (by default 1 layer), followed by a reduce operation that by default only returns the last output, but can perform other reduce functions. compute_tier: 1 activation: ui_display_name: null expected_impact: 2 bias_initializer: default_value_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. description_implications: It's rare to see any performance gains from choosing a different bias initialization. Some practitioners like to use a small constant value such as 0.01 for all biases to ensure that all ReLU units are activated in the beginning and have some effect on the gradient. However, it's still an open question as to whether this provides consistent improvement. expected_impact: 1 literature_references: - https://cs231n.github.io/neural-networks-2/ related_parameters: - weights_initializer suggested_values: zeros suggested_values_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. For ReLU non-linearities, some people like to use small constant value such as 0.01 for all biases because this ensures that all ReLU units fire in the beginning and therefore obtain and propagate some gradient. However, it is not clear if this provides a consistent improvement (in fact some results seem to indicate that this performs worse) and it is more common to simply use 0 bias initialization. ui_display_name: Bias Initializer bidirectional: ui_display_name: null expected_impact: 0 cell_type: ui_display_name: null expected_impact: 3 dropout: default_value_reasoning: Dropout can cause training to become less stable. Consider start with a dropout-free baseline, and add dropout gradually in subsequent experiments. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 3 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html related_parameters: - "dropout, recurrent_dropout, fc_dropout" suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: Dropout embedding_size: default_value_reasoning: Not too big, not too small. description_implications: 'An embedding is a relatively low-dimensional space that is used to translate high-dimensional vectors like words, which can have a large vocbulary size. Ideally, after an embedding is trained, it captures some of the semantics of the input by placing semantically similar inputs close together in the embedding space. In most cases, the embedding size is chosen empirically, by trial and error. From https://www.amazon.com/dp/1098115783, "one rule of thumb is to use the fourth root of the total number of unique categorical elements while another is that the embedding dimension should be approximately 1.6 times the square root of the number of unique elements in the category, and no less than 600." Increasing the embedding size may cause the model to train more slowly, but the higher dimensionality can also improve overall quality.' expected_impact: 3 literature_references: - https://developers.google.com/machine-learning/crash-course/embeddings/video-lecture suggested_values: 1.6 * sqrt(vocab_size) suggested_values_reasoning: Rule of thumb suggested by a deep learning textbook. Try models with smaller or larger embedding sizes to observe relative impact. ui_display_name: Embedding Size embeddings_on_cpu: default_value_reasoning: By default embeddings matrices are stored on GPU memory if a GPU is used, as it allows for faster access. description_implications: By default embeddings matrices are stored on GPU memory if a GPU is used, as it allows for faster access. However, in some cases when the vocabulary size is very large, the full embedding matrix may be really big and unwieldy to have in GPU memory. This parameter forces the placement of the embedding matrix in regular memory and the CPU is used to access them. This may slow down training due to additional data transfer between CPU and GPU memory, but can lead to healthier GPU memory resource usage. expected_impact: 1 suggested_values: - false suggested_values_reasoning: If GPU memory is not a constraint, having embeddings stored and accessed within the GPU is faster. ui_display_name: Embeddings on CPU embeddings_trainable: ui_display_name: null expected_impact: 1 fc_activation: default_value_reasoning: The Rectified Linear Units (ReLU) function is the standard activation function used for adding non-linearity. It is simple, fast, and empirically works well (https://arxiv.org/abs/1803.08375). description_implications: Changing the activation functions has an impact on the computational load of the model and might require further hypterparameter tuning example_value: - relu expected_impact: 1 literature_references: - https://ml-cheatsheet.readthedocs.io/en/latest/activation_functions.html related_parameters: - activation, activation_function, conv_activation, recurrent_activation suggested_values: relu, alternatively leakyRelu or elu suggested_values_reasoning: The default value will work well in the majority of the cases ui_display_name: FC Activation fc_dropout: default_value_reasoning: Dropout can cause training to become less stable. Consider start with a dropout-free baseline, and add dropout gradually in subsequent experiments. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 1 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html related_parameters: - dropout, recurrent_dropout suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: FC Dropout fc_layers: default_value_reasoning: By default the stack is built by using num_fc_layers, output_size, use_bias, weights_initializer, bias_initializer, norm, norm_params, activation, dropout. When a list of dictionaries is provided, the stack is built following the parameters of each dict for building each layer. description_implications: The more layers that are specified the deeper and higher capacity the model will be. This makes it possible to potentially achieve better performance when a big anough amount of data is provided, but also makes the model more computationally expensive and potentially more prone to overfitting. example_value: - dropout: 0.1 output_size: 128 - norm: layer output_size: 64 expected_impact: 1 related_parameters: - output_size - use_bias - weights_initializer - bias_initializer - norm - norm_params - activation - dropout suggested_values_reasoning: It is easier to define a stack of fully connected layers by just specifying num_fc_layers, output_size and the other individual parameters. It will create a stack of layers with identical properties. Use this parameter only if you need a fine grained level of control of each individual layer in the stack. ui_display_name: Fully Connected Layers max_sequence_length: default_value_reasoning: Sets the maximum sequence length of the expected inputs, so input/output shapes are computed accurately. internal_only: true ui_display_name: null norm: default_value_reasoning: While batch normalization and layer normalization usually lead to improvements, it can be useful to start with fewer bells and whistles. description_implications: Normalization helps stabilize the learning process and can have a regularizing effect that can help with generalization. It's often suggested that with normalization, you can use a higher learning rate. example_value: - batch expected_impact: 2 literature_references: - https://machinelearningmastery.com/batch-normalization-for-training-of-deep-neural-networks/ related_parameters: - norm_params suggested_values: '"batch" or "layer"' suggested_values_reasoning: Normalization tries to solve "internal covariate shift" that comes from the changing distributions of the inputs to layers deep in the network when weights are updated. For example, batch normalization standardizes the inputs to a layer for each mini-batch. Try out different normalizations to see if that helps with training stability ui_display_name: Normalization Type norm_params: default_value_reasoning: The default parameters that come with Torch's implementation of these normalization types are a trusted starting point. description_implications: There are a variety of ways a certain set of parameters specificed could influence performance here. Broadly speaking the different values passed in here allow for different levels of smoothness to be observed in the learning curves. Since setting this parameters depends on the type of `norm` set, see [BatchNorm2d](https://pytorch.org/docs/stable/generated/torch.nn.BatchNorm2d.html) for more information on the parameters to set for batch normalization, and see [LayerNorm](https://pytorch.org/docs/stable/generated/torch.nn.LayerNorm.html) for more information on the parameters to set for layer normalization. example_value: - affine: false momentum: 0.2 num_features: 100 expected_impact: 1 literature_references: - "For BatchNorm2d: https://arxiv.org/abs/1502.03167 For LayerNorm: https://arxiv.org/abs/1607.06450" related_parameters: - "`norm`" suggested_values: Depends on the type of `norm` set. suggested_values_reasoning: "NO" ui_display_name: Normalization Parameters num_fc_layers: default_value_reasoning: The encoder already has learnable parameters.Sometimes the default is 1 for modules where the FC stack is used for shape management, or the only source of learnable parameters. description_implications: Increasing num_fc_layers will increase the capacity of the model. The model will be slower to train, and there's a higher risk of overfitting. example_value: - 1 expected_impact: 1 other_information: Not all modules that have fc_layers also have an accompanying num_fc_layers parameter. Where both are present, fc_layers takes precedent over num_fc_layers. Specifying num_fc_layers alone uses fully connected layers that are configured by the defaults in FCStack. related_parameters: - fc_layers suggested_values: 0-1 suggested_values_reasoning: The full model likely contains many learnable parameters. Consider starting with very few, or without any additional fully connected layers and add them if you observe evidence of limited model capacity. Sometimes the default is 1 for modules where the FC stack is used for shape management, or the only source of learnable parameters. ui_display_name: Number of Fully Connected Layers num_layers: default_value_reasoning: The ideal number of layers depends on the data. For many data types, one layer is sufficient. description_implications: Increasing the number of layers may improve model performance for longer sequences or more complex tasks. example_value: - 1 expected_impact: 3 suggested_values: 1-3 suggested_values_reasoning: Increasing the number of layers may improve encoder performance. However, more layers will increase training time and may cause overfitting. Small numbers of layers usually work best. ui_display_name: Number of Recurrent Layers output_size: default_value_reasoning: A modest value, not too small, not too large. description_implications: If there are fully connected layers in this module, increasing the output size of each fully connected layer will increase the capacity of the model. However, the model may be slower to train, and there's a higher risk of overfitting. If it seems like the model could use even more capacity, consider increasing the number of fully connected layers, or explore other architectures. expected_impact: 3 other_information: If num_fc_layers=0 and fc_layers=None, and there are no fully connected layers defined on the module, then this parameter may have no effect on the module's final output shape. related_parameters: - num_fc_layers, fc_layers suggested_values: 10 - 1024 suggested_values_reasoning: Increasing the output size increases the capacity of the model. If this seems to have a positive effect, then it could be worth increasing the number of layers, or trying a different architecture with a larger capacity. ui_display_name: Output Size pretrained_embeddings: ui_display_name: null expected_impact: 0 recurrent_activation: default_value_reasoning: sigmoid' is commonly used expected_impact: 1 other_information: I don't think that this parameter is used anywhere in the code base. It's being passed down but not used in the actual RNN forwarding functions. suggested_values: sigmoid, ReLu, tanh ui_display_name: null recurrent_dropout: default_value_reasoning: Dropout can cause training to become less stable. Consider start with a dropout-free baseline, and add dropout gradually in subsequent experiments. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 2 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html related_parameters: - "dropout, recurrent_dropout, fc_dropout" suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: Recurrent Dropout recurrent_initializer: ui_display_name: null expected_impact: 1 reduce_output: ui_display_name: null expected_impact: 1 representation: ui_display_name: null expected_impact: 1 should_embed: internal_only: true ui_display_name: Not displayed state_size: ui_display_name: null expected_impact: 3 unit_forget_bias: ui_display_name: null expected_impact: 1 use_bias: default_value_reasoning: "Bias terms may improve model accuracy, and don't have much impact in terms of memory or training speed. For most models it is reasonable to use bias terms. Batch Normalization, however, adds a trainable shift parameter which is added to the activation. When Batch Normalization is used in a layer, bias terms are redundant and may be removed." description_implications: Bias terms may improve model accuracy, and don't have much impact in terms of memory or training speed. For most models it is reasonable to leave this parameter set to True. example_value: - true expected_impact: 1 other_information: If fc_layers is not specified, or use_bias is not specified for individual layers, the value of use_bias will be used as the default for all layers. related_parameters: - bias_initializer, fc_layers suggested_values: - true ui_display_name: Use Bias vocab: default_value_reasoning: Computed and passed along internally according to preprocessing settings. example_value: - a - b - c internal_only: true ui_display_name: Not Displayed weights_initializer: default_value_reasoning: Taken from [this paper](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf). description_implications: The method you choose to initialize layer weights during training can have a big impact on performance as well as the reproducibility of your final model between runs. As an example, if you were to randomly initialize weights you would risk non-reproducibility (and possibly general training performance), but sticking with constant values for initialization might significantly increase the time needed for model convergence. Generally, choosing one of the probabilistic approaches strikes a balance between the two extremes, and the literature kicked off by the landmark [*Xavier et al.* paper](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf) provides a few good options. See this nice discussion from [Weights and Biases](https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster.) for more information. expected_impact: 1 literature_references: - "Weights and Biases blog post: https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster. Xavier et al. paper: http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf" suggested_values: xavier_uniform suggested_values_reasoning: Changing the weights initialization scheme is something to consider if a model is having trouble with convergence, or otherwise it is something to experiment with after other factors are considered. The default choice (`xavier_uniform`) is a suitable starting point for most tasks. ui_display_name: Layer Weights Initializer StackedTransformer: type: short_description: Stack of transformer blocks with optional stack of fully connected layers. long_description: The transformer encoder implements a stack of transformer blocks, replicating the architecture introduced in the Attention is all you need paper, and adds am optional stack of fully connected layers at the end. literature_references: - https://arxiv.org/abs/1706.03762 compute_tier: 2 bias_initializer: default_value_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. description_implications: It's rare to see any performance gains from choosing a different bias initialization. Some practitioners like to use a small constant value such as 0.01 for all biases to ensure that all ReLU units are activated in the beginning and have some effect on the gradient. However, it's still an open question as to whether this provides consistent improvement. expected_impact: 1 literature_references: - https://cs231n.github.io/neural-networks-2/ related_parameters: - weights_initializer suggested_values: zeros suggested_values_reasoning: It is possible and common to initialize the biases to be zero, since the asymmetry breaking is provided by the small random numbers in the weights. For ReLU non-linearities, some people like to use small constant value such as 0.01 for all biases because this ensures that all ReLU units fire in the beginning and therefore obtain and propagate some gradient. However, it is not clear if this provides a consistent improvement (in fact some results seem to indicate that this performs worse) and it is more common to simply use 0 bias initialization. ui_display_name: Bias Initializer dropout: default_value_reasoning: Taken from published literature (https://arxiv.org/abs/1908.07442). description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 3 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html related_parameters: - fc_dropout suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: Dropout embedding_size: default_value_reasoning: Not too big, not too small. description_implications: 'An embedding is a relatively low-dimensional space that is used to translate high-dimensional vectors like words, which can have a large vocbulary size. Ideally, after an embedding is trained, it captures some of the semantics of the input by placing semantically similar inputs close together in the embedding space. In most cases, the embedding size is chosen empirically, by trial and error. From https://www.amazon.com/dp/1098115783, "one rule of thumb is to use the fourth root of the total number of unique categorical elements while another is that the embedding dimension should be approximately 1.6 times the square root of the number of unique elements in the category, and no less than 600." Increasing the embedding size may cause the model to train more slowly, but the higher dimensionality can also improve overall quality.' expected_impact: 3 literature_references: - https://developers.google.com/machine-learning/crash-course/embeddings/video-lecture suggested_values: 1.6 * sqrt(vocab_size) suggested_values_reasoning: Rule of thumb suggested by a deep learning textbook. Try models with smaller or larger embedding sizes to observe relative impact. ui_display_name: Embedding Size embeddings_on_cpu: default_value_reasoning: By default embeddings matrices are stored on GPU memory if a GPU is used, as it allows for faster access. description_implications: By default embeddings matrices are stored on GPU memory if a GPU is used, as it allows for faster access. However, in some cases when the vocabulary size is very large, the full embedding matrix may be really big and unwieldy to have in GPU memory. This parameter forces the placement of the embedding matrix in regular memory and the CPU is used to access them. This may slow down training due to additional data transfer between CPU and GPU memory, but can lead to healthier GPU memory resource usage. expected_impact: 1 suggested_values: - false suggested_values_reasoning: If GPU memory is not a constraint, having embeddings stored and accessed within the GPU is faster. ui_display_name: Embeddings on CPU embeddings_trainable: ui_display_name: null expected_impact: 1 fc_activation: default_value_reasoning: The Rectified Linear Units (ReLU) function is the standard activation function used for adding non-linearity. It is simple, fast, and empirically works well (https://arxiv.org/abs/1803.08375). description_implications: Changing the activation functions has an impact on the computational load of the model and might require further hypterparameter tuning example_value: - relu expected_impact: 1 literature_references: - https://ml-cheatsheet.readthedocs.io/en/latest/activation_functions.html related_parameters: - activation, activation_function, conv_activation, recurrent_activation suggested_values: relu, alternatively leakyRelu or elu suggested_values_reasoning: The default value will work well in the majority of the cases ui_display_name: FC Activation fc_dropout: default_value_reasoning: Dropout can cause training to become less stable. Consider start with a dropout-free baseline, and add dropout gradually in subsequent experiments. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 1 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html related_parameters: - dropout suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: FC Dropout fc_layers: default_value_reasoning: By default the stack is built by using num_fc_layers, output_size, use_bias, weights_initializer, bias_initializer, norm, norm_params, activation, dropout. When a list of dictionaries is provided, the stack is built following the parameters of each dict for building each layer. description_implications: The more layers that are specified the deeper and higher capacity the model will be. This makes it possible to potentially achieve better performance when a big anough amount of data is provided, but also makes the model more computationally expensive and potentially more prone to overfitting. example_value: - dropout: 0.1 output_size: 128 - norm: layer output_size: 64 expected_impact: 1 related_parameters: - output_size - use_bias - weights_initializer - bias_initializer - norm - norm_params - activation - dropout suggested_values_reasoning: It is easier to define a stack of fully connected layers by just specifying num_fc_layers, output_size and the other individual parameters. It will create a stack of layers with identical properties. Use this parameter only if you need a fine grained level of control of each individual layer in the stack. ui_display_name: Fully Connected Layers hidden_size: default_value_reasoning: Taken from literature (https://arxiv.org/abs/1706.03762) description_implications: Increasing the hidden size makes the model larger and slower to train, increases the model's capacity to capture more complexity. It also increases the chance of overfitting. expected_impact: 2 suggested_values: 10 - 2048 suggested_values_reasoning: Increasing the hidden size makes sense if the model is underfitting. It's useful to train both smaller and larger models to see how model capacity affects performance. This should only be explored after the architecture of the model has been settled. ui_display_name: Hidden Size max_sequence_length: default_value_reasoning: Sets the maximum sequence length of the expected inputs, so input/output shapes and the positional embedding matrix are computed accurately. internal_only: true ui_display_name: null norm: default_value_reasoning: While batch normalization and layer normalization usually lead to improvements, it can be useful to start with fewer bells and whistles. description_implications: Normalization helps stabilize the learning process and can have a regularizing effect that can help with generalization. It's often suggested that with normalization, you can use a higher learning rate. example_value: - batch expected_impact: 2 literature_references: - https://machinelearningmastery.com/batch-normalization-for-training-of-deep-neural-networks/ related_parameters: - norm_params suggested_values: '"batch" or "layer"' suggested_values_reasoning: Normalization tries to solve "internal covariate shift" that comes from the changing distributions of the inputs to layers deep in the network when weights are updated. For example, batch normalization standardizes the inputs to a layer for each mini-batch. Try out different normalizations to see if that helps with training stability ui_display_name: Normalization Type norm_params: default_value_reasoning: The default parameters that come with Torch's implementation of these normalization types are a trusted starting point. description_implications: There are a variety of ways a certain set of parameters specificed could influence performance here. Broadly speaking the different values passed in here allow for different levels of smoothness to be observed in the learning curves. Since setting this parameters depends on the type of `norm` set, see [BatchNorm2d](https://pytorch.org/docs/stable/generated/torch.nn.BatchNorm2d.html) for more information on the parameters to set for batch normalization, and see [LayerNorm](https://pytorch.org/docs/stable/generated/torch.nn.LayerNorm.html) for more information on the parameters to set for layer normalization. example_value: - affine: false momentum: 0.2 num_features: 100 expected_impact: 1 literature_references: - "For BatchNorm2d: https://arxiv.org/abs/1502.03167 For LayerNorm: https://arxiv.org/abs/1607.06450" related_parameters: - "`norm`" suggested_values: Depends on the type of `norm` set. suggested_values_reasoning: "NO" ui_display_name: Normalization Parameters num_fc_layers: default_value_reasoning: The encoder already has learnable parameters.Sometimes the default is 1 for modules where the FC stack is used for shape management, or the only source of learnable parameters. description_implications: Increasing num_fc_layers will increase the capacity of the model. The model will be slower to train, and there's a higher risk of overfitting. example_value: - 1 expected_impact: 1 other_information: Not all modules that have fc_layers also have an accompanying num_fc_layers parameter. Where both are present, fc_layers takes precedent over num_fc_layers. Specifying num_fc_layers alone uses fully connected layers that are configured by the defaults in FCStack. related_parameters: - fc_layers suggested_values: 0-1 suggested_values_reasoning: The full model likely contains many learnable parameters. Consider starting with very few, or without any additional fully connected layers and add them if you observe evidence of limited model capacity. Sometimes the default is 1 for modules where the FC stack is used for shape management, or the only source of learnable parameters. ui_display_name: Number of Fully Connected Layers num_heads: ui_display_name: null num_layers: default_value_reasoning: The ideal number of layers depends on the data. For many data types, one layer is sufficient. description_implications: "The ideal number of transformer layers depends on the length and complexity of input sequences, as well as the task. For more complex tasks, and higher number of transformer layers may be useful. However, too many layers will increase memory and slow training while providing diminishing returns of model performance." example_value: - 1 expected_impact: 3 suggested_values: 1 - 12 suggested_values_reasoning: Increasing the number of layers may improve encoder performance. However, more layers will increase training time and may cause overfitting. Small numbers of layers usually work best. ui_display_name: Number of Transformer Layers output_size: default_value_reasoning: A modest value, not too small, not too large. description_implications: If there are fully connected layers in this module, increasing the output size of each fully connected layer will increase the capacity of the model. However, the model may be slower to train, and there's a higher risk of overfitting. If it seems like the model could use even more capacity, consider increasing the number of fully connected layers, or explore other architectures. expected_impact: 3 other_information: If num_fc_layers=0 and fc_layers=None, and there are no fully connected layers defined on the module, then this parameter may have no effect on the module's final output shape. related_parameters: - num_fc_layers, fc_layers suggested_values: 10 - 1024 suggested_values_reasoning: Increasing the output size increases the capacity of the model. If this seems to have a positive effect, then it could be worth increasing the number of layers, or trying a different architecture with a larger capacity. ui_display_name: Output Size pretrained_embeddings: ui_display_name: null expected_impact: 0 reduce_output: ui_display_name: null expected_impact: 1 representation: ui_display_name: null expected_impact: 1 should_embed: internal_only: true ui_display_name: Not displayed transformer_output_size: default_value_reasoning: A modest value, not too small, not too large. description_implications: If there are fully connected layers in this module, increasing the output size of each fully connected layer will increase the capacity of the model. However, the model may be slower to train, and there's a higher risk of overfitting. If it seems like the model could use even more capacity, consider increasing the number of fully connected layers, or explore other architectures. expected_impact: 2 other_information: If num_fc_layers=0 and fc_layers=None, and there are no fully connected layers defined on the module, then this parameter may have no effect on the module's final output shape. related_parameters: - num_fc_layers, fc_layers suggested_values: 10 - 1024 suggested_values_reasoning: Increasing the output size increases the capacity of the model. If this seems to have a positive effect, then it could be worth increasing the number of layers, or trying a different architecture with a larger capacity. ui_display_name: Transformer Output Size use_bias: default_value_reasoning: "Bias terms may improve model accuracy, and don't have much impact in terms of memory or training speed. For most models it is reasonable to use bias terms. Batch Normalization, however, adds a trainable shift parameter which is added to the activation. When Batch Normalization is used in a layer, bias terms are redundant and may be removed." description_implications: Bias terms may improve model accuracy, and don't have much impact in terms of memory or training speed. For most models it is reasonable to leave this parameter set to True. example_value: - true expected_impact: 1 other_information: If fc_layers is not specified, or use_bias is not specified for individual layers, the value of use_bias will be used as the default for all layers. related_parameters: - bias_initializer, fc_layers suggested_values: - true ui_display_name: Use Bias vocab: default_value_reasoning: Computed and passed along internally according to preprocessing settings. example_value: - a - b - c internal_only: true ui_display_name: Not Displayed weights_initializer: default_value_reasoning: Taken from [this paper](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf). description_implications: The method you choose to initialize layer weights during training can have a big impact on performance as well as the reproducibility of your final model between runs. As an example, if you were to randomly initialize weights you would risk non-reproducibility (and possibly general training performance), but sticking with constant values for initialization might significantly increase the time needed for model convergence. Generally, choosing one of the probabilistic approaches strikes a balance between the two extremes, and the literature kicked off by the landmark [*Xavier et al.* paper](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf) provides a few good options. See this nice discussion from [Weights and Biases](https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster.) for more information. expected_impact: 1 literature_references: - "Weights and Biases blog post: https://wandb.ai/site/articles/the-effects-of-weight-initialization-on-neural-nets#:~:text=Studies%20have%20shown%20that%20initializing,net%20train%20better%20and%20faster. Xavier et al. paper: http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf" suggested_values: xavier_uniform suggested_values_reasoning: Changing the weights initialization scheme is something to consider if a model is having trouble with convergence, or otherwise it is something to experiment with after other factors are considered. The default choice (`xavier_uniform`) is a suitable starting point for most tasks. ui_display_name: Layer Weights Initializer T5: type: short_description: Text-to-text approach transformer with good transfer performance on multiple tasks. long_description: The `t5` encoder loads a pretrained [T5](https://arxiv.org/pdf/1910.10683.pdf) (default `t5-small`) model using the Hugging Face transformers package. T5 (Text-to-Text Transfer Transformer) is pre-trained on a huge text dataset crawled from the web and shows good transfer performance on multiple tasks. compute_tier: 2 d_ff: default_value_reasoning: Default value matches the pre-trained encoder. description_implications: If using a pre-trained encoder, this parameter will be automatically derived from the pre-trained model. expected_impact: 1 ui_display_name: Dimensionality of Feed-Forward Layer d_kv: ui_display_name: null d_model: ui_display_name: null dropout_rate: default_value_reasoning: Huggingface default. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 2 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: dropout_rate feed_forward_proj: ui_display_name: null initializer_factor: ui_display_name: null layer_norm_eps: ui_display_name: null max_sequence_length: default_value_reasoning: Sets the maximum sequence length of the expected inputs, so input/output shapes are computed accurately. internal_only: true ui_display_name: null num_decoder_layers: ui_display_name: null num_heads: ui_display_name: null num_layers: default_value_reasoning: The default value matches the number of layers in the default pretrained encoder. description_implications: "The ideal number of transformer layers depends on the length and complexity of input sequences, as well as the task. If using a pre-trained model, this parameter will be automatically derived from the pre-trained model." example_value: - 6 expected_impact: 2 related_parameters: - pretrained_model_or_path suggested_values: 1 - 12 suggested_values_reasoning: Increasing the number of layers may improve encoder performance. However, more layers will increase training time and may cause overfitting. Small numbers of layers usually work best. ui_display_name: Number of Transformer Layers pretrained_kwargs: ui_display_name: null pretrained_model_name_or_path: ui_display_name: null expected_impact: 2 reduce_output: ui_display_name: null expected_impact: 1 relative_attention_num_buckets: ui_display_name: null saved_weights_in_checkpoint: default_value_reasoning: The weights of the encoder are not necessarily saved in the checkpoint. The user has to save them first. description_implications: The memory footprint for some of these encoders can be large. internal_only: true related_parameters: - skip_save_model suggested_values: - false suggested_values_reasoning: Some of these encoders are large, so it might be better to load them as needed, especially if 1. they're not used frequently 2. the user doesn't have a lot of storage. ui_display_name: null trainable: expected_impact: 3 ui_display_name: null use_pretrained: default_value_reasoning: By default, the model is initialized as a pretrained model. description_implications: Pretrained models have typically already learned features that are difficult to learn from scratch. They are particularly beneficial when training on small amounts of data. expected_impact: 3 literature_references: - https://machinelearningmastery.com/transfer-learning-for-deep-learning/ related_parameters: - trainable, pretrained_model_name, pretrained_model_name_or_path, pretrained_kwargs suggested_values: - false suggested_values_reasoning: If you have a large amount of data and/or you have data that differs from the typical distribution, then it might be worth training the model from scratch. ui_display_name: Use Pretrained vocab: default_value_reasoning: Computed and passed along internally according to preprocessing settings. example_value: - a - b - c internal_only: true ui_display_name: Not Displayed vocab_size: internal_only: true ui_display_name: Not displayed TransformerXL: type: short_description: Transformer architecture that introduces the notion of recurrence to the deep self-attention network. long_description: The `transformer_xl` encoder loads a pretrained [Transformer-XL](https://arxiv.org/abs/1901.02860) (default `transfo-xl-wt103`) model using the Hugging Face transformers package. Adds novel positional encoding scheme which improves understanding and generation of long-form text up to thousands of tokens. Transformer-XL is a causal (uni-directional) transformer with relative positioning (sinusoïdal) embeddings which can reuse previously computed hidden-states to attend to longer context (memory). This model also uses adaptive softmax inputs and outputs (tied). compute_tier: 2 adaptive: default_value_reasoning: Huggingface default. description_implications: Adaptive softmax is a speedup technique for computing probability distributions over words. For text with large vocabulary, adaptive softmax improves both training speed. expected_impact: 1 related_parameters: - vocab_size ui_display_name: Adaptive Softmax attn_type: ui_display_name: null clamp_len: ui_display_name: null cutoffs: ui_display_name: null d_embed: ui_display_name: null d_head: ui_display_name: null d_inner: ui_display_name: null d_model: ui_display_name: null div_val: ui_display_name: null dropatt: ui_display_name: null dropout: default_value_reasoning: Huggingface default. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 2 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: dropout eos_token_id: default_value_reasoning: Default value used in pre-trained HF encoder. ui_display_name: End-of-Sequence Token Id init: ui_display_name: null init_range: ui_display_name: null init_std: ui_display_name: null layer_norm_epsilon: ui_display_name: null max_sequence_length: default_value_reasoning: Sets the maximum sequence length of the expected inputs, so input/output shapes are computed accurately. internal_only: true ui_display_name: null mem_len: ui_display_name: null n_head: ui_display_name: null n_layer: ui_display_name: null pre_lnorm: ui_display_name: null pretrained_kwargs: ui_display_name: null pretrained_model_name_or_path: ui_display_name: null expected_impact: 2 proj_init_std: ui_display_name: null proj_share_all_but_first: ui_display_name: null reduce_output: ui_display_name: null expected_impact: 1 same_length: ui_display_name: null sample_softmax: ui_display_name: null saved_weights_in_checkpoint: default_value_reasoning: The weights of the encoder are not necessarily saved in the checkpoint. The user has to save them first. description_implications: The memory footprint for some of these encoders can be large. internal_only: true related_parameters: - skip_save_model suggested_values: - false suggested_values_reasoning: Some of these encoders are large, so it might be better to load them as needed, especially if 1. they're not used frequently 2. the user doesn't have a lot of storage. ui_display_name: null trainable: expected_impact: 3 ui_display_name: null untie_r: ui_display_name: null use_pretrained: default_value_reasoning: By default, the model is initialized as a pretrained model. description_implications: Pretrained models have typically already learned features that are difficult to learn from scratch. They are particularly beneficial when training on small amounts of data. expected_impact: 3 literature_references: - https://machinelearningmastery.com/transfer-learning-for-deep-learning/ related_parameters: - trainable, pretrained_model_name, pretrained_model_name_or_path, pretrained_kwargs suggested_values: - false suggested_values_reasoning: If you have a large amount of data and/or you have data that differs from the typical distribution, then it might be worth training the model from scratch. ui_display_name: Use Pretrained vocab: default_value_reasoning: Computed and passed along internally according to preprocessing settings. example_value: - a - b - c internal_only: true ui_display_name: Not Displayed vocab_size: internal_only: true ui_display_name: Not displayed TVAlexNetEncoder: model_variant: ui_display_name: Model Variant type: ui_display_name: Type TVBaseEncoder: model_cache_dir: ui_display_name: Model Cache Directory saved_weights_in_checkpoint: default_value_reasoning: The weights of the encoder are not necessarily saved in the checkpoint. The user has to save them first. description_implications: The memory footprint for some of these encoders can be large. internal_only: true related_parameters: - skip_save_model suggested_values: - false suggested_values_reasoning: Some of these encoders are large, so it might be better to load them as needed, especially if 1. they're not used frequently 2. the user doesn't have a lot of storage. ui_display_name: Saved Weights in Checkpoint trainable: default_value_reasoning: By default, model components are trainable. description_implications: The tradeoff when using `trainable` is between speed and flexibility. If False, less weights are subject to change and the model will therefore train faster. However, the representations output by this component are fixed for each input. expected_impact: 3 literature_references: - "https://www.ibm.com/cloud/learn/overfitting http://d2l.ai/chapter_computer-vision/fine-tuning.html" related_parameters: - use_pretrained, pretrained_model, saved_weights_in_checkpoint suggested_values: - false suggested_values_reasoning: Freezing the weights (i.e. `trainable = False`) is only worth trying if you are loading in pretrained weights. In that case, check to see if your model is overfitting. If so, freezing the weights (and therefore reducing model complexity) may be beneficial. ui_display_name: Trainable use_pretrained: default_value_reasoning: By default, the model is initialized as a pretrained model. description_implications: Pretrained models have typically already learned features that are difficult to learn from scratch. They are particularly beneficial when training on small amounts of data. expected_impact: 3 literature_references: - https://machinelearningmastery.com/transfer-learning-for-deep-learning/ related_parameters: - trainable, pretrained_model_name, pretrained_model_name_or_path, pretrained_kwargs suggested_values: - false suggested_values_reasoning: If you have a large amount of data and/or you have data that differs from the typical distribution, then it might be worth training the model from scratch. ui_display_name: Use Pretrained TVConvNeXtEncoder: model_variant: ui_display_name: Model Variant type: ui_display_name: Type TVDenseNetEncoder: model_variant: ui_display_name: Model Variant type: ui_display_name: Type TVEfficientNetEncoder: model_variant: ui_display_name: Model Variant type: ui_display_name: Type TVGoogLeNetEncoder: model_variant: ui_display_name: Model Variant type: ui_display_name: Type TVInceptionV3Encoder: model_variant: ui_display_name: Model Variant type: ui_display_name: Type TVMaxVitEncoder: model_variant: ui_display_name: Model Variant type: ui_display_name: Type TVMNASNetEncoder: model_variant: ui_display_name: Model Variant type: ui_display_name: Type TVMobileNetV2Encoder: model_variant: ui_display_name: Model Variant type: ui_display_name: Type TVMobileNetV3Encoder: model_variant: ui_display_name: Model Variant type: ui_display_name: Type TVRegNetEncoder: model_variant: ui_display_name: Model Variant type: ui_display_name: Type TVResNetEncoder: model_variant: ui_display_name: Model Variant type: ui_display_name: Type TVResNeXtEncoder: model_variant: ui_display_name: Model Variant type: ui_display_name: Type TVShuffleNetV2Encoder: model_variant: ui_display_name: Model Variant type: ui_display_name: Type TVSqueezeNetEncoder: model_variant: ui_display_name: Model Variant type: ui_display_name: Type TVSwinTransformerEncoder: model_variant: ui_display_name: Model Variant type: ui_display_name: Type TVViTEncoder: model_variant: ui_display_name: Model Variant type: ui_display_name: Type TVVGGEncoder: model_variant: ui_display_name: Model Variant type: ui_display_name: Type TVWideResNetEncoder: model_variant: ui_display_name: Model Variant type: ui_display_name: Type ViT: type: short_description: ViT encoder divides images into patches, performs a linear transformation, and then applies a transformer. long_description: ViT, short for Vision Transformer, divides the image into equal-sized patches, uses a linear transformation to encode each flattened patch, then applies a deep transformer architecture to the sequence of encoded patches. compute_tier: 2 attention_probs_dropout_prob: default_value_reasoning: Taken from literature (https://arxiv.org/abs/2010.11929). description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 3 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html related_parameters: - "hidden_dropout_prob, attention_probs_dropout_prob" suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: Attention Dropout gradient_checkpointing: ui_display_name: null height: internal_only: true ui_display_name: null hidden_act: default_value_reasoning: Taken from huggingface. description_implications: Changing this activation function will only affect the feed-forward layers of the transformer. example_value: - relu expected_impact: 2 literature_references: - "[Huggingface docs for ViT config](https://huggingface.co/docs/transformers/model_doc/vit#transformers.ViTConfig.hidden_act) [Relevant StackOverflow discussion](https://ai.stackexchange.com/questions/30341/why-does-a-transformer-not-use-an-activation-function-following-the-multi-head-a)" suggested_values: gelu suggested_values_reasoning: Taken from huggingface defaults. ui_display_name: Hidden Layer Activation hidden_dropout_prob: default_value_reasoning: Taken from literature (https://arxiv.org/abs/2010.11929). description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 3 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html related_parameters: - "hidden_dropout_prob, attention_probs_dropout_prob" suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: Hidden Dropout hidden_size: default_value_reasoning: Huggingface default. description_implications: Increasing the hidden size makes the model larger and slower to train, increases the model's capacity to capture more complexity. It also increases the chance of overfitting. expected_impact: 2 suggested_values: 10 - 2048 suggested_values_reasoning: Increasing the hidden size makes sense if the model is underfitting. It's useful to train both smaller and larger models to see how model capacity affects performance. This should only be explored after the architecture of the model has been settled. ui_display_name: Hidden Size initializer_range: description_implications: There is an ideal value for this variable that doesn't lead to the outputs of these matrices to vanish or explode example_value: - 0.02 expected_impact: 1 other_information: Must be greater than 0 related_parameters: - weights_initializer suggested_values: 0.01-0.05 suggested_values_reasoning: Large values will likely lead to very large outputs. Small values will lead to vanishing outputs. ui_display_name: null intermediate_size: ui_display_name: null layer_norm_eps: ui_display_name: null num_attention_heads: ui_display_name: null num_channels: ui_display_name: null num_hidden_layers: ui_display_name: null patch_size: default_value_reasoning: Taken from ViT paper. description_implications: "The implications of the image patch size for this\ \ layer depend on other factors, such as the true resolution of the incoming\ \ image dataset. If the patch size is kept consistent but a higher resolution\ \ image is used as input, then the resulting chunked sequence of tokens\ \ will be longer than it would have been if the input resolution was lower.\ \ \n\nThe ViT paper notes that decreasing the patch size in this way led\ \ to robust improvements without introducing other parameters." expected_impact: 2 literature_references: - "[Huggingface docs](https://huggingface.co/docs/transformers/model_doc/vit) [ViT paper](https://arxiv.org/abs/2010.11929)" suggested_values: - 16 - 32 suggested_values_reasoning: 16 and 32 are the values used in the original ViT paper. ui_display_name: Patch Size pretrained_model: default_value_reasoning: The default model is the canonical model for this model architecture, and is therefore a good starting point for most use cases. description_implications: "There are two factors to consider when choosing\ \ a pre-trained model: (1) size, and (2) task similarity. \n\nThe larger\ \ the model, the more subtle its comprehension of inputs can become. However,\ \ larger models are also more compute and memory-intensive to train.\n\ \nModels pretrained on highly-related source tasks are more likely to\ \ be successful on the target task. Consider searching the HuggingFace\ \ model repository for models trained on similar tasks." expected_impact: 3 literature_references: - https://arxiv.org/abs/2010.11929 related_parameters: - use_pretrained, trainable, pretrained_kwargs suggested_values: google/vit-large-patch16-224 suggested_values_reasoning: "If you would like better performance and are not compute/memory-constrained, increasing model capacity can potentially provide a richer representation than the default. The suggested value upsizes the model while maintaining the same model architecture. Model trained on internet-scale datasets typically generalize well. Consider deviating from the default only if the images in the dataset originate from another domain (e.g. medical images, geospatial data)." ui_display_name: Pretrained model name saved_weights_in_checkpoint: default_value_reasoning: The weights of the encoder are not necessarily saved in the checkpoint. The user has to save them first. description_implications: The memory footprint for some of these encoders can be large. internal_only: true related_parameters: - skip_save_model suggested_values: - false suggested_values_reasoning: Some of these encoders are large, so it might be better to load them as needed, especially if 1. they're not used frequently 2. the user doesn't have a lot of storage. ui_display_name: null trainable: default_value_reasoning: By default, model components are trainable. description_implications: The tradeoff when using `trainable` is between speed and flexibility. If False, less weights are subject to change and the model will therefore train faster. However, the representations output by this component are fixed for each input. expected_impact: 3 literature_references: - "https://www.ibm.com/cloud/learn/overfitting http://d2l.ai/chapter_computer-vision/fine-tuning.html" related_parameters: - use_pretrained, pretrained_model, saved_weights_in_checkpoint suggested_values: - false suggested_values_reasoning: Freezing the weights (i.e. `trainable = False`) is only worth trying if you are loading in pretrained weights. In that case, check to see if your model is overfitting. If so, freezing the weights (and therefore reducing model complexity) may be beneficial. ui_display_name: Trainable use_pretrained: default_value_reasoning: By default, the model is initialized as a pretrained model. description_implications: Pretrained models have typically already learned features that are difficult to learn from scratch. They are particularly beneficial when training on small amounts of data. expected_impact: 3 literature_references: - https://machinelearningmastery.com/transfer-learning-for-deep-learning/ related_parameters: - trainable, pretrained_model_name, pretrained_model_name_or_path, pretrained_kwargs suggested_values: - false suggested_values_reasoning: If you have a large amount of data and/or you have data that differs from the typical distribution, then it might be worth training the model from scratch. ui_display_name: Use Pretrained width: internal_only: true ui_display_name: null XLM: type: short_description: XLM is pre-trained by cross-language modeling. long_description: The `xlm` encoder loads a pretrained [XLM](https://arxiv.org/abs/1901.07291) (default `xlm-mlm-en-2048`) model using the Hugging Face transformers package. Pre-trained by cross-language modeling. compute_tier: 2 asm: ui_display_name: null attention_dropout: default_value_reasoning: Huggingface default. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 2 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html related_parameters: - dropout suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: attention_dropout bos_index: ui_display_name: null bos_token_id: default_value_reasoning: Default value used in pre-trained HF encoder. ui_display_name: Beginning-of-Sentence Token Id causal: ui_display_name: null dropout: default_value_reasoning: Huggingface default. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 2 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html related_parameters: - attention_dropout suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: dropout emb_dim: ui_display_name: null embed_init_std: ui_display_name: null end_n_top: ui_display_name: null eos_index: ui_display_name: null gelu_activation: ui_display_name: null expected_impact: 1 init_std: ui_display_name: null is_encoder: ui_display_name: null lang_id: ui_display_name: null layer_norm_eps: ui_display_name: null mask_index: ui_display_name: null mask_token_id: default_value_reasoning: Default value used in pre-trained HF encoder. ui_display_name: Mask Token ID max_position_embeddings: default_value_reasoning: Taken from huggingface. description_implications: The size of the position embeddings table. This typically coincides with the maximum sequence length this model might ever be used with. Typically set this to something large just in case (e.g. 512, 1024, 2048). expected_impact: 2 suggested_values: 512 suggested_values_reasoning: Out of the box value based on published literature. Try models with smaller or larger embedding sizes to observe relative impact. ui_display_name: Max Position Embeddings max_sequence_length: default_value_reasoning: Sets the maximum sequence length of the expected inputs, so input/output shapes are computed accurately. internal_only: true ui_display_name: null n_heads: ui_display_name: null n_langs: default_value_reasoning: Default value used in pre-trained HF encoder. expected_impact: 1 ui_display_name: Number of Languages n_layers: ui_display_name: null pad_index: ui_display_name: null pad_token_id: ui_display_name: null pretrained_kwargs: ui_display_name: null pretrained_model_name_or_path: ui_display_name: null expected_impact: 2 reduce_output: ui_display_name: null expected_impact: 1 saved_weights_in_checkpoint: default_value_reasoning: The weights of the encoder are not necessarily saved in the checkpoint. The user has to save them first. description_implications: The memory footprint for some of these encoders can be large. internal_only: true related_parameters: - skip_save_model suggested_values: - false suggested_values_reasoning: Some of these encoders are large, so it might be better to load them as needed, especially if 1. they're not used frequently 2. the user doesn't have a lot of storage. ui_display_name: null sinusoidal_embeddings: ui_display_name: null start_n_top: ui_display_name: null trainable: expected_impact: 3 ui_display_name: null unk_index: ui_display_name: null use_lang_emb: ui_display_name: null use_pretrained: default_value_reasoning: By default, the model is initialized as a pretrained model. description_implications: Pretrained models have typically already learned features that are difficult to learn from scratch. They are particularly beneficial when training on small amounts of data. expected_impact: 3 literature_references: - https://machinelearningmastery.com/transfer-learning-for-deep-learning/ related_parameters: - trainable, pretrained_model_name, pretrained_model_name_or_path, pretrained_kwargs suggested_values: - false suggested_values_reasoning: If you have a large amount of data and/or you have data that differs from the typical distribution, then it might be worth training the model from scratch. ui_display_name: Use Pretrained vocab: default_value_reasoning: Computed and passed along internally according to preprocessing settings. example_value: - a - b - c internal_only: true ui_display_name: Not Displayed vocab_size: internal_only: true ui_display_name: Not displayed XLMRoBERTa: type: short_description: XLM-RoBERTa a large multi-lingual language model trained on 2.5TB of filtered CommonCrawl data. long_description: The `xlmroberta` encoder loads a pretrained [XLM-RoBERTa](https://arxiv.org/abs/1911.02116) (default `jplu/tf-xlm-reoberta-base`) model using the Hugging Face transformers package. XLM-RoBERTa is a multi-language model similar to BERT, trained on 100 languages. XLM-RoBERTa is based on Facebook’s RoBERTa model released in 2019. It is a large multi-lingual language model, trained on 2.5TB of filtered CommonCrawl data. compute_tier: 2 add_pooling_layer: ui_display_name: null bos_token_id: default_value_reasoning: Default value used in pre-trained HF encoder. ui_display_name: Beginning-of-Sentence Token Id eos_token_id: default_value_reasoning: Default value used in pre-trained HF encoder. ui_display_name: End-of-Sentence Token Id max_position_embeddings: default_value_reasoning: Taken from huggingface. description_implications: The size of the position embeddings table. This typically coincides with the maximum sequence length this model might ever be used with. Typically set this to something large just in case (e.g. 512, 1024, 2048). expected_impact: 1 suggested_values: 512 suggested_values_reasoning: Out of the box value based on published literature. Try models with smaller or larger embedding sizes to observe relative impact. ui_display_name: Max Position Embeddings max_sequence_length: default_value_reasoning: Sets the maximum sequence length of the expected inputs, so input/output shapes are computed accurately. internal_only: true ui_display_name: null pad_token_id: ui_display_name: null pretrained_kwargs: ui_display_name: null pretrained_model_name_or_path: ui_display_name: null expected_impact: 2 reduce_output: ui_display_name: null expected_impact: 1 saved_weights_in_checkpoint: default_value_reasoning: The weights of the encoder are not necessarily saved in the checkpoint. The user has to save them first. description_implications: The memory footprint for some of these encoders can be large. internal_only: true related_parameters: - skip_save_model suggested_values: - false suggested_values_reasoning: Some of these encoders are large, so it might be better to load them as needed, especially if 1. they're not used frequently 2. the user doesn't have a lot of storage. ui_display_name: null trainable: expected_impact: 3 ui_display_name: null type_vocab_size: ui_display_name: null expected_impact: 1 use_pretrained: default_value_reasoning: By default, the model is initialized as a pretrained model. description_implications: Pretrained models have typically already learned features that are difficult to learn from scratch. They are particularly beneficial when training on small amounts of data. expected_impact: 3 literature_references: - https://machinelearningmastery.com/transfer-learning-for-deep-learning/ related_parameters: - trainable, pretrained_model_name, pretrained_model_name_or_path, pretrained_kwargs suggested_values: - false suggested_values_reasoning: If you have a large amount of data and/or you have data that differs from the typical distribution, then it might be worth training the model from scratch. ui_display_name: Use Pretrained vocab: default_value_reasoning: Computed and passed along internally according to preprocessing settings. example_value: - a - b - c internal_only: true ui_display_name: Not Displayed vocab_size: internal_only: true ui_display_name: Not displayed XLNet: type: short_description: XLNet is a transformer that outperforms BERT on a variety of benchmarks. long_description: The `xlnet` encoder loads a pretrained [XLNet](https://arxiv.org/abs/1906.08237) (default `xlnet-base-cased`) model using the Hugging Face transformers package. XLnet is an extension of the Transformer-XL model pre-trained using an autoregressive method to learn bidirectional contexts by maximizing the expected likelihood over all permutations of the input sequence factorization order. XLNet outperforms BERT on a variety of benchmarks. compute_tier: 2 attn_type: ui_display_name: null bi_data: ui_display_name: null bos_token_id: default_value_reasoning: Default value used in pre-trained HF encoder. ui_display_name: Beginning-of-Sentence Token Id clamp_len: ui_display_name: null d_inner: ui_display_name: null d_model: ui_display_name: null dropout: default_value_reasoning: Huggingface default. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 2 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html related_parameters: - summary_last_dropout suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: dropout end_n_top: ui_display_name: null eos_token_id: default_value_reasoning: Default value used in pre-trained HF encoder. ui_display_name: End-of-Sequence Token Id ff_activation: ui_display_name: null expected_impact: 1 initializer_range: description_implications: There is an ideal value for this variable that doesn't lead to the outputs of these matrices to vanish or explode example_value: - 0.02 expected_impact: 1 other_information: Must be greater than 0 related_parameters: - weights_initializer suggested_values: 0.01-0.05 suggested_values_reasoning: Large values will likely lead to very large outputs. Small values will lead to vanishing outputs. ui_display_name: null layer_norm_eps: ui_display_name: null max_sequence_length: default_value_reasoning: Sets the maximum sequence length of the expected inputs, so input/output shapes are computed accurately. internal_only: true ui_display_name: null mem_len: ui_display_name: null n_head: ui_display_name: null n_layer: ui_display_name: null pad_token_id: ui_display_name: null pretrained_kwargs: ui_display_name: null pretrained_model_name_or_path: ui_display_name: null expected_impact: 2 reduce_output: ui_display_name: null expected_impact: 1 reuse_len: ui_display_name: null same_length: ui_display_name: null saved_weights_in_checkpoint: default_value_reasoning: The weights of the encoder are not necessarily saved in the checkpoint. The user has to save them first. description_implications: The memory footprint for some of these encoders can be large. internal_only: true related_parameters: - skip_save_model suggested_values: - false suggested_values_reasoning: Some of these encoders are large, so it might be better to load them as needed, especially if 1. they're not used frequently 2. the user doesn't have a lot of storage. ui_display_name: null start_n_top: ui_display_name: null summary_activation: default_value_reasoning: Default value used in pre-trained HF encoder. ui_display_name: Summary Activation Function expected_impact: 1 summary_last_dropout: default_value_reasoning: Huggingface default. description_implications: "Dropout is a computationally cheap regularization\ \ method where during training, some neurons are randomly ignored or \u201C\ dropped out\u201D. Increasing dropout has the effect of making the training\ \ process more noisy and lowering overall network capacity, but it can\ \ be an effective regularization method to reduce overfitting and improve\ \ generalization." example_value: - 0.2 expected_impact: 1 literature_references: - https://pytorch.org/docs/stable/generated/torch.nn.Dropout.html related_parameters: - dropout suggested_values: 0.05 - 0.8 suggested_values_reasoning: Tuning dropout is really something to be done when all of the big choices about architecture have been settled. Consider starting with 0.5 and adjusting the dropout depending on observed model performance. ui_display_name: summary_last_dropout summary_type: ui_display_name: null summary_use_proj: ui_display_name: null trainable: expected_impact: 3 ui_display_name: null untie_r: ui_display_name: null use_mems_eval: ui_display_name: null use_mems_train: ui_display_name: null use_pretrained: default_value_reasoning: By default, the model is initialized as a pretrained model. description_implications: Pretrained models have typically already learned features that are difficult to learn from scratch. They are particularly beneficial when training on small amounts of data. expected_impact: 3 literature_references: - https://machinelearningmastery.com/transfer-learning-for-deep-learning/ related_parameters: - trainable, pretrained_model_name, pretrained_model_name_or_path, pretrained_kwargs suggested_values: - false suggested_values_reasoning: If you have a large amount of data and/or you have data that differs from the typical distribution, then it might be worth training the model from scratch. ui_display_name: Use Pretrained vocab: default_value_reasoning: Computed and passed along internally according to preprocessing settings. example_value: - a - b - c internal_only: true ui_display_name: Not Displayed vocab_size: internal_only: true ui_display_name: Not displayed conv_params: num_conv_layers: description_implications: The more layers that are specified the deeper and higher capacity the model will be. This makes it possible to potentially achieve better performance when a large amount of data is provided, but also makes the model more computationally expensive and potentially more prone to overfitting. expected_impact: 3 related_parameters: - conv_layers ui_display_name: Number of Convolutional Layers conv_layers: description_implications: The more layers that are specified the deeper and higher capacity the model will be. This makes it possible to potentially achieve better performance when a large amount of data is provided, but also makes the model more computationally expensive and potentially more prone to overfitting. expected_impact: 1 related_parameters: - num_conv_layers ui_display_name: Convolutional Layers pool_function: default_value_reasoning: "Within a given sliding window (e.g. a \"patch\"\ \ of a 3-channel image), the maximum value for each channel is kept. All\ \ other values in the patch are discarded. Repeat this step for every\ \ patch and you have a more compact representation of the image. \n\n\ Intuitively, each patch encodes the features from a particular part of\ \ an image, and it is more informative to look at the most prominent features\ \ of an image than the average of all of them." description_implications: Both average and max pooling can achieve strong performance. expected_impact: 1 literature_references: - "https://pytorch.org/docs/stable/generated/torch.nn.MaxPool2d.html https://machinelearningmastery.com/pooling-layers-for-convolutional-neural-networks/" suggested_values: Default suggested_values_reasoning: "No" ui_display_name: Pooling function pool_size: ui_display_name: null expected_impact: 1 num_filters: ui_display_name: null filter_size: ui_display_name: null expected_impact: 2 UNetEncoder: type: short_description: The UNet encoder convolutional and max pool layers long_description: Stacks of two 2D convolutional layers with optional normalization and relu activation, followed by a max pool layer in all but the final level of the encoder. compute_tier: 1 conv_norm: expected_impact: 2 ui_display_name: Convolutional Normalization height: default_value_reasoning: Computed internally, automatically, based on image data preprocessing. internal_only: true ui_display_name: NOT DISPLAYED num_channels: default_value_reasoning: Computed internally, automatically, based on image data preprocessing. internal_only: true ui_display_name: NOT DISPLAYED width: default_value_reasoning: Computed internally, automatically, based on image data preprocessing. internal_only: true ui_display_name: NOT DISPLAYED TimmEncoder: model_name: ui_display_name: Model Name use_pretrained: ui_display_name: Use Pretrained saved_weights_in_checkpoint: internal_only: true ui_display_name: Saved Weights in Checkpoint trainable: ui_display_name: Trainable TimmCAFormerEncoder: model_name: ui_display_name: Model Name TimmConvFormerEncoder: model_name: ui_display_name: Model Name TimmPoolFormerEncoder: model_name: ui_display_name: Model Name ================================================ FILE: ludwig/schema/metadata/configs/features.yaml ================================================ audio: preprocessing: audio_file_length_limit_in_s: ui_display_name: null expected_impact: 2 computed_fill_value: internal_only: true ui_display_name: null fill_value: ui_display_name: Fill Value expected_impact: 2 in_memory: ui_display_name: null expected_impact: 1 missing_value_strategy: default_value_reasoning: The default `fill_with_const` replaces missing values with the value specified by `fill_value`. description_implications: Determines how missing values will be handled in the dataset. Not all strategies are valid for all datatypes. For example, `fill_with_mean` is applicable to continuous numerical data. Note that choosing to drop rows with missing values could result in losing information, especially if there is a high proportion of missing values in the dataset. expected_impact: 3 related_parameters: - fill_value ui_display_name: Missing Value Strategy norm: default_value_reasoning: While batch normalization and layer normalization usually lead to improvements, it can be useful to start with fewer bells and whistles. description_implications: Normalization helps stabilize the learning process and can have a regularizing effect that can help with generalization. It's often suggested that with normalization, you can use a higher learning rate. example_value: - batch expected_impact: 2 literature_references: - https://machinelearningmastery.com/batch-normalization-for-training-of-deep-neural-networks/ related_parameters: - norm_params suggested_values: '"batch" or "layer"' suggested_values_reasoning: Normalization tries to solve "internal covariate shift" that comes from the changing distributions of the inputs to layers deep in the network when weights are updated. For example, batch normalization standardizes the inputs to a layer for each mini-batch. Try out different normalizations to see if that helps with training stability ui_display_name: Normalization Type num_fft_points: ui_display_name: null expected_impact: 1 num_filter_bands: literature_references: - "https://medium.com/analytics-vidhya/simplifying-audio-data-fft-stft-mfcc-for-machine-learning-and-deep-learning-443a2f962e0e " related_parameters: - window_length_in_s - type - window_shift_in_s ui_display_name: Type expected_impact: 1 padding_value: ui_display_name: null expected_impact: 1 type: default_value_reasoning: The default type fbank is set based on values that we have tested and determined to be a good starting point for audio feature preprocessing. This is not to say that it is the best way to process every audio feature, it is just a good starting place that performs well in general. description_implications: The different type of audio you select hear will determine how your audio feature is preprocessed and transformed into trainable data for the model. example_value: - stft expected_impact: 3 literature_references: - "https://medium.com/analytics-vidhya/simplifying-audio-data-fft-stft-mfcc-for-machine-learning-and-deep-learning-443a2f962e0e " other_information: Audio feature preprocessing depends heavily on the type of audio data you are dealing with. The type of audio preprocessing you will want to use will be dictated by the audio data you are dealing with. related_parameters: - audio_file_length_limit_in_s - norm - padding_value - in_memory ui_display_name: Type window_length_in_s: literature_references: - "https://medium.com/analytics-vidhya/simplifying-audio-data-fft-stft-mfcc-for-machine-learning-and-deep-learning-443a2f962e0e " related_parameters: - window_shift_in_s - type - num_filter_bands ui_display_name: Window Length in Seconds expected_impact: 2 window_shift_in_s: literature_references: - "https://medium.com/analytics-vidhya/simplifying-audio-data-fft-stft-mfcc-for-machine-learning-and-deep-learning-443a2f962e0e " related_parameters: - window_length_in_s - type - num_filter_bands ui_display_name: Window Shift in Seconds expected_impact: 2 window_type: ui_display_name: null expected_impact: 2 bag: preprocessing: computed_fill_value: internal_only: true ui_display_name: null fill_value: ui_display_name: Fill Value expected_impact: 2 lowercase: ui_display_name: null expected_impact: 2 missing_value_strategy: default_value_reasoning: The default `fill_with_const` replaces missing values with the value specified by `fill_value`. description_implications: Determines how missing values will be handled in the dataset. Not all strategies are valid for all datatypes. For example, `fill_with_mean` is applicable to continuous numerical data. Note that choosing to drop rows with missing values could result in losing information, especially if there is a high proportion of missing values in the dataset. expected_impact: 3 related_parameters: - fill_value ui_display_name: Missing Value Strategy most_common: default_value_reasoning: If there are more than 10000 unique categories in the data, it is likely that they will follow a long-tailed distribution and the least common ones may not provide a lot of information description_implications: A smaller number will reduce the vocabulary, making the embedding matrix smaller and reduce the memory footprint, but will also collapse more tokens into the rare one, so the model may perform worse when rare tokens appear in the data example_value: - 10000 expected_impact: 2 other_information: Specifying a vocab_file overrides this parameter related_parameters: - vocab_file, pretrained_embeddings suggested_values: A value that covers at least 95% of the tokens in the data suggested_values_reasoning: Depending on the data distribution and how important rare tokens are, 90%, 95% or 99% of the number of tokens will leave out only very rare tokens that should not influence performance substantially ui_display_name: Most common (vocabulary size) tokenizer: ui_display_name: null expected_impact: 3 binary: preprocessing: computed_fill_value: internal_only: true ui_display_name: null fallback_true_label: description_implications: Modeling performance should not be affected, but the semantics of some binary metrics may change like for "false positives", "false negatives", etc. if the true label is pinned to the other value. expected_impact: 2 ui_display_name: Fallback True Label fill_value: expected_impact: 2 ui_display_name: Fill Value missing_value_strategy: default_value_reasoning: The default `fill_with_const` replaces missing values with the value specified by `fill_value`. description_implications: Determines how missing values will be handled in the dataset. Not all strategies are valid for all datatypes. For example, `fill_with_mean` is applicable to continuous numerical data. Note that choosing to drop rows with missing values could result in losing information, especially if there is a high proportion of missing values in the dataset. related_parameters: - fill_value ui_display_name: Missing Value Strategy expected_impact: 3 calibration: expected_impact: 3 dependencies: expected_impact: 1 reduce_dependencies: expected_impact: 1 reduce_input: expected_impact: 1 threshold: expected_impact: 3 category: preprocessing: computed_fill_value: internal_only: true ui_display_name: null fill_value: expected_impact: 2 ui_display_name: Fill Value lowercase: ui_display_name: null expected_impact: 2 missing_value_strategy: default_value_reasoning: The default `fill_with_const` replaces missing values with the value specified by `fill_value`. description_implications: Determines how missing values will be handled in the dataset. Not all strategies are valid for all datatypes. For example, `fill_with_mean` is applicable to continuous numerical data. Note that choosing to drop rows with missing values could result in losing information, especially if there is a high proportion of missing values in the dataset. related_parameters: - fill_value ui_display_name: Missing Value Strategy expected_impact: 3 most_common: default_value_reasoning: If there are more than 10000 unique categories in the data, it is likely that they will follow a long-tailed distribution and the least common ones may not provide a lot of information description_implications: A smaller number will reduce the vocabulary, making the embedding matrix smaller and reduce the memory footprint, but will also collapse more tokens into the rare one, so the model may perform worse when rare tokens appear in the data example_value: - 10000 expected_impact: 2 other_information: Specifying a vocab_file overrides this parameter related_parameters: - vocab_file, pretrained_embeddings suggested_values: A value that covers at least 95% of the tokens in the data suggested_values_reasoning: Depending on the data distribution and how important rare tokens are, 90%, 95% or 99% of the number of tokens will leave out only very rare tokens that should not influence performance substantially ui_display_name: Most common (vocabulary size) calibration: expected_impact: 3 dependencies: expected_impact: 1 reduce_dependencies: expected_impact: 1 reduce_input: expected_impact: 1 top_k: expected_impact: 3 date: preprocessing: computed_fill_value: internal_only: true ui_display_name: null datetime_format: default_value_reasoning: Ludwig will try to infer the date format automatically, but a specific format can be provided. The date string spec is the same as the one described in python's datetime. description_implications: If Ludwig has trouble parsing dates, it could be useful to specify an explicit format that Ludwig should parse date feature values as. This could also serve as a form of normalization, for example, if not all datetimes have the same granularity (some have days, some have times), then the common format (i.e. %d %m %Y) serves as a truncator. example_value: - "%d %b %Y" expected_impact: 2 suggested_values_reasoning: Have Ludwig figure out the date format automatically. ui_display_name: Datetime format fill_value: expected_impact: 2 ui_display_name: Fill Value missing_value_strategy: default_value_reasoning: The default `fill_with_const` replaces missing values with the value specified by `fill_value`. description_implications: Determines how missing values will be handled in the dataset. Not all strategies are valid for all datatypes. For example, `fill_with_mean` is applicable to continuous numerical data. Note that choosing to drop rows with missing values could result in losing information, especially if there is a high proportion of missing values in the dataset. related_parameters: - fill_value ui_display_name: Missing Value Strategy expected_impact: 3 h3: preprocessing: computed_fill_value: internal_only: true ui_display_name: null fill_value: expected_impact: 2 ui_display_name: Fill Value missing_value_strategy: default_value_reasoning: The default `fill_with_const` replaces missing values with the value specified by `fill_value`. description_implications: Determines how missing values will be handled in the dataset. Not all strategies are valid for all datatypes. For example, `fill_with_mean` is applicable to continuous numerical data. Note that choosing to drop rows with missing values could result in losing information, especially if there is a high proportion of missing values in the dataset. related_parameters: - fill_value ui_display_name: Missing Value Strategy expected_impact: 3 image: # TODO: review metadata generated by Copilot augmentation: auto_augmentation_method: default_value_reasoning: Trivial augment is computationally more efficient than the other methods. description_implications: Type of auto-augmentation method to apply to batch of images to improve model generalization example_value: "trivial_augment" expected_impact: 1 ui_display_name: Auto Augmentation Method max_brightness: default_value_reasoning: The default value of 3.0. description_implications: The maximum factor by which the brightness of the image will be randomly changed. example_value: - 3.9 expected_impact: 1 ui_display_name: Maximum Brightness min_brightness: default_value_reasoning: The default value of 0.1. description_implications: The minimum brightness factor to apply to the image. example_value: - 0.5 expected_impact: 1 ui_display_name: Minimum Brightness max_contrast: default_value_reasoning: The default value of 3.0 description_implications: The maximum factor by which the contrast of the image will be randomly changed. example_value: - 3.0 expected_impact: 1 ui_display_name: Maximum Contrast min_contrast: default_value_reasoning: The default value of 0.1. description_implications: The minimum contrast factor to apply to the image. example_value: - 0.1 expected_impact: 1 ui_display_name: Minimum contrast kernel_size: default_value_reasoning: The default value is 3. description_implications: The kernel size is the size of the filter matrix. A larger kernel size will result in a blurrier image, while a smaller kernel size will result in less blurring. example_value: - 3 expected_impact: 2 suggested_values: - 3 - 5 - 7 suggested_values_reasoning: The default value is 3, which is a common value for image processing ui_display_name: Kernel Size rotation_degree: default_value_reasoning: The default value of 15 means that the image will be randomly rotated between -15 to +15 degrees. description_implications: The degree of rotation to apply to the image. expected_impact: 1 ui_display_name: Rotation Degree type: description_implications: The type of augmentation to perform on the image. expected_impact: 1 ui_display_name: Type preprocessing: computed_fill_value: internal_only: true ui_display_name: null fill_value: expected_impact: 2 ui_display_name: Fill Value height: ui_display_name: null expected_impact: 2 in_memory: ui_display_name: null expected_impact: 1 infer_image_dimensions: ui_display_name: null expected_impact: 1 infer_image_max_height: ui_display_name: null expected_impact: 1 infer_image_max_width: ui_display_name: null expected_impact: 1 infer_image_num_channels: ui_display_name: null expected_impact: 1 infer_image_sample_size: ui_display_name: null expected_impact: 1 infer_image_num_classes: ui_display_name: null expected_impact: 1 missing_value_strategy: default_value_reasoning: The default `fill_with_const` replaces missing values with the value specified by `fill_value`. description_implications: Determines how missing values will be handled in the dataset. Not all strategies are valid for all datatypes. For example, `fill_with_mean` is applicable to continuous numerical data. Note that choosing to drop rows with missing values could result in losing information, especially if there is a high proportion of missing values in the dataset. related_parameters: - fill_value ui_display_name: Missing Value Strategy expected_impact: 3 num_channels: ui_display_name: null expected_impact: 2 num_classes: ui_display_name: null expected_impact: 2 num_processes: ui_display_name: null expected_impact: 2 resize_method: default_value_reasoning: Interpolation may stretch or squish the image, but it does not remove content or change the statistical distribution of image values so it is more appropriate for most tasks. description_implications: "interpolation will not change the content of the image, but it will change the aspect ratio. crop_or_pad will preserve the aspect ratio of the image, but may remove some content (in the case of cropping)." expected_impact: 1 related_parameters: - height, width ui_display_name: Resize Method standardize_image: ui_display_name: null expected_impact: 1 width: ui_display_name: null expected_impact: 2 requires_equal_dimensions: ui_display_name: null expected_impact: 1 dependencies: expected_impact: 1 reduce_dependencies: expected_impact: 1 reduce_input: expected_impact: 1 number: preprocessing: computed_fill_value: internal_only: true ui_display_name: null computed_outlier_fill_value: internal_only: true ui_display_name: null fill_value: expected_impact: 2 ui_display_name: Fill Value missing_value_strategy: default_value_reasoning: The default `fill_with_const` replaces missing values with the value specified by `fill_value`. description_implications: Determines how missing values will be handled in the dataset. Not all strategies are valid for all datatypes. For example, `fill_with_mean` is applicable to continuous numerical data. Note that choosing to drop rows with missing values could result in losing information, especially if there is a high proportion of missing values in the dataset. related_parameters: - fill_value ui_display_name: Missing Value Strategy expected_impact: 3 outlier_strategy: default_value_reasoning: Outlier definitions and how to handle them are very task-specific, so we leave this feature disabled by default and ask the user to choose the strategy that works best for them. description_implications: Determines how outliers will be handled in the dataset. In most cases replacing outliers with the column mean (`fill_with_mean`) will be sufficient, but in others the outliers may be damaging enough to merit dropping the entire row of data (`drop_row`). In some cases, the best way to handle outliers is to leave them in the data, which is the behavior when this parameter is left as `null`. related_parameters: - outlier_threshold suggested_values: fill_with_mean ui_display_name: Outlier Strategy expected_impact: 3 outlier_threshold: default_value_reasoning: The definition of an outlier is often dataset and task dependent, but 2 or 3 standard deviations from the mean is a common heuristic. description_implications: "Determines the threshold past which a number will be considered an outlier in the dataset. The 3-sigma rule in statistics tells us that when data is normally distributed, 95% of the data will lie within 2 standard deviations of the mean, and greater than 99% of the data will lie within 3 standard deviations of the mean (see: 68–95–99.7 rule). As such anything farther away than that is highly likely to be an outlier, and may distort the learning process by disproportionately affecting the model." related_parameters: - outlier_strategy literature_references: - https://en.wikipedia.org/wiki/68%E2%80%9395%E2%80%9399.7_rule suggested_values: 2 - 3 ui_display_name: Outlier Threshold expected_impact: 2 normalization: default_value_reasoning: Z-score normalization helps improve the training stability and convergence of neural networks by rescaling the numeric input features to have a mean of 0 and a standard deviation of 1, reducing the variability and distribution of the data. This improves neural network training. description_implications: The goal of normalization is to transform features to be on a similar scale. Normalization can be a form of feature smoothing that improves the performance and training stability of the model. Normalizations may result in different effects on the semantics of your number features. The best normalization technique is one that empirically works well, so try new ideas if you think they'll work well on your feature distribution. expected_impact: 3 literature_references: - https://developers.google.com/machine-learning/data-prep/transform/normalization suggested_values: zscore suggested_values_reasoning: "Z-score is a variation of scaling that represents\ \ the number of standard deviations away from the mean. You would\ \ use z-score to ensure your feature distributions have mean = 0 and\ \ std = 1. It\u2019s useful when there are a few outliers, but not\ \ so extreme that you need clipping." ui_display_name: Normalization clip: expected_impact: 2 dependencies: expected_impact: 1 reduce_dependencies: expected_impact: 1 reduce_input: expected_impact: 1 sequence: preprocessing: computed_fill_value: internal_only: true ui_display_name: null fill_value: expected_impact: 2 ui_display_name: Fill Value lowercase: ui_display_name: null expected_impact: 2 sequence_length: default_value_reasoning: The default value is `None`. Which means that the sequence length will be inferred from the dataset, which may save you compute resources on datasets with short sequence samples. description_implications: A larger sequence length keeps more information from the data, but also makes it more computationally expensive (more memory and longer training time). A smaller sequence length keeps less information from the data, but also makes it less computationally expensive (less memory and shorter training time). expected_impact: 3 related_parameters: - max_sequence_length suggested_values: If tying the weights of multiple sequence encoders together, this parameter may need to be set to ensure that all sequence features have the same sequence length. ui_display_name: Sequence Length max_sequence_length: default_value_reasoning: The default value is 256. Every sequence will be truncated to this length. description_implications: A larger sequence length keeps more information from the data, but also makes it more computationally expensive (more memory and longer training time). A smaller sequence length keeps less information from the data, but also makes it less computationally expensive (less memory and shorter training time). expected_impact: 3 related_parameters: - vocab_size, embedding_size suggested_values: Use the lowest value that covers most of your input data. Only increase the value if crucial parts of the input data are truncated. ui_display_name: Maximum Sequence Length missing_value_strategy: default_value_reasoning: The default `fill_with_const` replaces missing values with the value specified by `fill_value`. description_implications: Determines how missing values will be handled in the dataset. Not all strategies are valid for all datatypes. For example, `fill_with_mean` is applicable to continuous numerical data. Note that choosing to drop rows with missing values could result in losing information, especially if there is a high proportion of missing values in the dataset. related_parameters: - fill_value ui_display_name: Missing Value Strategy expected_impact: 3 most_common: default_value_reasoning: If there are more than 10000 unique categories in the data, it is likely that they will follow a long-tailed distribution and the least common ones may not provide a lot of information description_implications: A smaller number will reduce the vocabulary, making the embedding matrix smaller and reduce the memory footprint, but will also collapse more tokens into the rare one, so the model may perform worse when rare tokens appear in the data example_value: - 10000 expected_impact: 2 other_information: Specifying a vocab_file overrides this parameter related_parameters: - vocab_file, pretrained_embeddings suggested_values: A value that covers at least 95% of the tokens in the data suggested_values_reasoning: Depending on the data distribution and how important rare tokens are, 90%, 95% or 99% of the number of tokens will leave out only very rare tokens that should not influence performance substantially ui_display_name: Most common (vocabulary size) ngram_size: default_value_reasoning: Size of the n-gram when using the `ngram` tokenizer. example_value: - 3 ui_display_name: n-gram size expected_impact: 2 padding: ui_display_name: null expected_impact: 1 padding_symbol: ui_display_name: null expected_impact: 1 tokenizer: ui_display_name: null expected_impact: 3 unknown_symbol: ui_display_name: null expected_impact: 1 vocab_file: default_value_reasoning: The vocabulary can be parsed automatically from the incoming input features. description_implications: It can be useful to specify your own vocabulary list if the vocabulary is very large, there's no out of the box tokenizer that fits your data, or if there are several uncommon or infrequently occurring tokens that we want to guarantee to be a part of the vocabulary, rather than treated as an unknown. expected_impact: 0 ui_display_name: Vocab File dependencies: expected_impact: 1 reduce_dependencies: expected_impact: 1 reduce_input: expected_impact: 1 set: preprocessing: computed_fill_value: internal_only: true ui_display_name: null fill_value: expected_impact: 2 ui_display_name: Fill Value lowercase: ui_display_name: null expected_impact: 2 missing_value_strategy: default_value_reasoning: The default `fill_with_const` replaces missing values with the value specified by `fill_value`. description_implications: Determines how missing values will be handled in the dataset. Not all strategies are valid for all datatypes. For example, `fill_with_mean` is applicable to continuous numerical data. Note that choosing to drop rows with missing values could result in losing information, especially if there is a high proportion of missing values in the dataset. expected_impact: 3 related_parameters: - fill_value ui_display_name: Missing Value Strategy most_common: default_value_reasoning: If there are more than 10000 unique categories in the data, it is likely that they will follow a long-tailed distribution and the least common ones may not provide a lot of information description_implications: A smaller number will reduce the vocabulary, making the embedding matrix smaller and reduce the memory footprint, but will also collapse more tokens into the rare one, so the model may perform worse when rare tokens appear in the data example_value: - 10000 expected_impact: 2 other_information: Specifying a vocab_file overrides this parameter related_parameters: - vocab_file, pretrained_embeddings suggested_values: A value that covers at least 95% of the tokens in the data suggested_values_reasoning: Depending on the data distribution and how important rare tokens are, 90%, 95% or 99% of the number of tokens will leave out only very rare tokens that should not influence performance substantially ui_display_name: Most common (vocabulary size) tokenizer: ui_display_name: null expected_impact: 3 dependencies: expected_impact: 1 reduce_dependencies: expected_impact: 1 reduce_input: expected_impact: 1 threshold: expected_impact: 3 text: preprocessing: computed_fill_value: example_value: - Depends on dtype internal_only: true related_parameters: - missing_value_strategy, fill_value ui_display_name: DOCSTRING ONLY fill_value: expected_impact: 2 ui_display_name: Fill Value lowercase: default_value_reasoning: Reading the text in lowercase enables the model to treat capitalized and lowercase words as the same, effectively increasing the number of data points per word. description_implications: If you set lowercase to False, then capitalized words are seen as completely separate entities than lowercase words. example_value: - true expected_impact: 2 related_parameters: - vocab_size suggested_values: "TRUE" suggested_values_reasoning: If there is a strong reason to treat capitalized words and lowercased words differently, then set this to False. Otherwise, it is preferable to bucket the words and make the model case-insensitive. ui_display_name: Convert to lowercase sequence_length: default_value_reasoning: The default value is `None`. Which means that the sequence length will be inferred from the dataset, which may save you compute resources on datasets with short text samples. description_implications: A larger sequence length keeps more information from the data, but also makes it more computationally expensive (more memory and longer training time). A smaller sequence length keeps less information from the data, but also makes it less computationally expensive (less memory and shorter training time). expected_impact: 3 related_parameters: - max_sequence_length suggested_values: If tying the weights of multiple text encoders together, this parameter may need to be set to ensure that all text features have the same sequence length. ui_display_name: Sequence Length max_sequence_length: default_value_reasoning: The default value is 256. Every sequence will be truncated to this length. description_implications: A larger sequence length keeps more information from the data, but also makes it more computationally expensive (more memory and longer training time). A smaller sequence length keeps less information from the data, but also makes it less computationally expensive (less memory and shorter training time). expected_impact: 3 related_parameters: - vocab_size, embedding_size suggested_values: Use the lowest value that covers most of your input data. Only increase the value if crucial parts of the input data are truncated. ui_display_name: Maximum Sequence Length missing_value_strategy: default_value_reasoning: The default `fill_with_const` replaces missing values with the value specified by `fill_value`. description_implications: Determines how missing values will be handled in the dataset. Not all strategies are valid for all datatypes. For example, `fill_with_mean` is applicable to continuous numerical data. Note that choosing to drop rows with missing values could result in losing information, especially if there is a high proportion of missing values in the dataset. related_parameters: - fill_value ui_display_name: Missing Value Strategy expected_impact: 3 most_common: default_value_reasoning: If there are more than 10000 unique categories in the data, it is likely that they will follow a long-tailed distribution and the least common ones may not provide a lot of information description_implications: A smaller number will reduce the vocabulary, making the embedding matrix smaller and reduce the memory footprint, but will also collapse more tokens into the rare one, so the model may perform worse when rare tokens appear in the data example_value: - 10000 expected_impact: 2 other_information: Specifying a vocab_file overrides this parameter related_parameters: - vocab_file, pretrained_embeddings suggested_values: A value that covers at least 95% of the tokens in the data suggested_values_reasoning: Depending on the data distribution and how important rare tokens are, 90%, 95% or 99% of the number of tokens will leave out only very rare tokens that should not influence performance substantially ui_display_name: Most common (vocabulary size) ngram_size: default_value_reasoning: Size of the n-gram when using the `ngram` tokenizer. example_value: - 3 ui_display_name: n-gram size expected_impact: 2 padding: default_value_reasoning: We usually want to add padding to the end of a text sequence to fill in any remaining space as opposed to the beggining so we set the default to right. description_implications: If you pad to the left, the encoded vector will have leading padding tokens as opposed to trailing padding tokens. This could matter based on the type of text input you are expecting. expected_impact: 1 related_parameters: - "padding_symbol, max_sequence_length" suggested_values: "'right'" suggested_values_reasoning: right padding is the usual way to add padding to a text sequence ui_display_name: Padding padding_symbol: ui_display_name: null expected_impact: 1 pretrained_model_name_or_path: internal_only: true ui_display_name: null expected_impact: 0 tokenizer: default_value_reasoning: 'The default tokenizer is `space_punct`, an abbreviation of "Space punctuation". This tokenizer creates sub-words by dividing the text on whitespace and punctuation characters. For example: The text `''hello world!isn''t this great?''` would be transformed to `[''hello'', ''world'', ''!'', ''isn'', "''", ''t'', ''this'', ''great'', ''?'']`. This is the default value because it is a fast tokenizer that works reasonably well.' description_implications: Choosing a tokenizer can be difficult. The primary thing to check is that the tokenizer you have selected is compatible with the language(s) in your text data. This means either selecting a tokenizer that is language-specific (i.e. `french_tokenize` if working with French text) or general enough that its tokenizations are language-agnostic (i.e. `space_punct`). example_value: - space_punct expected_impact: 3 literature_references: - https://huggingface.co/course/chapter2/4?fw=pt related_parameters: - vocab_file, pretrained_model_name_or_path suggested_values: sentencepiece suggested_values_reasoning: "SentencePiece is a tokenizer developed by Google which utilizes Byte-Pair Encoding (BPE), which strikes a good balance between character-level and word-level tokenization (more info on BPE here: https://towardsdatascience.com/byte-pair-encoding-the-dark-horse-of-modern-nlp-eb36c7df4f10 ). This tokenizer is language-agnostic and more sophisticated than the default." ui_display_name: Tokenizer unknown_symbol: ui_display_name: null expected_impact: 1 vocab_file: default_value_reasoning: The vocabulary can be parsed automatically from the incoming input features. description_implications: It can be useful to specify your own vocabulary list if the vocabulary is very large, there's no out of the box tokenizer that fits your data, or if there are several uncommon or infrequently occurring tokens that we want to guarantee to be a part of the vocabulary, rather than treated as an unknown. expected_impact: 0 ui_display_name: Vocab File class_similarities: expected_impact: 1 dependencies: expected_impact: 1 reduce_dependencies: expected_impact: 1 reduce_input: expected_impact: 1 timeseries: preprocessing: computed_fill_value: internal_only: true ui_display_name: null fill_value: expected_impact: 2 ui_display_name: Fill Value missing_value_strategy: default_value_reasoning: The default `fill_with_const` replaces missing values with the value specified by `fill_value`. description_implications: Determines how missing values will be handled in the dataset. Not all strategies are valid for all datatypes. For example, `fill_with_mean` is applicable to continuous numerical data. Note that choosing to drop rows with missing values could result in losing information, especially if there is a high proportion of missing values in the dataset. related_parameters: - fill_value ui_display_name: Missing Value Strategy expected_impact: 3 padding: ui_display_name: null expected_impact: 1 padding_value: ui_display_name: null expected_impact: 1 timeseries_length_limit: ui_display_name: null expected_impact: 2 tokenizer: ui_display_name: null expected_impact: 3 vector: preprocessing: computed_fill_value: internal_only: true ui_display_name: null fill_value: expected_impact: 2 ui_display_name: Fill Value missing_value_strategy: default_value_reasoning: The default `fill_with_const` replaces missing values with the value specified by `fill_value`. description_implications: Determines how missing values will be handled in the dataset. Not all strategies are valid for all datatypes. For example, `fill_with_mean` is applicable to continuous numerical data. Note that choosing to drop rows with missing values could result in losing information, especially if there is a high proportion of missing values in the dataset. expected_impact: 3 related_parameters: - fill_value ui_display_name: Missing Value Strategy vector_size: ui_display_name: null expected_impact: 3 dependencies: expected_impact: 1 reduce_dependencies: expected_impact: 1 reduce_input: expected_impact: 1 softmax: expected_impact: 3 vector_size: expected_impact: 3 ================================================ FILE: ludwig/schema/metadata/configs/llm.yaml ================================================ base_model: _anyOf: preset: ui_display_name: Preset expected_impact: 3 custom: ui_display_name: Custom expected_impact: 3 _meta: ui_display_name: Model Name expected_impact: 3 ui_component_type: radio_string_combined short_description: This can be one of the presets or a fully qualified name of a pretrained model from the HuggingFace Hub generation: temperature: ui_display_name: Temperature default_value_reasoning: Increasing the temperature will allow the model to generate more diverse sequences, but will also increase the likelihood of generating nonsense. As such, we recommend setting this value to something closer to 0 for classification tasks, and something closer to 1 for text generation tasks where the goal is to generate novel text. expected_impact: 3 max_new_tokens: ui_display_name: Max New Tokens default_value_reasoning: Increasing the maximum number of new tokens will allow the model to generate longer sequences, but because inference time scales linearly with the sequence length, longer sequences will be much slower to generate. For classification tasks, it's generally better to use a smaller number of new tokens, while for text generation tasks, it's generally better to use a larger number of new tokens. expected_impact: 3 num_beams: ui_display_name: Number of Beams default_value_reasoning: Increasing the number of beams will allow the model to generate more diverse sequences, but will also increase inference time. Some backends (like DeepSpeed) also do not support beam search. As such, we recommend leaving this as 1 in most cases, unless you're finding the quality of the generated sequences to be lacking. expected_impact: 2 top_k: ui_display_name: Top K expected_impact: 2 top_p: ui_display_name: Top P expected_impact: 2 max_length: ui_display_name: Max Length expected_impact: 2 min_length: ui_display_name: Min Length expected_impact: 2 min_new_tokens: ui_display_name: Min New Tokens expected_impact: 2 do_sample: ui_display_name: Do Sample expected_impact: 2 use_cache: ui_display_name: Use Cache expected_impact: 2 prompt_lookup_num_tokens: ui_display_name: Prompt Lookup Num Tokens expected_impact: 2 prompt: retrieval: type: ui_display_name: Type expected_impact: 3 index_name: ui_display_name: Index Name expected_impact: 2 model_name: ui_display_name: Model Name expected_impact: 2 k: ui_display_name: Top K expected_impact: 2 task: ui_display_name: Task ui_component_type: textarea expected_impact: 3 template: ui_display_name: Template ui_component_type: textarea expected_impact: 3 adapter: _oneOf: allOf: ui_display_name: Perform parameter efficient fine-tuning expected_impact: 3 none: ui_display_name: Disabled expected_impact: 3 _meta: expected_impact: 3 ui_component_type: radio_string_combined lora: type: long_description: | LoRA is a simple, yet effective, method for parameter-efficient fine-tuning of pretrained language models. It works by adding a small number of trainable parameters to the model, which are used to adapt the pretrained parameters to the downstream task. This allows the model to be fine-tuned with a much smaller number of training examples, and can even be used to fine-tune models on tasks that have no training data available at all. r: ui_display_name: R expected_impact: 3 alpha: ui_display_name: Alpha expected_impact: 1 dropout: ui_display_name: Dropout expected_impact: 2 target_modules: ui_display_name: Target Modules expected_impact: 2 use_rslora: ui_display_name: Enable RSLora expected_impact: 2 use_dora: ui_display_name: Enable DoRa expected_impact: 2 adalora: type: long_description: | AdaLoRA is an extension of LoRA that allows the model to adapt the pretrained parameters to the downstream task in a task-specific manner. This is done by adding a small number of trainable parameters to the model, which are used to adapt the pretrained parameters to the downstream task. This allows the model to be fine-tuned with a much smaller number of training examples, and can even be used to fine-tune models on tasks that have no training data available at all. prompt_learning: num_virtual_tokens: ui_display_name: Num Virtual Tokens expected_impact: 3 prompt_tuning: prompt_tuning_init: ui_display_name: Prompt Tuning Init expected_impact: 2 prompt_tuning_init_text: ui_display_name: Prompt Tuning Init Text expected_impact: 2 adaption_prompt: type: long_description: | Adaption Prompt is taken from the paper [LLaMA-Adapter: Efficient Fine-tuning of Language Models with Zero-init Attention](https://arxiv.org/pdf/2303.16199.pdf). It adds a set of learnable adaption prompts and prepends them to the word tokens at higher transformer layers. Then, a zero-initialized attention mechanism with zero gating is introduced, which adaptively injects new instructional cues into LLaMA, while effectively preserving its pre-trained knowledge. According to the paper, LLaMA-Adapter can generate high-quality responses, comparable to Alpaca with fully fine-tuned 7B parameters. adapter_len: ui_display_name: Adapter Length expected_impact: 3 adapter_layers: ui_display_name: Adapter Layers expected_impact: 3 ia3: type: long_description: | [Infused Adapter by Inhibiting and Amplifying Inner Activations](https://arxiv.org/pdf/2205.05638.pdf), or IA3, is a method that adds three learned vectors `l_k``, `l_v``, and `l_ff`, to rescale the keys and values of the self-attention and encoder-decoder attention layers, and the intermediate activation of the position-wise feed-forward network respectively. These learned vectors are the only trainable parameters during fine-tuning, and thus the original weights remain frozen. Dealing with learned vectors (as opposed to learned low-rank updates to a weight matrix like LoRA) keeps the number of trainable parameters much smaller. target_modules: ui_display_name: Target Modules expected_impact: 3 feedforward_modules: ui_display_name: Feedforward Modules expected_impact: 3 fan_in_fan_out: ui_display_name: Fan In Fan Out expected_impact: 3 modules_to_save: ui_display_name: Modules to Save expected_impact: 3 init_ia3_weights: ui_display_name: Init IA3 Weights expected_impact: 3 quantization: _oneOf: object: ui_display_name: Quantization expected_impact: 3 none: ui_display_name: No Quantization expected_impact: 3 _meta: expected_impact: 3 ui_component_type: radio_string_combined bits: ui_display_name: Bits per parameter expected_impact: 3 ================================================ FILE: ludwig/schema/metadata/configs/loss.yaml ================================================ MSELoss: weight: expected_impact: 2 MAELoss: weight: expected_impact: 2 RMSELoss: weight: expected_impact: 2 RMSPELoss: weight: expected_impact: 2 BWCEWLoss: positive_class_weight: expected_impact: 3 robust_lambda: expected_impact: 2 confidence_penalty: expected_impact: 2 weight: expected_impact: 2 SoftmaxCrossEntropyLoss: class_weights: expected_impact: 3 robust_lambda: expected_impact: 2 confidence_penalty: expected_impact: 2 class_similarities: expected_impact: 2 class_similarities_temperature: expected_impact: 2 weight: expected_impact: 2 SequenceSoftmaxCrossEntropyLoss: class_weights: expected_impact: 3 robust_lambda: expected_impact: 2 confidence_penalty: expected_impact: 2 class_similarities: expected_impact: 2 class_similarities_temperature: expected_impact: 2 weight: expected_impact: 2 unique: expected_impact: 2 SigmoidCrossEntropyLoss: class_weights: expected_impact: 3 weight: expected_impact: 2 ================================================ FILE: ludwig/schema/metadata/configs/optimizers.yaml ================================================ gradient_clipping: default_value_reasoning: A conservative cap on the maximum gradient size to apply over a single training step. description_implications: Gradient clipping is a technique to prevent exploding gradients in very deep networks. Increasing gradient clipping can help with model training loss curve stability, but it can also make training less efficient as weight at each training step is capped. expected_impact: 1 suggested_values_reasoning: It's usually sensible to have some conservative notion of gradient clipping to make modeling robust to a particularly bad or noisy batch of examples. ui_display_name: Gradient Clipping momentum: expected_impact: 1 weight_decay: expected_impact: 1 dampening: expected_impact: 1 nesterov: expected_impact: 1 max_iter: expected_impact: 1 max_eval: expected_impact: 1 tolerance_grad: expected_impact: 1 tolerance_change: expected_impact: 1 history_size: expected_impact: 1 line_search_fn: expected_impact: 1 betas: expected_impact: 1 amsgrad: expected_impact: 1 rho: expected_impact: 1 initial_accumulator_value: expected_impact: 1 lr_decay: expected_impact: 1 learning_rate_power: expected_impact: 1 l1_regularization_strength: expected_impact: 1 l2_regularization_strength: expected_impact: 1 momentum_decay: expected_impact: 1 alpha: expected_impact: 1 eps: expected_impact: 1 centered: expected_impact: 1 ================================================ FILE: ludwig/schema/metadata/configs/preprocessing.yaml ================================================ force_split: default_value_reasoning: We do not expect most datasets to have an explicit "split" column in the data. Used mostly internally by ludwig datasets. expected_impact: 3 related_parameters: - split_probabilities, stratify ui_display_name: Force Split oversample_minority: default_value_reasoning: We do not want to randomly oversample by default since this is a strategy to deal with imbalanced datasets, but can cause issues if not implemented correctly. description_implications: The higher the value you choose gets to 1, the closer you will be to having an equal imbalance ratio (i.e. 1:1 positive to negative class), however this can lead to problems of overfitting when oversampling is used too liberally. As a rule of thumb, starting oversampling with a very conservative approach and increasing in small incremements is probably the best way to improve your model without experiencing model overfitting. example_value: - 0.5 expected_impact: 2 literature_references: - https://machinelearningmastery.com/random-oversampling-and-undersampling-for-imbalanced-classification/ other_information: This parameter is one of many strategies to combat issues with class imbalance, though it is not a cure all. Oversampling too much can cause overfitting which can adversely affect your model so use with caution. suggested_values: Depends on imbalance ratio and dataset size ui_display_name: Oversample Minority sample_ratio: default_value_reasoning: The default value is 1.0 because we do not want to shrink the dataset by default. In the rare occurences when you do want to downsample the entire dataset, this parameter is available, however it is not enabled by default, hence a default value of 1.0 description_implications: Decreases the amount of data you are inputting into the model. Could be useful if you have more data than you need and you are concerned with computational costs. example_value: - 0.8 expected_impact: 2 suggested_values: Depends on data size ui_display_name: Sample Ratio sample_size: default_value_reasoning: The default value is None because we do not want to shrink the dataset by default, and we do not know the size of an arbitrary dataset. By setting the default to None, we fall back on the sample_ratio to determine the size of the dataset. description_implications: Decreases the amount of data you are inputting into the model. Could be useful if you have more data than you need and you are concerned with computational costs. More useful than sample_ratio if you know the exact number of samples you want to train on instead of knowing the proportion. example_value: - 1000 expected_impact: 2 suggested_values: Depends on data size ui_display_name: Sample Size column: expected_impact: 3 ui_display_name: Split Column ui_component_type: column_selector split_probabilities: default_value_reasoning: Most of the dataset should be used for training, with some portion heldout for validation and testing. description_implications: "In machine learning, data splitting is typically done to avoid overfitting. That is an instance where a machine learning model fits its training data too well and fails to reliably fit additional data. The training set is the portion of data used to train the model. The model should observe and learn from the training set, optimizing any of its parameters. The dev set is a data set of examples used to change learning process parameters. It is also called the cross-validation or model validation set. This set of data has the goal of ranking the model's accuracy and can help with model selection. The testing set is the portion of data that is tested in the final model and is compared against the previous sets of data. The testing set acts as an evaluation of the final mode and algorithm." expected_impact: 3 literature_references: - "https://www.techtarget.com/searchenterpriseai/definition/data-splitting#:~:text=Data%20splitting%20is%20when%20data,creating%20models%20based%20on%20data. " other_information: "Split data into train, validation, and test. By default, Ludwig looks for a column named split (case-sensitive) which is expected to consist of 3 possible values that correspond to different datasets: 0: train 1: validation 2: test If the data does not contain the split column, then data is randomly split based on splitting percentages, defined by split_probabilities. If force_split is true, the the split column in the dataset is ignored and the dataset is randomly split based on splitting percentages, defined by split_probabilities." related_parameters: - force_split, stratify suggested_values: - 0.8 - 0.1 - 0.1 suggested_values_reasoning: For larger datasets, it can be beneficial to use more data for training, since the test and validation sets are still plenty big for getting a good sense of model generalization. ui_display_name: Split Probabilities stratify: default_value_reasoning: The default is set to None since we do not want to stratify unless specifically told to do so. There are a variety of reasons for this, but one example is that our data set may not even have a categorical feature to stratify on. description_implications: Depends on dataset example_value: - Category_Feature_A expected_impact: 3 literature_references: - https://medium.com/analytics-vidhya/stratified-sampling-in-machine-learning-f5112b5b9cfe related_parameters: - force_split, split_probabilities suggested_values: Depends on dataset ui_display_name: Stratify undersample_majority: default_value_reasoning: We do not want to randomly undersample by default since this is a strategy to deal with imbalanced datasets, but can cause issues if not implemented correctly. description_implications: The higher the value you choose gets to 1, the closer you will be to having an equal imbalance ratio (i.e. 1:1 positive to negative class), however this can lead to problems of data loss when undersampling is used too liberally. As a rule of thumb, starting undersampling with a very conservative approach and increasing in small incremements is probably the best way to improve your model without experiencing catastrophic data loss effects. example_value: - 0.5 expected_impact: 2 literature_references: - https://machinelearningmastery.com/random-oversampling-and-undersampling-for-imbalanced-classification/ other_information: This parameter is one of many strategies to combat issues with class imbalance, though it is not a cure all. Undersampling too much can cause loss of data which can adversely affect your model so use with caution. suggested_values: Depends on imbalance ratio and dataset size ui_display_name: Undersample Majority cache_encoder_embeddings: default_value_reasoning: Caching encoder embeddings means preprocessed data is not reusable across other model architectures, so it's not always the case that you would always want to enable it when possible. expected_impact: 1 ui_display_name: Cache Encoder Embeddings global_max_sequence_length: expected_impact: 2 ui_display_name: Global Max Sequence Length description_implications: Specifically for LLMs. This is the maximum number of tokens going into the model's forward pass during training. Sequences will be truncated to this length after merging the tokens from the input with tokens from the target. If not set, the total length of the merged input and target token sequences will be used. example_value: - 512 ================================================ FILE: ludwig/schema/metadata/configs/trainer.yaml ================================================ ecd: effective_batch_size: commonly_used: true expected_impact: 2 related_parameters: - batch_size suggested_values: auto ui_display_name: Effective Batch Size batch_size: commonly_used: true default_value_reasoning: Not too big, not too small. description_implications: There's conflicting evidence about what batch size to use. Using a higher batch size will achieve the highest throughput and training efficiency. However, there's also evidence that depending on other hyperparameters, a smaller batch size may produce a higher quality model. Batch size and learning rate are strongly intertwined, so a commonly adopted strategy to set them is to find a the largest batch size that allows the training process not to run out of memory, and then find the best learning rate that makes the training converge with that batch size. expected_impact: 3 related_parameters: - eval_batch_size - learning_rate suggested_values: auto suggested_values_reasoning: Auto batch size will determine the largest batch size that allows the training process not to run out of memory. Alternatively, try at least a few different batch sizes to get a sense of whether and how batch size affects model performance. ui_display_name: Batch Size bucketing_field: expected_impact: 1 other_information: When not null, when creating batches, instead of shuffling randomly, the length along the last dimension of the matrix of the specified input feature (i.e. the length of a sequence or text) is used for bucketing examples and then randomly shuffled examples from the same bin are sampled. Padding is trimmed to the longest example in the batch. The specified feature should be either a sequence or text feature and the encoder encoding it has to be rnn. When used, bucketing improves speed of rnn encoding up to 1.5x, depending on the length distribution of the inputs. ui_display_name: Bucketing Field checkpoints_per_epoch: default_value_reasoning: Per-epoch behavior, which scales according to the dataset size. description_implications: "Epoch-based evaluation (using the default: 0) is an appropriate fit for small datasets that fit in memory and train quickly. Commonly available tabular datasets fit in this cateogry. However, this is a poor fit for unstructured datasets, which tend to be much larger, and train more slowly due to larger models. It's important to setup evaluation such that you do not wait several hours before getting a single evaluation result. In general, it is not necessary for models to train over the entirety of a dataset, nor evaluate over the entirety of a test set, to produce useful monitoring metrics and signals to indicate model health. It is also more engaging and more valuable to ensure a frequent pulse of evaluation metrics, even if they are partial." expected_impact: 2 related_parameters: - train_steps - steps_per_checkpoint suggested_values: 2 - 10, for larger datasets suggested_values_reasoning: Running evaluation too frequently can be wasteful while running evaluation not frequently enough can be prohibitively uninformative. In many large-scale training runs, evaluation is often configured to run on a sub-epoch time scale, or every few thousand steps. ui_display_name: Checkpoints per epoch layers_to_freeze_regex: default_value_reasoning: By default no layers will be frozen when fine-tuning a pretrained model. description_implications: Freezing specific layers can improve a pretrained model's performance in a number of ways. At a basic level, freezing early layers can prevent overfitting by retaining more general features (beneficial for small datasets). Also can reduce computational resource use and lower overall training time due to less gradient calculations. expected_impact: 1 early_stop: default_value_reasoning: Deep learning models are prone to overfitting. It's generally a good policy to set up some early stopping criteria as it's not useful to have a model train after it's maximized what it can learn. 5 consecutive rounds of evaluation where there hasn't been any improvement on the validation set (including chance) is a reasonable policy to start with. description_implications: Decreasing this value is a more aggressive policy. Decreasing early stopping makes model training less forgiving, as the model has less runway to demonstrate consecutive metric improvements before the training run is quit. This can be efficient for pruning bad models earlier, but since the training process is inherently non-deterministic and noisy, sometimes improvements happen very gradually over a long period of time. Extending this value leads to longer training times, but potentially also better final performance. expected_impact: 3 related_parameters: - epochs - train_steps suggested_values: 5 - 10 suggested_values_reasoning: There's potentially a lot of randomness in how models train, but so many consecutive rounds of no improvement is usually a good indicator that the model converged or overfitted. ui_display_name: Early Stop epochs: default_value_reasoning: A very high training length ceiling. Models will almost always hit early stopping criteria before hitting a 100-epoch ceiling. description_implications: Decreasing this will shorten the overall runway for training the model. expected_impact: 3 related_parameters: - train_steps suggested_values: 100 suggested_values_reasoning: Usually it's sensible to leave this very high and rely on a solid early stopping policy to dictate when the model should stop training. Some models and hyperparameter configurations require many epochs through the dataset to converge while others converge before a single epoch through the data. ui_display_name: Epochs eval_batch_size: default_value_reasoning: Use the same batch size used for training. description_implications: By increasing the `eval_batch_size` past the `batch_size` parameter set value, you allow for more parallelism in the batch evaluation step and speed up evaluation. For example, if you have to evaluate the model on a test set of size 1000, it is faster to evaluate two times with two batches of size 500 as opposed to ten times with ten batches of 100. Setting this parameter higher without getting past out memory limits will speed up the model training process overall. example_value: - 512 expected_impact: 1 other_information: Should only set the eval_batch_size to a level that you can fit in memory. related_parameters: - batch_size suggested_values: - 256 - 512 - 1024 suggested_values_reasoning: By observing memory consumption on training jobs, you can get a sense of how much extra memory is available for increasing this value. A good rule of thumb can be experimentally doubling the eval batch size if you do not have insight into memory usage. ui_display_name: Evaluation Batch Size evaluate_training_set: default_value_reasoning: It could be useful to monitor evaluation metrics on the training set to understand convergence. description_implications: Running evaluation on the full training set, when your training set is large, can be a huge computational cost. Turning off training set evaluation will lead to significant gains in training throughput and efficiency. For small datasets that train and evaluate quickly, the choice is trivial. expected_impact: 1 suggested_values: false suggested_values_reasoning: Running full-scale evaluation on the full training set doesn't usually provide any useful information over the validation dataset. Even with this set to False, continuous training loss metrics are still computed, so it will still be easy to spot signs of overfitting like when the training-validation loss curves diverge. ui_display_name: Evaluate Training Set gradient_clipping: default_value_reasoning: A conservative cap on the maximum gradient size to apply over a single training step. description_implications: Gradient clipping is a technique to prevent exploding gradients in very deep networks. Increasing gradient clipping can help with model training loss curve stability, but it can also make training slower as weights may not be updated as fast. expected_impact: 2 suggested_values_reasoning: It's usually sensible to enable gradient clipping to make modeling robust to particularly bad or noisy batches of examples. ui_display_name: Gradient Clipping increase_batch_size_eval_metric: expected_impact: 1 ui_display_name: "Batch Size Increase: Evaluation Metric" increase_batch_size_eval_split: expected_impact: 1 ui_display_name: "Batch Size Increase: Evaluation Split" increase_batch_size_on_plateau: expected_impact: 1 ui_display_name: Batch Size Increase On Plateau increase_batch_size_on_plateau_patience: expected_impact: 1 ui_display_name: "Batch Size Increase On Plateau: Patience" increase_batch_size_on_plateau_rate: expected_impact: 1 ui_display_name: "Batch Size Increase On Plateau: Rate" learning_rate: commonly_used: true default_value_reasoning: Middle of the road learning rate to start with. description_implications: The learning rate is a hyperparameter that controls how much to change the model in response to the estimated error each time the model weights are updated. Increasing the learning rate may decrease learning curve stability but also increase learning speed and efficiency, leading to faster model convergence. Decreasing the learning rate can help stabilize learning curves at the cost of slower time to convergence. expected_impact: 3 suggested_values: 0.00001 - 0.1 or auto related_parameters: - decay suggested_values_reasoning: Tabular models trained from scratch typically use learning rates around 1e-3 while learning rates for pre-trained models should be much smaller, typically around 1e-5, which is important to mitigate catastrophic forgetting. To make the model more robust to any specific choice of learning rate, consider turning enabling learning rate decay. ui_display_name: Learning Rate learning_rate_scaling: default_value_reasoning: Traditionally the learning rate is scaled linearly with the number of workers to reflect the proportion by which the effective batch size is increased. description_implications: Traditionally the learning rate is scaled linearly with the number of workers to reflect the proportion by which the effective batch size is increased. For very large batch sizes, a softer square-root scale can sometimes lead to better model performance. If the learning rate is hand-tuned for a given number of workers, setting this value to constant can be used to disable scale-up. expected_impact: 1 suggested_values: linear or sqrt suggested_values_reasoning: Traditionally the learning rate is scaled linearly with the number of workers to reflect the proportion by which the effective batch size is increased. For very large batch sizes, a softer square-root scale can sometimes lead to better model performance. If the learning rate is hand-tuned for a given number of workers, setting this value to constant can be used to disable scale-up. ui_display_name: Learning Rate Scaling max_batch_size: default_value_reasoning: Not typically required. description_implications: Value used to manually limit the batch sizes explored by auto batch size tuning and batch size increasing on plateau. example_value: - 1024 expected_impact: 1 related_parameters: - batch_size - increase_batch_size_on_plateau ui_display_name: Max Batch Size optimizer: default_value_reasoning: First try Adam because it is shown to return good results without an advanced fine tuning. description_implications: "Choosing a good optimizer for your machine learning project can be overwhelming. Popular deep learning libraries such as PyTorch or TensorFLow offer a broad selection of different optimizers, each with its own strengths and weaknesses. However, picking the wrong optimizer can have a substantial negative impact on the performance of your machine learning model [1][2]. This makes optimizers a critical design choice in the process of building, testing, and deploying your machine learning model." expected_impact: 3 literature_references: - https://www.youtube.com/watch?v=mdKjMPmcWjY suggested_values: adam, adamw suggested_values_reasoning: "As a rule of thumb: If you have the resources to find a good learning rate schedule, SGD with momentum is a solid choice. If you are in need of quick results without extensive hyperparameter tuning, adaptive gradient methods like adam or adamw are good choices." ui_display_name: Optimizer regularization_lambda: default_value_reasoning: How to tune the overall impact of the regularization term by multiplying its value by a scalar known as lambda (also called the regularization rate). description_implications: "When choosing a lambda value, the goal is to strike the right balance between simplicity and training-data fit: If your lambda value is too high, your model will be simple, but you run the risk of underfitting your data. Your model won't learn enough about the training data to make useful predictions. If your lambda value is too low, your model will be more complex, and you run the risk of overfitting your data. Your model will learn too much about the particularities of the training data, and won't be able to generalize to new data. The ideal value of lambda produces a model that generalizes well to new, previously unseen data. Unfortunately, that ideal value of lambda is data-dependent, so you'll need to do some tuning. We recommend trying a handful of values (0.001, 0.02, ... 0.4) gradually increasing the value until training curves get worse" expected_impact: 2 literature_references: - "https://developers.google.com/machine-learning/crash-course/regularization-for-simplicity/lambda " related_parameters: - regularization_type suggested_values: 0.1 suggested_values_reasoning: "The most common type of regularization is L2, also called weight decay, with values often on a logarithmic scale between 0 and 0.1, such as 0.1, 0.001, 0.0001, etc." ui_display_name: Regularization Lambda regularization_type: default_value_reasoning: L2 is a standard regularization to start with. description_implications: "L1 regularization penalizes the sum of absolute values of the weights, whereas L2 regularization penalizes the sum of squares of the weights. The L1 regularization solution is sparse, meaning some weights will be zero, others will be large. The L2 regularization solution is non-sparse, most weights will be small. L2 regularization does not perform feature selection, since weights are only reduced to values near 0 instead of 0. L1 regularization implicitly performs feature selection. L1 regularization is more robust to outliers." expected_impact: 3 literature_references: - "https://neptune.ai/blog/fighting-overfitting-with-l1-or-l2-regularization#:~:text=The%20differences%20between%20L1%20and,regularization%20solution%20is%20non%2Dsparse. " related_parameters: - regularization_lambda suggested_values: L2 ui_display_name: Regularization Type should_shuffle: default_value_reasoning: In general, it's a good idea to mix up data on each batch so that the neural network gets the broadest exposure to the dataset. description_implications: Turning off mini-batch shuffling can make training faster, but it may lead to worse performance overall as shuffling helps mitigate overfitting. expected_impact: 1 literature_references: - "https://stats.stackexchange.com/questions/245502/why-should-we-shuffle-data-while-training-a-neural-network#:~:text=it%20helps%20the%20training%20converge,the%20order%20of%20the%20training " suggested_values: true suggested_values_reasoning: One of the most powerful things about neural networks is that they can be very complex functions, allowing one to learn very complex relationships between your input and output data. These relationships can include things you would never expect, such as the order in which data is fed in per epoch. If the order of data within each epoch is the same, then the model may use this as a way of reducing the training error, which is a sort of overfitting. ui_display_name: Should Shuffle steps_per_checkpoint: default_value_reasoning: By default, we evaluate once per epoch, which scales according to the dataset size. description_implications: "Epoch-based evaluation (using the default: 0) is an appropriate fit for tabular datasets, which are small, fit in memory, and train quickly. However, this is a poor fit for unstructured datasets, which tend to be much larger, and train more slowly due to larger models. It's important to setup evaluation such that you do not wait several hours before getting a single evaluation result. In general, it is not necessary for models to train over the entirety of a dataset, nor evaluate over the entirety of a test set, to produce useful monitoring metrics and signals to indicate model health. It is also more engaging and more valuable to ensure a frequent pulse of evaluation metrics, even if they are partial." expected_impact: 1 related_parameters: - checkpoints_per_epoch suggested_values: 1000-10000 for larger datasets suggested_values_reasoning: Running evaluation too frequently can be wasteful while running evaluation not frequently enough can be prohibitively uninformative. In many large-scale training runs, evaluation is often configured to run on a sub-epoch time scale, or every few thousand steps. ui_display_name: Steps Per Checkpoint train_steps: default_value_reasoning: This defaults to `epochs`, which is a very high training length ceiling. Models will almost always hit early stopping criteria before reaching the absolute end of the training runway. description_implications: Decreasing this parameter will shorten the overall runway for training the model. expected_impact: 1 related_parameters: - epochs suggested_values: Leave unset, or 1000000, 1 for debugging suggested_values_reasoning: Usually it's sensible to leave the value of this parameter very high and rely on a solid early stopping policy to dictate when the model should stop training. Some models and hyperparameter configurations require many epochs through the dataset to converge while others converge before a single epoch through the data. ui_display_name: Train Steps eval_steps: default_value_reasoning: The default value is None because we do not want to lower the number of evaluation steps by default, and we do not know the size of an arbitrary dataset. By setting the default to None, we simply evaluate on the full evaluation set. description_implications: The smaller this value of this parameter, the less time evaluation will take. expected_impact: 2 suggested_values: Depends on data size and prioritization of quality vs. speed suggested_values_reasoning: Normally, evaluation should use the entire evaluation set, and this is recommended to achieve the highest quality evaluation. However, using the full evaluation set can be slow, so the value of this parameter should be set depending on which is more important for the task at hand -- quality or speed. ui_display_name: Evaluation Steps use_mixed_precision: default_value_reasoning: Speed up training by using float16 parameters where it makes sense. description_implications: Mixed precision training on GPU can dramatically speedup training, with some risks to model convergence. expected_impact: 3 literature_references: - https://pytorch.org/blog/what-every-user-should-know-about-mixed-precision-training-in-pytorch/ suggested_values: false suggested_values_reasoning: Suggested to enable this if training is taking too long on GPU. ui_display_name: Use Mixed Precision compile: default_value_reasoning: Model compilation has been shown to significantly speedup training by upwards of 20%, but does impose some delay to compile the model at the beginning of training. This feature is experimental for now, but may become the default in future versions. description_implications: Model compilation on GPU, when used in conjunction with automatic mixed precision, can speed up training by upwards of 20%. expected_impact: 3 suggested_values: false suggested_values_reasoning: Suggested to enable this if training is taking too long on GPU. ui_display_name: Compile gradient_accumulation_steps: default_value_reasoning: Gradient accumulation is something that should be enabled only once it has been observed that either GPU utilization is low due to low bandwidth between distributed workers, or that there is too much variance in the training process due to very low batch sizes. description_implications: Gradient accumulation is useful to (1) reduce network bandwidth overhead in multi-node distributed training scenarios where bandwidth is the bottleneck, and (2) train with larger effective batch sizes when the max batch size the GPU can accommodate is very small. The first scenario occurs when the interconnect between nodes is slow, so performing gradient synchronization (allreduce) less frequently will speed up training. The second scenario occurs in cases where the model being trained is very large (e.g., LLM) so training with a larger batch size will help to smooth out the variance from training with a very small batch size. expected_impact: 2 suggested_values: false suggested_values_reasoning: Suggested to enable this if training is proceeding very slowly in distributed training (and GPU utilization is low), or the batch size is very small and the loss curves look very spiky. ui_display_name: Gradient Accumulation Steps enable_gradient_checkpointing: expected_impact: 2 ui_display_name: Enable Gradient Checkpointing default_value_reasoning: Gradient checkpointing is a technique to reduce the memory footprint of the model by trading compute for memory. This is useful when training very large models that run into out of memory errors very quickly during training. It is particularly helpful when doing non-quantization based training (adapter based or full fine-tuning). Gradient checkpointing works by recomputing the activations of the model during the backward pass, rather than storing them in memory during the forward pass. This is a tradeoff between compute and memory, as the activations need to be recomputed during the backward pass, but the memory footprint is reduced. This is set to false by default because it is not always beneficial to use gradient checkpointing, and it can sometimes slow down training. validation_field: default_value_reasoning: Concrete evaluation metrics are usually better than loss, the penalty for a bad prediction, which is only a proxy for prediction correctness. description_implications: This parameter affects 1) what the early stopping policy looks at to determine when to early stop and 2) hyperparameter optimization for determining the best trial. expected_impact: 1 related_parameters: - validation_metric suggested_values: default behavior ui_display_name: Validation Field validation_metric: description_implications: This parameter affects 1) what the early stopping policy looks at to determine when to early stop and 2) hyperparameter optimization for determining the best trial. expected_impact: 1 related_parameters: - validation_field suggested_values: default behavior ui_display_name: Validation Metric learning_rate_scheduler: warmup_evaluations: default_value_reasoning: "Learning rate warmup is most commonly used when training with large batch sizes / distributed training to avoid taking overly large steps at the beginning of training that might result in the process getting stuck in a local optimum. Conventional wisdom when training with large batch sizes is to use a larger learning rate (see: `learning_rate_scaling`) but gradually warm up to the larger learning rate over a few epochs of training in the beginning. Even when not training with large batch sizes, the randomness of how weights are initialized can result in strange, noisy gradient updates during the beginning of your training run. As such, it's generally recommended to use a small amount of warmup (e.g., 1 epoch / evaluation) even when the batch size is relatively small." description_implications: Learning rate warmup sets a very low learning rate at the beginning of training and gradually (linearly) increases to the base learning rate each step (batch) during training. After your warmup steps you use your "regular" learning rate or learning rate scheduler. expected_impact: 2 related_parameters: - warmup_fraction - learning_rate_scaling literature_references: - https://arxiv.org/abs/1711.00489 - https://datascience.stackexchange.com/questions/55991/in-the-context-of-deep-learning-what-is-training-warmup-steps suggested_values: 0 - 5 suggested_values_reasoning: You don't want to warm up for too long, as after the model is starting to hill climb, you want to use the full weight of the learning rate to descend into good loss minima. If you observe your loss curve converging very early into training, within the first few epochs, then increasing learning rate warmup may help to mitigate this effect. Pretrained models can benefit from more warmup to help offset the effects of catastrophic forgetting due to an overly high learning rate. ui_display_name: Warmup Evaluations warmup_fraction: default_value_reasoning: Similar to `warmup_evaluations` but expressed as a fraction of the total number of training steps, rather that a certain number of evaluation phases. description_implications: See `warmup_evaluations`. expected_impact: 2 related_parameters: - warmup_evaluations - learning_rate_scaling suggested_values: 0.05 - 0.2 suggested_values_reasoning: You don't want to warm up for too long, as after the model is starting to hill climb, you want to use the full weight of the learning rate to descend into good loss minima. ui_display_name: Warmup Fraction decay: description_implications: "It\u2019s almost always a good idea to use a schedule.\ \ For most models, try the exponential decay schedule first.\n\nThe exponential\ \ schedule divides the learning rate by the same factor (%) every epoch. This\ \ means that the learning rate will decrease rapidly in the first few epochs,\ \ and spend more epochs with a lower value, but never reach exactly zero.\ \ As a rule of thumb, compared to training without a schedule, you can use\ \ a slightly higher maximum learning rate. Since the learning rate changes\ \ over time, the whole training is not so sensitive to the value picked." expected_impact: 3 literature_references: - "https://peltarion.com/knowledge-center/documentation/modeling-view/run-a-model/optimization-principles-(in-deep-learning)/learning-rate-schedule " related_parameters: - decay_rate - decay_steps - learning_rate suggested_values: exponential suggested_values_reasoning: Starting with exponential decay is a safe place to start, as it is a "softer" decrease in the learning rate over time, as compared with linear, which is more steep after the initial drop. Linear decay is most useful when the risk of catastrophic forgetting is very high (e.g, for fine-tuning pretrained models). Cosine annealing is a type of learning rate schedule that has the effect of starting with a large learning rate that is relatively rapidly decreased to a minimum value before being increased rapidly again. The resetting of the learning rate acts like a simulated restart of the learning process. If you observe your loss curves shooting up (even on the training set) in later epochs, increasing the decay rate may help mitigate this effect. ui_display_name: Decay decay_rate: default_value_reasoning: 4-5% decay each step is an empirically useful decay rate to start with. description_implications: Increasing the decay rate will lower the learning rate faster. This could make the model more robust to a bad (too high) initial learning rate, but a decay rate that is too high could prohibit the model from learning anything at all. expected_impact: 2 literature_references: - "https://peltarion.com/knowledge-center/documentation/modeling-view/run-a-model/optimization-principles-(in-deep-learning)/learning-rate-schedule " related_parameters: - decay_steps - learning_rate suggested_values: 0.9 - 0.96 suggested_values_reasoning: Since this controls exponential decay, even a small decay rate will still be strongly impactful. ui_display_name: Decay Rate decay_steps: default_value_reasoning: This default essentially enables the `learning_rate` to decay by a factor of the `decay_rate` at 10000 training steps. description_implications: By increasing the value of decay steps, you are increasing the number of training steps it takes to decay the learning rate by a factor of `decay_rate`. In other words, the bigger this parameter, the slower the learning rate decays. example_value: - 5000 expected_impact: 2 related_parameters: - decay_rate - learning_rate suggested_values: 10000 +/- 500 at a time suggested_values_reasoning: The decay in the learning rate is calculated as the training step divided by the `decay_steps` plus one. Then the `decay_rate` is raised to the power of this exponent which is then multiplied to the current learning rate. All this to say that the learning rate is only decayed by a factor of the set `decay_rate` when the training step reaches the `decay_steps` and then subsequently when it reaches any multiple of `decay_steps`. You can think of `decay_steps` as a rate of decay for the `decay_rate`. ui_display_name: Decay Steps staircase: default_value_reasoning: Performs learning rate decay in stepwise discrete manner. description_implications: An excessively aggressive decay results in optimizers never reaching the minima, whereas a slow decay leads to chaotic updates without significant improvement. Discrete learning rate decay is another parameter to help tune a balance. expected_impact: 1 literature_references: - https://neptune.ai/blog/how-to-choose-a-learning-rate-scheduler suggested_values: false suggested_values_reasoning: We have not found strong evidence that discretely decaying the learning rate is superior to doing so continuously in general, but in specific tasks it might have a positive impact. ui_display_name: Staircase reduce_on_plateau: expected_impact: 3 ui_display_name: Reduce On Plateau reduce_on_plateau_patience: expected_impact: 2 ui_display_name: Reduce On Plateau Patience reduce_on_plateau_rate: expected_impact: 2 ui_display_name: Reduce On Plateau Rate reduce_eval_metric: expected_impact: 1 ui_display_name: Reduce Eval Metric reduce_eval_split: expected_impact: 1 ui_display_name: Reduce Eval Split t_0: expected_impact: 1 ui_display_name: T_0 t_mult: expected_impact: 1 ui_display_name: T_mult eta_min: expected_impact: 1 ui_display_name: Eta Min gbm: learning_rate: commonly_used: true default_value_reasoning: Middle of the road learning rate to start with. description_implications: The learning rate is a hyperparameter that controls how much to change the model in response to the estimated error each time the model weights are updated. Increasing the learning rate may decrease learning curve stability but also increase learning speed and efficiency, leading to faster model convergence. Decreasing the learning rate can help stabilize learning curves at the cost of slower time to convergence. expected_impact: 3 suggested_values: 0.00001 - 0.1 or auto related_parameters: - decay suggested_values_reasoning: Tabular models trained from scratch typically use learning rates around 1e-3 while learning rates for pre-trained models should be much smaller, typically around 1e-5, which is important to mitigate catastrophic forgetting. To make the model more robust to any specific choice of learning rate, consider turning enabling learning rate decay. ui_display_name: Learning Rate early_stop: default_value_reasoning: Deep learning models are prone to overfitting. It's generally a good policy to set up some early stopping criteria as it's not useful to have a model train after it's maximized what it can learn. 5 consecutive rounds of evaluation where there hasn't been any improvement on the validation set (including chance) is a reasonable policy to start with. description_implications: Decreasing this value is a more aggressive policy. Decreasing early stopping makes model training less forgiving, as the model has less runway to demonstrate consecutive metric improvements before the training run is quit. This can be efficient for pruning bad models earlier, but since the training process is inherently non-deterministic and noisy, sometimes improvements happen very gradually over a long period of time. Extending this value leads to longer training times, but potentially also better final performance. expected_impact: 3 related_parameters: - epochs - train_steps suggested_values: 5 - 10 suggested_values_reasoning: There's potentially a lot of randomness in how models train, but so many consecutive rounds of no improvement is usually a good indicator that the model converged or overfitted. ui_display_name: Early Stop eval_batch_size: default_value_reasoning: Use the same batch size used for training. description_implications: By increasing the `eval_batch_size` past the `batch_size` parameter set value, you allow for more parallelism in the batch evaluation step and speed up evaluation. For example, if you have to evaluate the model on a test set of size 1000, it is faster to evaluate two times with two batches of size 500 as opposed to ten times with ten batches of 100. Setting this parameter higher without getting past out memory limits will speed up the model training process overall. example_value: - 512 expected_impact: 1 other_information: Should only set the eval_batch_size to a level that you can fit in memory. related_parameters: - batch_size suggested_values: - 256 - 512 - 1024 suggested_values_reasoning: By observing memory consumption on training jobs, you can get a sense of how much extra memory is available for increasing this value. A good rule of thumb can be experimentally doubling the eval batch size if you do not have insight into memory usage. ui_display_name: Evaluation Batch Size evaluate_training_set: default_value_reasoning: It could be useful to monitor evaluation metrics on the training set to understand convergence. description_implications: Running evaluation on the full training set, when your training set is large, can be a huge computational cost. Turning off training set evaluation will lead to significant gains in training throughput and efficiency. For small datasets that train and evaluate quickly, the choice is trivial. expected_impact: 1 suggested_values: false suggested_values_reasoning: Running full-scale evaluation on the full training set doesn't usually provide any useful information over the validation dataset. Even with this set to False, continuous training loss metrics are still computed, so it will still be easy to spot signs of overfitting like when the training-validation loss curves diverge. ui_display_name: Evaluate Training Set validation_field: default_value_reasoning: Concrete evaluation metrics are usually better than loss, the penalty for a bad prediction, which is only a proxy for prediction correctness. description_implications: This parameter affects 1) what the early stopping policy looks at to determine when to early stop and 2) hyperparameter optimization for determining the best trial. expected_impact: 1 related_parameters: - validation_metric suggested_values: default behavior ui_display_name: Validation Field validation_metric: description_implications: This parameter affects 1) what the early stopping policy looks at to determine when to early stop and 2) hyperparameter optimization for determining the best trial. expected_impact: 1 related_parameters: - validation_field suggested_values: default behavior ui_display_name: Validation Metric max_depth: expected_impact: 3 drop_rate: expected_impact: 2 tree_learner: expected_impact: 2 boosting_type: expected_impact: 3 boosting_rounds_per_checkpoint: expected_impact: 2 num_boost_round: expected_impact: 2 num_leaves: expected_impact: 2 min_data_in_leaf: expected_impact: 2 min_sum_hessian_in_leaf: expected_impact: 1 bagging_fraction: expected_impact: 3 pos_bagging_fraction: expected_impact: 2 neg_bagging_fraction: expected_impact: 2 bagging_freq: expected_impact: 2 bagging_seed: expected_impact: 2 feature_fraction: expected_impact: 3 feature_fraction_bynode: expected_impact: 2 feature_fraction_seed: expected_impact: 2 extra_trees: expected_impact: 3 extra_seed: expected_impact: 2 max_delta_step: expected_impact: 1 lambda_l1: expected_impact: 3 lambda_l2: expected_impact: 3 linear_lambda: expected_impact: 2 min_gain_to_split: expected_impact: 1 max_drop: expected_impact: 2 skip_drop: expected_impact: 2 xgboost_dart_mode: expected_impact: 1 uniform_drop: expected_impact: 2 drop_seed: expected_impact: 2 top_rate: expected_impact: 1 other_rate: expected_impact: 1 min_data_per_group: expected_impact: 1 max_cat_threshold: expected_impact: 1 cat_l2: expected_impact: 1 cat_smooth: expected_impact: 1 max_cat_to_onehot: expected_impact: 1 cegb_tradeoff: expected_impact: 1 cegb_penalty_split: expected_impact: 1 path_smooth: expected_impact: 1 verbose: expected_impact: 1 max_bin: expected_impact: 1 feature_pre_filter: expected_impact: 1 llm: type: commonly_used: true default_value_reasoning: It's useful to start with zero-shot or few-shot learning to see what the model can do as a baseline before fine-tuning. suggested_values: none or finetune suggested_values_reasoning: If you want to perform zero shot learning or few shot learning, you should set this to `none`. If you want to perform fine-tuning, you should set this to `finetune`. ui_display_name: Trainer Type expected_impact: 3 ================================================ FILE: ludwig/schema/metadata/feature_metadata.py ================================================ ================================================ FILE: ludwig/schema/metadata/parameter_metadata.py ================================================ import json from dataclasses import dataclass from enum import Enum from typing import Any from dataclasses_json import dataclass_json from ludwig.api_annotations import DeveloperAPI from ludwig.utils.misc_utils import memoized_method @DeveloperAPI class ExpectedImpact(int, Enum): """The expected impact of determining a "good" value for a specific parameter. - HIGH: this parameter should almost always be included in a hyperopt run and can make or break a good model. - MEDIUM: this parameter can sometimes make or break a good model. - LOW: this parameter usually does not have a significant impact on model performance. """ UNKNOWN = 0 LOW = 1 MEDIUM = 2 HIGH = 3 @DeveloperAPI class ComputeTier(int, Enum): """The compute tier defines the type of compute resources that a model typically needs to get good throughput.""" CPU = 0 """Model can train effectively on CPU hardware.""" GPU_LOW = 1 """Model can train effectively on commodity GPU hardware, or inference optimized SKUs like NVIDIA T4.""" GPU_MEDIUM = 2 """Model can train effectively on training-optimized GPU hardware like V100, A10G, or A5000.""" GPU_HIGH = 3 """Model requires high-end GPUs like A100 or H100 to achieve good throughput.""" @DeveloperAPI @dataclass_json() @dataclass class ParameterMetadata: """Contains descriptive information that pertains to a Ludwig configuration parameter.""" short_description: str = "" """Quick description generally for UI display.""" long_description: str = "" """In depth description generally for documentation purposes.""" ui_display_name: str | None = "" """How this parameter can be displayed in a human-readable form.""" default_value_reasoning: str | None = None """The reasoning behind the default value for this parameter.""" example_value: list[Any] | None = None """Examples of other values that can be used for this parameter.""" related_parameters: list[str] | None = None """List of related parameters that this parameter interacts with or depends on.""" other_information: str | None = None """Other information that is relevant for this parameter.""" description_implications: str | None = None """The intuition for how model performance would change if this parameter is changed.""" suggested_values: Any = None """What values would a machine learning expert suggest users try to help improve their model? Should cover 95% (2-sigma) worth of use-cases. """ suggested_values_reasoning: str | None = None """The reasoning behind the suggested values, as well as model performance indicators or other intuition that could help inform a user to make an educated decision about what values to experiment with for this parameter.""" commonly_used: bool = False """True if this parameter could be frequently used, would have a high impact, and/or would be interesting for a machine learning practitioner.""" expected_impact: ExpectedImpact = ExpectedImpact.UNKNOWN """The expected impact of determining a "good" value for this parameter.""" literature_references: list[str] | None = None """List of links, papers, and blog posts to learn more.""" internal_only: bool = False """True if this parameter is used strictly internally and should not be exposed to users.""" compute_tier: ComputeTier = ComputeTier.CPU """The compute tier defines the type of compute resources that a model typically needs to get good throughput.""" ui_component_type: str | None = None """Override for HTML component type that should be used to render this field in UIs.""" @memoized_method(maxsize=1) def to_json_dict(self) -> dict[str, Any]: return json.loads(self.to_json()) @DeveloperAPI def convert_metadata_to_json(pm: ParameterMetadata) -> dict[str, Any]: """Converts a ParameterMetadata dict to a normal JSON dict. NOTE: Without the json.loads call, to_json() returns a string repr that is improperly parsed. """ if not pm: return ParameterMetadata().to_json_dict() return pm.to_json_dict() # This is a quick way to flag schema parameters as internal only via the `parameter_metadata` argument INTERNAL_ONLY = ParameterMetadata(internal_only=True) ================================================ FILE: ludwig/schema/model_config.py ================================================ # TODO(travis) consider removing this in the future after deprecation period from ludwig.schema.model_types.base import ModelConfig # noqa ================================================ FILE: ludwig/schema/model_types/__init__.py ================================================ import ludwig.schema.model_types.ecd # noqa import ludwig.schema.model_types.llm # noqa ================================================ FILE: ludwig/schema/model_types/base.py ================================================ import copy from abc import ABC from typing import Any from ludwig.api_annotations import DeveloperAPI from ludwig.config_validation.checks import get_config_check_registry from ludwig.config_validation.validation import check_schema from ludwig.constants import ( BACKEND, COLUMN, DEPENDENCIES, ENCODER, INPUT_FEATURES, MODEL_ECD, NAME, OUTPUT_FEATURES, TIED, ) from ludwig.error import ConfigValidationError from ludwig.globals import LUDWIG_VERSION from ludwig.schema import utils as schema_utils from ludwig.schema.defaults.base import BaseDefaultsConfig from ludwig.schema.features.base import BaseInputFeatureConfig, BaseOutputFeatureConfig, FeatureCollection from ludwig.schema.hyperopt import HyperoptConfig from ludwig.schema.model_types.utils import ( merge_fixed_preprocessing_params, merge_with_defaults, sanitize_and_filter_combiner_entities_, set_derived_feature_columns_, set_hyperopt_defaults_, set_llm_parameters, set_preprocessing_parameters, set_tagger_decoder_parameters, set_validation_parameters, ) from ludwig.schema.preprocessing import PreprocessingConfig from ludwig.schema.trainer import BaseTrainerConfig from ludwig.schema.utils import ludwig_dataclass from ludwig.types import ModelConfigDict from ludwig.utils.backward_compatibility import upgrade_config_dict_to_latest_version from ludwig.utils.data_utils import get_sanitized_feature_name, load_yaml from ludwig.utils.registry import Registry model_type_schema_registry = Registry() @DeveloperAPI @ludwig_dataclass class ModelConfig(schema_utils.BaseMarshmallowConfig, ABC): input_features: FeatureCollection[BaseInputFeatureConfig] output_features: FeatureCollection[BaseOutputFeatureConfig] model_type: str trainer: BaseTrainerConfig preprocessing: PreprocessingConfig defaults: BaseDefaultsConfig hyperopt: HyperoptConfig | None = None backend: dict[str, Any] = schema_utils.Dict() # TODO(jeffkinnison): Add backend schema ludwig_version: str = schema_utils.ProtectedString(LUDWIG_VERSION) def __post_init__(self): merge_fixed_preprocessing_params(self) set_validation_parameters(self) set_hyperopt_defaults_(self) set_tagger_decoder_parameters(self) sanitize_and_filter_combiner_entities_(self) # Reconcile LLM parameters set_llm_parameters(self) # Reconcile conflicting preprocessing parameters set_preprocessing_parameters(self) # Derive proc_col for each feature from the feature's preprocessing parameters # after all preprocessing parameters have been set set_derived_feature_columns_(self) # Auxiliary checks. get_config_check_registry().check_config(self) @staticmethod def from_dict(config: ModelConfigDict) -> "ModelConfig": config = copy.deepcopy(config) config = upgrade_config_dict_to_latest_version(config) # Use sanitized feature names. # NOTE: This must be kept consistent with build_dataset() for input_feature in config[INPUT_FEATURES]: input_feature[NAME] = get_sanitized_feature_name(input_feature[NAME]) if COLUMN in input_feature and input_feature[COLUMN]: input_feature[COLUMN] = get_sanitized_feature_name(input_feature[COLUMN]) for output_feature in config[OUTPUT_FEATURES]: output_feature[NAME] = get_sanitized_feature_name(output_feature[NAME]) if COLUMN in output_feature and output_feature[COLUMN]: output_feature[COLUMN] = get_sanitized_feature_name(output_feature[COLUMN]) # Sanitize tied feature names. for input_feature in config[INPUT_FEATURES]: if TIED in input_feature and input_feature[TIED]: input_feature[TIED] = get_sanitized_feature_name(input_feature[TIED]) # Sanitize dependent feature names. for output_feature in config[OUTPUT_FEATURES]: if DEPENDENCIES in output_feature and output_feature[DEPENDENCIES]: output_feature[DEPENDENCIES] = [ get_sanitized_feature_name(feature_name) for feature_name in output_feature[DEPENDENCIES] ] config["model_type"] = config.get("model_type", MODEL_ECD) model_type = config["model_type"] if model_type not in model_type_schema_registry: raise ConfigValidationError( f"Invalid model type: '{model_type}', expected one of: {list(model_type_schema_registry.keys())}" ) config = merge_with_defaults(config) # TODO(travis): handle this with helper function backend = config.get(BACKEND) if isinstance(backend, str): config[BACKEND] = {"type": backend} # JSON schema validation. Note that this is desireable on top of `schema.load(config)` below because marshmallow # deserialization permits additional properties while JSON schema validation, for schema (e.g. `trainer`) that # have `additionalProperties=False`, does not. # # Illustrative example: test_validate_config_misc.py::test_validate_no_trainer_type # # TODO: Set `additionalProperties=False` for all Ludwig schema, and look into passing in `unknown='RAISE'` to # marshmallow.load(), which raises an error for unknown fields during deserialization. # https://marshmallow.readthedocs.io/en/stable/marshmallow.schema.html#marshmallow.schema.Schema.load check_schema(config) cls = model_type_schema_registry[model_type] schema = cls.get_class_schema()() try: config_obj: ModelConfig = schema.load(config) except ConfigValidationError: raise except ValueError as e: raise ConfigValidationError(f"Config validation error raised during config deserialization: {e}") from e except (OSError, ValueError) as e: raise ConfigValidationError(f"Config validation error raised during config post-init: {e}") from e return config_obj @staticmethod def from_yaml(config_path: str) -> "ModelConfig": return ModelConfig.from_dict(load_yaml(config_path)) def get_feature_names(self) -> set[str]: """Returns a set of all feature names.""" feature_names = set() feature_names.update([f.column for f in self.input_features]) feature_names.update([f.column for f in self.output_features]) return feature_names def get_feature_config(self, feature_column_name: str) -> BaseInputFeatureConfig | None: """Returns the feature config for the given feature name.""" for feature in self.input_features: if feature.column == feature_column_name: return feature for feature in self.output_features: if feature.column == feature_column_name: return feature @DeveloperAPI def register_model_type(name: str): def wrap(model_type_config: ModelConfig) -> ModelConfig: model_type_schema_registry[name] = model_type_config return model_type_config return wrap def _merge_encoder_cache_params(preprocessing_params: dict[str, Any], encoder_params: dict[str, Any]) -> dict[str, Any]: if preprocessing_params.get("cache_encoder_embeddings"): preprocessing_params[ENCODER] = encoder_params return preprocessing_params ================================================ FILE: ludwig/schema/model_types/ecd.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.schema import utils as schema_utils from ludwig.schema.combiners.base import BaseCombinerConfig from ludwig.schema.combiners.utils import CombinerSelection from ludwig.schema.defaults.ecd import ECDDefaultsConfig, ECDDefaultsField from ludwig.schema.features.base import ( BaseInputFeatureConfig, BaseOutputFeatureConfig, ECDInputFeatureSelection, ECDOutputFeatureSelection, FeatureCollection, ) from ludwig.schema.hyperopt import HyperoptConfig, HyperoptField from ludwig.schema.model_types.base import ModelConfig, register_model_type from ludwig.schema.preprocessing import PreprocessingConfig, PreprocessingField from ludwig.schema.trainer import ECDTrainerConfig, ECDTrainerField from ludwig.schema.utils import ludwig_dataclass @DeveloperAPI @register_model_type(name="ecd") @ludwig_dataclass class ECDModelConfig(ModelConfig): """Parameters for ECD.""" model_type: str = schema_utils.ProtectedString("ecd") input_features: FeatureCollection[BaseInputFeatureConfig] = ECDInputFeatureSelection().get_list_field() output_features: FeatureCollection[BaseOutputFeatureConfig] = ECDOutputFeatureSelection().get_list_field() combiner: BaseCombinerConfig = CombinerSelection().get_default_field() trainer: ECDTrainerConfig = ECDTrainerField().get_default_field() preprocessing: PreprocessingConfig = PreprocessingField().get_default_field() defaults: ECDDefaultsConfig = ECDDefaultsField().get_default_field() hyperopt: HyperoptConfig | None = HyperoptField().get_default_field() ================================================ FILE: ludwig/schema/model_types/llm.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.schema import utils as schema_utils from ludwig.schema.defaults.llm import LLMDefaultsConfig, LLMDefaultsField from ludwig.schema.features.base import ( BaseInputFeatureConfig, BaseOutputFeatureConfig, FeatureCollection, LLMInputFeatureSelection, LLMOutputFeatureSelection, ) from ludwig.schema.hyperopt import HyperoptConfig, HyperoptField from ludwig.schema.llms.base_model import BaseModelDataclassField from ludwig.schema.llms.generation import LLMGenerationConfig, LLMGenerationConfigField from ludwig.schema.llms.model_parameters import ModelParametersConfig, ModelParametersConfigField from ludwig.schema.llms.peft import AdapterDataclassField, BaseAdapterConfig from ludwig.schema.llms.prompt import PromptConfig, PromptConfigField from ludwig.schema.llms.quantization import QuantizationConfig, QuantizationConfigField from ludwig.schema.model_types.base import ModelConfig, register_model_type from ludwig.schema.preprocessing import PreprocessingConfig, PreprocessingField from ludwig.schema.trainer import LLMTrainerConfig, LLMTrainerDataclassField from ludwig.schema.utils import ludwig_dataclass @DeveloperAPI @register_model_type(name="llm") @ludwig_dataclass class LLMModelConfig(ModelConfig): """Parameters for LLM Model Type.""" model_type: str = schema_utils.ProtectedString("llm") base_model: str = BaseModelDataclassField() input_features: FeatureCollection[BaseInputFeatureConfig] = LLMInputFeatureSelection().get_list_field() output_features: FeatureCollection[BaseOutputFeatureConfig] = LLMOutputFeatureSelection().get_list_field() preprocessing: PreprocessingConfig = PreprocessingField().get_default_field() defaults: LLMDefaultsConfig | None = LLMDefaultsField().get_default_field() hyperopt: HyperoptConfig | None = HyperoptField().get_default_field() prompt: PromptConfig = PromptConfigField().get_default_field() # trainer: LLMTrainerConfig = LLMTrainerField().get_default_field() trainer: LLMTrainerConfig = LLMTrainerDataclassField( description="The trainer to use for the model", ) generation: LLMGenerationConfig = LLMGenerationConfigField().get_default_field() adapter: BaseAdapterConfig | None = AdapterDataclassField() quantization: QuantizationConfig | None = QuantizationConfigField().get_default_field() model_parameters: ModelParametersConfig | None = ModelParametersConfigField().get_default_field() trust_remote_code: bool = schema_utils.Boolean( default=False, description=( "Whether to trust and execute remote code from the HuggingFace model repository. " "Required for some models (e.g. Phi-2, Qwen) that use custom architectures. " "Only enable this for models you trust." ), ) ================================================ FILE: ludwig/schema/model_types/utils.py ================================================ import copy import logging import sys import warnings from collections.abc import Mapping from typing import Any, TYPE_CHECKING from transformers import AutoConfig from ludwig.api_annotations import DeveloperAPI from ludwig.constants import ( CATEGORY, COMBINED, DECODER, DEFAULTS, ENCODER, GRID_SEARCH, INPUT_FEATURES, LOSS, MODEL_ECD, MODEL_LLM, OUTPUT_FEATURES, PARAMETERS, PREPROCESSING, SEQUENCE, SPACE, TEXT, TYPE, ) from ludwig.error import ConfigValidationError from ludwig.features.feature_utils import compute_feature_hash from ludwig.schema.features.utils import output_config_registry from ludwig.schema.hyperopt.scheduler import BaseHyperbandSchedulerConfig from ludwig.schema.llms.generation import LLMGenerationConfig from ludwig.schema.trainer import ECDTrainerConfig from ludwig.types import HyperoptConfigDict, ModelConfigDict from ludwig.utils.data_utils import get_sanitized_feature_name from ludwig.utils.llm_utils import get_context_len if TYPE_CHECKING: from ludwig.schema.model_types.base import ModelConfig logger = logging.getLogger(__name__) @DeveloperAPI def merge_with_defaults(config_dict: ModelConfigDict) -> ModelConfigDict: # Recursive merge of the features, except that if we find a dictionary containing # an explicit "type" key, we ignore defaults if they don't match. defaults = config_dict.get(DEFAULTS) if not defaults: return config_dict config_dict = copy.deepcopy(config_dict) _merge_features_(config_dict.get(INPUT_FEATURES, []), defaults, {DECODER, LOSS}) _merge_features_(config_dict.get(OUTPUT_FEATURES, []), defaults, {ENCODER, PREPROCESSING}) return config_dict def _merge_features_(features: list[dict[str, Any]], defaults: dict[str, Any], exclude_keys: set[str]): for feature in features: ftype = feature.get(TYPE) if not ftype: continue default_feature = defaults.get(ftype, {}) merged_feature = _merge_dict_with_types(default_feature, feature, exclude_keys) # In-place replacement of the old feature with the new feature.clear() feature.update(merged_feature) def _merge_dict_with_types(dct: dict[str, Any], merge_dct: dict[str, Any], exclude_keys: set[str]) -> dict[str, Any]: dct = copy.deepcopy(dct) dct = {k: v for k, v in dct.items() if k not in exclude_keys} for k, v in merge_dct.items(): # TODO(travis): below type comparison is not perfect, as it doesn't consider the case where the default type # is omitted while the encoder type is explicitly set to the default type, in which case they # should resolve to equal, but will be considered different. if ( k in dct and isinstance(dct[k], dict) and isinstance(v, Mapping) and dct[k].get(TYPE) == v.get(TYPE, dct[k].get(TYPE)) ): dct[k] = _merge_dict_with_types(dct[k], v, exclude_keys) else: dct[k] = v return dct @DeveloperAPI def merge_fixed_preprocessing_params(config: "ModelConfig"): """Update preprocessing parameters if encoders require fixed preprocessing parameters.""" for feature in config.input_features: feature.encoder.set_fixed_preprocessing_params(config.model_type, feature.preprocessing) def set_validation_parameters(config: "ModelConfig"): """Sets validation-related parameters used for early stopping, determining the best hyperopt trial, etc.""" if not config.output_features: return # First set the validation field so we know what feature we're validating on if not config.trainer.validation_field: if config.trainer.validation_metric is None or config.trainer.validation_metric == LOSS: # Loss is valid for all features. config.trainer.validation_field = config.output_features[0].name else: # Determine the proper validation field for the user, like if the user specifies "accuracy" but forgets to # change the validation field from "combined" to the name of the feature that produces accuracy metrics. from ludwig.utils.metric_utils import get_feature_to_metric_names_map feature_to_metric_names_map = get_feature_to_metric_names_map(config.output_features.to_list()) validation_field = None for feature_name, metric_names in feature_to_metric_names_map.items(): if config.trainer.validation_metric in metric_names: if validation_field is None: validation_field = feature_name else: raise ConfigValidationError( f"The validation_metric: '{config.trainer.validation_metric}' corresponds to multiple " f"possible validation_fields, '{validation_field}' and '{feature_name}'. Please explicitly " "specify the validation_field that should be used with the validation_metric " f"'{config.trainer.validation_metric}'." ) if validation_field is None: raise ConfigValidationError( "User-specified trainer.validation_metric is not valid for any output feature." ) config.trainer.validation_field = validation_field # If the field is combined, then make sure the metric is loss and then return if config.trainer.validation_field == COMBINED: # Only loss is supported for combined if not config.trainer.validation_metric: config.trainer.validation_metric = LOSS elif config.trainer.validation_metric != LOSS: raise ConfigValidationError( f"Must set validation_metric=loss when validation_field=combined, " f"found validation_metric={config.trainer.validation_metric}" ) return # Field is not combined, so use the default validation metric for the single feature validation_features = [f for f in config.output_features if f.name == config.trainer.validation_field] if len(validation_features) > 1: raise ConfigValidationError( f"Found more than one feature matching validation field: {config.trainer.validation_field}" ) if len(validation_features) == 0: raise ConfigValidationError( f"No output feature found matching validation field: {config.trainer.validation_field}" ) validation_feature = validation_features[0] if not config.trainer.validation_metric: # The user has not explicitly set any validation fields. # Default to using the first output feature's default validation metric. out_type = validation_feature.type config.trainer.validation_metric = output_config_registry(config.model_type)[out_type].default_validation_metric def set_derived_feature_columns_(config_obj: "ModelConfig"): """Assigns column and proc_column values to features that do not have them set. Proc_column is set to a hash of the feature's preprocessing configuration. """ for feature in config_obj.input_features: if feature.column is None: feature.column = feature.name if feature.proc_column is None: feature.proc_column = compute_feature_hash(feature.to_dict()) for feature in config_obj.output_features: if feature.column is None: feature.column = feature.name if feature.proc_column is None: feature.proc_column = compute_feature_hash(feature.to_dict()) def sanitize_and_filter_combiner_entities_(config: "ModelConfig"): if config.model_type != MODEL_ECD or config.combiner.type != "comparator": return input_feature_names = {input_feature.name for input_feature in config.input_features} # Sanitize feature names. config.combiner.entity_1 = [get_sanitized_feature_name(fname) for fname in config.combiner.entity_1] config.combiner.entity_2 = [get_sanitized_feature_name(fname) for fname in config.combiner.entity_2] entity_1_excluded = {fname for fname in config.combiner.entity_1 if fname not in input_feature_names} if entity_1_excluded: logger.warning( f"Excluding `entity_1` features {entity_1_excluded} from the comparator combiner because they are not " f"present in the `input_features`." ) config.combiner.entity_1 = [fname for fname in config.combiner.entity_1 if fname not in entity_1_excluded] entity_2_excluded = {fname for fname in config.combiner.entity_2 if fname not in input_feature_names} if entity_2_excluded: logger.warning( f"Excluding `entity_2` features {entity_2_excluded} from the comparator combiner because they are not " f"present in the `input_features`." ) config.combiner.entity_2 = [fname for fname in config.combiner.entity_2 if fname not in entity_2_excluded] def set_hyperopt_defaults_(config: "ModelConfig"): """This function was migrated from defaults.py with the intention of setting some hyperopt defaults while the hyperopt section of the config object is not fully complete. Returns: None -> modifies trainer and hyperopt sections """ if not config.hyperopt: return # Set default num_samples based on search space if not set by user if config.hyperopt.executor.num_samples is None: _contains_grid_search_params = contains_grid_search_parameters(config.hyperopt.to_dict()) if _contains_grid_search_params: logger.info( "Setting hyperopt num_samples to 1 to prevent duplicate trials from being run. Duplicate trials are" " created when there are hyperopt parameters that use the `grid_search` search space.", ) config.hyperopt.executor.num_samples = 1 else: logger.info("Setting hyperopt num_samples to 10.") config.hyperopt.executor.num_samples = 10 scheduler = config.hyperopt.executor.scheduler if scheduler.type == "fifo": # FIFO scheduler has no constraints return # Disable early stopping when using a scheduler. We achieve this by setting the parameter # to -1, which ensures the condition to apply early stopping is never met. early_stop = config.trainer.early_stop if early_stop is not None and early_stop != -1: warnings.warn("Can't utilize `early_stop` while using a hyperopt scheduler. Setting early stop to -1.") config.trainer.early_stop = -1 if isinstance(config.trainer, ECDTrainerConfig) and isinstance(scheduler, BaseHyperbandSchedulerConfig): # TODO(travis): explore similar constraints for other model types that may not have epochs max_t = scheduler.max_t time_attr = scheduler.time_attr epochs = config.trainer.epochs if max_t is not None: if time_attr == "time_total_s": if epochs is None: # Continue training until time limit hit config.trainer.epochs = sys.maxsize # else continue training until either time or trainer epochs limit hit elif epochs is not None and epochs != max_t: raise ValueError( "Cannot set trainer `epochs` when using hyperopt scheduler w/different training_iteration `max_t`. " "Unset one of these parameters in your config or make sure their values match." ) else: # Run trainer until scheduler epochs limit hit config.trainer.epochs = max_t elif epochs is not None: scheduler.max_t = epochs # run scheduler until trainer epochs limit hit def set_preprocessing_parameters(config: "ModelConfig") -> None: # noqa: F821 """Reconcile conflicting preprocessing parameters in place.""" _set_max_sequence_length(config) def _set_max_sequence_length(config: "ModelConfig") -> None: # noqa: F821 """Ensures that `max_sequence_length` is never less than `sequence_length`.""" types_with_sequence_length = [SEQUENCE, TEXT] for input_feature in config.input_features: if input_feature.type in types_with_sequence_length: sequence_length = input_feature.preprocessing.sequence_length max_sequence_length = input_feature.preprocessing.max_sequence_length if sequence_length is not None and sequence_length > max_sequence_length: warnings.warn( "if `sequence_length` is not None, `max_sequence_length` must be greater than or equal " "to `sequence_length`. Setting `max_sequence_length` to `sequence_length`." ) input_feature.preprocessing.max_sequence_length = sequence_length def set_tagger_decoder_parameters(config: "ModelConfig") -> None: """Overrides the reduce_input parameter for text and sequence output features when a tagger decoder is used. This is done to ensure that the decoder correctly gets a 3D tensor as input. Returns: None -> modifies output_features """ for output_feature in config.output_features: if output_feature.type in {TEXT, SEQUENCE} and output_feature.decoder.type == "tagger": if output_feature.reduce_input is not None: warnings.warn( "reduce_input must be set to `None` when using a tagger decoder for your output feature. " f"Setting reduce_input to `None` for `{output_feature.name}`." ) output_feature.reduce_input = None def set_llm_parameters(config: "ModelConfig") -> None: if config.model_type != MODEL_LLM: return # Set preprocessing parameters for text features for LLM model type _set_llm_tokenizers(config) # Set max_new_tokens in generation config to the max sequence length of the output features _set_generation_max_new_tokens(config) # HACK(Arnav): Set Mixtral target modules when using LoRA # GitHub issue: https://github.com/ludwig-ai/ludwig/issues/3853 # PEFT PR: https://github.com/huggingface/peft/pull/1376 _set_mixtral_target_modules(config) # HACK(Arnav): Set Phi-2 target modules when using LoRA # GitHub issue: https://github.com/ludwig-ai/ludwig/issues/3910 # PEFT PR: https://github.com/huggingface/peft/pull/1375 _set_phi2_target_modules(config) # HACK(Arnav): Set Phi-3 target modules when using LoRA _set_phi3_target_modules(config) # HACK(Arnav): Set Gemma target modules when using LoRA # GitHub issue: https://github.com/ludwig-ai/ludwig/issues/3937 # PEFT PR: https://github.com/huggingface/peft/pull/1499 _set_gemma_target_modules(config) def _set_llm_tokenizers(config: "ModelConfig") -> None: """Sets the tokenizers for the LLM model to the pretrained model name or path. This ensures that they use the correct shared vocabulary from the tokenizer. This also ensures padding is correctly set to left padding to prevent the LLM from trying to continue to sequence based on the right padding tokens, which might exist based on sequence length. """ pretrained_model_name_or_path = config.base_model if not isinstance(pretrained_model_name_or_path, str) or pretrained_model_name_or_path is None: raise ValueError("Must set `base_model` when using the LLM model.") for input_feature in config.input_features: if input_feature.type == TEXT: input_feature.preprocessing.tokenizer = "hf_tokenizer" input_feature.preprocessing.pretrained_model_name_or_path = pretrained_model_name_or_path input_feature.preprocessing.padding = "left" for output_feature in config.output_features: if output_feature.type == TEXT: # Add tokenizer parameters to preprocessing so it can be used during post processing output_feature.preprocessing.tokenizer = "hf_tokenizer" output_feature.preprocessing.pretrained_model_name_or_path = pretrained_model_name_or_path output_feature.preprocessing.padding = "left" # Add tokenizer parameters to decoder so it can be used during the forward pass output_feature.decoder.pretrained_model_name_or_path = pretrained_model_name_or_path output_feature.decoder.max_new_tokens = config.generation.max_new_tokens elif output_feature.type == CATEGORY: # Tokenizer parameters output_feature.decoder.tokenizer = "hf_tokenizer" output_feature.decoder.pretrained_model_name_or_path = pretrained_model_name_or_path # Parameters for building decoder vocabulary output_feature.decoder.fallback_label = output_feature.preprocessing.fallback_label def _get_maximum_possible_sequence_length(config: "ModelConfig", default_max_sequence_length: int) -> int: """Returns the maximum possible sequence length for the LLM model based on the model config.""" max_possible_sequence_length = default_max_sequence_length if config.output_features[0].preprocessing.max_sequence_length is not None: # Note: We don't need to check for max between feature.preprocessing.max_sequence_length and # defaults.text.preprocessing.max_sequence_length because the latter is only applied to input features. max_possible_sequence_length = max( default_max_sequence_length, config.output_features[0].preprocessing.max_sequence_length ) elif config.preprocessing.global_max_sequence_length is not None: # This is not perfect since it includes tokens from both input + output features, but this at least # ensures that max possible of the sequence length is used. It is very likely that the model learns # to generate sequences than this value. max_possible_sequence_length = max( max_possible_sequence_length, config.preprocessing.global_max_sequence_length ) elif max_possible_sequence_length == default_max_sequence_length: # It's possible that both max_sequence_length and global_max_sequence_length are not set, in which case # we should fall back to the window size of the pretrained model. By this point, because of schema validation # checks, we know that the base_model exists so we can safely grab the base model's config. # TODO (Arnav): Figure out how to factor in rope scaling factor into this calculation. model_config = AutoConfig.from_pretrained(config.base_model) max_possible_sequence_length = get_context_len(model_config) # Artifically leave a buffer of half the total model window size to trade off # runtime while likely covering a majority of the max sequence length. max_possible_sequence_length = max_possible_sequence_length // 2 return max_possible_sequence_length def _set_generation_max_new_tokens(config: "ModelConfig") -> None: """Sets the max_new_tokens parameter in the generation config to the max sequence length of the output features. This ensures that the generation config is set to the correct value for the LLM model type. """ _DEFAULT_MAX_SEQUENCE_LENGTH = LLMGenerationConfig().max_new_tokens if config.generation.max_new_tokens != _DEFAULT_MAX_SEQUENCE_LENGTH: # Max new tokens is explicitly set by user, so don't override return if config.output_features[0].type != TEXT: # This is trickier to set for other output features, so don't override for now. # TODO: Add better support for category output features return max_possible_sequence_length = _get_maximum_possible_sequence_length(config, _DEFAULT_MAX_SEQUENCE_LENGTH) logger.info( f"Setting generation max_new_tokens to {max_possible_sequence_length} to correspond with the max " "sequence length assigned to the output feature or the global max sequence length. This will ensure that " "the correct number of tokens are generated at inference time. To override this behavior, set " "`generation.max_new_tokens` to a different value in your Ludwig config." ) config.generation.max_new_tokens = max_possible_sequence_length def _set_mixtral_target_modules(config: "ModelConfig") -> None: """If the base model is Mixtral 7x8, LoRA is enabled and the target modules are not set, set the target modules to q_proj and v_proj.""" if config.base_model not in {"mistralai/Mixtral-8x7B-v0.1", "mistralai/Mixtral-8x7B-Instruct-v0.1"}: return if not config.adapter: return if config.adapter.type != "lora" or config.adapter.target_modules: return target_modules = ["q_proj", "v_proj"] logger.info(f"Setting adapter target modules to {target_modules} for Mixtral 7x8 base model with LoRA adapter.") config.adapter.target_modules = target_modules def _set_phi2_target_modules(config: "ModelConfig") -> None: """If the base model is Phi-2, LoRA is enabled and the target modules are not set, set the target modules to maximize performance.""" if config.base_model not in { "microsoft/phi-1", "microsoft/phi-1_5", "microsoft/phi-2", }: return if not config.adapter: return if config.adapter.type != "lora" or config.adapter.target_modules: return target_modules = ["q_proj", "k_proj", "v_proj", "dense", "fc1", "fc2"] logger.info(f"Setting adapter target modules to {target_modules} for Phi-2 base model with LoRA adapter.") config.adapter.target_modules = target_modules def _set_phi3_target_modules(config: "ModelConfig") -> None: if config.base_model not in { "microsoft/Phi-3-mini-4k-instruct", "microsoft/Phi-3-mini-128k-instruct", }: return if not config.adapter: return if config.adapter.type != "lora" or config.adapter.target_modules: return target_modules = ["qkv_proj", "o_proj", "gate_up_proj", "down_proj"] logger.info(f"Setting adapter target modules to {target_modules} for Phi-3 base model with LoRA adapter.") config.adapter.target_modules = target_modules def _set_gemma_target_modules(config: "ModelConfig") -> None: """If the base model is Gemma, LoRA is enabled and the target modules are not set, set the target modules to maximize performance.""" if config.base_model not in {"google/gemma-2b", "google/gemma-2b-it", "google/gemma-7b", "google/gemma-7b-it"}: return if not config.adapter: return if config.adapter.type != "lora" or config.adapter.target_modules: return target_modules = ["q_proj", "v_proj"] config.adapter.target_modules = target_modules @DeveloperAPI def contains_grid_search_parameters(hyperopt_config: HyperoptConfigDict) -> bool: """Returns True if any hyperopt parameter in the config is using the grid_search space.""" for _, param_info in hyperopt_config[PARAMETERS].items(): if param_info.get(SPACE, None) == GRID_SEARCH: return True return False ================================================ FILE: ludwig/schema/optimizers.py ================================================ from abc import ABC from dataclasses import field from typing import ClassVar import torch try: import bitsandbytes as bnb except Exception: bnb = None import ludwig.schema.utils as schema_utils from ludwig.api_annotations import DeveloperAPI from ludwig.error import ConfigValidationError from ludwig.schema.metadata import OPTIMIZER_METADATA from ludwig.schema.metadata.parameter_metadata import convert_metadata_to_json, ParameterMetadata from ludwig.schema.utils import ludwig_dataclass from ludwig.utils.registry import Registry optimizer_registry = Registry() @DeveloperAPI def register_optimizer(name: str): def wrap(optimizer_config: BaseOptimizerConfig): optimizer_registry[name] = (optimizer_config.optimizer_class, optimizer_config) return optimizer_config return wrap @DeveloperAPI def get_optimizer_cls(name: str): """Get the optimizer schema class from the optimizer schema class registry.""" return optimizer_registry[name][1] @DeveloperAPI @ludwig_dataclass class BaseOptimizerConfig(schema_utils.BaseMarshmallowConfig, ABC): """Base class for optimizers. Not meant to be used directly. The dataclass format prevents arbitrary properties from being set. Consequently, in child classes, all properties from the corresponding `torch.optim.Optimizer` class are copied over: check each class to check which attributes are different from the torch-specified defaults. """ optimizer_class: ClassVar[torch.optim.Optimizer | None] = None "Class variable pointing to the corresponding `torch.optim.Optimizer` class." type: str """Name corresponding to an optimizer `ludwig.modules.optimization_modules.optimizer_registry`. Technically mutable, but attempting to load a derived optimizer with `type` set to a mismatched value will result in a `ValidationError`. """ @property def is_paged(self) -> bool: """Returns True if the optimizer is a Paged optimizer.""" return False @property def is_8bit(self) -> bool: """Returns True if the optimizer is an 8-bit optimizer.""" return False @DeveloperAPI @register_optimizer(name="sgd") @ludwig_dataclass class SGDOptimizerConfig(BaseOptimizerConfig): """Parameters for stochastic gradient descent.""" optimizer_class: ClassVar[torch.optim.Optimizer] = torch.optim.SGD """Points to `torch.optim.SGD`.""" type: str = schema_utils.ProtectedString("sgd") """Must be 'sgd' - corresponds to name in `ludwig.modules.optimization_modules.optimizer_registry` (default: 'sgd')""" # Defaults taken from https://pytorch.org/docs/stable/generated/torch.optim.SGD.html#torch.optim.SGD : momentum: float = schema_utils.NonNegativeFloat( default=0.0, description="Momentum factor.", parameter_metadata=OPTIMIZER_METADATA["momentum"], ) weight_decay: float = schema_utils.NonNegativeFloat( default=0.0, description="Weight decay ($L2$ penalty).", parameter_metadata=OPTIMIZER_METADATA["weight_decay"], ) dampening: float = schema_utils.NonNegativeFloat( default=0.0, description="Dampening for momentum.", parameter_metadata=OPTIMIZER_METADATA["dampening"], ) nesterov: bool = schema_utils.Boolean( default=False, description="Enables Nesterov momentum.", parameter_metadata=OPTIMIZER_METADATA["nesterov"], ) if bnb is not None: @DeveloperAPI @register_optimizer(name="sgd_8bit") @ludwig_dataclass class SGD8BitOptimizerConfig(SGDOptimizerConfig): """Parameters for stochastic gradient descent.""" optimizer_class: ClassVar[torch.optim.Optimizer] = bnb.optim.SGD8bit type: str = schema_utils.ProtectedString("sgd_8bit") block_wise: bool = schema_utils.Boolean( default=False, description="Whether to use block wise update.", ) percentile_clipping: int = schema_utils.IntegerRange( default=100, min=0, max=100, description="Percentile clipping.", ) @property def is_8bit(self) -> bool: return True @DeveloperAPI @register_optimizer(name="lbfgs") @ludwig_dataclass class LBFGSOptimizerConfig(BaseOptimizerConfig): """Parameters for stochastic gradient descent.""" optimizer_class: ClassVar[torch.optim.Optimizer] = torch.optim.LBFGS """Points to `torch.optim.LBFGS`.""" type: str = schema_utils.ProtectedString("lbfgs") """Must be 'lbfgs' - corresponds to name in `ludwig.modules.optimization_modules.optimizer_registry` (default: 'lbfgs')""" # Defaults taken from https://pytorch.org/docs/stable/generated/torch.optim.LBFGS.html#torch.optim.LBFGS max_iter: int = schema_utils.Integer( default=20, description="Maximum number of iterations per optimization step.", parameter_metadata=OPTIMIZER_METADATA["max_iter"], ) max_eval: int = schema_utils.Integer( default=None, allow_none=True, description="Maximum number of function evaluations per optimization step. Default: `max_iter` * 1.25.", parameter_metadata=OPTIMIZER_METADATA["max_eval"], ) tolerance_grad: float = schema_utils.NonNegativeFloat( default=1e-07, description="Termination tolerance on first order optimality.", parameter_metadata=OPTIMIZER_METADATA["tolerance_grad"], ) tolerance_change: float = schema_utils.NonNegativeFloat( default=1e-09, description="Termination tolerance on function value/parameter changes.", parameter_metadata=OPTIMIZER_METADATA["tolerance_change"], ) history_size: int = schema_utils.Integer( default=100, description="Update history size.", parameter_metadata=OPTIMIZER_METADATA["history_size"] ) line_search_fn: str = schema_utils.StringOptions( ["strong_wolfe"], default=None, allow_none=True, description="Line search function to use.", parameter_metadata=OPTIMIZER_METADATA["line_search_fn"], ) @DeveloperAPI @register_optimizer(name="adam") @ludwig_dataclass class AdamOptimizerConfig(BaseOptimizerConfig): """Parameters for adam optimization.""" optimizer_class: ClassVar[torch.optim.Optimizer] = torch.optim.Adam """Points to `torch.optim.Adam`.""" type: str = schema_utils.ProtectedString("adam") """Must be 'adam' - corresponds to name in `ludwig.modules.optimization_modules.optimizer_registry` (default: 'adam')""" # Defaults taken from https://pytorch.org/docs/stable/generated/torch.optim.Adam.html#torch.optim.Adam : betas: tuple[float, float] = schema_utils.FloatRangeTupleDataclassField( default=(0.9, 0.999), description="Coefficients used for computing running averages of gradient and its square.", parameter_metadata=OPTIMIZER_METADATA["betas"], ) eps: float = schema_utils.NonNegativeFloat( default=1e-08, description="Term added to the denominator to improve numerical stability.", parameter_metadata=OPTIMIZER_METADATA["eps"], ) weight_decay: float = schema_utils.NonNegativeFloat( default=0.0, description="Weight decay (L2 penalty).", parameter_metadata=OPTIMIZER_METADATA["weight_decay"] ) amsgrad: bool = schema_utils.Boolean( default=False, description="Whether to use the AMSGrad variant of this algorithm from the paper 'On the Convergence of Adam " "and Beyond'.", parameter_metadata=OPTIMIZER_METADATA["amsgrad"], ) if bnb is not None: @DeveloperAPI @register_optimizer(name="adam_8bit") @ludwig_dataclass class Adam8BitOptimizerConfig(AdamOptimizerConfig): optimizer_class: ClassVar[torch.optim.Optimizer] = bnb.optim.Adam8bit type: str = schema_utils.ProtectedString("adam_8bit") block_wise: bool = schema_utils.Boolean( default=True, description="Whether to use block wise update.", ) percentile_clipping: int = schema_utils.IntegerRange( default=100, min=0, max=100, description="Percentile clipping.", ) @property def is_8bit(self) -> bool: return True @DeveloperAPI @register_optimizer(name="paged_adam") @ludwig_dataclass class PagedAdamOptimizerConfig(Adam8BitOptimizerConfig): optimizer_class: ClassVar[torch.optim.Optimizer] = bnb.optim.PagedAdam type: str = schema_utils.ProtectedString("paged_adam") @property def is_paged(self) -> bool: return True @property def is_8bit(self) -> bool: return False @DeveloperAPI @register_optimizer(name="paged_adam_8bit") @ludwig_dataclass class PagedAdam8BitOptimizerConfig(PagedAdamOptimizerConfig): optimizer_class: ClassVar[torch.optim.Optimizer] = bnb.optim.PagedAdam8bit type: str = schema_utils.ProtectedString("paged_adam_8bit") @property def is_8bit(self) -> bool: return True @DeveloperAPI @register_optimizer(name="adamw") @ludwig_dataclass class AdamWOptimizerConfig(BaseOptimizerConfig): """Parameters for adamw optimization.""" optimizer_class: ClassVar[torch.optim.Optimizer] = torch.optim.AdamW """Points to `torch.optim.AdamW`.""" type: str = schema_utils.ProtectedString("adamw") """Must be 'adamw' - corresponds to name in `ludwig.modules.optimization_modules.optimizer_registry` (default: 'adamw')""" # Defaults taken from https://pytorch.org/docs/stable/generated/torch.optim.Adam.html#torch.optim.Adam : betas: tuple[float, float] = schema_utils.FloatRangeTupleDataclassField( default=(0.9, 0.999), description="Coefficients used for computing running averages of gradient and its square.", parameter_metadata=OPTIMIZER_METADATA["betas"], ) eps: float = schema_utils.NonNegativeFloat( default=1e-08, description="Term added to the denominator to improve numerical stability.", parameter_metadata=OPTIMIZER_METADATA["eps"], ) weight_decay: float = schema_utils.NonNegativeFloat( default=0.0, description="Weight decay ($L2$ penalty).", parameter_metadata=OPTIMIZER_METADATA["weight_decay"] ) amsgrad: bool = schema_utils.Boolean( default=False, description="Whether to use the AMSGrad variant of this algorithm from the paper 'On the Convergence of Adam " "and Beyond'. ", parameter_metadata=OPTIMIZER_METADATA["amsgrad"], ) if bnb is not None: @DeveloperAPI @register_optimizer(name="adamw_8bit") @ludwig_dataclass class AdamW8BitOptimizerConfig(AdamWOptimizerConfig): optimizer_class: ClassVar[torch.optim.Optimizer] = bnb.optim.AdamW8bit type: str = schema_utils.ProtectedString("adamw_8bit") block_wise: bool = schema_utils.Boolean( default=True, description="Whether to use block wise update.", ) percentile_clipping: int = schema_utils.IntegerRange( default=100, min=0, max=100, description="Percentile clipping.", ) @property def is_8bit(self) -> bool: return True @DeveloperAPI @register_optimizer(name="paged_adamw") @ludwig_dataclass class PagedAdamWOptimizerConfig(AdamW8BitOptimizerConfig): optimizer_class: ClassVar[torch.optim.Optimizer] = bnb.optim.PagedAdamW type: str = schema_utils.ProtectedString("paged_adamw") @property def is_paged(self) -> bool: return True @property def is_8bit(self) -> bool: return False @DeveloperAPI @register_optimizer(name="paged_adamw_8bit") @ludwig_dataclass class PagedAdamW8BitOptimizerConfig(PagedAdamWOptimizerConfig): optimizer_class: ClassVar[torch.optim.Optimizer] = bnb.optim.PagedAdamW8bit type: str = schema_utils.ProtectedString("paged_adamw_8bit") @property def is_8bit(self) -> bool: return True @DeveloperAPI @register_optimizer(name="adadelta") @ludwig_dataclass class AdadeltaOptimizerConfig(BaseOptimizerConfig): """Parameters for adadelta optimization.""" optimizer_class: ClassVar[torch.optim.Optimizer] = torch.optim.Adadelta """Points to `torch.optim.Adadelta`.""" type: str = schema_utils.ProtectedString("adadelta") """Must be 'adadelta' - corresponds to name in `ludwig.modules.optimization_modules.optimizer_registry` (default: 'adadelta')""" # Defaults taken from https://pytorch.org/docs/stable/generated/torch.optim.Adadelta.html#torch.optim.Adadelta : rho: float = schema_utils.FloatRange( default=0.9, min=0, max=1, description="Coefficient used for computing a running average of squared gradients.", parameter_metadata=OPTIMIZER_METADATA["rho"], ) eps: float = schema_utils.NonNegativeFloat( default=1e-06, description="Term added to the denominator to improve numerical stability.", parameter_metadata=OPTIMIZER_METADATA["eps"], ) weight_decay: float = schema_utils.NonNegativeFloat( default=0.0, description="Weight decay ($L2$ penalty).", parameter_metadata=OPTIMIZER_METADATA["weight_decay"] ) @DeveloperAPI @register_optimizer(name="adagrad") @ludwig_dataclass class AdagradOptimizerConfig(BaseOptimizerConfig): """Parameters for adagrad optimization.""" # Example docstring optimizer_class: ClassVar[torch.optim.Optimizer] = torch.optim.Adagrad """Points to `torch.optim.Adagrad`.""" type: str = schema_utils.ProtectedString("adagrad") """Must be 'adagrad' - corresponds to name in `ludwig.modules.optimization_modules.optimizer_registry` (default: 'adagrad')""" # Defaults taken from https://pytorch.org/docs/stable/generated/torch.optim.Adagrad.html#torch.optim.Adagrad : initial_accumulator_value: float = schema_utils.NonNegativeFloat( default=0, description="", parameter_metadata=OPTIMIZER_METADATA["initial_accumulator_value"] ) lr_decay: float = schema_utils.FloatRange( default=0, description="Learning rate decay.", parameter_metadata=OPTIMIZER_METADATA["lr_decay"] ) weight_decay: float = schema_utils.FloatRange( default=0, description="Weight decay ($L2$ penalty).", parameter_metadata=OPTIMIZER_METADATA["weight_decay"] ) eps: float = schema_utils.FloatRange( default=1e-10, description="Term added to the denominator to improve numerical stability.", parameter_metadata=OPTIMIZER_METADATA["eps"], ) if bnb is not None: @DeveloperAPI @register_optimizer(name="adagrad_8bit") @ludwig_dataclass class Adagrad8BitOptimizerConfig(AdagradOptimizerConfig): optimizer_class: ClassVar[torch.optim.Optimizer] = bnb.optim.Adagrad8bit type: str = schema_utils.ProtectedString("adagrad_8bit") block_wise: bool = schema_utils.Boolean( default=True, description="Whether to use block wise update.", ) percentile_clipping: int = schema_utils.IntegerRange( default=100, min=0, max=100, description="Percentile clipping.", ) @property def is_8bit(self) -> bool: return True @DeveloperAPI @register_optimizer(name="adamax") @ludwig_dataclass class AdamaxOptimizerConfig(BaseOptimizerConfig): """Parameters for adamax optimization.""" optimizer_class: ClassVar[torch.optim.Optimizer] = torch.optim.Adamax """Points to `torch.optim.Adamax`.""" type: str = schema_utils.ProtectedString("adamax") """Must be 'adamax' - corresponds to name in `ludwig.modules.optimization_modules.optimizer_registry` (default: 'adamax')""" # Defaults taken from https://pytorch.org/docs/stable/generated/torch.optim.Adamax.html#torch.optim.Adamax : betas: tuple[float, float] = schema_utils.FloatRangeTupleDataclassField( default=(0.9, 0.999), description="Coefficients used for computing running averages of gradient and its square.", parameter_metadata=OPTIMIZER_METADATA["betas"], ) eps: float = schema_utils.NonNegativeFloat( default=1e-08, description="Term added to the denominator to improve numerical stability.", parameter_metadata=OPTIMIZER_METADATA["eps"], ) weight_decay: float = schema_utils.NonNegativeFloat( default=0.0, description="Weight decay ($L2$ penalty).", parameter_metadata=OPTIMIZER_METADATA["weight_decay"] ) # NOTE: keep ftrl and nadam optimizers out of registry: # @register_optimizer(name="ftrl") @DeveloperAPI @ludwig_dataclass class FtrlOptimizerConfig(BaseOptimizerConfig): # optimizer_class: ClassVar[torch.optim.Optimizer] = torch.optim.Ftrl type: str = schema_utils.ProtectedString("ftrl") learning_rate_power: float = schema_utils.FloatRange( default=-0.5, max=0, parameter_metadata=OPTIMIZER_METADATA["learning_rate_power"] ) initial_accumulator_value: float = schema_utils.NonNegativeFloat( default=0.1, parameter_metadata=OPTIMIZER_METADATA["initial_accumulator_value"] ) l1_regularization_strength: float = schema_utils.NonNegativeFloat( default=0.0, parameter_metadata=OPTIMIZER_METADATA["l1_regularization_strength"] ) l2_regularization_strength: float = schema_utils.NonNegativeFloat( default=0.0, parameter_metadata=OPTIMIZER_METADATA["l2_regularization_strength"] ) @DeveloperAPI @register_optimizer(name="nadam") @ludwig_dataclass class NadamOptimizerConfig(BaseOptimizerConfig): optimizer_class: ClassVar[torch.optim.Optimizer] = torch.optim.NAdam """Points to `torch.optim.NAdam`.""" type: str = schema_utils.ProtectedString("nadam") # Defaults taken from https://pytorch.org/docs/stable/generated/torch.optim.NAdam.html#torch.optim.NAdam : betas: tuple[float, float] = schema_utils.FloatRangeTupleDataclassField( default=(0.9, 0.999), description="Coefficients used for computing running averages of gradient and its square.", parameter_metadata=OPTIMIZER_METADATA["betas"], ) eps: float = schema_utils.NonNegativeFloat( default=1e-08, description="Term added to the denominator to improve numerical stability.", parameter_metadata=OPTIMIZER_METADATA["eps"], ) weight_decay: float = schema_utils.NonNegativeFloat( default=0.0, description="Weight decay ($L2$ penalty).", parameter_metadata=OPTIMIZER_METADATA["weight_decay"] ) momentum_decay: float = schema_utils.NonNegativeFloat( default=4e-3, description="Momentum decay.", parameter_metadata=OPTIMIZER_METADATA["momentum_decay"] ) @DeveloperAPI @register_optimizer(name="rmsprop") @ludwig_dataclass class RMSPropOptimizerConfig(BaseOptimizerConfig): """Parameters for rmsprop optimization.""" optimizer_class: ClassVar[torch.optim.Optimizer] = torch.optim.RMSprop """Points to `torch.optim.RMSprop`.""" type: str = schema_utils.ProtectedString("rmsprop") """Must be 'rmsprop' - corresponds to name in `ludwig.modules.optimization_modules.optimizer_registry` (default: 'rmsprop')""" # Defaults taken from https://pytorch.org/docs/stable/generated/torch.optim.RMSprop.html#torch.optim.RMSprop: momentum: float = schema_utils.NonNegativeFloat( default=0.0, description="Momentum factor.", parameter_metadata=OPTIMIZER_METADATA["momentum"], ) alpha: float = schema_utils.NonNegativeFloat( default=0.99, description="Smoothing constant.", parameter_metadata=OPTIMIZER_METADATA["alpha"], ) eps: float = schema_utils.NonNegativeFloat( default=1e-08, description="Term added to the denominator to improve numerical stability.", parameter_metadata=OPTIMIZER_METADATA["eps"], ) centered: bool = schema_utils.Boolean( default=False, description="If True, computes the centered RMSProp, and the gradient is normalized by an estimation of its " "variance.", parameter_metadata=OPTIMIZER_METADATA["centered"], ) weight_decay: float = schema_utils.NonNegativeFloat(default=0.0, description="Weight decay ($L2$ penalty).") if bnb is not None: @DeveloperAPI @register_optimizer(name="rmsprop_8bit") @ludwig_dataclass class RMSProp8BitOptimizerConfig(RMSPropOptimizerConfig): optimizer_class: ClassVar[torch.optim.Optimizer] = bnb.optim.RMSprop8bit type: str = schema_utils.ProtectedString("rmsprop_8bit") block_wise: bool = schema_utils.Boolean( default=True, description="Whether to use block wise update.", ) percentile_clipping: int = schema_utils.IntegerRange( default=100, min=0, max=100, description="Percentile clipping.", ) @property def is_8bit(self) -> bool: return True if bnb is not None: @DeveloperAPI @register_optimizer(name="lamb") @ludwig_dataclass class LAMBOptimizerConfig(BaseOptimizerConfig): """Layer-wise Adaptive Moments optimizer for Batch training. Paper: https://arxiv.org/pdf/1904.00962.pdf """ optimizer_class: ClassVar[torch.optim.Optimizer] = bnb.optim.LAMB type: str = schema_utils.ProtectedString("lamb") bias_correction: bool = schema_utils.Boolean( default=True, ) betas: tuple[float, float] = schema_utils.FloatRangeTupleDataclassField( default=(0.9, 0.999), description="Coefficients used for computing running averages of gradient and its square.", parameter_metadata=OPTIMIZER_METADATA["betas"], ) eps: float = schema_utils.NonNegativeFloat( default=1e-08, description="Term added to the denominator to improve numerical stability.", parameter_metadata=OPTIMIZER_METADATA["eps"], ) weight_decay: float = schema_utils.NonNegativeFloat( default=0.0, description="Weight decay (L2 penalty).", parameter_metadata=OPTIMIZER_METADATA["weight_decay"], ) amsgrad: bool = schema_utils.Boolean( default=False, description=( "Whether to use the AMSGrad variant of this algorithm from the paper " "'On the Convergence of Adam and Beyond'." ), parameter_metadata=OPTIMIZER_METADATA["amsgrad"], ) adam_w_mode: bool = schema_utils.Boolean( default=True, description="Whether to use the AdamW mode of this algorithm from the paper " "'Decoupled Weight Decay Regularization'.", ) percentile_clipping: int = schema_utils.IntegerRange( default=100, min=0, max=100, description="Percentile clipping.", ) block_wise: bool = schema_utils.Boolean( default=False, description="Whether to use block wise update.", ) max_unorm: float = schema_utils.FloatRange( default=1.0, min=0.0, max=1.0, ) @DeveloperAPI @register_optimizer(name="lamb_8bit") @ludwig_dataclass class LAMB8BitOptimizerConfig(LAMBOptimizerConfig): optimizer_class: ClassVar[torch.optim.Optimizer] = bnb.optim.LAMB8bit type: str = schema_utils.ProtectedString("lamb_8bit") @property def is_8bit(self) -> bool: return True if bnb is not None: @DeveloperAPI @register_optimizer(name="lars") @ludwig_dataclass class LARSOptimizerConfig(BaseOptimizerConfig): """Layerwise Adaptive Rate Scaling. Paper: https://arxiv.org/pdf/1708.03888.pdf """ optimizer_class: ClassVar[torch.optim.Optimizer] = bnb.optim.LARS type: str = schema_utils.ProtectedString("lars") # 0.9 taken from the original paper - momentum requires a non zero value # https://arxiv.org/pdf/1708.03888v3.pdf momentum: float = schema_utils.FloatRange( default=0.9, min=0.0, max=1.0, min_inclusive=False, description="Momentum factor.", parameter_metadata=OPTIMIZER_METADATA["momentum"], ) dampening: float = schema_utils.FloatRange( default=0.0, min=0.0, max=1.0, description="Dampening for momentum.", parameter_metadata=OPTIMIZER_METADATA["dampening"], ) weight_decay: float = schema_utils.NonNegativeFloat( default=0.0, description="Weight decay (L2 penalty).", parameter_metadata=OPTIMIZER_METADATA["weight_decay"], ) nesterov: bool = schema_utils.Boolean( default=False, description="Enables Nesterov momentum.", parameter_metadata=OPTIMIZER_METADATA["nesterov"], ) percentile_clipping: int = schema_utils.IntegerRange( default=100, min=0, max=100, description="Percentile clipping.", ) max_unorm: float = schema_utils.FloatRange( default=1.0, min=0.0, max=1.0, ) @DeveloperAPI @register_optimizer(name="lars_8bit") @ludwig_dataclass class LARS8BitOptimizerConfig(LARSOptimizerConfig): optimizer_class: ClassVar[torch.optim.Optimizer] = bnb.optim.LARS8bit type: str = schema_utils.ProtectedString("lars_8bit") @property def is_8bit(self) -> bool: return True if bnb is not None: @DeveloperAPI @register_optimizer(name="lion") @ludwig_dataclass class LIONOptimizerConfig(BaseOptimizerConfig): """Evolved Sign Momentum. Paper: https://arxiv.org/pdf/2302.06675.pdf """ optimizer_class: ClassVar[torch.optim.Optimizer] = bnb.optim.Lion type: str = schema_utils.ProtectedString("lion") betas: tuple[float, float] = schema_utils.FloatRangeTupleDataclassField( default=(0.9, 0.999), description="Coefficients used for computing running averages of gradient and its square.", parameter_metadata=OPTIMIZER_METADATA["betas"], ) weight_decay: float = schema_utils.NonNegativeFloat( default=0.0, description="Weight decay (L2 penalty).", parameter_metadata=OPTIMIZER_METADATA["weight_decay"], ) percentile_clipping: int = schema_utils.IntegerRange( default=100, min=0, max=100, description="Percentile clipping.", ) block_wise: bool = schema_utils.Boolean( default=True, description="Whether to use block wise update.", ) @DeveloperAPI @register_optimizer(name="lion_8bit") @ludwig_dataclass class LION8BitOptimizerConfig(LIONOptimizerConfig): optimizer_class: ClassVar[torch.optim.Optimizer] = bnb.optim.Lion8bit type: str = schema_utils.ProtectedString("lion_8bit") @property def is_8bit(self) -> bool: return True @DeveloperAPI @register_optimizer(name="paged_lion") @ludwig_dataclass class PagedLionOptimizerConfig(LIONOptimizerConfig): optimizer_class: ClassVar[torch.optim.Optimizer] = bnb.optim.PagedLion type: str = schema_utils.ProtectedString("paged_lion") @property def is_paged(self) -> bool: return True @DeveloperAPI @register_optimizer(name="paged_lion_8bit") @ludwig_dataclass class PagedLion8BitOptimizerConfig(PagedLionOptimizerConfig): optimizer_class: ClassVar[torch.optim.Optimizer] = bnb.optim.PagedLion8bit type: str = schema_utils.ProtectedString("paged_lion_8bit") @property def is_8bit(self) -> bool: return True @DeveloperAPI def get_optimizer_conds(): """Returns a JSON schema of conditionals to validate against optimizer types defined in `ludwig.modules.optimization_modules.optimizer_registry`.""" conds = [] for optimizer in optimizer_registry: optimizer_cls = optimizer_registry[optimizer][1] other_props = schema_utils.unload_jsonschema_from_marshmallow_class(optimizer_cls)["properties"] schema_utils.remove_duplicate_fields(other_props) preproc_cond = schema_utils.create_cond( {"type": optimizer}, other_props, ) conds.append(preproc_cond) return conds @DeveloperAPI def OptimizerDataclassField(default="adam", description="", parameter_metadata: ParameterMetadata = None): """Custom dataclass field that when used inside of a dataclass will allow any optimizer in `ludwig.modules.optimization_modules.optimizer_registry`. Sets default optimizer to 'adam'. :param default: Dict specifying an optimizer with a `type` field and its associated parameters. Will attempt to use `type` to load optimizer from registry with given params. (default: {"type": "adam"}). :return: Initialized dataclass field that converts untyped dicts with params to optimizer dataclass instances. """ class OptimizerSelection(schema_utils.TypeSelection): """Custom marshmallow field that deserializes a dict to a valid optimizer from `ludwig.modules.optimization_modules.optimizer_registry` and creates a corresponding `oneOf` JSON schema for external usage.""" def __init__(self): super().__init__( registry=optimizer_registry, default_value=default, description=description, parameter_metadata=parameter_metadata, ) def get_schema_from_registry(self, key: str) -> type[schema_utils.BaseMarshmallowConfig]: return get_optimizer_cls(key) def _jsonschema_type_mapping(self): # Note that this uses the same conditional pattern as combiners: return { "type": "object", "properties": { "type": { "type": "string", "enum": list(optimizer_registry.keys()), "default": default, "description": "The type of optimizer to use during the learning process", }, }, "title": "optimizer_options", "allOf": get_optimizer_conds(), "required": ["type"], "description": description, } return OptimizerSelection().get_default_field() @DeveloperAPI @ludwig_dataclass class GradientClippingConfig(schema_utils.BaseMarshmallowConfig): """Dataclass that holds gradient clipping parameters.""" clipglobalnorm: float | None = schema_utils.FloatRange( default=0.5, allow_none=True, description="Maximum allowed norm of the gradients", parameter_metadata=OPTIMIZER_METADATA["gradient_clipping"], ) # TODO(travis): is this redundant with `clipglobalnorm`? clipnorm: float | None = schema_utils.FloatRange( default=None, allow_none=True, description="Maximum allowed norm of the gradients", parameter_metadata=OPTIMIZER_METADATA["gradient_clipping"], ) clipvalue: float | None = schema_utils.FloatRange( default=None, allow_none=True, description="Maximum allowed value of the gradients", parameter_metadata=OPTIMIZER_METADATA["gradient_clipping"], ) @DeveloperAPI def GradientClippingDataclassField(description: str, default: dict = {}): """Returns custom dataclass field for `ludwig.modules.optimization_modules.GradientClippingConfig`. Allows `None` by default. :param description: Description of the gradient dataclass field :param default: dict that specifies clipping param values that will be loaded by its schema class (default: {}). """ allow_none = True class GradientClippingMarshmallowField(schema_utils.LudwigSchemaField): """Custom field class for gradient clipping. Deserializes a dict to a valid instance of `ludwig.modules.optimization_modules.GradientClippingConfig` and creates a corresponding JSON schema for external usage. """ def _deserialize(self, value, attr, data, **kwargs): if value is None: return value if isinstance(value, dict): try: return GradientClippingConfig.Schema().load(value) except (TypeError, ConfigValidationError): raise ConfigValidationError( f"Invalid params for gradient clipping: {value}, see GradientClippingConfig class." ) raise ConfigValidationError("Field should be None or dict") def _jsonschema_type_mapping(self): return { "oneOf": [ {"type": "null", "title": "disabled", "description": "Disable gradient clipping."}, { **schema_utils.unload_jsonschema_from_marshmallow_class(GradientClippingConfig), "title": "enabled_options", }, ], "title": "gradient_clipping_options", "description": description, } if not isinstance(default, dict): raise ConfigValidationError(f"Invalid default: `{default}`") def load_default(): return GradientClippingConfig.Schema().load(default) dump_default = GradientClippingConfig.Schema().dump(default) return field( metadata={ "marshmallow_field": GradientClippingMarshmallowField( allow_none=allow_none, load_default=load_default, dump_default=dump_default, metadata={ "description": description, "parameter_metadata": convert_metadata_to_json(OPTIMIZER_METADATA["gradient_clipping"]), }, ) }, default_factory=load_default, ) ================================================ FILE: ludwig/schema/preprocessing.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.constants import RANDOM from ludwig.schema import utils as schema_utils from ludwig.schema.metadata import PREPROCESSING_METADATA from ludwig.schema.split import BaseSplitConfig, SplitDataclassField from ludwig.schema.utils import ludwig_dataclass @DeveloperAPI @ludwig_dataclass class PreprocessingConfig(schema_utils.BaseMarshmallowConfig): """Global preprocessing config is a dataclass that configures the parameters used for global preprocessing.""" sample_ratio: float = schema_utils.NonNegativeFloat( default=1.0, description="The ratio of the dataset to use. For instance, if 0.5, half of the dataset " "provided will be used.", parameter_metadata=PREPROCESSING_METADATA["sample_ratio"], ) sample_size: float = schema_utils.NonNegativeInteger( default=None, allow_none=True, description="The maximum number of samples from the dataset to use. Cannot be set if sample_ratio is set to be " "< 1.0. If sample_ratio is set to 1.0, this will override the number of samples to used.", parameter_metadata=PREPROCESSING_METADATA["sample_size"], ) oversample_minority: float = schema_utils.NonNegativeFloat( default=None, allow_none=True, description="If not None, the minority class will be oversampled to reach the specified ratio respective to " "the majority class. ", parameter_metadata=PREPROCESSING_METADATA["oversample_minority"], ) undersample_majority: float = schema_utils.NonNegativeFloat( default=None, allow_none=True, description="If not None, the majority class will be undersampled to reach the specified ratio respective " "to the minority class. ", parameter_metadata=PREPROCESSING_METADATA["undersample_majority"], ) split: BaseSplitConfig = SplitDataclassField( default=RANDOM, ) global_max_sequence_length: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Specifically for LLMs. This is the maximum length of the input sequence going into the model's " "forward pass during training. Sequences will be truncated to this length after merging inputs and targets. " "If not set, the total length of the merged input and target token sequences will be used.", parameter_metadata=PREPROCESSING_METADATA["global_max_sequence_length"], ) @DeveloperAPI class PreprocessingField(schema_utils.DictMarshmallowField): def __init__(self): super().__init__(PreprocessingConfig) ================================================ FILE: ludwig/schema/profiler.py ================================================ from dataclasses import field import ludwig.schema.utils as schema_utils from ludwig.api_annotations import DeveloperAPI from ludwig.error import ConfigValidationError from ludwig.schema.utils import ludwig_dataclass @DeveloperAPI @ludwig_dataclass class ProfilerConfig(schema_utils.BaseMarshmallowConfig): """Dataclass that holds profiling parameters for torch profile scheduler. The profiler will skip the first skip_first steps, then wait for wait steps, then do the warmup for the next warmup steps, then do the active recording for the next active steps and then repeat the cycle starting with wait steps. The optional number of cycles is specified with the repeat parameter, the zero value means that the cycles will continue until the profiling is finished. """ wait: int = schema_utils.IntegerRange( default=1, min=0, description="The number of steps to wait profiling.", ) warmup: int = schema_utils.IntegerRange( default=1, min=0, description="The number of steps for profiler warmup after waiting finishes.", ) active: int = schema_utils.IntegerRange( default=3, min=0, description="The number of steps that are actively recorded. Values more than 10 wil dramatically slow down " "tensorboard loading.", ) repeat: int = schema_utils.IntegerRange( default=5, min=0, description="The optional number of profiling cycles. Use 0 to profile the entire training run.", ) skip_first: int = schema_utils.IntegerRange( default=0, min=0, max=100, description="The number of steps to skip in the beginning of training.", ) @DeveloperAPI def ProfilerDataclassField(description: str, default: dict = {}): """Returns custom dataclass field for `ludwig.modules.profiler.ProfilerConfig`. Allows `None` by default. :param description: Description of the torch profiler field :param default: dict that specifies clipping param values that will be loaded by its schema class (default: {}). """ allow_none = True class ProfilingMarshmallowField(schema_utils.LudwigSchemaField): """Custom field class for the torch profiler. Deserializes a dict to a valid instance of `ludwig.modules.optimization_modules.ProfilerConfig` and creates a corresponding JSON schema for external usage. """ def _deserialize(self, value, attr, data, **kwargs): if value is None: return value if isinstance(value, dict): try: return ProfilerConfig.Schema().load(value) except (TypeError, ConfigValidationError): raise ConfigValidationError( f"Invalid params for profiling config: {value}, see ProfilerConfig class." ) raise ConfigValidationError("Field should be None or dict") def _jsonschema_type_mapping(self): return { **schema_utils.unload_jsonschema_from_marshmallow_class(ProfilerConfig), "title": "profiler_options", "description": description, } if not isinstance(default, dict): raise ConfigValidationError(f"Invalid default: `{default}`") def load_default(): return ProfilerConfig.Schema().load(default) dump_default = ProfilerConfig.Schema().dump(default) return field( metadata={ "marshmallow_field": ProfilingMarshmallowField( allow_none=allow_none, load_default=load_default, dump_default=dump_default, metadata={ "description": description, "parameter_metadata": None, }, ) }, default_factory=load_default, ) ================================================ FILE: ludwig/schema/split.py ================================================ from dataclasses import Field from ludwig.api_annotations import DeveloperAPI from ludwig.constants import SPLIT, TYPE from ludwig.schema import utils as schema_utils from ludwig.schema.metadata import PREPROCESSING_METADATA from ludwig.schema.utils import ludwig_dataclass from ludwig.utils.registry import Registry split_config_registry = Registry() DEFAULT_PROBABILITIES = [0.7, 0.1, 0.2] @DeveloperAPI def get_split_cls(name: str): return split_config_registry[name] @DeveloperAPI @ludwig_dataclass class BaseSplitConfig(schema_utils.BaseMarshmallowConfig): """This Dataclass is a base schema for the nested split config under preprocessing.""" type: str "Name corresponding to the splitting type." @DeveloperAPI @split_config_registry.register("random") @ludwig_dataclass class RandomSplitConfig(BaseSplitConfig): """This Dataclass generates a schema for the random splitting config.""" type: str = schema_utils.ProtectedString( "random", description="Type of splitting to use during preprocessing.", ) probabilities: list = schema_utils.List( list_type=float, default=DEFAULT_PROBABILITIES, description="Probabilities for splitting data into train, validation, and test sets.", parameter_metadata=PREPROCESSING_METADATA["split_probabilities"], ) @DeveloperAPI @split_config_registry.register("fixed") @ludwig_dataclass class FixedSplitConfig(BaseSplitConfig): """This Dataclass generates a schema for the fixed splitting config.""" type: str = schema_utils.ProtectedString( "fixed", description="Type of splitting to use during preprocessing.", ) column: str = schema_utils.String( default=SPLIT, allow_none=False, description="The column name to use for fixed splitting.", parameter_metadata=PREPROCESSING_METADATA["column"], ) @DeveloperAPI @split_config_registry.register("stratify") @ludwig_dataclass class StratifySplitConfig(BaseSplitConfig): """This Dataclass generates a schema for the fixed splitting config.""" type: str = schema_utils.ProtectedString( "stratify", description="Type of splitting to use during preprocessing.", ) column: str = schema_utils.String( default=None, allow_none=True, description="The column name to base the stratified splitting on.", parameter_metadata=PREPROCESSING_METADATA["column"], ) probabilities: list = schema_utils.List( list_type=float, default=DEFAULT_PROBABILITIES, description="Probabilities for splitting data into train, validation, and test sets.", parameter_metadata=PREPROCESSING_METADATA["split_probabilities"], ) @DeveloperAPI @split_config_registry.register("datetime") @ludwig_dataclass class DateTimeSplitConfig(BaseSplitConfig): """This Dataclass generates a schema for the fixed splitting config.""" type: str = schema_utils.ProtectedString( "datetime", description="Type of splitting to use during preprocessing.", ) column: str = schema_utils.String( default=None, allow_none=True, description="The column name to perform datetime splitting on.", parameter_metadata=PREPROCESSING_METADATA["column"], ) probabilities: list = schema_utils.List( list_type=float, default=DEFAULT_PROBABILITIES, description="Proportion of data to split into train, validation, and test sets.", parameter_metadata=PREPROCESSING_METADATA["split_probabilities"], ) @DeveloperAPI @split_config_registry.register("hash") @ludwig_dataclass class HashSplitConfig(BaseSplitConfig): """This Dataclass generates a schema for the hash splitting config. This is useful for deterministically splitting on a unique ID. Even when additional rows are added to the dataset in the future, each ID will retain its original split assignment. This approach does not guarantee that the split proportions will be assigned exactly, but the larger the dataset, the more closely the assignment should match the given proportions. This approach can be used on a column with duplicates, but it will further skew the assignments of rows to splits. """ type: str = schema_utils.ProtectedString( "hash", description="Type of splitting to use during preprocessing.", ) column: str = schema_utils.String( default=None, allow_none=True, description="The column name to perform hash splitting on.", parameter_metadata=PREPROCESSING_METADATA["column"], ) probabilities: list = schema_utils.List( list_type=float, default=DEFAULT_PROBABILITIES, description="Proportion of data to split into train, validation, and test sets.", parameter_metadata=PREPROCESSING_METADATA["split_probabilities"], ) @DeveloperAPI def get_split_conds(): """Returns a JSON schema of conditionals to validate against optimizer types defined in `ludwig.modules.optimization_modules.optimizer_registry`.""" conds = [] for splitter in split_config_registry.data: splitter_cls = split_config_registry.data[splitter] other_props = schema_utils.unload_jsonschema_from_marshmallow_class(splitter_cls)["properties"] schema_utils.remove_duplicate_fields(other_props, [TYPE]) splitter_cond = schema_utils.create_cond( {"type": splitter}, other_props, ) conds.append(splitter_cond) return conds @DeveloperAPI def SplitDataclassField(default: str) -> Field: """Custom dataclass field that when used inside a dataclass will allow the user to specify a nested split config. Returns: Initialized dataclass field that converts an untyped dict with params to a split config. """ class SplitSelection(schema_utils.TypeSelection): def __init__(self): super().__init__(registry=split_config_registry.data, default_value=default) def get_schema_from_registry(self, key: str) -> type[schema_utils.BaseMarshmallowConfig]: return split_config_registry.data[key] def _jsonschema_type_mapping(self): return { "type": "object", "properties": { "type": { "type": "string", "description": "Type of splitting to use during preprocessing.", "enum": list(split_config_registry.data.keys()), "default": default, }, }, "title": "split_options", "allOf": get_split_conds(), } return SplitSelection().get_default_field() ================================================ FILE: ludwig/schema/trainer.py ================================================ import re from abc import ABC import torch from packaging.version import parse as parse_version from ludwig.api_annotations import DeveloperAPI from ludwig.constants import ( AUTO, EFFECTIVE_BATCH_SIZE, LOSS, MAX_BATCH_SIZE, MAX_POSSIBLE_BATCH_SIZE, MODEL_ECD, MODEL_LLM, TRAINING, ) from ludwig.error import ConfigValidationError from ludwig.schema import utils as schema_utils from ludwig.schema.lr_scheduler import LRSchedulerConfig, LRSchedulerDataclassField from ludwig.schema.metadata import TRAINER_METADATA from ludwig.schema.optimizers import ( BaseOptimizerConfig, GradientClippingConfig, GradientClippingDataclassField, OptimizerDataclassField, ) from ludwig.schema.profiler import ProfilerConfig, ProfilerDataclassField from ludwig.schema.utils import ludwig_dataclass from ludwig.utils.registry import Registry _torch_200 = parse_version(torch.__version__) >= parse_version("2.0") trainer_schema_registry = Registry() _llm_trainer_schema_registry = Registry() @DeveloperAPI def register_trainer_schema(model_type: str): def wrap(trainer_config: BaseTrainerConfig): trainer_schema_registry[model_type] = trainer_config return trainer_config return wrap @DeveloperAPI def register_llm_trainer_schema(trainer_type: str): def wrap(trainer_config: BaseTrainerConfig): _llm_trainer_schema_registry[trainer_type] = trainer_config return trainer_config return wrap @DeveloperAPI def get_llm_trainer_cls(trainer_type: str): """Returns the adapter config class registered with the given name.""" return _llm_trainer_schema_registry[trainer_type] @DeveloperAPI @ludwig_dataclass class BaseTrainerConfig(schema_utils.BaseMarshmallowConfig, ABC): """Common trainer parameter values.""" validation_field: str = schema_utils.String( default=None, allow_none=True, description="The field for which the `validation_metric` is used for validation-related mechanics like early " "stopping, parameter change plateaus, as well as what hyperparameter optimization uses to determine the best " "trial. If unset (default), the first output feature is used. If explicitly specified, neither " "`validation_field` nor `validation_metric` are overwritten.", ) validation_metric: str = schema_utils.String( default=None, allow_none=True, description=( "Metric from `validation_field` that is used. If validation_field is not explicitly specified, this is " "overwritten to be the first output feature type's `default_validation_metric`, consistent with " "validation_field. If the validation_metric is specified, then we will use the first output feature that " "produces this metric as the `validation_field`." ), ) early_stop: int = schema_utils.IntegerRange( default=5, min=-1, description=( "Number of consecutive rounds of evaluation without any improvement on the `validation_metric` that " "triggers training to stop. Can be set to -1, which disables early stopping entirely." ), ) skip_all_evaluation: bool = schema_utils.Boolean( default=False, description=( "Whether to skip evaluation entirely. If you are training a model with a well-known configuration on a " "well-known dataset and are confident about the expected results, you might skip all evaluation. Moreover, " "evaluating a model, especially on large validation or test sets, can be time-consuming." ), ) enable_profiling: bool = schema_utils.Boolean( default=False, description="Whether to enable profiling of the training process using torch.profiler.profile.", ) profiler: ProfilerConfig | None = ProfilerDataclassField( description="Parameter values for profiling config.", default={}, ) def can_tune_batch_size(self) -> bool: return True @DeveloperAPI @register_trainer_schema(MODEL_ECD) @ludwig_dataclass class ECDTrainerConfig(BaseTrainerConfig): """Dataclass that configures most of the hyperparameters used for ECD model training.""" def __post_init__(self): if self.compile and not _torch_200: raise ConfigValidationError( "Trainer param `compile: true` requires PyTorch 2.0.0 or higher. Please upgrade PyTorch and try again." ) if self.effective_batch_size != AUTO and self.max_batch_size < self.effective_batch_size: raise ConfigValidationError( f"`max_batch_size` ({self.max_batch_size}) must be greater than or equal to " f"`effective_batch_size` ({self.effective_batch_size})." ) if self.effective_batch_size != AUTO and self.batch_size != AUTO: if self.effective_batch_size < self.batch_size: raise ConfigValidationError( f"`effective_batch_size` ({self.effective_batch_size}) " f"must be greater than or equal to `batch_size` ({self.batch_size})." ) if self.effective_batch_size % self.batch_size != 0: raise ConfigValidationError( f"`effective_batch_size` ({self.effective_batch_size}) " f"must be divisible by `batch_size` ({self.batch_size})." ) if self.effective_batch_size != AUTO and self.gradient_accumulation_steps != AUTO: if self.effective_batch_size < self.gradient_accumulation_steps: raise ConfigValidationError( f"`effective_batch_size` ({self.effective_batch_size}) must be greater than or equal to " f"`gradient_accumulation_steps` ({self.gradient_accumulation_steps})." ) if self.effective_batch_size % self.gradient_accumulation_steps != 0: raise ConfigValidationError( f"`effective_batch_size` ({self.effective_batch_size}) must be divisible by " f"`gradient_accumulation_steps` ({self.gradient_accumulation_steps})." ) if self.layers_to_freeze_regex: try: re.compile(self.layers_to_freeze_regex) except re.error: raise ConfigValidationError( f"`layers_to_freeze_regex` ({self.layers_to_freeze_regex}) must be a valid regular expression." ) learning_rate: float | str = schema_utils.OneOfOptionsField( default=0.001, allow_none=False, description=( "Controls how much to change the model in response to the estimated error each time the model weights are " "updated. If 'auto', the optimal learning rate is estimated by choosing the learning rate that produces " "the smallest non-diverging gradient update." ), parameter_metadata=TRAINER_METADATA[MODEL_ECD]["learning_rate"], field_options=[ schema_utils.FloatRange(default=0.001, allow_none=False, min=0, max=1), schema_utils.StringOptions(options=["auto"], default="auto", allow_none=False), ], ) learning_rate_scheduler: LRSchedulerConfig = LRSchedulerDataclassField( description="Parameter values for learning rate scheduler.", default=None, ) epochs: int = schema_utils.PositiveInteger( default=100, description="Number of epochs the algorithm is intended to be run over. Overridden if `train_steps` is set", parameter_metadata=TRAINER_METADATA[MODEL_ECD]["epochs"], ) checkpoints_per_epoch: int = schema_utils.NonNegativeInteger( default=0, description=( "Number of checkpoints per epoch. For example, 2 -> checkpoints are written every half of an epoch. Note " "that it is invalid to specify both non-zero `steps_per_checkpoint` and non-zero `checkpoints_per_epoch`." ), parameter_metadata=TRAINER_METADATA[MODEL_ECD]["checkpoints_per_epoch"], ) train_steps: int = schema_utils.PositiveInteger( default=None, allow_none=True, description=( "Maximum number of training steps the algorithm is intended to be run over. Unset by default. " "If set, will override `epochs` and if left unset then `epochs` is used to determine training length." ), parameter_metadata=TRAINER_METADATA[MODEL_ECD]["train_steps"], ) eval_steps: float = schema_utils.NonNegativeInteger( default=None, allow_none=True, description="The number of steps to use for evaluation. If None, the entire evaluation set will be used.", parameter_metadata=TRAINER_METADATA[MODEL_ECD]["eval_steps"], ) steps_per_checkpoint: int = schema_utils.NonNegativeInteger( default=0, description=( "How often the model is checkpointed. Also dictates maximum evaluation frequency. If 0 the model is " "checkpointed after every epoch." ), parameter_metadata=TRAINER_METADATA[MODEL_ECD]["steps_per_checkpoint"], ) effective_batch_size: int | str = schema_utils.OneOfOptionsField( default=AUTO, allow_none=False, description=( "The effective batch size is the total number of samples used to compute a single gradient update " "to the model weights. This differs from `batch_size` by taking `gradient_accumulation_steps` and number " "of training worker processes into account. In practice, " "`effective_batch_size = batch_size * gradient_accumulation_steps * num_workers`. " "If 'auto', the effective batch size is derivied implicitly from `batch_size`, but if set explicitly, then " "one of `batch_size` or `gradient_accumulation_steps` must be set to something other than 'auto', and " "consequently will be set following the formula given above." ), parameter_metadata=TRAINER_METADATA[MODEL_ECD][EFFECTIVE_BATCH_SIZE], field_options=[ schema_utils.PositiveInteger(default=128, description="", allow_none=False), schema_utils.StringOptions(options=["auto"], default="auto", allow_none=False), ], ) batch_size: int | str = schema_utils.OneOfOptionsField( default=AUTO, allow_none=False, description=( "The number of training examples utilized in one training step of the model. If ’auto’, the " "batch size that maximized training throughput (samples / sec) will be used. For CPU training, the " "tuned batch size is capped at 128 as throughput benefits of large batch sizes are less noticeable without " "a GPU." ), parameter_metadata=TRAINER_METADATA[MODEL_ECD]["batch_size"], field_options=[ schema_utils.PositiveInteger(default=128, description="", allow_none=False), schema_utils.StringOptions(options=["auto"], default="auto", allow_none=False), ], ) max_batch_size: int = schema_utils.PositiveInteger( default=MAX_POSSIBLE_BATCH_SIZE, allow_none=True, description=( "Auto batch size tuning and increasing batch size on plateau will be capped at this value. The default " "value is 2^40." ), parameter_metadata=TRAINER_METADATA[MODEL_ECD][MAX_BATCH_SIZE], ) gradient_accumulation_steps: int | str = schema_utils.OneOfOptionsField( default=AUTO, allow_none=False, description="Number of steps to accumulate gradients over before performing a weight update.", parameter_metadata=TRAINER_METADATA[MODEL_ECD]["gradient_accumulation_steps"], field_options=[ schema_utils.PositiveInteger(default=1, description="", allow_none=False), schema_utils.StringOptions(options=["auto"], default="auto", allow_none=False), ], ) early_stop: int = schema_utils.IntegerRange( default=5, min=-1, description=( "Number of consecutive rounds of evaluation without any improvement on the `validation_metric` that " "triggers training to stop. Can be set to -1, which disables early stopping entirely." ), parameter_metadata=TRAINER_METADATA[MODEL_ECD]["early_stop"], ) eval_batch_size: None | int | str = schema_utils.OneOfOptionsField( default=None, allow_none=True, description=( "Size of batch to pass to the model for evaluation. If it is `0` or `None`, the same value of `batch_size` " "is used. This is useful to speedup evaluation with a much bigger batch size than training, if enough " "memory is available. If ’auto’, the biggest batch size (power of 2) that can fit in memory will be used." ), parameter_metadata=TRAINER_METADATA[MODEL_ECD]["eval_batch_size"], field_options=[ schema_utils.PositiveInteger(default=128, description="", allow_none=False), schema_utils.StringOptions(options=["auto"], default="auto", allow_none=False), ], ) evaluate_training_set: bool = schema_utils.Boolean( default=False, description=( "Whether to evaluate on the entire training set during evaluation. By default, training metrics will be " "computed at the end of each training step, and accumulated up to the evaluation phase. In practice, " "computing training set metrics during training is up to 30% faster than running a separate evaluation " "pass over the training set, but results in more noisy training metrics, particularly during the earlier " "epochs. It's recommended to only set this to True if you need very exact training set metrics, and are " "willing to pay a significant performance penalty for them." ), parameter_metadata=TRAINER_METADATA[MODEL_ECD]["evaluate_training_set"], ) validation_field: str = schema_utils.String( default=None, allow_none=True, description="The field for which the `validation_metric` is used for validation-related mechanics like early " "stopping, parameter change plateaus, as well as what hyperparameter optimization uses to determine the best " "trial. If unset (default), the first output feature is used. If explicitly specified, neither " "`validation_field` nor `validation_metric` are overwritten.", parameter_metadata=TRAINER_METADATA[MODEL_ECD]["validation_field"], ) validation_metric: str = schema_utils.String( default=None, allow_none=True, description=( "Metric from `validation_field` that is used. If validation_field is not explicitly specified, this is " "overwritten to be the first output feature type's `default_validation_metric`, consistent with " "validation_field. If the validation_metric is specified, then we will use the first output feature that " "produces this metric as the `validation_field`." ), parameter_metadata=TRAINER_METADATA[MODEL_ECD]["validation_metric"], ) optimizer: BaseOptimizerConfig = OptimizerDataclassField( default="adam", description=( "Optimizer type and its parameters. The optimizer is responsble for applying the gradients computed " "from the loss during backpropagation as updates to the model weights." ), parameter_metadata=TRAINER_METADATA[MODEL_ECD]["optimizer"], ) regularization_type: str | None = schema_utils.RegularizerOptions( default="l2", allow_none=True, description="Type of regularization.", parameter_metadata=TRAINER_METADATA[MODEL_ECD]["regularization_type"], ) regularization_lambda: float = schema_utils.FloatRange( default=0.0, min=0, max=1, description="Strength of the regularization.", parameter_metadata=TRAINER_METADATA[MODEL_ECD]["regularization_lambda"], ) should_shuffle: bool = schema_utils.Boolean( default=True, description="Whether to shuffle batches during training when true.", parameter_metadata=TRAINER_METADATA[MODEL_ECD]["should_shuffle"], ) increase_batch_size_on_plateau: int = schema_utils.NonNegativeInteger( default=0, description="The number of times to increase the batch size on a plateau.", parameter_metadata=TRAINER_METADATA[MODEL_ECD]["increase_batch_size_on_plateau"], ) increase_batch_size_on_plateau_patience: int = schema_utils.NonNegativeInteger( default=5, description="How many epochs to wait for before increasing the batch size.", parameter_metadata=TRAINER_METADATA[MODEL_ECD]["increase_batch_size_on_plateau_patience"], ) increase_batch_size_on_plateau_rate: float = schema_utils.NonNegativeFloat( default=2.0, description="Rate at which the batch size increases.", parameter_metadata=TRAINER_METADATA[MODEL_ECD]["increase_batch_size_on_plateau_rate"], ) increase_batch_size_eval_metric: str = schema_utils.String( default=LOSS, description="Which metric to listen on for increasing the batch size.", parameter_metadata=TRAINER_METADATA[MODEL_ECD]["increase_batch_size_eval_metric"], ) increase_batch_size_eval_split: str = schema_utils.String( default=TRAINING, description="Which dataset split to listen on for increasing the batch size.", parameter_metadata=TRAINER_METADATA[MODEL_ECD]["increase_batch_size_eval_split"], ) gradient_clipping: GradientClippingConfig | None = GradientClippingDataclassField( description="Parameter values for gradient clipping.", default={}, ) learning_rate_scaling: str = schema_utils.StringOptions( ["constant", "sqrt", "linear"], default="linear", description="Scale by which to increase the learning rate as the number of distributed workers increases. " "Traditionally the learning rate is scaled linearly with the number of workers to reflect the " "proportion by" " which the effective batch size is increased. For very large batch sizes, a softer square-root " "scale can " "sometimes lead to better model performance. If the learning rate is hand-tuned for a given " "number of " "workers, setting this value to constant can be used to disable scale-up.", parameter_metadata=TRAINER_METADATA[MODEL_ECD]["learning_rate_scaling"], ) bucketing_field: str = schema_utils.String( default=None, allow_none=True, description="Feature to use for bucketing datapoints", parameter_metadata=TRAINER_METADATA[MODEL_ECD]["bucketing_field"], ) use_mixed_precision: bool = schema_utils.Boolean( default=False, description="Enable automatic mixed-precision (AMP) during training.", parameter_metadata=TRAINER_METADATA[MODEL_ECD]["use_mixed_precision"], ) compile: bool = schema_utils.Boolean( default=False, description="Whether to compile the model before training.", parameter_metadata=TRAINER_METADATA[MODEL_ECD]["compile"], ) enable_gradient_checkpointing: bool = schema_utils.Boolean( default=False, description="Whether to enable gradient checkpointing, which trades compute for memory." "This is useful for training very deep models with limited memory.", parameter_metadata=TRAINER_METADATA[MODEL_ECD]["enable_gradient_checkpointing"], ) layers_to_freeze_regex: str = schema_utils.String( default=None, allow_none=True, description=( "Freeze specific layers based on provided regex. Freezing specific layers can improve a " "pretrained model's performance in a number of ways. At a basic level, freezing early layers can " "prevent overfitting by retaining more general features (beneficial for small datasets). Also can " "reduce computational resource use and lower overall training time due to less gradient calculations. " ), ) def update_batch_size_grad_accum(self, num_workers: int): from ludwig.utils.trainer_utils import get_rendered_batch_size_grad_accum self.batch_size, self.gradient_accumulation_steps = get_rendered_batch_size_grad_accum(self, num_workers) @DeveloperAPI @ludwig_dataclass class LLMTrainerConfig(BaseTrainerConfig): """Base class for all LLM trainer configs.""" learning_rate: float | str = schema_utils.OneOfOptionsField( default=0.0002, allow_none=False, description=( "Controls how much to change the model in response to the estimated error each time the model weights are " "updated. If 'auto', the optimal learning rate is estimated by choosing the learning rate that produces " "the smallest non-diverging gradient update." ), parameter_metadata=TRAINER_METADATA[MODEL_ECD]["learning_rate"], field_options=[ schema_utils.FloatRange(default=0.001, allow_none=False, min=0, max=1), schema_utils.StringOptions(options=["auto"], default="auto", allow_none=False), ], ) batch_size: int = schema_utils.PositiveInteger( default=1, description="Batch size used for training in the LLM trainer.", ) base_learning_rate: float = schema_utils.NonNegativeFloat( default=0.0, description="Base learning rate used for training in the LLM trainer.", ) should_shuffle: bool = schema_utils.Boolean( default=True, description="Whether to shuffle the training data in the LLM trainer.", ) epochs: int = schema_utils.PositiveInteger( default=3, description="Number of epochs to train in the LLM trainer.", ) train_steps: int = schema_utils.PositiveInteger( default=None, allow_none=True, description="Number of training steps to train in the LLM trainer.", ) eval_steps: float = schema_utils.NonNegativeInteger( default=None, allow_none=True, description="The number of steps to evaluate in the LLM trainer.", ) steps_per_checkpoint: int = schema_utils.NonNegativeInteger( default=0, description="Number of steps per checkpoint in the LLM trainer.", ) checkpoints_per_epoch: int = schema_utils.NonNegativeInteger( default=0, description="Number of checkpoints per epoch in the LLM trainer.", ) early_stop: int = schema_utils.IntegerRange( default=-1, min=-1, description=( "Number of consecutive rounds of evaluation without any improvement on the `validation_metric` that " "triggers training to stop. Can be set to -1, which disables early stopping entirely." ), ) eval_batch_size: int = schema_utils.PositiveInteger( default=2, description="Batch size used for evaluation in the LLM trainer.", ) evaluate_training_set: bool = schema_utils.Boolean( default=False, description="Whether to evaluate the training set in the LLM trainer. Note: this operation may be slow.", ) @DeveloperAPI @register_llm_trainer_schema("none") @ludwig_dataclass class NoneTrainerConfig(LLMTrainerConfig): """Dataclass that configures most of the hyperparameters used for zero-shot / few-shot LLM model training.""" # Required for lookup during trainer initialization type: str = schema_utils.ProtectedString( "none", description="The type of trainer used to train the model. ", parameter_metadata=TRAINER_METADATA[MODEL_LLM]["type"], ) def can_tune_batch_size(self) -> bool: return False @DeveloperAPI @register_llm_trainer_schema("finetune") @ludwig_dataclass class FineTuneTrainerConfig(ECDTrainerConfig): """Dataclass that configures most of the hyperparameters used for fine-tuning LLM model training.""" # Required for lookup during trainer initialization type: str = schema_utils.ProtectedString("finetune") base_learning_rate: float = schema_utils.NonNegativeFloat( default=0.0, description="Base learning rate used for training in the LLM trainer.", ) batch_size: int | str | None = schema_utils.OneOfOptionsField( default=1, allow_none=False, description=( "The number of training examples utilized in one training step of the model. If `auto`, the " "batch size that maximized training throughput (samples / sec) will be used." ), field_options=[ schema_utils.PositiveInteger(default=1, description="", allow_none=False), schema_utils.StringOptions(options=["auto"], default="auto", allow_none=False), ], ) eval_batch_size: int | str | None = schema_utils.OneOfOptionsField( default=2, allow_none=True, description=( "Size of batch to pass to the model for evaluation. If it is `0` or `None`, the same value of `batch_size` " "is used. This is useful to speedup evaluation with a much bigger batch size than training, if enough " "memory is available. If `auto`, the biggest batch size (power of 2) that can fit in memory will be used." ), field_options=[ schema_utils.PositiveInteger(default=2, description="", allow_none=False), schema_utils.StringOptions(options=["auto"], default="auto", allow_none=False), ], ) @DeveloperAPI def get_model_type_jsonschema(model_type: str = MODEL_ECD): if model_type == MODEL_LLM: enum = [MODEL_LLM] else: enum = [MODEL_ECD] return { "type": "string", "enum": enum, "default": MODEL_ECD, "title": "model_type", "description": "Select the model type.", } @DeveloperAPI def get_trainer_jsonschema(model_type: str): trainer_cls = trainer_schema_registry[model_type] props = schema_utils.unload_jsonschema_from_marshmallow_class(trainer_cls)["properties"] return { "type": "object", "properties": props, "title": "trainer_options", "additionalProperties": False, "description": "Schema for trainer determined by Model Type", } @DeveloperAPI class ECDTrainerField(schema_utils.DictMarshmallowField): def __init__(self): super().__init__(ECDTrainerConfig) def _jsonschema_type_mapping(self): return get_trainer_jsonschema(MODEL_ECD) @DeveloperAPI def get_llm_trainer_conds(): """Returns a JSON schema of conditionals to validate against adapter types.""" conds = [] for trainer in _llm_trainer_schema_registry: trainer_cls = _llm_trainer_schema_registry[trainer] other_props = schema_utils.unload_jsonschema_from_marshmallow_class(trainer_cls)["properties"] schema_utils.remove_duplicate_fields(other_props) preproc_cond = schema_utils.create_cond( {"type": trainer}, other_props, ) conds.append(preproc_cond) return conds @DeveloperAPI def LLMTrainerDataclassField(default="none", description=""): class LLMTrainerSelection(schema_utils.TypeSelection): def __init__(self): super().__init__( registry=_llm_trainer_schema_registry, default_value=default, description=description, ) def get_schema_from_registry(self, key: str) -> type[schema_utils.BaseMarshmallowConfig]: return get_llm_trainer_cls(key) def _jsonschema_type_mapping(self): return { "type": "object", "properties": { "type": { "type": "string", "enum": list(_llm_trainer_schema_registry.keys()), "default": default, "description": "The type of LLM trainer to use", }, }, "title": "llm_trainer_options", "allOf": get_llm_trainer_conds(), "required": ["type"], "description": description, } return LLMTrainerSelection().get_default_field() ================================================ FILE: ludwig/schema/utils.py ================================================ """Ludwig schema utilities - pydantic 2 based. This module provides the foundation for Ludwig's declarative config system. All config classes inherit from BaseMarshmallowConfig (a pydantic BaseModel) and use field factory functions (String, Integer, Float, etc.) that return pydantic Field() objects. """ import copy import logging import os import warnings from abc import ABC, abstractmethod from functools import lru_cache from typing import Any import yaml from pydantic import BaseModel, ConfigDict, Field, model_validator from pydantic import ValidationError as PydanticValidationError from pydantic.fields import FieldInfo from ludwig.api_annotations import DeveloperAPI from ludwig.constants import ACTIVE, COLUMN, LUDWIG_SCHEMA_VALIDATION_POLICY, NAME, PROC_COLUMN, TYPE from ludwig.error import ConfigValidationError from ludwig.modules.reduction_modules import reduce_mode_registry from ludwig.schema.metadata import COMMON_METADATA from ludwig.schema.metadata.parameter_metadata import convert_metadata_to_json, ParameterMetadata from ludwig.utils.misc_utils import scrub_creds from ludwig.utils.registry import Registry from ludwig.utils.torch_utils import activations, initializer_registry # ============================================================================ # LudwigSchemaField - base class replacing marshmallow fields.Field # ============================================================================ class LudwigSchemaField: """Plain Python base class for Ludwig schema fields. Replaces marshmallow fields.Field as the base for TypeSelection, DictMarshmallowField (NestedConfigField), and all custom field classes. The contract (get_default_field, _jsonschema_type_mapping, _deserialize) stays identical. """ def __init__(self, **kwargs): # Store all keyword arguments as attributes for backward compat for k, v in kwargs.items(): setattr(self, k, v) def get_default_field(self) -> FieldInfo: """Create a pydantic FieldInfo for this field. Override in subclasses. """ return Field(default=None) def _jsonschema_type_mapping(self): """Return a JSON schema dict for this field. Override in subclasses. """ return None def _deserialize(self, value, attr, data, **kwargs): """Deserialize a raw value. Override in subclasses. """ return value logger = logging.getLogger(__name__) RECURSION_STOP_ENUM = {"weights_initializer", "bias_initializer", "norm_params"} def ludwig_dataclass(cls): """No-op decorator. Config classes now inherit directly from BaseMarshmallowConfig (pydantic BaseModel). """ return cls # TODO: Change to RAISE and update descriptions once we want to enforce strict schemas. LUDWIG_SCHEMA_VALIDATION_POLICY_VAR = os.environ.get(LUDWIG_SCHEMA_VALIDATION_POLICY, "exclude").lower() class _SchemaAdapter: """Adapts pydantic model to marshmallow-like Schema interface for backward compatibility. This allows existing code that calls cls.Schema().load(data), cls.Schema().dump(data), and cls.Schema().fields to continue working. """ def __init__(self, cls): self._cls = cls def __call__(self): """Allow Schema()() pattern (double-call).""" return self def load(self, data): """Validate and create a config instance from a dict.""" return self._cls.model_validate(data) def dump(self, data): """Serialize a config instance or dict to a plain dict.""" if isinstance(data, BaseMarshmallowConfig): return data.to_dict() if isinstance(data, dict): try: instance = self._cls.model_validate(data) return instance.to_dict() except Exception: return data return data @property def fields(self): """Return field info dict (pydantic model_fields).""" return self._cls.model_fields # Sentinel for TypeSelection and DictMarshmallowField metadata markers class _TypeSelectionMarker: """Marker stored in Field.metadata to indicate this field uses TypeSelection dispatch.""" def __init__(self, type_selection): self.type_selection = type_selection class _NestedConfigMarker: """Marker stored in Field.metadata to indicate this field uses DictMarshmallowField dispatch.""" def __init__(self, cls, allow_none=True): self.cls = cls self.allow_none = allow_none ConfigT = Any # TypeVar("ConfigT", bound="BaseMarshmallowConfig") def _convert_dataclass_field_to_pydantic(dc_field) -> FieldInfo: """Convert a dataclasses.Field to a pydantic FieldInfo. This is the bridge that allows old marshmallow-style field definitions (using dataclasses.field(metadata={"marshmallow_field": ...})) to work with pydantic BaseModel classes during the migration period. """ import dataclasses as _dc metadata_list = [] marshmallow_field = None # Extract marshmallow_field from metadata if dc_field.metadata: marshmallow_field = dc_field.metadata.get("marshmallow_field") if marshmallow_field is not None: # Store as a marker so model_validator can use it for dispatch if isinstance(marshmallow_field, TypeSelection): metadata_list.append(_TypeSelectionMarker(marshmallow_field)) elif isinstance(marshmallow_field, DictMarshmallowField): # Check if the subclass overrides _jsonschema_type_mapping has_custom_schema = ( type(marshmallow_field)._jsonschema_type_mapping is not DictMarshmallowField._jsonschema_type_mapping ) if has_custom_schema: # Store as MarshmallowFieldMarker to preserve custom JSON schema generation metadata_list.append(_MarshmallowFieldMarker(marshmallow_field)) else: metadata_list.append(_NestedConfigMarker(marshmallow_field.cls, marshmallow_field.allow_none)) else: # Generic marshmallow field - store for reference metadata_list.append(_MarshmallowFieldMarker(marshmallow_field)) # Extract default and create FieldInfo. # Note: pydantic 2's Field() does not accept a `metadata` kwarg — set it on the FieldInfo after creation. if dc_field.default is not _dc.MISSING: fi = Field(default=dc_field.default) elif dc_field.default_factory is not _dc.MISSING: fi = Field(default_factory=dc_field.default_factory) else: # No default - this is a required field fi = Field() if metadata_list: fi.metadata = metadata_list return fi class _MarshmallowFieldMarker: """Stores a marshmallow field for backward compat during migration.""" def __init__(self, marshmallow_field): self.marshmallow_field = marshmallow_field class _LudwigModelMeta(type(BaseModel)): """Metaclass that bridges marshmallow-dataclass patterns to pydantic 2. Handles two key behaviors: 1. Converts dataclasses.Field objects to pydantic FieldInfo in __new__ 2. Allows class-level access to field defaults via __getattr__ """ def __new__(mcs, name, bases, namespace, **kwargs): import dataclasses as _dc annotations = namespace.get("__annotations__", {}) # Detect @property definitions and prevent pydantic from treating them as field defaults. # Properties that don't shadow inherited fields work fine as-is because pydantic # only processes annotated attributes. Properties that DO shadow inherited fields # should be converted to fields with constant defaults instead (done at the schema # class level, not here). _saved_properties: dict[str, property] = {} for attr_name, value in list(namespace.items()): if isinstance(value, property) and attr_name in annotations: # A property in this class's own annotations would confuse pydantic. # Remove it from annotations (it won't become a field). _saved_properties[attr_name] = value del namespace[attr_name] annotations.pop(attr_name, None) # Convert dataclass field() objects and marshmallow field descriptors to pydantic Field() for attr_name in list(annotations.keys()): if attr_name in namespace: value = namespace[attr_name] if isinstance(value, _dc.Field): namespace[attr_name] = _convert_dataclass_field_to_pydantic(value) elif isinstance(value, LudwigSchemaField) and hasattr(value, "get_default_field"): # TypeSelection and DictMarshmallowField instances need conversion namespace[attr_name] = value.get_default_field() # Auto-widen annotations to bridge marshmallow→pydantic gap. # In marshmallow, annotations were decorative. In pydantic, they're enforced. import types import typing for attr_name, ann in list(annotations.items()): # Skip ClassVar annotations origin = getattr(ann, "__origin__", None) if origin is typing.ClassVar: continue if attr_name not in namespace: continue value = namespace[attr_name] # For fields with markers (TypeSelection/DictMarshmallowField/MarshmallowField), # set annotation to Any since the actual validation happens in the marker if isinstance(value, FieldInfo): jse = getattr(value, "json_schema_extra", None) has_marker = False if isinstance(jse, dict) and "metadata" in jse: has_marker = any( isinstance(m, (_TypeSelectionMarker, _NestedConfigMarker, _MarshmallowFieldMarker)) for m in jse["metadata"] ) for meta in getattr(value, "metadata", None) or []: if isinstance(meta, (_TypeSelectionMarker, _NestedConfigMarker, _MarshmallowFieldMarker)): has_marker = True break if has_marker: annotations[attr_name] = Any continue # Widen to include None if default is None or enum contains None from pydantic_core import PydanticUndefined should_widen = value.default is None and value.default is not PydanticUndefined if not should_widen: # Also widen if the enum (from allow_none=True in StringOptions etc.) contains None jse_enum = (jse or {}).get("enum") if isinstance(jse, dict) else None if isinstance(jse_enum, list) and None in jse_enum: should_widen = True if not should_widen: # Also widen if allow_none=True was explicitly set in the field factory if isinstance(jse, dict) and jse.get("allow_none"): should_widen = True if should_widen: is_union = origin in (types.UnionType,) try: is_union = is_union or origin is typing.Union except (AttributeError, TypeError): pass has_none = False if is_union: has_none = type(None) in getattr(ann, "__args__", ()) if not has_none: try: annotations[attr_name] = ann | None except TypeError: pass elif value is None: # Plain None default try: annotations[attr_name] = ann | None except TypeError: pass namespace["__annotations__"] = annotations import warnings with warnings.catch_warnings(): warnings.filterwarnings("ignore", message="Field name .* shadows an attribute in parent") cls = super().__new__(mcs, name, bases, namespace, **kwargs) # Restore @property descriptors that we removed from namespace. if _saved_properties: for pname, prop in _saved_properties.items(): setattr(cls, pname, prop) return cls def __getattr__(cls, name: str) -> Any: """Allow accessing field defaults as class attributes (e.g., cls.type).""" for klass in cls.__mro__: pf = vars(klass).get("__pydantic_fields__") if pf is not None and isinstance(pf, dict) and name in pf: field_info = pf[name] from pydantic_core import PydanticUndefined if field_info.default is not PydanticUndefined: return field_info.default break raise AttributeError(name) @DeveloperAPI class BaseMarshmallowConfig(BaseModel, metaclass=_LudwigModelMeta): """Base pydantic model for all Ludwig config classes. Maintains backward-compatible API (from_dict, to_dict, Schema, etc.) while using pydantic 2 internally for validation and serialization. """ model_config = ConfigDict( extra="ignore" if LUDWIG_SCHEMA_VALIDATION_POLICY_VAR == "exclude" else "forbid", arbitrary_types_allowed=True, validate_default=False, revalidate_instances="never", populate_by_name=True, strict=False, ) @model_validator(mode="before") @classmethod def _pre_validate(cls, data: Any) -> Any: """Pre-validation: log deprecation warnings, resolve TypeSelection/nested fields.""" if not isinstance(data, dict): return data # Log deprecation warnings for unknown fields valid_fields = set(cls.model_fields.keys()) for key in list(data.keys()): if key not in valid_fields and key != "type": warnings.warn( f'"{key}" is not a valid parameter for the "{cls.__name__}" schema, will be flagged ' "as an error in a future version", DeprecationWarning, ) # Resolve TypeSelection, DictMarshmallowField, and legacy marshmallow fields for fname, finfo in cls.model_fields.items(): if fname not in data: continue value = data[fname] # Get markers from both metadata and json_schema_extra markers = list(finfo.metadata or []) jse = finfo.json_schema_extra if isinstance(jse, dict) and "metadata" in jse: markers.extend(jse["metadata"]) for meta in markers: if isinstance(meta, _TypeSelectionMarker): data[fname] = meta.type_selection.resolve(value) break elif isinstance(meta, _NestedConfigMarker): if isinstance(value, BaseMarshmallowConfig): break # Already a config instance, skip re-validation if isinstance(value, dict): try: data[fname] = meta.cls.model_validate(value) except Exception as e: raise ConfigValidationError( f"Invalid params: {value}, see `{meta.cls}` definition. Error: {e}" ) break elif isinstance(meta, _MarshmallowFieldMarker): # Legacy marshmallow field - use its _deserialize for validation # Skip if value is already a config instance (avoid double-validation) if isinstance(value, BaseMarshmallowConfig): break mfield = meta.marshmallow_field if hasattr(mfield, "_deserialize") and value is not None: try: data[fname] = mfield._deserialize(value, fname, data) except Exception as e: # Re-raise ConfigValidationError (from __post_init__) and # from _deserialize rather than swallowing them if isinstance(e, ConfigValidationError): raise pass # Let pydantic handle other validation errors break return data @model_validator(mode="after") def _validate_field_constraints(self): """Post-validation: enforce enum constraints stored in json_schema_extra.""" for fname, finfo in type(self).model_fields.items(): value = getattr(self, fname, None) extra = finfo.json_schema_extra if not isinstance(extra, dict): continue # Validate enum constraints (from StringOptions, IntegerOptions) if "enum" in extra and value is not None: allowed = extra["enum"] if value not in allowed: raise ValueError(f"Field '{fname}': value {value!r} not in allowed options {allowed}") # Validate float tuple range constraints if "_float_tuple_range" in extra and value is not None: spec = extra["_float_tuple_range"] if not isinstance(value, (tuple, list)) or len(value) != spec["n"]: raise ValueError(f"Field '{fname}': expected {spec['n']}-tuple, got {value!r}") for v in value: if spec.get("min") is not None and v < spec["min"]: raise ValueError(f"Field '{fname}': value {v} below minimum {spec['min']}") if spec.get("max") is not None and v > spec["max"]: raise ValueError(f"Field '{fname}': value {v} above maximum {spec['max']}") # Validate embed field (int or str from options) if "_embed_options" in extra and value is not None: embed_options = extra["_embed_options"] if isinstance(value, str) and value not in embed_options: raise ValueError(f"Field '{fname}': string value {value!r} not in {embed_options}") if not isinstance(value, (str, int)): raise ValueError(f"Field '{fname}': expected str, int, or None, got {type(value).__name__}") # Validate initializer_or_dict field if "_initializer_options" in extra and value is not None: init_options = extra["_initializer_options"] if isinstance(value, str) and value not in init_options: raise ValueError(f"Field '{fname}': initializer {value!r} not in {init_options}") if isinstance(value, dict): if "type" not in value: raise ValueError(f"Field '{fname}': dict must contain 'type' key") if value["type"] not in init_options: raise ValueError(f"Field '{fname}': initializer type {value['type']!r} not in {init_options}") if not isinstance(value, (str, dict)): raise ValueError(f"Field '{fname}': expected str or dict, got {type(value).__name__}") return self def __setattr__(self, name: str, value: Any) -> None: """Allow setting arbitrary attributes on config instances. Ludwig code dynamically sets attributes like saved_weights_in_checkpoint, proc_column, etc. on config objects. Pydantic 2 normally rejects setting attributes not defined as fields, so we override to allow it. """ try: super().__setattr__(name, value) except ValueError: # Attribute not in model fields - allow it anyway (dataclass behavior) object.__setattr__(self, name, value) def model_post_init(self, __context: Any) -> None: """Bridge: call __post_init__ if defined by subclass (dataclass convention).""" super().model_post_init(__context) # Check if THIS class (or a parent) defines __post_init__ post_init = getattr(type(self), "__post_init__", None) if post_init is not None: post_init(self) def to_dict(self) -> dict[str, Any]: """Get a dictionary representation of this config. Recursively converts nested config objects and scrubs credentials. """ return scrub_creds(convert_submodules(vars(self))) @classmethod def from_dict(cls, d: dict[str, Any]) -> "BaseMarshmallowConfig": """Create a config instance from a dictionary.""" return cls.model_validate(d) @classmethod @lru_cache(maxsize=None) def get_valid_field_names(cls) -> set[str]: """Return the set of valid field names for this config class.""" return set(cls.model_fields.keys()) @classmethod @lru_cache(maxsize=None) def get_class_schema(cls): """Return a schema adapter for backward compatibility. Returns an object with .load() and .fields methods. """ return _SchemaAdapter(cls) @classmethod def Schema(cls): """Backward compatibility: return a schema adapter with .load(), .dump(), .fields.""" return _SchemaAdapter(cls) def __repr__(self): return yaml.dump(self.to_dict(), sort_keys=False) @DeveloperAPI def get_marshmallow_field_class_name(field_info): """Returns a human-readable string of the field class name. For backward compat, checks both pydantic metadata and marshmallow_field. """ # Check for marshmallow_field in metadata (legacy) if hasattr(field_info, "metadata"): for meta in field_info.metadata or []: if hasattr(meta, "__class__"): return meta.__class__.__name__ # For pydantic FieldInfo, return the annotation name if hasattr(field_info, "annotation"): return str(field_info.annotation) return "Unknown" @DeveloperAPI def load_config(cls: type["BaseMarshmallowConfig"], **kwargs) -> "BaseMarshmallowConfig": """Takes a config class and instantiates it with the given keyword args as parameters.""" assert_is_a_marshmallow_class(cls) return cls.model_validate(kwargs) @DeveloperAPI def load_trainer_with_kwargs(model_type: str, kwargs: dict) -> tuple["BaseMarshmallowConfig", dict[str, Any]]: """Special case of `load_config_with_kwargs` for the trainer schemas.""" from ludwig.constants import MODEL_LLM from ludwig.schema.trainer import ECDTrainerConfig, LLMTrainerConfig if model_type == MODEL_LLM: trainer_schema = LLMTrainerConfig else: trainer_schema = ECDTrainerConfig return load_config_with_kwargs(trainer_schema, kwargs) @DeveloperAPI def load_config_with_kwargs( cls: type["BaseMarshmallowConfig"], kwargs_overrides ) -> tuple["BaseMarshmallowConfig", dict[str, Any]]: """Instantiates a config class filtering kwargs to only valid fields. Returns a tuple of (config, remaining_kwargs). """ assert_is_a_marshmallow_class(cls) fields = cls.model_fields.keys() return load_config(cls, **{k: v for k, v in kwargs_overrides.items() if k in fields}), { k: v for k, v in kwargs_overrides.items() if k not in fields } @DeveloperAPI def convert_submodules(config_dict: dict) -> dict[str, Any]: """Helper for converting submodules to dictionaries during config serialization.""" output_dict = copy.deepcopy(config_dict) for k, v in output_dict.items(): if isinstance(v, dict): convert_submodules(v) elif isinstance(v, BaseMarshmallowConfig): output_dict[k] = v.to_dict() convert_submodules(output_dict[k]) elif isinstance(v, list): output_dict[k] = [x.to_dict() if isinstance(x, BaseMarshmallowConfig) else x for x in v] elif isinstance(v, ListSerializable): output_dict[k] = v.to_list() return output_dict @DeveloperAPI def create_cond(if_pred: dict, then_pred: dict): """Returns a JSONSchema conditional for the given if-then predicates.""" return { "if": {"properties": {k: {"const": v} for k, v in if_pred.items()}}, "then": {"properties": then_pred}, } @DeveloperAPI def remove_duplicate_fields(properties: dict, fields: list[str] | None = None) -> None: """Util function for removing duplicated schema elements.""" duplicate_fields = [NAME, TYPE, COLUMN, PROC_COLUMN, ACTIVE] if fields is None else fields for key in duplicate_fields: if key in properties: del properties[key] @DeveloperAPI class ListSerializable(ABC): @abstractmethod def to_list(self) -> list: pass @DeveloperAPI def assert_is_a_marshmallow_class(cls): """Assert that cls is a Ludwig config class (pydantic BaseModel).""" assert issubclass( cls, BaseMarshmallowConfig ), f"Expected a Ludwig config class (BaseMarshmallowConfig subclass), but `{cls}` is not." def _default_matches_json_type(default_val, type_str) -> bool: """Check if a default value is consistent with a JSON schema type string. Returns True if the default value matches the type string, False otherwise. This is used to avoid emitting 'type': 'integer' when the default is 7.5 (float), which was a common pattern in the marshmallow era where type enforcement was looser. """ if isinstance(type_str, list): # Union type like ["integer", "null"] return any(_default_matches_json_type(default_val, t) for t in type_str) _CHECKS = { "string": lambda v: isinstance(v, str), "integer": lambda v: isinstance(v, int) and not isinstance(v, bool), "number": lambda v: isinstance(v, (int, float)) and not isinstance(v, bool), "boolean": lambda v: isinstance(v, bool), "object": lambda v: isinstance(v, dict), "array": lambda v: isinstance(v, (list, tuple)), "null": lambda v: v is None, } check = _CHECKS.get(type_str) if check is None: return True # Unknown type, don't block return check(default_val) def _field_info_to_jsonschema(fname: str, finfo: FieldInfo, annotation: type | None = None) -> dict: """Convert a pydantic FieldInfo to a JSON schema fragment. Checks metadata markers for TypeSelection/DictMarshmallowField/legacy marshmallow fields, and falls back to type- based mapping for plain fields. """ # Check for markers in both metadata and json_schema_extra markers = list(finfo.metadata or []) jse = finfo.json_schema_extra if isinstance(jse, dict) and "metadata" in jse: markers.extend(jse["metadata"]) for meta in markers: if isinstance(meta, _TypeSelectionMarker): ts = meta.type_selection custom = ts._jsonschema_type_mapping() if custom is not None: return custom return {"type": "object"} if isinstance(meta, _NestedConfigMarker): return unload_jsonschema_from_marshmallow_class(meta.cls) if isinstance(meta, _MarshmallowFieldMarker): mf = meta.marshmallow_field if hasattr(mf, "_jsonschema_type_mapping"): custom = mf._jsonschema_type_mapping() if custom is not None: return custom # Handle FeatureList-style fields with inner and length constraints if hasattr(mf, "inner") and mf.inner is not None: inner_schema = {} if hasattr(mf.inner, "_jsonschema_type_mapping"): inner_schema = mf.inner._jsonschema_type_mapping() or {} result = {"type": "array", "items": inner_schema} if hasattr(mf, "min_length") and mf.min_length is not None: result["minItems"] = mf.min_length if hasattr(mf, "max_length") and mf.max_length is not None: result["maxItems"] = mf.max_length return result return {"type": "object"} # Handle InitializerOrDict fields from pydantic_core import PydanticUndefined extra = finfo.json_schema_extra if isinstance(extra, dict) and "_initializer_options" in extra: init_options = extra["_initializer_options"] return { "oneOf": [ {"type": "string", "enum": init_options}, { "type": "object", "properties": {"type": {"type": "string", "enum": init_options}}, "required": ["type"], "additionalProperties": True, }, {"type": "null"}, ], "default": finfo.default if finfo.default is not PydanticUndefined else "xavier_uniform", "description": finfo.description or "", } # Build schema from field info schema: dict[str, Any] = {} # Description desc = finfo.description or "" if desc: schema["description"] = desc # Default value from pydantic_core import PydanticUndefined if finfo.default is not PydanticUndefined: if not callable(finfo.default) and not isinstance(finfo.default, property): schema["default"] = finfo.default # Enum constraint from json_schema_extra extra = finfo.json_schema_extra if isinstance(extra, dict): if "enum" in extra: schema["enum"] = extra["enum"] if "parameter_metadata" in extra: schema["parameter_metadata"] = copy.deepcopy(extra["parameter_metadata"]) # Always include parameter_metadata (default if not explicitly provided) if "parameter_metadata" not in schema: schema["parameter_metadata"] = convert_metadata_to_json(None) # Map type annotation to JSON schema type # Only emit type if annotation and default are consistent (avoid mismatches # like annotation=int but default=7.5 which was common in marshmallow era) if annotation is not None: type_str = _annotation_to_json_type(annotation) if type_str: # If the enum contains None, the JSON schema type must include "null" enum_vals = schema.get("enum") if enum_vals is not None and None in enum_vals: if isinstance(type_str, list): if "null" not in type_str: type_str = type_str + ["null"] elif type_str != "null": type_str = [type_str, "null"] # Check for mismatch between annotation type and default value from pydantic_core import PydanticUndefined default_val = finfo.default if finfo.default is not PydanticUndefined else None if default_val is not None and not _default_matches_json_type(default_val, type_str): pass # Skip emitting type to avoid JSON schema validation failures else: schema["type"] = type_str # Range constraints and pattern from pydantic Field metadata from annotated_types import Ge, Gt, Le, Lt for meta in finfo.metadata or []: if isinstance(meta, Ge): schema["minimum"] = meta.ge elif isinstance(meta, Gt): schema["exclusiveMinimum"] = meta.gt elif isinstance(meta, Le): schema["maximum"] = meta.le elif isinstance(meta, Lt): schema["exclusiveMaximum"] = meta.lt elif hasattr(meta, "pattern") and getattr(meta, "pattern", None) is not None: schema["pattern"] = meta.pattern return schema def _annotation_to_json_type(annotation) -> str | list | None: """Map a Python type annotation to a JSON schema type string.""" import types origin = getattr(annotation, "__origin__", None) # Handle Python 3.10+ union types (e.g. float | None) which are instances of # types.UnionType directly, without __origin__ if isinstance(annotation, types.UnionType): args = annotation.__args__ has_none = type(None) in args non_none = [a for a in args if a is not type(None)] if len(non_none) == 1: base = _annotation_to_json_type(non_none[0]) if has_none and base: return [base, "null"] return base return None # Also handle typing.Union try: import typing if origin is typing.Union: args = annotation.__args__ has_none = type(None) in args non_none = [a for a in args if a is not type(None)] if len(non_none) == 1: base = _annotation_to_json_type(non_none[0]) if has_none and base: return [base, "null"] return base return None except (AttributeError, TypeError): pass _TYPE_MAP = { str: "string", int: "integer", float: "number", bool: "boolean", dict: "object", list: "array", tuple: "array", } if annotation in _TYPE_MAP: return _TYPE_MAP[annotation] return None @DeveloperAPI def unload_jsonschema_from_marshmallow_class(mclass, additional_properties: bool = True, title: str = None) -> dict: """Get a JSON schema dict for a Ludwig config class. Iterates over pydantic model_fields and checks metadata markers for TypeSelection, DictMarshmallowField, and legacy marshmallow fields. """ assert_is_a_marshmallow_class(mclass) properties = {} annotations = {} # Gather annotations from the class and its MRO for klass in reversed(mclass.__mro__): annotations.update(getattr(klass, "__annotations__", {})) for fname, finfo in mclass.model_fields.items(): ann = annotations.get(fname) properties[fname] = _field_info_to_jsonschema(fname, finfo, ann) schema = { "type": "object", "properties": properties, "additionalProperties": additional_properties, } if title is not None: schema["title"] = title return schema # ============================================================================ # Field Factory Functions # ============================================================================ # All return pydantic Field() objects (FieldInfo) that can be used as class # variable defaults in BaseMarshmallowConfig subclasses. # ============================================================================ def _make_json_schema_extra( description: str = "", parameter_metadata: ParameterMetadata = None, **extra, ) -> dict | None: """Build json_schema_extra dict for Field(), returning None if empty.""" result = {} if parameter_metadata: result["parameter_metadata"] = convert_metadata_to_json(parameter_metadata) result.update(extra) return result or None @DeveloperAPI def InitializerOptions(default: str = "xavier_uniform", description="", parameter_metadata: ParameterMetadata = None): """Utility wrapper that returns a `StringOptions` field with keys from `initializer_registry`.""" return StringOptions( list(initializer_registry.keys()), default=default, allow_none=False, description=description, parameter_metadata=parameter_metadata, ) @DeveloperAPI def ActivationOptions(default: str | None = "relu", description=None, parameter_metadata: ParameterMetadata = None): """Utility wrapper that returns a `StringOptions` field with keys from `activations` registry.""" description = description or "Default activation function applied to the output of the fully connected layers." parameter_metadata = parameter_metadata or COMMON_METADATA["activation"] return StringOptions( list(activations.keys()), default=default, allow_none=True, description=description, parameter_metadata=parameter_metadata, ) @DeveloperAPI def ReductionOptions(default: None | str = None, description="", parameter_metadata: ParameterMetadata = None): """Utility wrapper that returns a `StringOptions` field with keys from `reduce_mode_registry`.""" return StringOptions( list(reduce_mode_registry.keys()), default=default, allow_none=True, description=description, parameter_metadata=parameter_metadata, ) @DeveloperAPI def RegularizerOptions( default: None | str, allow_none: bool = False, description="", parameter_metadata: ParameterMetadata = None, ): """Utility wrapper that returns a `StringOptions` field with prefilled regularizer options.""" return StringOptions( ["l1", "l2", "l1_l2"], default=default, allow_none=allow_none, description=description, parameter_metadata=parameter_metadata, ) @DeveloperAPI def String( description: str, default: None | str, allow_none: bool = False, pattern: str = None, parameter_metadata: ParameterMetadata = None, ): """Returns a pydantic Field for string values.""" if not allow_none and default is not None and not isinstance(default, str): raise ValueError(f"Provided default `{default}` should be a string!") extra_kwargs = {} if allow_none: extra_kwargs["allow_none"] = True json_extra = _make_json_schema_extra(description=description, parameter_metadata=parameter_metadata, **extra_kwargs) kwargs = {} if pattern is not None: kwargs["pattern"] = pattern return Field( default=default, description=description, json_schema_extra=json_extra, **kwargs, ) @DeveloperAPI def StringOptions( options: list[str], default: None | str, allow_none: bool = False, description: str = "", parameter_metadata: ParameterMetadata = None, ): """Returns a pydantic Field that enforces string inputs must be one of `options`.""" options = list(options) # ensure list, not dict_keys or other iterable assert len(options) > 0, "Must provide non-empty list of options!" if default is not None: assert isinstance(default, str), f"Provided default `{default}` should be a string!" if allow_none and None not in options: options = options + [None] if not allow_none and None in options: options = [o for o in options if o is not None] assert len(options) == len( {o for o in options if o is not None} | ({None} if None in options else set()) ), f"Provided options must be unique! See: {options}" assert default in options, f"Provided default `{default}` is not one of allowed options: {options}" json_extra = _make_json_schema_extra( description=description, parameter_metadata=parameter_metadata, enum=options, ) return Field(default=default, description=description, json_schema_extra=json_extra) @DeveloperAPI def ProtectedString( pstring: str, description: str = "", parameter_metadata: ParameterMetadata = None, ): """Alias for a `StringOptions` field with only one option.""" return StringOptions( options=[pstring], default=pstring, allow_none=False, description=description, parameter_metadata=parameter_metadata, ) @DeveloperAPI def IntegerOptions( options: list[int], default: None | int, allow_none: bool = False, description: str = "", parameter_metadata: ParameterMetadata = None, ): """Returns a pydantic Field that enforces integer inputs must be one of `options`.""" if len(options) <= 0: raise ValueError("Must provide non-empty list of options!") if default is not None and not isinstance(default, int): raise ValueError(f"Provided default `{default}` should be an int!") if allow_none and None not in options: options = list(options) + [None] if not allow_none and None in options: options = [o for o in options if o is not None] if default not in options: raise ValueError(f"Provided default `{default}` is not one of allowed options: {options}") json_extra = _make_json_schema_extra( description=description, parameter_metadata=parameter_metadata, enum=options, ) return Field(default=default, description=description, json_schema_extra=json_extra) @DeveloperAPI def Boolean(default: bool, description: str = "", parameter_metadata: ParameterMetadata = None): """Returns a pydantic Field for boolean values.""" if default is not None and not isinstance(default, bool): raise ValueError(f"Invalid default: `{default}`") json_extra = _make_json_schema_extra(description=description, parameter_metadata=parameter_metadata) return Field(default=default, description=description, json_schema_extra=json_extra) @DeveloperAPI def Integer( default: None | int, allow_none=False, description="", parameter_metadata: ParameterMetadata = None, ): """Returns a pydantic Field strictly enforcing integer inputs.""" if default is not None and not isinstance(default, int): raise ValueError(f"Invalid default: `{default}`") extra_kwargs = {} if allow_none: extra_kwargs["allow_none"] = True json_extra = _make_json_schema_extra(description=description, parameter_metadata=parameter_metadata, **extra_kwargs) return Field(default=default, description=description, json_schema_extra=json_extra) @DeveloperAPI def PositiveInteger( description: str, default: None | int, allow_none: bool = False, parameter_metadata: ParameterMetadata = None, ): """Returns a pydantic Field enforcing positive integer inputs (>= 1).""" if default is not None: if not isinstance(default, int) or default < 1: raise ValueError(f"Invalid default: `{default}`") extra_kwargs = {} if allow_none: extra_kwargs["allow_none"] = True json_extra = _make_json_schema_extra(description=description, parameter_metadata=parameter_metadata, **extra_kwargs) return Field(default=default, ge=1, description=description, json_schema_extra=json_extra) @DeveloperAPI def NonNegativeInteger( description: str, default: None | int, allow_none: bool = False, parameter_metadata: ParameterMetadata = None, ): """Returns a pydantic Field enforcing nonnegative integer inputs (>= 0).""" if default is not None: if not isinstance(default, int) or default < 0: raise ValueError(f"Invalid default: `{default}`") extra_kwargs = {} if allow_none: extra_kwargs["allow_none"] = True json_extra = _make_json_schema_extra(description=description, parameter_metadata=parameter_metadata, **extra_kwargs) return Field(default=default, ge=0, description=description, json_schema_extra=json_extra) @DeveloperAPI def IntegerRange( description: str, default: None | int, allow_none: bool = False, parameter_metadata: ParameterMetadata = None, min: int = None, max: int = None, min_inclusive: bool = True, max_inclusive: bool = True, ): """Returns a pydantic Field enforcing integer inputs within a range.""" if default is not None: if not isinstance(default, int): raise ValueError(f"Invalid default: `{default}`") if min is not None and ((min_inclusive and default < min) or (not min_inclusive and default <= min)): raise ValueError(f"Invalid default: `{default}` (below min {min})") if max is not None and ((max_inclusive and default > max) or (not max_inclusive and default >= max)): raise ValueError(f"Invalid default: `{default}` (above max {max})") kwargs = {} if min is not None: kwargs["ge" if min_inclusive else "gt"] = min if max is not None: kwargs["le" if max_inclusive else "lt"] = max extra_kwargs = {} if allow_none: extra_kwargs["allow_none"] = True json_extra = _make_json_schema_extra(description=description, parameter_metadata=parameter_metadata, **extra_kwargs) return Field(default=default, description=description, json_schema_extra=json_extra, **kwargs) @DeveloperAPI def Float( default: None | float | int, allow_none=False, description="", parameter_metadata: ParameterMetadata = None, ): """Returns a pydantic Field for float inputs.""" if default is not None and not isinstance(default, (float, int)): raise ValueError(f"Invalid default: `{default}`") extra_kwargs = {} if allow_none: extra_kwargs["allow_none"] = True json_extra = _make_json_schema_extra(description=description, parameter_metadata=parameter_metadata, **extra_kwargs) return Field(default=default, description=description, json_schema_extra=json_extra) @DeveloperAPI def NonNegativeFloat( default: None | float, allow_none: bool = False, description: str = "", max: float | None = None, parameter_metadata: ParameterMetadata = None, ): """Returns a pydantic Field enforcing nonnegative float inputs.""" if default is not None: if not isinstance(default, (float, int)) or default < 0: raise ValueError(f"Invalid default: `{default}`") if max is not None and default > max: raise ValueError(f"Invalid default: `{default}` (above max {max})") kwargs = {"ge": 0.0} if max is not None: kwargs["le"] = max extra_kwargs = {} if allow_none: extra_kwargs["allow_none"] = True json_extra = _make_json_schema_extra(description=description, parameter_metadata=parameter_metadata, **extra_kwargs) return Field(default=default, description=description, json_schema_extra=json_extra, **kwargs) @DeveloperAPI def FloatRange( default: None | float, allow_none: bool = False, description: str = "", parameter_metadata: ParameterMetadata = None, min: int = None, max: int = None, min_inclusive: bool = True, max_inclusive: bool = True, ): """Returns a pydantic Field enforcing float inputs within a range.""" if default is not None: if not isinstance(default, (float, int)): raise ValueError(f"Invalid default: `{default}`") kwargs = {} if min is not None: kwargs["ge" if min_inclusive else "gt"] = min if max is not None: kwargs["le" if max_inclusive else "lt"] = max extra_kwargs = {} if allow_none: extra_kwargs["allow_none"] = True json_extra = _make_json_schema_extra(description=description, parameter_metadata=parameter_metadata, **extra_kwargs) return Field(default=default, description=description, json_schema_extra=json_extra, **kwargs) @DeveloperAPI def Dict( default: None | dict = None, allow_none: bool = True, description: str = "", parameter_metadata: ParameterMetadata = None, ): """Returns a pydantic Field for dict values.""" allow_none = allow_none or default is None if default is not None: if not isinstance(default, dict): raise ValueError(f"Invalid default: `{default}`") if not all(isinstance(k, str) for k in default.keys()): raise ValueError(f"Invalid default: `{default}` (non-string keys)") elif not allow_none: default = {} json_extra = _make_json_schema_extra(description=description, parameter_metadata=parameter_metadata) if default is None: return Field(default=None, description=description, json_schema_extra=json_extra) return Field(default_factory=lambda: copy.deepcopy(default), description=description, json_schema_extra=json_extra) @DeveloperAPI def List( list_type: type[str] | type[int] | type[float] | type[list] = str, inner_type: type[str] | type[int] | type[float] | type[dict] = float, default: None | list[Any] = None, allow_none: bool = True, description: str = "", parameter_metadata: ParameterMetadata = None, ): """Returns a pydantic Field for list values.""" if default is not None: if not isinstance(default, list): raise ValueError(f"Invalid default: `{default}`") elif not allow_none: default = [] json_extra = _make_json_schema_extra(description=description, parameter_metadata=parameter_metadata) if default is None: return Field(default=None, description=description, json_schema_extra=json_extra) return Field(default_factory=lambda: copy.deepcopy(default), description=description, json_schema_extra=json_extra) @DeveloperAPI def DictList( default: None | list[dict] = None, allow_none: bool = True, description: str = "", parameter_metadata: ParameterMetadata = None, ): """Returns a pydantic Field for list-of-dicts values.""" if default is not None: if not isinstance(default, list) or not all(isinstance(d, dict) for d in default): raise ValueError(f"Invalid default: `{default}`") elif not allow_none: default = [] json_extra = _make_json_schema_extra(description=description, parameter_metadata=parameter_metadata) if default is None: return Field(default=None, description=description, json_schema_extra=json_extra) return Field(default_factory=lambda: copy.deepcopy(default), description=description, json_schema_extra=json_extra) @DeveloperAPI def Embed(description: str = "", parameter_metadata: ParameterMetadata = None): """Returns a pydantic Field for embedding input feature names (int, str, or None).""" _embed_options = ["add"] json_extra = _make_json_schema_extra( description=description, parameter_metadata=parameter_metadata, _embed_options=_embed_options, ) return Field(default=None, description=description, json_schema_extra=json_extra) @DeveloperAPI def InitializerOrDict( default: str = "xavier_uniform", description: str = "", parameter_metadata: ParameterMetadata = None ): """Returns a pydantic Field allowing str or dict initializer values.""" initializers = list(initializer_registry.keys()) if not isinstance(default, str) or default not in initializers: raise ValueError(f"Invalid default: `{default}`") json_extra = _make_json_schema_extra( description=description, parameter_metadata=parameter_metadata, _initializer_options=initializers, ) return Field(default=default, description=description, json_schema_extra=json_extra) @DeveloperAPI def FloatRangeTupleDataclassField( n: int = 2, default: tuple | None = (0.9, 0.999), allow_none: bool = False, min: int | None = 0, max: int | None = 1, description: str = "", parameter_metadata: ParameterMetadata = None, ): """Returns a pydantic Field for an N-dim tuple with values in a range.""" if default is not None: if n != len(default): raise ValueError(f"Dimension of tuple '{n}' must match dimension of default val. '{default}'") for v in default: if min is not None and v < min: raise ValueError(f"Invalid default: value {v} below minimum {min}") if max is not None and v > max: raise ValueError(f"Invalid default: value {v} above maximum {max}") if default is None and not allow_none: raise ValueError("Default value must not be None if allow_none is False") extra_kwargs = {} if allow_none: extra_kwargs["allow_none"] = True json_extra = _make_json_schema_extra( description=description, parameter_metadata=parameter_metadata, _float_tuple_range={"n": n, "min": min, "max": max}, **extra_kwargs, ) return Field(default=default, description=description, json_schema_extra=json_extra) @DeveloperAPI def OneOfOptionsField( default: Any, description: str, field_options: list, allow_none: bool = False, parameter_metadata: ParameterMetadata = None, ): """Returns a pydantic Field that accepts values matching any of the field_options. Pydantic union validation handles the multi-type dispatch. The field_options are stored in json_schema_extra for JSON schema generation. """ extra_kwargs = {} if allow_none: extra_kwargs["allow_none"] = True json_extra = _make_json_schema_extra( description=description, parameter_metadata=parameter_metadata, _oneof_options=True, **extra_kwargs, ) if default is None or isinstance(default, (int, str, bool)): return Field(default=default, description=description, json_schema_extra=json_extra) return Field(default_factory=lambda: copy.deepcopy(default), description=description, json_schema_extra=json_extra) # ============================================================================ # TypeSelection - Polymorphic config dispatch based on registry # ============================================================================ class TypeSelection(LudwigSchemaField): """Resolves polymorphic config types from a registry based on a key field. Used for fields like encoder, decoder, optimizer where the config class depends on a "type" key in the dict value. """ def __init__( self, registry: Registry, default_value: str | None = None, key: str = "type", description: str = "", parameter_metadata: ParameterMetadata = None, allow_str_value: bool = False, allow_none: bool = False, **kwargs, ): self.registry = registry self.default_value = default_value self.key = key self.allow_str_value = allow_str_value self.allow_none = allow_none self.description = description self.parameter_metadata = parameter_metadata def _deserialize(self, value, attr, data, **kwargs): """Marshmallow deserialization - delegates to resolve().""" return self.resolve(value) def resolve(self, value): """Resolve a raw value (dict, str, None) to a config instance.""" if value is None: if self.allow_none: return None return None # Already a config instance if isinstance(value, BaseMarshmallowConfig): return value if self.allow_str_value and isinstance(value, str): value = self.str_value_to_object(value) if isinstance(value, dict): cls_type = value.get(self.key) cls_type = cls_type.lower() if cls_type else self.default_value if cls_type and cls_type in self.registry: cls = self.get_schema_from_registry(cls_type) try: return cls.model_validate(value) except (TypeError, PydanticValidationError) as e: raise ConfigValidationError(f"Invalid params: {value}, see `{cls}` definition") from e raise ConfigValidationError(f"Invalid type: '{cls_type}', expected one of: {list(self.registry.keys())}") maybe_str = ", `str`," if self.allow_str_value else "" raise ConfigValidationError(f"Invalid param {value}, expected `None`{maybe_str} or `dict`") def str_value_to_object(self, value: str) -> dict: """Convert a string shorthand to a dict with the type key.""" return {self.key: value} def get_schema_from_registry(self, key: str) -> type[BaseMarshmallowConfig]: """Look up a config class from the registry.""" return self.registry[key] def get_default_field(self) -> FieldInfo: """Create a pydantic Field wrapping this TypeSelection. The TypeSelection instance is stored in Field.metadata so the base class's model_validator can use it for dispatch. """ if self.default_value is not None: cls = self.get_schema_from_registry(self.default_value.lower()) key = self.key dv = self.default_value def default_factory(cls=cls, key=key, dv=dv): return cls.model_validate({key: dv}) else: def default_factory(): return None fi = Field(default_factory=default_factory) fi.metadata = [_TypeSelectionMarker(self)] return fi def _jsonschema_type_mapping(self): """Override in subclass for custom JSON schema.""" return None @DeveloperAPI class DictMarshmallowField(LudwigSchemaField): """Validates a dict as a specific config class (non-polymorphic). Used for fields where a dict should be deserialized into a fixed config class. """ def __init__( self, cls: type[BaseMarshmallowConfig], allow_none: bool = True, default_missing: bool = False, description: str = "", **kwargs, ): self.cls = cls self.allow_none = allow_none self.default_missing = default_missing self.description = description def _deserialize(self, value, attr, data, **kwargs): """Deserialize a dict to a config instance via pydantic model_validate.""" if value is None: return value if isinstance(value, dict): try: return self.cls.model_validate(value) except (TypeError, PydanticValidationError) as e: raise ConfigValidationError(f"Invalid params: {value}, see `{self.cls}` definition") from e raise ConfigValidationError("Field should be None or dict") def get_default_field(self) -> FieldInfo: """Create a pydantic Field wrapping this DictMarshmallowField.""" if not self.default_missing: cls = self.cls def default_factory(cls=cls): return cls.model_validate({}) else: def default_factory(): return None # Check if subclass overrides _jsonschema_type_mapping - if so, use # MarshmallowFieldMarker to preserve custom JSON schema generation has_custom_schema = type(self)._jsonschema_type_mapping is not DictMarshmallowField._jsonschema_type_mapping if has_custom_schema: marker = _MarshmallowFieldMarker(self) else: marker = _NestedConfigMarker(self.cls, self.allow_none) fi = Field(default_factory=default_factory) fi.metadata = [marker] return fi def _jsonschema_type_mapping(self): return unload_jsonschema_from_marshmallow_class(self.cls) # Backward compatibility aliases ValidationError = ConfigValidationError NestedConfigField = DictMarshmallowField LudwigConfig = BaseMarshmallowConfig unload_jsonschema_from_config_class = unload_jsonschema_from_marshmallow_class ================================================ FILE: ludwig/serve.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import argparse import io import json import logging import os import sys import tempfile import pandas as pd import torch from torchvision.io import decode_image from ludwig.api import LudwigModel from ludwig.constants import AUDIO, COLUMN from ludwig.contrib import add_contrib_callback_args from ludwig.globals import LUDWIG_VERSION from ludwig.utils.print_utils import get_logging_level_registry, print_ludwig from ludwig.utils.server_utils import NumpyJSONResponse logger = logging.getLogger(__name__) try: import uvicorn from fastapi import FastAPI from starlette.datastructures import UploadFile from starlette.middleware import Middleware from starlette.middleware.cors import CORSMiddleware from starlette.requests import Request except ImportError as e: logger.error(e) logger.error( " fastapi and other serving dependencies cannot be loaded" "and may have not been installed. " "In order to install all serving dependencies run " "pip install ludwig[serve]" ) sys.exit(-1) ALL_FEATURES_PRESENT_ERROR = {"error": "entry must contain all input features"} COULD_NOT_RUN_INFERENCE_ERROR = {"error": "Unexpected Error: could not run inference on model"} def server(model, allowed_origins=None): middleware = [Middleware(CORSMiddleware, allow_origins=allowed_origins)] if allowed_origins else None app = FastAPI(middleware=middleware) config = model.config input_features = {f[COLUMN] for f in config["input_features"]} @app.get("/") def check_health(): return NumpyJSONResponse({"message": "Ludwig server is up"}) @app.post("/predict") async def predict(request: Request): try: form = await request.form() entry, files = convert_input(form, model.model.input_features) except Exception: logger.exception("Failed to parse predict form") return NumpyJSONResponse(COULD_NOT_RUN_INFERENCE_ERROR, status_code=500) try: if (entry.keys() & input_features) != input_features: missing_features = set(input_features) - set(entry.keys()) return NumpyJSONResponse( { "error": "Data received does not contain all input features. " f"Missing features: {missing_features}." }, status_code=400, ) try: resp, _ = model.predict(dataset=[entry], data_format=dict) resp = resp.to_dict("records")[0] return NumpyJSONResponse(resp) except Exception as exc: logger.exception(f"Failed to run predict: {exc}") return NumpyJSONResponse(COULD_NOT_RUN_INFERENCE_ERROR, status_code=500) finally: for f in files: os.remove(f.name) @app.post("/batch_predict") async def batch_predict(request: Request): try: form = await request.form() data, files = convert_batch_input(form, model.model.input_features) data_df = pd.DataFrame.from_records(data["data"], index=data.get("index"), columns=data["columns"]) except Exception: logger.exception("Failed to parse batch_predict form") return NumpyJSONResponse(COULD_NOT_RUN_INFERENCE_ERROR, status_code=500) if (set(data_df.columns) & input_features) != input_features: missing_features = set(input_features) - set(data_df.columns) return NumpyJSONResponse( { "error": "Data received does not contain all input features. " f"Missing features: {missing_features}." }, status_code=400, ) try: resp, _ = model.predict(dataset=data_df) resp = resp.to_dict("split") return NumpyJSONResponse(resp) except Exception: logger.exception("Failed to run batch_predict: {}") return NumpyJSONResponse(COULD_NOT_RUN_INFERENCE_ERROR, status_code=500) return app def _write_file(v, files): # Convert UploadFile to a NamedTemporaryFile to ensure it's on the disk suffix = os.path.splitext(v.filename)[1] named_file = tempfile.NamedTemporaryFile(delete=False, suffix=suffix) files.append(named_file) named_file.write(v.file.read()) named_file.close() return named_file.name def _read_image_buffer(v): # read bytes sent via REST API and convert to image tensor # in [channels, height, width] format byte_string = io.BytesIO(v.file.read()).read() image = decode_image(torch.frombuffer(byte_string, dtype=torch.uint8)) return image # channels, height, width def convert_input(form, input_features): """Returns a new input and a list of files to be cleaned up.""" new_input = {} files = [] for k, v in form.multi_items(): if isinstance(v, UploadFile): # check if audio or image file if input_features.get(k).type() == AUDIO: new_input[k] = _write_file(v, files) else: new_input[k] = _read_image_buffer(v) else: new_input[k] = v return new_input, files def convert_batch_input(form, input_features): """Returns a new input and a list of files to be cleaned up.""" file_index = {} files = [] for k, v in form.multi_items(): if isinstance(v, UploadFile): file_index[v.filename] = v data = json.loads(form["dataset"]) for row in data["data"]: for i, value in enumerate(row): if value in file_index: feature_name = data["columns"][i] if input_features.get(feature_name).type() == AUDIO: row[i] = _write_file(file_index[value], files) else: row[i] = _read_image_buffer(file_index[value]) return data, files def run_server( model_path: str, host: str, port: int, allowed_origins: list, ) -> None: """Loads a pre-trained model and serve it on an http server. # Inputs :param model_path: (str) filepath to pre-trained model. :param host: (str, default: `0.0.0.0`) host ip address for the server to use. :param port: (int, default: `8000`) port number for the server to use. :param allowed_origins: (list) list of origins allowed to make cross-origin requests. # Return :return: (`None`) """ # Use local backend for serving to use pandas DataFrames. model = LudwigModel.load(model_path, backend="local") app = server(model, allowed_origins) uvicorn.run(app, host=host, port=port) def cli(sys_argv): parser = argparse.ArgumentParser( description="This script serves a pretrained model", prog="ludwig serve", usage="%(prog)s [options]" ) # ---------------- # Model parameters # ---------------- parser.add_argument("-m", "--model_path", help="model to load", required=True) parser.add_argument( "-l", "--logging_level", default="info", help="the level of logging to use", choices=["critical", "error", "warning", "info", "debug", "notset"], ) # ---------------- # Server parameters # ---------------- parser.add_argument( "-p", "--port", help="port for server (default: 8000)", default=8000, type=int, ) parser.add_argument("-H", "--host", help="host for server (default: 0.0.0.0)", default="0.0.0.0") parser.add_argument( "-ao", "--allowed_origins", nargs="*", help="A list of origins that should be permitted to make cross-origin requests. " 'Use "*" to allow any origin. See https://www.starlette.io/middleware/#corsmiddleware.', ) add_contrib_callback_args(parser) args = parser.parse_args(sys_argv) args.callbacks = args.callbacks or [] for callback in args.callbacks: callback.on_cmdline("serve", *sys_argv) args.logging_level = get_logging_level_registry()[args.logging_level] logging.getLogger("ludwig").setLevel(args.logging_level) global logger logger = logging.getLogger("ludwig.serve") print_ludwig("Serve", LUDWIG_VERSION) run_server(args.model_path, args.host, args.port, args.allowed_origins) if __name__ == "__main__": cli(sys.argv[1:]) ================================================ FILE: ludwig/train.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import argparse import logging import sys import pandas as pd from ludwig.api import LudwigModel from ludwig.backend import ALL_BACKENDS, Backend, initialize_backend from ludwig.callbacks import Callback from ludwig.constants import CONTINUE_PROMPT, HYPEROPT, HYPEROPT_WARNING from ludwig.contrib import add_contrib_callback_args from ludwig.globals import LUDWIG_VERSION from ludwig.utils.data_utils import load_config_from_str, load_yaml from ludwig.utils.defaults import default_random_seed from ludwig.utils.print_utils import get_logging_level_registry, print_ludwig, query_yes_no logger = logging.getLogger(__name__) def train_cli( config: str | dict = None, dataset: str | dict | pd.DataFrame = None, training_set: str | dict | pd.DataFrame = None, validation_set: str | dict | pd.DataFrame = None, test_set: str | dict | pd.DataFrame = None, training_set_metadata: str | dict = None, data_format: str = None, experiment_name: str = "api_experiment", model_name: str = "run", model_load_path: str = None, model_resume_path: str = None, skip_save_training_description: bool = False, skip_save_training_statistics: bool = False, skip_save_model: bool = False, skip_save_progress: bool = False, skip_save_log: bool = False, skip_save_processed_input: bool = False, output_directory: str = "results", gpus: str | int | list[int] = None, gpu_memory_limit: float | None = None, allow_parallel_threads: bool = True, callbacks: list[Callback] = None, backend: Backend | str = None, random_seed: int = default_random_seed, logging_level: int = logging.INFO, **kwargs ) -> None: """*train* defines the entire training procedure used by Ludwig's internals. Requires most of the parameters that are taken into the model. Builds a full ludwig model and performs the training. :param config: (Union[str, dict]) in-memory representation of config or string path to a YAML config file. :param dataset: (Union[str, dict, pandas.DataFrame], default: `None`) source containing the entire dataset to be used for training. If it has a split column, it will be used for splitting (0 for train, 1 for validation, 2 for test), otherwise the dataset will be randomly split. :param training_set: (Union[str, dict, pandas.DataFrame], default: `None`) source containing training data. :param validation_set: (Union[str, dict, pandas.DataFrame], default: `None`) source containing validation data. :param test_set: (Union[str, dict, pandas.DataFrame], default: `None`) source containing test data. :param training_set_metadata: (Union[str, dict], default: `None`) metadata JSON file or loaded metadata. Intermediate preprocessed structure containing the mappings of the input dataset created the first time an input file is used in the same directory with the same name and a '.meta.json' extension. :param data_format: (str, default: `None`) format to interpret data sources. Will be inferred automatically if not specified. Valid formats are `'auto'`, `'csv'`, `'excel'`, `'feather'`, `'fwf'`, `'hdf5'` (cache file produced during previous training), `'html'` (file containing a single HTML `
`), `'json'`, `'jsonl'`, `'parquet'`, `'pickle'` (pickled Pandas DataFrame), `'sas'`, `'spss'`, `'stata'`, `'tsv'`. :param experiment_name: (str, default: `'experiment'`) name for the experiment. :param model_name: (str, default: `'run'`) name of the model that is being used. :param model_load_path: (str, default: `None`) if this is specified the loaded model will be used as initialization (useful for transfer learning). :param model_resume_path: (str, default: `None`) resumes training of the model from the path specified. The config is restored. In addition to config, training statistics, loss for each epoch and the state of the optimizer are restored such that training can be effectively continued from a previously interrupted training process. :param skip_save_training_description: (bool, default: `False`) disables saving the description JSON file. :param skip_save_training_statistics: (bool, default: `False`) disables saving training statistics JSON file. :param skip_save_model: (bool, default: `False`) disables saving model weights and hyperparameters each time the model improves. By default Ludwig saves model weights after each epoch the validation metric improves, but if the model is really big that can be time consuming. If you do not want to keep the weights and just find out what performance a model can get with a set of hyperparameters, use this parameter to skip it, but the model will not be loadable later on and the returned model will have the weights obtained at the end of training, instead of the weights of the epoch with the best validation performance. :param skip_save_progress: (bool, default: `False`) disables saving progress each epoch. By default Ludwig saves weights and stats after each epoch for enabling resuming of training, but if the model is really big that can be time consuming and will uses twice as much space, use this parameter to skip it, but training cannot be resumed later on. :param skip_save_log: (bool, default: `False`) disables saving TensorBoard logs. By default Ludwig saves logs for the TensorBoard, but if it is not needed turning it off can slightly increase the overall speed. :param skip_save_processed_input: (bool, default: `False`) if input dataset is provided it is preprocessed and cached by saving an HDF5 and JSON files to avoid running the preprocessing again. If this parameter is `False`, the HDF5 and JSON file are not saved. :param output_directory: (str, default: `'results'`) the directory that will contain the training statistics, TensorBoard logs, the saved model and the training progress files. :param gpus: (list, default: `None`) list of GPUs that are available for training. :param gpu_memory_limit: (float: default: `None`) maximum memory fraction [0, 1] allowed to allocate per GPU device. :param allow_parallel_threads: (bool, default: `True`) allow PyTorch to use multithreading parallelism to improve performance at the cost of determinism. :param callbacks: (list, default: `None`) a list of `ludwig.callbacks.Callback` objects that provide hooks into the Ludwig pipeline. :param backend: (Union[Backend, str]) `Backend` or string name of backend to use to execute preprocessing / training steps. :param random_seed: (int: default: 42) random seed used for weights initialization, splits and any other random function. :param logging_level: (int) Log level that will be sent to stderr. # Return :return: (`None`) """ if HYPEROPT in config: if not query_yes_no(HYPEROPT_WARNING + CONTINUE_PROMPT): exit(1) # Stop gap: remove hyperopt from the config to prevent interference with training step sizes # TODO: https://github.com/ludwig-ai/ludwig/issues/2633 # Need to investigate why the presence of hyperopt in the config interferes with training step sizes config.pop(HYPEROPT) if model_load_path: model = LudwigModel.load( model_load_path, logging_level=logging_level, backend=backend, gpus=gpus, gpu_memory_limit=gpu_memory_limit, allow_parallel_threads=allow_parallel_threads, callbacks=callbacks, ) else: model = LudwigModel( config=config, logging_level=logging_level, backend=backend, gpus=gpus, gpu_memory_limit=gpu_memory_limit, allow_parallel_threads=allow_parallel_threads, callbacks=callbacks, ) model.train( dataset=dataset, training_set=training_set, validation_set=validation_set, test_set=test_set, training_set_metadata=training_set_metadata, data_format=data_format, experiment_name=experiment_name, model_name=model_name, model_resume_path=model_resume_path, skip_save_training_description=skip_save_training_description, skip_save_training_statistics=skip_save_training_statistics, skip_save_model=skip_save_model, skip_save_progress=skip_save_progress, skip_save_log=skip_save_log, skip_save_processed_input=skip_save_processed_input, output_directory=output_directory, random_seed=random_seed, ) def cli(sys_argv): parser = argparse.ArgumentParser( description="This script trains a model", prog="ludwig train", usage="%(prog)s [options]" ) # ---------------------------- # Experiment naming parameters # ---------------------------- parser.add_argument("--output_directory", type=str, default="results", help="directory that contains the results") parser.add_argument("--experiment_name", type=str, default="experiment", help="experiment name") parser.add_argument("--model_name", type=str, default="run", help="name for the model") # --------------- # Data parameters # --------------- parser.add_argument( "--dataset", help="input data file path. " "If it has a split column, it will be used for splitting " "(0: train, 1: validation, 2: test), " "otherwise the dataset will be randomly split", ) parser.add_argument("--training_set", help="input train data file path") parser.add_argument("--validation_set", help="input validation data file path") parser.add_argument("--test_set", help="input test data file path") parser.add_argument( "--training_set_metadata", help="input metadata JSON file path. An intermediate preprocessed file " "containing the mappings of the input file created " "the first time a file is used, in the same directory " "with the same name and a .json extension", ) parser.add_argument( "--data_format", help="format of the input data", default="auto", choices=[ "auto", "csv", "excel", "feather", "fwf", "hdf5", "html" "tables", "json", "jsonl", "parquet", "pickle", "sas", "spss", "stata", "tsv", ], ) parser.add_argument( "-sspi", "--skip_save_processed_input", help="skips saving intermediate HDF5 and JSON files", action="store_true", default=False, ) # ---------------- # Model parameters # ---------------- config = parser.add_mutually_exclusive_group(required=True) config.add_argument( "-c", "--config", type=load_yaml, help="Path to the YAML file containing the model configuration", ) config.add_argument( "-cs", "--config_str", dest="config", type=load_config_from_str, help="JSON or YAML serialized string of the model configuration", ) parser.add_argument("-mlp", "--model_load_path", help="path of a pretrained model to load as initialization") parser.add_argument("-mrp", "--model_resume_path", help="path of the model directory to resume training of") parser.add_argument( "-sstd", "--skip_save_training_description", action="store_true", default=False, help="disables saving the description JSON file", ) parser.add_argument( "-ssts", "--skip_save_training_statistics", action="store_true", default=False, help="disables saving training statistics JSON file", ) parser.add_argument( "-ssm", "--skip_save_model", action="store_true", default=False, help="disables saving weights each time the model improves. " "By default Ludwig saves weights after each epoch " "the validation metric (improves, but if the model is really big " "that can be time consuming. If you do not want to keep " "the weights and just find out what performance a model can get " "with a set of hyperparameters, use this parameter to skip it", ) parser.add_argument( "-ssp", "--skip_save_progress", action="store_true", default=False, help="disables saving weights after each epoch. By default ludwig saves " "weights after each epoch for enabling resuming of training, but " "if the model is really big that can be time consuming and will " "save twice as much space, use this parameter to skip it", ) parser.add_argument( "-ssl", "--skip_save_log", action="store_true", default=False, help="disables saving TensorBoard logs. By default Ludwig saves " "logs for the TensorBoard, but if it is not needed turning it off " "can slightly increase the overall speed", ) # ------------------ # Runtime parameters # ------------------ parser.add_argument( "-rs", "--random_seed", type=int, default=42, help="a random seed that is going to be used anywhere there is a call " "to a random number generator: data splitting, parameter " "initialization and training set shuffling", ) parser.add_argument("-g", "--gpus", nargs="+", type=int, default=None, help="list of gpus to use") parser.add_argument( "-gml", "--gpu_memory_limit", type=float, default=None, help="maximum memory fraction [0, 1] allowed to allocate per GPU device", ) parser.add_argument( "-dpt", "--disable_parallel_threads", action="store_false", dest="allow_parallel_threads", help="disable PyTorch from using multithreading for reproducibility", ) parser.add_argument( "-b", "--backend", help="specifies backend to use for parallel / distributed execution, " "defaults to local execution", choices=ALL_BACKENDS, ) parser.add_argument( "-l", "--logging_level", default="info", help="the level of logging to use", choices=["critical", "error", "warning", "info", "debug", "notset"], ) add_contrib_callback_args(parser) args = parser.parse_args(sys_argv) args.callbacks = args.callbacks or [] for callback in args.callbacks: callback.on_cmdline("train", *sys_argv) args.logging_level = get_logging_level_registry()[args.logging_level] logging.getLogger("ludwig").setLevel(args.logging_level) global logger logger = logging.getLogger("ludwig.train") args.backend = initialize_backend(args.backend or args.config.get("backend")) if args.backend.is_coordinator(): print_ludwig("Train", LUDWIG_VERSION) train_cli(**vars(args)) if __name__ == "__main__": cli(sys.argv[1:]) ================================================ FILE: ludwig/trainers/__init__.py ================================================ # register trainers import ludwig.trainers.trainer # noqa: F401 try: import ludwig.trainers.trainer_llm # noqa: F401 except ImportError: pass ================================================ FILE: ludwig/trainers/base.py ================================================ from abc import ABC, abstractmethod from ludwig.data.dataset.base import Dataset from ludwig.globals import MODEL_FILE_NAME from ludwig.schema.trainer import BaseTrainerConfig from ludwig.types import ModelConfigDict from ludwig.utils.defaults import default_random_seed class BaseTrainer(ABC): @abstractmethod def train(self, training_set, validation_set=None, test_set=None, save_path=MODEL_FILE_NAME, **kwargs): raise NotImplementedError() @abstractmethod def train_online( self, dataset, ): raise NotImplementedError() @abstractmethod def tune_batch_size( self, config: ModelConfigDict, training_set: Dataset, random_seed: int = default_random_seed, max_trials: int = 10, halving_limit: int = 3, tune_for_training: bool = True, ) -> int: raise NotImplementedError() @property @abstractmethod def validation_field(self): raise NotImplementedError() @property @abstractmethod def validation_metric(self): raise NotImplementedError() # Remote implementations may override this def shutdown(self): pass @property def local_rank(self) -> int: return 0 def barrier(self): pass # Functions needed to treat Trainer as a context manager def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): self.shutdown() @staticmethod @abstractmethod def get_schema_cls() -> BaseTrainerConfig: raise NotImplementedError() ================================================ FILE: ludwig/trainers/registry.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.utils.registry import DEFAULT_KEYS, Registry _trainers_registry = Registry() _ray_trainers_registry = Registry() _llm_trainers_registry = Registry() _llm_ray_trainers_registry = Registry() @DeveloperAPI def get_trainers_registry() -> Registry: return _trainers_registry @DeveloperAPI def get_ray_trainers_registry() -> Registry: return _ray_trainers_registry @DeveloperAPI def get_llm_trainers_registry() -> Registry: return _llm_trainers_registry @DeveloperAPI def get_llm_ray_trainers_registry() -> Registry: return _llm_ray_trainers_registry @DeveloperAPI def register_trainer(model_type: str, default=False): """Register a trainer class that supports training the given model types. Using default=True will make the trainer the default trainer for the model type. Args: model_type: The model_type which dictates the trainer type to use. default: Whether the trainer should be the default trainer for the model type. """ def wrap(cls): _trainers_registry[model_type] = cls if default: if DEFAULT_KEYS[0] in _trainers_registry: raise ValueError(f"Default trainer already registered for model type {model_type}") for key in DEFAULT_KEYS: _trainers_registry[key] = cls return cls return wrap @DeveloperAPI def register_ray_trainer(model_type: str, default=False): """Register a trainer class that supports training the given model types with Ray backend. Using default=True will make the trainer the default trainer for the model type. Args: model_type: The model_type which dictates the trainer type to use. default: Whether the trainer should be the default trainer for the model type. """ def wrap(cls): _ray_trainers_registry[model_type] = cls if default: if DEFAULT_KEYS[0] in _ray_trainers_registry: raise ValueError(f"Default trainer already registered for model type {model_type}") for key in DEFAULT_KEYS: _ray_trainers_registry[key] = cls return cls return wrap @DeveloperAPI def register_llm_trainer(trainer_type: str, default=False): """Register a trainer class that supports training the specific type of training strategy for LLM Models. Using default=True will make the trainer the default trainer for the LLM model type. Args: trainer_type: The trainer_type which dictates what training strategy to use. default: Whether the trainer should be the default trainer for LLMs. """ def wrap(cls): _llm_trainers_registry[trainer_type] = cls if default: if DEFAULT_KEYS[0] in _trainers_registry: raise ValueError(f"Default trainer {trainer_type} already registered for LLM") for key in DEFAULT_KEYS: _llm_trainers_registry[key] = cls return cls return wrap @DeveloperAPI def register_llm_ray_trainer(trainer_type: str, default=False): """Register a trainer class that supports training the specific type of training strategy for LLM Models with Ray backend. Using default=True will make the trainer the default trainer for the LLM model type. Args: trainer_type: The trainer_type which dictates what training strategy to use. default: Whether the trainer should be the default trainer for LLMs. """ def wrap(cls): _llm_ray_trainers_registry[trainer_type] = cls if default: if DEFAULT_KEYS[0] in _trainers_registry: raise ValueError(f"Default ray trainer {trainer_type} already registered for LLM") for key in DEFAULT_KEYS: _llm_ray_trainers_registry[key] = cls return cls return wrap ================================================ FILE: ludwig/trainers/trainer.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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 module contains the class and auxiliary methods of a model.""" import contextlib import csv import logging import math import os import os.path import signal import sys import tempfile import threading import time from collections.abc import Callable import numpy as np import packaging import pandas as pd import psutil import torch from torch.utils.tensorboard import SummaryWriter from ludwig.constants import ( AUTO, LOSS, MAX_CPU_BATCH_SIZE, MINIMIZE, MODEL_ECD, MODEL_LLM, TEST, TRAINING, USED_TOKENS, VALIDATION, ) from ludwig.data.dataset.base import Dataset from ludwig.distributed.base import DistributedStrategy, LocalStrategy from ludwig.globals import ( is_progressbar_disabled, MODEL_FILE_NAME, MODEL_HYPERPARAMETERS_FILE_NAME, TRAINING_CHECKPOINTS_DIR_PATH, TRAINING_PROGRESS_TRACKER_FILE_NAME, ) from ludwig.models.ecd import ECD from ludwig.models.llm import LLM from ludwig.models.predictor import Predictor from ludwig.modules.lr_scheduler import LRScheduler from ludwig.modules.metric_modules import get_improved_fn, get_initial_validation_value from ludwig.modules.metric_registry import get_metric_objective from ludwig.modules.optimization_modules import create_clipper from ludwig.progress_bar import LudwigProgressBar from ludwig.schema.trainer import ECDTrainerConfig from ludwig.trainers.base import BaseTrainer from ludwig.trainers.registry import register_trainer from ludwig.types import ModelConfigDict from ludwig.utils import time_utils from ludwig.utils.batch_size_tuner import BatchSizeEvaluator from ludwig.utils.checkpoint_utils import Checkpoint, CheckpointManager from ludwig.utils.config_utils import get_quantization from ludwig.utils.data_utils import load_json from ludwig.utils.defaults import default_random_seed from ludwig.utils.fs_utils import path_exists from ludwig.utils.llm_utils import update_embedding_layer from ludwig.utils.metric_utils import get_metric_names, TrainerMetric from ludwig.utils.metrics_printed_table import print_metrics_table from ludwig.utils.misc_utils import set_random_seed from ludwig.utils.model_utils import contains_nan_or_inf_tensors from ludwig.utils.torch_utils import get_torch_device from ludwig.utils.trainer_utils import ( append_metrics, freeze_layers_regex, get_final_steps_per_checkpoint, get_latest_metrics_dict, get_new_progress_tracker, get_total_expected_checkpoints, get_total_steps, ProgressTracker, ) logger = logging.getLogger(__name__) _TORCH210 = packaging.version.parse(torch.__version__) >= packaging.version.parse("2.1.0") @register_trainer(MODEL_ECD, default=True) class Trainer(BaseTrainer): """Trainer is a class that trains a model.""" @staticmethod def get_schema_cls(): return ECDTrainerConfig def __init__( self, config: ECDTrainerConfig, model: ECD, resume: float = False, skip_save_model: bool = False, skip_save_progress: bool = False, skip_save_log: bool = False, callbacks: list = None, report_tqdm_to_ray=False, random_seed: float = default_random_seed, distributed: DistributedStrategy | None = None, device: str | None = None, **kwargs, ): """Trains a model with a set of options and hyperparameters listed below. Customizable. :param model: Underlying Ludwig model :type model: `ludwig.models.ecd.ECD` :param resume: Resume training a model that was being trained. (default: False). :type resume: Boolean :param skip_save_model: Disables saving model weights and hyperparameters each time the model improves. By default Ludwig saves model weights after each round of evaluation the validation metric (improves, but if the model is really big that can be time consuming. If you do not want to keep the weights and just find out what performance a model can get with a set of hyperparameters, use this parameter to skip it, but the model will not be loadable later on. (default: False). :type skip_save_model: Boolean :param skip_save_progress: Disables saving progress each round of evaluation. By default Ludwig saves weights and stats after each round of evaluation for enabling resuming of training, but if the model is really big that can be time consuming and will uses twice as much space, use this parameter to skip it, but training cannot be resumed later on. (default: False). :type skip_save_progress: Boolean :param skip_save_log: Disables saving TensorBoard logs. By default Ludwig saves logs for the TensorBoard, but if it is not needed turning it off can slightly increase the overall speed. (default: False). :type skip_save_log: Boolean :param callbacks: List of `ludwig.callbacks.Callback` objects that provide hooks into the Ludwig pipeline. (default: None). :type callbacks: list :param report_tqdm_to_ray: Enables using the ray based tqdm Callback for progress bar reporting :param random_seed: Default initialization for the random seeds (default: 42). :type random_seed: Float :param distributed: Distributed strategy (default: None). :type distributed: `DistributedStrategy` :param device: Device to load the model on from a saved checkpoint (default: None). :type device: str :param config: `ludwig.schema.trainer.BaseTrainerConfig` instance that specifies training hyperparameters (default: `ludwig.schema.trainer.ECDTrainerConfig()`). """ self.distributed = distributed if distributed is not None else LocalStrategy() self.epochs = config.epochs self.train_steps = config.train_steps self.enable_profiling = config.enable_profiling self.steps_per_epoch = 0 # Computed during training, after batcher has been initialized. self.total_steps = 0 # Computed during training, after batcher has been initialized. self.total_expected_checkpoints = 0 # Computed during training, after batcher has been initialized. self.regularization_lambda = config.regularization_lambda self.regularization_type = config.regularization_type self.batch_size = config.batch_size self.effective_batch_size = config.effective_batch_size self.max_batch_size = config.max_batch_size self.eval_batch_size = config.batch_size if config.eval_batch_size is None else config.eval_batch_size self.should_shuffle = config.should_shuffle self._validation_field = config.validation_field self._validation_metric = config.validation_metric self.early_stop = config.early_stop self.layers_to_freeze_regex = config.layers_to_freeze_regex self.steps_per_checkpoint = config.steps_per_checkpoint self.checkpoints_per_epoch = config.checkpoints_per_epoch self.evaluate_training_set = config.evaluate_training_set self.skip_all_evaluation = config.skip_all_evaluation self.increase_batch_size_on_plateau = config.increase_batch_size_on_plateau self.increase_batch_size_on_plateau_patience = config.increase_batch_size_on_plateau_patience self.increase_batch_size_on_plateau_rate = config.increase_batch_size_on_plateau_rate self.increase_batch_size_eval_metric = config.increase_batch_size_eval_metric self.increase_batch_size_eval_split = config.increase_batch_size_eval_split self.gradient_accumulation_steps = ( config.gradient_accumulation_steps if self.distributed.allow_gradient_accumulation() and config.gradient_accumulation_steps != AUTO else 1 ) self.resume = resume self.skip_save_model = skip_save_model self.skip_save_progress = skip_save_progress self.skip_save_log = skip_save_log self.random_seed = random_seed self.received_sigint = False self.report_tqdm_to_ray = report_tqdm_to_ray self.callbacks = callbacks or [] self.device = device if self.device is None: self.device = get_torch_device() self.model = model self.model.prepare_for_training() self.model = self.distributed.to_device(self.model) self.model.metrics_to_device(self.device) self.compiled_model = self.model if config.compile: self.compiled_model = torch.compile(self.model) logger.info("Training with torchdynamo compiled model") # ================ Optimizer tuning ================ self.gradient_clipping_config = create_clipper(config.gradient_clipping) self.config = config self.base_learning_rate = None self.dist_model = None self.optimizer = None self.scheduler = None self.prepare() # Setup for automatic mixed precision (AMP) self.use_amp = config.use_mixed_precision and self.distributed.allow_mixed_precision() if self.use_amp: if torch.cuda.is_available(): logger.info("Enabling automatic mixed precision (AMP)") else: logger.info("`trainer.use_mixed_precision=True`, but no GPU device found. Setting to `False`") self.use_amp = False self.scaler = torch.amp.GradScaler("cuda") if self.use_amp else None # when training starts the sigint handler will be replaced with # set_steps_to_1_or_quit so this is needed to remember # the original sigint to restore at the end of training # and before set_steps_to_1_or_quit returns self.original_sigint_handler = None def prepare(self): base_learning_rate = self.config.learning_rate if self.distributed: lr_scale_fn = learning_rate_scale_fns[self.config.learning_rate_scaling] base_learning_rate *= lr_scale_fn(self.distributed.size()) self.base_learning_rate = base_learning_rate # Given that regex is supplied, freeze layers if self.config.layers_to_freeze_regex: freeze_layers_regex(self.config, self.model) # We may need to replace the embedding layer when using 8-bit optimizers from bitsandbytes. update_embedding_layer(self.compiled_model, self.config) # Register any post forward hooks for the model self.compiled_model._activate_forward_hooks() # Enable gradient checkpointing if configured if self.config.enable_gradient_checkpointing: # TODO(Arnav): Add support for gradient checkpointing in the compiled model # when the model is an ECD model using torch.utils.checkpoint (torch.utils.checkpoint.sequential()) if not isinstance(self.compiled_model, LLM): logger.warning("Gradient checkpointing is currently only supported for model_type: llm. Skipping...") elif not hasattr(self.compiled_model, "model") and not hasattr( self.compiled_model.model, "gradient_checkpointing_enable" ): logger.warning("Gradient checkpointing is not supported by this model. Skipping...") elif hasattr(self.compiled_model.model, "gradient_checkpointing_enable"): if _TORCH210: # https://pytorch.org/docs/stable/checkpoint.html # https://github.com/huggingface/transformers/blob/02f8738ef8c674300c314d004ba436cb5aaca165/src/transformers/modeling_utils.py#L2094 # noqa: E501 self.compiled_model.model.gradient_checkpointing_enable( gradient_checkpointing_kwargs={"use_reentrant": True} ) else: self.compiled_model.model.gradient_checkpointing_enable() # `use_cache=True` is incompatible with gradient checkpointing. self.compiled_model.model.config.use_cache = False self.compiled_model.model.enable_input_require_grads() logger.info("Gradient checkpointing enabled for training.") else: raise RuntimeError("Error when trying to enable gradient checkpointing.") self.dist_model, self.optimizer = self.distributed.prepare( self.compiled_model, self.config, self.base_learning_rate, ) # NOTE: This is a partially configured LRScheduler. It will be updated in the first call to train_step. self.scheduler = LRScheduler(self.config.learning_rate_scheduler, self.optimizer, 0, 0) def train_step( self, inputs: dict[str, torch.Tensor], targets: dict[str, torch.Tensor], should_step: bool = True, profiler: torch.profiler.profile | None = None, ) -> tuple[torch.Tensor, dict[str, torch.Tensor], torch.Tensor]: """Performs a single training step. Params: inputs: A dictionary of input data, from feature name to tensor. targets: A dictionary of target data, from feature name to tensor. should_step: Whether to perform a step of the optimizer after computing gradients. Returns: A tuple of: 1. loss tensor 2. dictionary of loss for every output feature. 3. tokens usage tensor """ if isinstance(self.optimizer, torch.optim.LBFGS): # NOTE: AMP is not supported for L-BFGS yet. # NOTE: gradient accumulation is not supported for L-BFGS yet. def closure(): # Allows L-BFGS to reevaluate the loss function self.distributed.zero_grad(self.optimizer) model_outputs = self.dist_model((inputs, targets)) loss, _ = self.model.train_loss( targets, model_outputs, self.regularization_type, self.regularization_lambda ) loss.backward() return loss self.distributed.step(self.optimizer, closure) # Obtain model predictions and loss model_outputs = self.dist_model((inputs, targets)) loss, all_losses = self.model.train_loss( targets, model_outputs, self.regularization_type, self.regularization_lambda ) if not self.evaluate_training_set: # Update evaluation metrics with current model params: # noisy but fast way to get metrics on the training set predictions = self.model.outputs_to_predictions(model_outputs) self.model.update_metrics(targets, predictions) return loss, all_losses, model_outputs[USED_TOKENS] with torch.amp.autocast("cuda") if self.use_amp else contextlib.nullcontext(): with self.distributed.prepare_model_update(self.dist_model, should_step=should_step): # Obtain model predictions and loss model_outputs = self.dist_model((inputs, targets)) loss, all_losses = self.model.train_loss( targets, model_outputs, self.regularization_type, self.regularization_lambda ) loss = loss / self.gradient_accumulation_steps used_tokens = model_outputs[USED_TOKENS] # Begin the backward pass variables = self.dist_model.parameters() if self.use_amp: self.scaler.scale(loss).backward() else: self.distributed.backward(loss, self.dist_model) if not should_step: # Short-circuit the parameter updates if we are still accumulating gradients return loss, all_losses, used_tokens # Wait for gradient aggregation to complete before clipping the gradients. # When using AMP, we need to do this before unscaling. self.distributed.wait_optimizer_synced(self.optimizer) if self.use_amp: # In-place unscaling of all gradients before weights update # Do this before gradient clipping per docs: # https://pytorch.org/docs/master/notes/amp_examples.html#gradient-clipping self.scaler.unscale_(self.optimizer) if self.distributed.allow_clip_gradients(): # Clip gradients self.clip_grads(variables) # Apply gradient updates with self.distributed.prepare_optimizer_update(self.optimizer): # Because we already synchronized above, we skip doing so here if self.use_amp: self.scaler.step(self.optimizer) else: self.distributed.step(self.optimizer) if self.use_amp: # Update scaler in case of overflow/underflow self.scaler.update() if not self.evaluate_training_set: # Update evaluation metrics with current model params: # noisy but fast way to get metrics on the training set predictions = self.model.outputs_to_predictions(model_outputs) self.model.update_metrics(targets, predictions) self.distributed.zero_grad(self.optimizer) if profiler: profiler.step() return loss, all_losses, used_tokens def clip_grads(self, variables): """Applies gradient clipping.""" if self.gradient_clipping_config.clipglobalnorm: torch.nn.utils.clip_grad_norm_(variables, self.gradient_clipping_config.clipglobalnorm) if self.gradient_clipping_config.clipnorm: torch.nn.utils.clip_grad_norm_(variables, self.gradient_clipping_config.clipnorm) if self.gradient_clipping_config.clipvalue: torch.nn.utils.clip_grad_value_(variables, self.gradient_clipping_config.clipvalue) @classmethod def write_eval_summary( cls, summary_writer, metrics, step, ): if not summary_writer: return for feature_name, output_feature in metrics.items(): for metric_name, metrics in output_feature.items(): if metrics: metric_tag = f"{feature_name}/epoch_{metric_name}" metric_val = metrics[-1][-1] summary_writer.add_scalar(metric_tag, metric_val, global_step=step) summary_writer.flush() @classmethod def write_step_summary( cls, train_summary_writer, combined_loss, all_losses, step, used_tokens, total_tokens_used, learning_rate=None ): if not train_summary_writer: return # token information. train_summary_writer.add_scalar("tokens/tokens", used_tokens, global_step=step) train_summary_writer.add_scalar("tokens/total_tokens_used", total_tokens_used, global_step=step) # combined loss train_summary_writer.add_scalar("combined/step_training_loss", combined_loss, global_step=step) # all other losses for feature_name, loss in all_losses.items(): loss_tag = f"{feature_name}/step_training_loss" train_summary_writer.add_scalar(loss_tag, loss.detach().float(), global_step=step) if learning_rate: train_summary_writer.add_scalar("combined/step_learning_rate", learning_rate, global_step=step) # Log CUDA memory stats. if torch.cuda.is_available(): for i in range(torch.cuda.device_count()): device = torch.device(f"cuda:{i}") memory_stats = torch.cuda.memory_stats(device=device) gb_memory_stats = {k: v / (1000**3) for k, v in memory_stats.items()} # Allocated bytes. train_summary_writer.add_scalar( f"cuda/device{i}/allocated_gb.all.current", gb_memory_stats["allocated_bytes.all.current"], global_step=step, ) train_summary_writer.add_scalar( f"cuda/device{i}/allocated_gb.all.peak", gb_memory_stats["allocated_bytes.all.peak"], global_step=step, ) train_summary_writer.add_scalar( f"cuda/device{i}/allocated_gb.all.allocated", gb_memory_stats["allocated_bytes.all.allocated"], global_step=step, ) train_summary_writer.add_scalar( f"cuda/device{i}/allocated_gb.all.freed", gb_memory_stats["allocated_bytes.all.freed"], global_step=step, ) # Reserved bytes. train_summary_writer.add_scalar( f"cuda/device{i}/reserved_gb.all.current", gb_memory_stats["reserved_bytes.all.current"], global_step=step, ) train_summary_writer.add_scalar( f"cuda/device{i}/reserved_gb.all.peak", gb_memory_stats["reserved_bytes.all.peak"], global_step=step ) train_summary_writer.add_scalar( f"cuda/device{i}/reserved_gb.all.allocated", gb_memory_stats["reserved_bytes.all.allocated"], global_step=step, ) train_summary_writer.add_scalar( f"cuda/device{i}/reserved_gb.all.freed", gb_memory_stats["reserved_bytes.all.freed"], global_step=step, ) # Active bytes. train_summary_writer.add_scalar( f"cuda/device{i}/active_gb.all.current", gb_memory_stats["active_bytes.all.current"], global_step=step, ) train_summary_writer.add_scalar( f"cuda/device{i}/active_gb.all.peak", gb_memory_stats["active_bytes.all.peak"], global_step=step ) train_summary_writer.add_scalar( f"cuda/device{i}/active_gb.all.allocated", gb_memory_stats["active_bytes.all.allocated"], global_step=step, ) train_summary_writer.add_scalar( f"cuda/device{i}/active_gb.all.freed", gb_memory_stats["active_bytes.all.freed"], global_step=step ) # Global free memory. train_summary_writer.add_scalar( f"cuda/device{i}/global_free_memory_gb", torch.cuda.mem_get_info(device=device)[0] / (1000**3), global_step=step, ) # Total memory occupied. train_summary_writer.add_scalar( f"cuda/device{i}/total_memory_occupied_gb", torch.cuda.mem_get_info(device=device)[1] / (1000**3), global_step=step, ) # Total memory used. train_summary_writer.add_scalar( f"cuda/device{i}/total_memory_used_gb", (torch.cuda.mem_get_info(device=device)[1] - torch.cuda.mem_get_info(device=device)[0]) / (1000**3), global_step=step, ) # Utilization. # https://pytorch.org/docs/stable/generated/torch.cuda.utilization.html#torch.cuda.utilization train_summary_writer.add_scalar( f"cuda/device{i}/utilization", torch.cuda.utilization(device=device), global_step=step, ) train_summary_writer.flush() def is_cpu_training(self): return torch.device(self.device) == torch.device("cpu") def tune_batch_size( self, config: ModelConfigDict, training_set: Dataset, random_seed: int = default_random_seed, max_trials: int = 20, halving_limit: int = 3, snapshot_weights: bool = True, on_best_batch_size_updated: Callable[[int, float, int], None] | None = None, tune_for_training: bool = True, global_max_sequence_length: int | None = None, ) -> int: logger.info("Tuning batch size...") skip_save_model = self.skip_save_model skip_save_progress = self.skip_save_progress skip_save_log = self.skip_save_log # Set temporary values self.skip_save_model = True self.skip_save_progress = True self.skip_save_log = True # When training on CPU, larger batch sizes offer limited benefits due to lack of effective # parallelization within a batch. As such, to increase chances of stable training, we cap the maximum # batch size at MAX_CPU_BATCH_SIZE max_batch_size = ( self.max_batch_size if torch.cuda.is_available() else min(self.max_batch_size, MAX_CPU_BATCH_SIZE) ) if self.effective_batch_size != AUTO: # If an effective batch size is set, we must ensure that batch size tuning doesn't exceed it max_batch_size = min(self.effective_batch_size, max_batch_size) if not tune_for_training: # No need to save and restore model and optimizer states, as they aren't modified during predict snapshot_weights = False self.dist_model.train() # Sets model training mode. evaluator = ( self._create_batch_size_evaluator() if tune_for_training else self._create_predict_batch_size_evaluator() ) with tempfile.TemporaryDirectory() as tmpdir: if snapshot_weights: # Save a snapshot of the model and optimizer state to restore later, as they will be modified # when we call the train step as part of the auto-tuning. This is undesirable, particularly for # pretrained models. checkpoint = self.distributed.create_checkpoint_handle( dist_model=self.dist_model, model=self.model, optimizer=self.optimizer, scheduler=self.scheduler ) checkpoint.save(os.path.join(tmpdir, "latest.ckpt"), global_step=0) try: best_batch_size = evaluator.select_best_batch_size( len(training_set), max_batch_size, max_trials, self.is_coordinator(), global_max_sequence_length ) best_batch_size = self.distributed.broadcast_object(best_batch_size) if tune_for_training: # Update batch size / gradient accumulation before preparing the trainer. This is needed primarily # for DeepSpeed, which needs to know the batch size and gradient accumulation steps before init self.config.batch_size = best_batch_size self.config.update_batch_size_grad_accum(self.distributed.size()) self.batch_size = self.config.batch_size self.gradient_accumulation_steps = self.config.gradient_accumulation_steps return best_batch_size finally: # Restore original parameters to defaults self.skip_save_model = skip_save_model self.skip_save_progress = skip_save_progress self.skip_save_log = skip_save_log if snapshot_weights: # Restore the model weights prior to batch size tuning to undo any updates made to the weights if self.distributed.prepare_before_load(): # Some distributed strategies, like DeepSpeed, need to re-init before loading the model self.prepare() self.resume_weights_and_optimizer(str(tmpdir), checkpoint) def _create_batch_size_evaluator(self) -> BatchSizeEvaluator: trainer = self class _TrainerBatchSizeEvaluator(BatchSizeEvaluator): def reset(self): trainer.model.reset_metrics() trainer.optimizer.zero_grad() def step(self, batch_size: int, global_max_sequence_length: int | None = None): trainer.distributed.set_batch_size(trainer.dist_model, batch_size) inputs = { input_feature_name: input_feature.create_sample_input(batch_size=batch_size).to(trainer.device) for input_feature_name, input_feature in trainer.model.input_features.items() } targets = { output_feature_name: output_feature.create_sample_output(batch_size=batch_size).to(trainer.device) for output_feature_name, output_feature in trainer.model.output_features.items() } trainer.train_step(inputs, targets) return _TrainerBatchSizeEvaluator() def _create_predict_batch_size_evaluator(self) -> BatchSizeEvaluator: trainer = self class _PredictBatchSizeEvaluator(BatchSizeEvaluator): def reset(self): trainer.model.reset_metrics() trainer.optimizer.zero_grad() def step(self, batch_size: int, global_max_sequence_length: int | None = None): trainer.distributed.set_batch_size(trainer.dist_model, batch_size) inputs = { input_feature_name: input_feature.create_sample_input(batch_size=batch_size).to(trainer.device) for input_feature_name, input_feature in trainer.model.input_features.items() } targets = { output_feature_name: output_feature.create_sample_output(batch_size=batch_size).to(trainer.device) for output_feature_name, output_feature in trainer.model.output_features.items() } with torch.no_grad(): trainer.dist_model((inputs, targets)) return _PredictBatchSizeEvaluator() def run_evaluation( self, training_set, validation_set, test_set, progress_tracker: ProgressTracker, train_summary_writer, validation_summary_writer, test_summary_writer, model_hyperparameters_path, output_features, metrics_names, save_path, loss: torch.Tensor, all_losses: dict[str, torch.Tensor], early_stopping_steps: int, checkpoint_manager: CheckpointManager, ) -> bool: """Runs evaluation over training, validation, and test sets. Also: - Prints results, saves results to the progress tracker. - Saves the model if the validation score is the best so far - If there is no validation set, the model is always saved. Returns whether the trainer should early stop, based on validation metrics history. """ start_time = time.time() self.callback(lambda c: c.on_eval_start(self, progress_tracker, save_path)) if self.is_coordinator(): logger.info(f"\nRunning evaluation for step: {progress_tracker.steps}, epoch: {progress_tracker.epoch}") # ================ Eval ================ # eval metrics on train self.eval_batch_size = max(self.eval_batch_size, progress_tracker.batch_size) if self.evaluate_training_set: # Run a separate pass over the training data to compute metrics self.evaluation( training_set, "train", progress_tracker.train_metrics, self.eval_batch_size, progress_tracker ) else: # Use metrics accumulated during training metrics = self.model.get_metrics() append_metrics(self.model, "train", metrics, progress_tracker.train_metrics, progress_tracker) self.model.reset_metrics() self.write_eval_summary( summary_writer=train_summary_writer, metrics=progress_tracker.train_metrics, step=progress_tracker.steps, ) if validation_set is not None: self.callback(lambda c: c.on_validation_start(self, progress_tracker, save_path)) # eval metrics on validation set self.evaluation( validation_set, VALIDATION, progress_tracker.validation_metrics, self.eval_batch_size, progress_tracker, ) llm_eval_examples = progress_tracker.llm_eval_examples dict_save_dir = os.path.join(os.path.dirname(checkpoint_manager.directory), "llm_eval_examples") os.makedirs(dict_save_dir, exist_ok=True) dict_save_path = os.path.join(dict_save_dir, f"{progress_tracker.checkpoint_number}.csv") llm_eval_examples = pd.DataFrame(llm_eval_examples).to_dict(orient="records") with open(dict_save_path, "w", encoding="utf-8") as outfile: writer = csv.DictWriter(outfile, fieldnames=["inputs", "targets", "outputs"]) writer.writeheader() writer.writerows(llm_eval_examples) self.write_eval_summary( summary_writer=validation_summary_writer, metrics=progress_tracker.validation_metrics, step=progress_tracker.steps, ) self.callback(lambda c: c.on_validation_end(self, progress_tracker, save_path)) if test_set is not None: self.callback(lambda c: c.on_test_start(self, progress_tracker, save_path)) # eval metrics on test set self.evaluation(test_set, TEST, progress_tracker.test_metrics, self.eval_batch_size, progress_tracker) self.write_eval_summary( summary_writer=test_summary_writer, metrics=progress_tracker.test_metrics, step=progress_tracker.steps, ) self.callback(lambda c: c.on_test_end(self, progress_tracker, save_path)) elapsed_time = (time.time() - start_time) * 1000.0 if self.is_coordinator(): logger.info(f"Evaluation took {time_utils.strdelta(elapsed_time)}\n") print_metrics_table( output_features, progress_tracker.train_metrics, progress_tracker.validation_metrics, progress_tracker.test_metrics, ) # ================ Validation Logic ================ should_break = False if validation_set is not None and validation_set.size > 0: should_break = self.check_progress_on_validation( progress_tracker, self.validation_field, self.validation_metric, save_path, model_hyperparameters_path, self.increase_batch_size_on_plateau, self.increase_batch_size_on_plateau_patience, self.increase_batch_size_on_plateau_rate, self.max_batch_size, self.increase_batch_size_eval_metric, self.increase_batch_size_eval_split, early_stopping_steps, self.skip_save_model, checkpoint_manager, ) else: # There's no validation, so we save the model. if not self.skip_save_model: if self.is_coordinator(): logger.info("Saving model.\n") checkpoint_manager.save_best(progress_tracker.steps) self.callback(lambda c: c.on_save_best_checkpoint(self, progress_tracker, save_path)) # Trigger eval end callback after any model weights save for complete checkpoint self.callback(lambda c: c.on_eval_end(self, progress_tracker, save_path)) # Clear the CUDA cache to free up memory torch.cuda.empty_cache() return should_break def save_checkpoint(self, progress_tracker: ProgressTracker, save_path: str, checkpoint_manager: CheckpointManager): """Checkpoints the model, progress tracker, and invokes the checkpoint callback.""" progress_tracker.increment_checkpoint() checkpoint_manager.save(progress_tracker.steps) if self.is_coordinator(): progress_tracker.save(os.path.join(save_path, TRAINING_PROGRESS_TRACKER_FILE_NAME)) # Callback that the checkpoint was reached, regardless of whether the model was evaluated. self.callback(lambda c: c.on_checkpoint(self, progress_tracker)) def create_checkpoint_handle(self): return self.distributed.create_checkpoint_handle( dist_model=self.dist_model, model=self.model, optimizer=self.optimizer, scheduler=self.scheduler ) def train( self, training_set, validation_set=None, test_set=None, save_path=MODEL_FILE_NAME, return_state_dict: bool = False, **kwargs, ): """Trains a model with a set of hyperparameters listed below. Customizable. :param training_set: The training set :param validation_set: The validation dataset :param test_set: The test dataset :param save_path: The directory that will contain the saved model :param return_state_dict: Whether to return the state dict of the model instead of the model itself """ # ====== General setup ======= output_features = self.model.output_features # Only use signals when on the main thread to avoid issues with CherryPy # https://github.com/ludwig-ai/ludwig/issues/286 if threading.current_thread() == threading.main_thread(): # set the original sigint signal handler # as we want to restore it at the end of training self.original_sigint_handler = signal.getsignal(signal.SIGINT) signal.signal(signal.SIGINT, self.set_steps_to_1_or_quit) metrics_names = get_metric_names(output_features) # ====== Setup file names ======= model_hyperparameters_path = None tensorboard_log_dir = None if self.is_coordinator(): os.makedirs(save_path, exist_ok=True) model_hyperparameters_path = os.path.join(save_path, MODEL_HYPERPARAMETERS_FILE_NAME) tensorboard_log_dir = os.path.join(save_path, "logs") # Sync save_path across the workers save_path = self.distributed.broadcast_object(save_path or "") training_progress_tracker_path = None training_checkpoints_path = None if save_path: training_progress_tracker_path = os.path.join(save_path, TRAINING_PROGRESS_TRACKER_FILE_NAME) training_checkpoints_path = os.path.join(save_path, TRAINING_CHECKPOINTS_DIR_PATH) self.callback( lambda c: c.on_trainer_train_setup(self, save_path, self.is_coordinator()), coordinator_only=False ) # ====== Setup session ======= checkpoint = self.create_checkpoint_handle() checkpoint_manager = CheckpointManager(checkpoint, training_checkpoints_path, device=self.device) # ====== Setup Tensorboard writers ======= train_summary_writer = None validation_summary_writer = None test_summary_writer = None if self.is_coordinator() and not self.skip_save_log and tensorboard_log_dir: train_summary_writer = SummaryWriter(os.path.join(tensorboard_log_dir, TRAINING)) if validation_set is not None and validation_set.size > 0: validation_summary_writer = SummaryWriter(os.path.join(tensorboard_log_dir, VALIDATION)) if test_set is not None and test_set.size > 0: test_summary_writer = SummaryWriter(os.path.join(tensorboard_log_dir, TEST)) # ================ Resume logic ================ self.callback(lambda c: c.on_resume_training(self.is_coordinator())) should_resume = self.resume and self.resume_files_exist( training_progress_tracker_path, training_checkpoints_path ) # make sure all workers are on the same page about resuming. should_resume = self.distributed.broadcast_object(should_resume, name="should_resume") if should_resume: try: progress_tracker = self.resume_training_progress_tracker(training_progress_tracker_path) self.resume_weights_and_optimizer(training_checkpoints_path, checkpoint) if self.is_coordinator(): logger.info("Resuming training from previous run.") except Exception: # This may happen if model training is interrupted after the progress tracker is initialized # but before any real training progress is made. progress_tracker = get_new_progress_tracker( batch_size=self.batch_size, learning_rate=self.base_learning_rate, best_eval_metric_value=get_initial_validation_value(self.validation_metric), best_increase_batch_size_eval_metric=get_initial_validation_value( self.increase_batch_size_eval_metric ), output_features=output_features, ) if self.is_coordinator(): logger.info("Failed to resume training from previous run. Creating fresh model training run.") else: progress_tracker = get_new_progress_tracker( batch_size=self.batch_size, learning_rate=self.base_learning_rate, best_eval_metric_value=get_initial_validation_value(self.validation_metric), best_increase_batch_size_eval_metric=get_initial_validation_value(self.increase_batch_size_eval_metric), output_features=output_features, ) if self.is_coordinator(): logger.info("Creating fresh model training run.") # Distributed: broadcast initial variable states from rank 0 to all other processes. # This is necessary to ensure consistent initialization of all workers when # training is started with random weights or restored from a checkpoint. self.distributed.sync_model(self.dist_model) self.distributed.sync_optimizer(self.optimizer) self.scheduler.load_state_dict(self.distributed.broadcast_object(self.scheduler.state_dict())) # For DeepSpeed, we need to set the batch size here in case it was modfied during auto-tuning self.distributed.set_batch_size(self.dist_model, self.batch_size) set_random_seed(self.random_seed) if self.enable_profiling: logger.warning("Full torch profiler is enabled. Training may be significantly slower.") profiler = torch.profiler.profile( schedule=torch.profiler.schedule( wait=self.config.profiler.wait, warmup=self.config.profiler.warmup, active=self.config.profiler.active, repeat=self.config.profiler.repeat, ), on_trace_ready=torch.profiler.tensorboard_trace_handler(os.path.join(tensorboard_log_dir, "profiling")), record_shapes=True, with_stack=True, profile_memory=True, ) else: profiler = None try: with training_set.initialize_batcher( batch_size=self.batch_size, should_shuffle=self.should_shuffle, random_seed=self.random_seed, distributed=self.distributed, ignore_last=True, augmentation_pipeline=self.model.get_augmentation_pipelines(), ) as batcher: # ================ Training Loop ================ self.steps_per_epoch = batcher.steps_per_epoch self.total_steps = get_total_steps(self.epochs, batcher.steps_per_epoch, self.train_steps) # NOTE(geoffrey): this ensures that the total number of epochs coincides with the number of # times `batcher.set_epoch` is called. old_epochs = self.epochs self.epochs = math.ceil(self.total_steps / self.steps_per_epoch) if old_epochs != self.epochs: logger.warning( f"The number of epochs has been adjusted from config-specified {old_epochs} " f"to {self.epochs} to match the total number of steps." ) # Get the terminal steps per checkpoint. final_steps_per_checkpoint = get_final_steps_per_checkpoint( batcher.steps_per_epoch, self.steps_per_checkpoint, self.checkpoints_per_epoch, self.is_coordinator(), ) final_steps_per_checkpoint = min(final_steps_per_checkpoint, self.total_steps) early_stopping_steps = final_steps_per_checkpoint * self.early_stop if not self.skip_save_progress: self.total_expected_checkpoints = get_total_expected_checkpoints( self.total_steps, final_steps_per_checkpoint, self.epochs ) # Initialize the learning rate scheduler. self.scheduler = LRScheduler( self.config.learning_rate_scheduler, self.optimizer, steps_per_checkpoint=final_steps_per_checkpoint, total_steps=self.total_steps, ) if self.is_coordinator(): logger.info( f"Training for {self.total_steps} step(s), approximately " f"{int(self.total_steps / batcher.steps_per_epoch)} epoch(s)." ) if self.early_stop < 0: logger.info("Early stopping policy: None") else: logger.info( f"Early stopping policy: {self.early_stop} round(s) of evaluation, or " f"{early_stopping_steps} step(s), approximately " f"{int(early_stopping_steps / batcher.steps_per_epoch)} epoch(s).\n" ) logger.info(f"Starting with step {progress_tracker.steps}, epoch: {progress_tracker.epoch}") progress_bar_config = { "desc": "Training", "initial": progress_tracker.steps, "total": self.total_steps, "disable": is_progressbar_disabled(), "file": sys.stdout, } progress_bar = LudwigProgressBar(self.report_tqdm_to_ray, progress_bar_config, self.is_coordinator()) if profiler: profiler.start() while progress_tracker.steps < self.total_steps: # note that batch size may change over epochs batcher.set_epoch(progress_tracker.epoch, progress_tracker.batch_size) # epoch init start_time = time.time() # Reset the metrics at the start of the next epoch self.dist_model.train() # Sets model to training mode. self.model.reset_metrics() self.callback(lambda c: c.on_epoch_start(self, progress_tracker, save_path)) # Trains over a full epoch of data or up to the last training step, whichever is sooner. should_break, has_nan_or_inf_tensors = self._train_loop( batcher, progress_tracker, save_path, train_summary_writer, progress_bar, training_set, validation_set, test_set, start_time, validation_summary_writer, test_summary_writer, model_hyperparameters_path, output_features, metrics_names, checkpoint_manager, final_steps_per_checkpoint, early_stopping_steps, profiler, ) if self.is_coordinator(): # ========== Save training progress ========== logger.debug( f"Epoch {progress_tracker.epoch} took: " f"{time_utils.strdelta((time.time() - start_time) * 1000.0)}." ) # Skip saving progress if we're not saving the model. We should do this so as to not overwrite the # best model checkpoint from the previous round of evaluation so that the previous best model # weights can be used for inference instead of the current weights which are in a bad state. if has_nan_or_inf_tensors: break if not self.skip_save_progress: self.save_checkpoint(progress_tracker, save_path, checkpoint_manager) if not self.skip_save_model and self.skip_all_evaluation: # All evaluation was skipped, so save the current step as the best so far. checkpoint_manager.save_best(progress_tracker.steps) # Early stop if needed. if should_break: break finally: # ================ Finished Training ================ self.callback( lambda c: c.on_trainer_train_teardown(self, progress_tracker, save_path, self.is_coordinator()), coordinator_only=False, ) # Deactivate any forward hooks for the model used at training time. self.compiled_model._deactivate_forward_hooks() # Stop the profiler. if profiler: profiler.stop() # Close the summary writers. if train_summary_writer is not None: train_summary_writer.close() if validation_summary_writer is not None: validation_summary_writer.close() if test_summary_writer is not None: test_summary_writer.close() if not self.skip_save_model and self.skip_all_evaluation and not has_nan_or_inf_tensors: # All evaluation was skipped, so save the current step as the best so far. checkpoint_manager.save_best(progress_tracker.steps) if not self.skip_save_progress: checkpoint_manager.close() # Load the best weights from saved checkpoint state_dict = None if self.distributed.is_coordinator(): if not self.skip_save_model: state_dict = checkpoint_manager.get_best_checkpoint_state_for_inference(self.return_device) if not state_dict: error_message = "Training ran into an error. No checkpoint was saved." if has_nan_or_inf_tensors: error_message += ( " This is because training was terminated early due to the presence of NaN or " "Inf values in the model weights before a single valid checkpoint could be saved." ) raise RuntimeError(error_message) if not return_state_dict: if self.distributed.is_model_parallel(): # Assume the full weights cannot fit in memory on GPU self.model = self.model.cpu() # For a full explanation of this 8-bit workaround, see https://github.com/ludwig-ai/ludwig/pull/3606 # TODO (jeffkinnison): Determine why `SCB` and `CB` are deleted from parameter state quantization = get_quantization(self.model.config_obj) uses_quantization = bool(quantization) if not isinstance(quantization, list) else any(quantization) if uses_quantization and 8 in quantization: # If the model was previously placed on GPU, 8-bit parameter state will be updated with several # matrices containing quantization information. These are recorded matrices are recorded in the # training checkpoint state dicts, but do not necessarily exist in the parameter object, leading # to a RuntimeError in `load_state_dict`. Explicitly call `model.cuda()` to make sure the # matrices are part of model state. This workaround is necessary because the matrices are # deleted during the model's forward pass. if self.model.config_obj.model_type == MODEL_LLM and self.model.model.device.type == "cuda": self.model.model.cuda() elif self.model.config_obj.model_type == MODEL_ECD and self.model.device.type == "cuda": self.model.cuda() _, unexpected_keys = self.model.load_state_dict(state_dict, strict=False) only_weights_format_keys = ["weights_format" in k for k in unexpected_keys] # bitsandbytes adds a number of `weights_format` metadata fields to the state dict in # `Linear8bitLt._save_to_state_dict`. These contain information about how the 8-bit tensors # are tiled, but the fields themselves never exist in the module and get returned as unexpected # keys when loading the state dict. The assert ( unexpected_keys == [] or only_weights_format_keys ), f"Unexpected keys found in state dict: {unexpected_keys}" else: _, unexpected_keys = self.model.load_state_dict(state_dict, strict=False) assert unexpected_keys == [], f"Unexpected keys found in state dict: {unexpected_keys}" elif return_state_dict: state_dict = self.model.cpu().state_dict() # When running with Ray, we only need to return the state dict, as it's faster and cheaper to send the # state dict over the network than to load the model state here, serialize it back to a state dict, then # load it back on the head node. return_value = self.model if not return_state_dict else state_dict # restore original sigint signal handler if self.original_sigint_handler and threading.current_thread() == threading.main_thread(): signal.signal(signal.SIGINT, self.original_sigint_handler) return ( return_value, progress_tracker.train_metrics, progress_tracker.validation_metrics, progress_tracker.test_metrics, ) def _train_loop( self, batcher, progress_tracker: ProgressTracker, save_path, train_summary_writer, progress_bar: LudwigProgressBar, training_set, validation_set, test_set, start_time, validation_summary_writer, test_summary_writer, model_hyperparameters_path, output_features, metrics_names, checkpoint_manager: CheckpointManager, final_steps_per_checkpoint: int, early_stopping_steps: int, profiler: torch.profiler.profile | None, ) -> tuple[bool, bool]: """Completes up to one epoch through the data. This function completes a single pass (epoch) through the training data and returns two boolean values: Returns: should_break (bool): Indicates whether the training loop should be terminated prematurely. has_nan_or_inf_tensors (bool): Indicates whether the model weights contain NaN or Inf values. """ self.distributed.zero_grad(self.optimizer) batch_idx = 0 should_break = False has_nan_or_inf_tensors = False while not batcher.last_batch() and progress_tracker.steps < self.total_steps and not should_break: progress_tracker.learning_rate = self.optimizer.param_groups[0]["lr"] self.callback(lambda c: c.on_batch_start(self, progress_tracker, save_path)) # obtain batch batch = batcher.next_batch() # determine whether we need to accumulate gradients as trigger a full parameter update should_sync_grads = (batch_idx + 1) % self.gradient_accumulation_steps == 0 is_checkpoint_step = (progress_tracker.steps + 1) % final_steps_per_checkpoint == 0 should_step = should_sync_grads or is_checkpoint_step batch_idx += 1 # Move tensors to cuda here. inputs = { i_feat.feature_name: torch.from_numpy(np.array(batch[i_feat.proc_column], copy=True)).to(self.device) for i_feat in self.model.input_features.values() } targets = { o_feat.feature_name: torch.from_numpy(np.array(batch[o_feat.proc_column], copy=True)).to(self.device) for o_feat in self.model.output_features.values() } loss, all_losses, used_tokens = self.train_step(inputs, targets, should_step=should_step, profiler=profiler) # Update LR schduler here instead of train loop to avoid updating during batch size tuning, etc. self.scheduler.step() # Update progress tracker with token information. progress_tracker.set_token_usage_for_this_step(used_tokens) if self.is_coordinator() and not self.skip_save_log: self.write_step_summary( train_summary_writer=train_summary_writer, combined_loss=loss.detach().float(), all_losses=all_losses, step=progress_tracker.steps, used_tokens=used_tokens, total_tokens_used=progress_tracker.total_tokens_used, learning_rate=progress_tracker.learning_rate, ) progress_tracker.steps += 1 progress_bar.set_postfix({"loss": loss.detach().item()}) progress_bar.update(1) if self.is_coordinator(): logger.debug( "training: completed batch %s memory used: %.2fMB", progress_bar.total_steps, psutil.Process(os.getpid()).memory_info()[0] / 1e6, ) # Executing `on_batch_end` calls before `run_evaluation` enables more accurate # batch duration measurements when using timer callbacks. self.callback(lambda c: c.on_batch_end(self, progress_tracker, save_path, sync_step=should_step)) # If this is the last batch in the epoch, increment before running evaluation so that metrics are reported # with the correct epoch. if batcher.last_batch(): progress_tracker.epoch += 1 if progress_tracker.steps % final_steps_per_checkpoint == 0: # Before continuing to evaluation or skipping evaluation altogether, we should use this point to # ensure that the model weights are not NaN or Inf. has_nan_or_inf_tensors = self._has_nan_or_inf_weights(self.dist_model) # If a nan/inf tensor is detected, we should break out of the training loop immediately and raise an # # error. There is no point in running evaluation for this step as the model weights are already in # a bad state. Theere is also no point in continuing to train the model since the loss will always be # NaN or Inf from this point forward. if has_nan_or_inf_tensors: return True, has_nan_or_inf_tensors if not self.skip_all_evaluation: # Publishes metrics to MLFLow if there are any MLFlow callbacks. should_break = self.run_evaluation( training_set, validation_set, test_set, progress_tracker, train_summary_writer, validation_summary_writer, test_summary_writer, model_hyperparameters_path, output_features, metrics_names, save_path, loss, all_losses, early_stopping_steps, checkpoint_manager, ) else: should_break = False # Checkpoint the model. # NOTE: Ideally we would do this before evaluation, but for some reason DeepSpeed will complain # about inflight params if we do that, which is why we checkpoint after eval instead. In practice, # this should not make a difference, except in the unlikely event an error occurs during eval and we # want to resume from the last checkpoint, in which case we will lose slightly more progress this way. if not self.skip_save_progress: self.save_checkpoint(progress_tracker, save_path, checkpoint_manager) # If this was the last batch, then increment the epoch counter and invoke the `on_epoch_end` callback. if batcher.last_batch(): self.callback(lambda c: c.on_epoch_end(self, progress_tracker, save_path)) return should_break, has_nan_or_inf_tensors def _has_nan_or_inf_weights(self, model: torch.nn.Module) -> bool: """Check for NaN or infinity (inf) values in the weights (parameters and buffers) of a PyTorch model in a local or distributed training environment. It is called to ensure the model's numerical stability during training. It works for both model parallel and data parallel training. This function recursively inspects the model's parameters and buffers to identify NaN or inf values. It communicates and aggregates the results across all distributed processes using the `all_reduce` operation. If any process finds NaN or inf values, it is considered a critical error, and the main coordinator process will return True to halt training in the main training loop. Parameters: model (torch.nn.Module): The PyTorch model to check for NaN or inf weights. Returns: bool: Returns True if any NaN or inf tensors are found in the model's weights. Otherwise, returns False. """ local_has_nan_or_inf = contains_nan_or_inf_tensors(model) # Use all_reduce to aggregate local_has_nan across all processes and sum the result into global_has_nan, which # will be a tensor with a single element on all processes after the all_reduce operation. global_has_nan_or_inf = torch.tensor(int(local_has_nan_or_inf), device=self.device) self.distributed.allreduce(global_has_nan_or_inf) # The main coordinator process will raise a runtime error if any of the processes found NaN or inf weights. if self.distributed.local_rank() == 0: if global_has_nan_or_inf.item() > 0: logger.warning("NaN or inf tensors found in the model. Stopping training.") return True return False def train_online(self, dataset): self.dist_model.train() # Sets model training mode. with dataset.initialize_batcher( batch_size=self.batch_size, should_shuffle=self.should_shuffle, distributed=self.distributed, ignore_last=True, ) as batcher: # training step loop progress_bar_config = { "desc": "Training online", "total": batcher.steps_per_epoch, "file": sys.stdout, "disable": is_progressbar_disabled(), } progress_bar = LudwigProgressBar(self.report_tqdm_to_ray, progress_bar_config, self.is_coordinator()) while not batcher.last_batch(): batch = batcher.next_batch() inputs = { i_feat.feature_name: torch.from_numpy(np.array(batch[i_feat.proc_column], copy=True)).to( self.device ) for i_feat in self.model.input_features.values() } targets = { o_feat.feature_name: torch.from_numpy(np.array(batch[o_feat.proc_column], copy=True)).to( self.device ) for o_feat in self.model.output_features.values() } self.train_step( inputs, targets, ) progress_bar.update(1) progress_bar.close() return self.model @property def validation_field(self): return self._validation_field @property def validation_metric(self): return self._validation_metric def evaluation(self, dataset, dataset_name, metrics_log, batch_size, progress_tracker): predictor = Predictor( self.dist_model, batch_size=batch_size, distributed=self.distributed, report_tqdm_to_ray=self.report_tqdm_to_ray, model=self.model, ) metrics, _ = predictor.batch_evaluation(dataset, collect_predictions=False, dataset_name=dataset_name) return append_metrics(self.model, dataset_name, metrics, metrics_log, progress_tracker) def check_progress_on_validation( self, progress_tracker, validation_output_feature_name, validation_metric: str, save_path, model_hyperparameters_path, increase_batch_size_on_plateau, increase_batch_size_on_plateau_patience, increase_batch_size_on_plateau_rate, increase_batch_size_on_plateau_max, increase_batch_size_eval_metric, increase_batch_size_eval_split, early_stopping_steps: int, skip_save_model, checkpoint_manager: CheckpointManager, ) -> bool: """Checks the history of validation scores. Uses history of validation scores to reduce learning rate, increase batch size, and decide whether training should stop. Saves the model if scores have improved. Returns whether the model should stop training. """ should_break = False improved_fn = get_improved_fn(validation_metric) all_validation_metrics = progress_tracker.validation_metrics[validation_output_feature_name] # The most recent validation_metric metric. eval_metric: TrainerMetric = all_validation_metrics[validation_metric][-1] eval_metric_value = eval_metric[-1] if eval_metric_value != eval_metric_value: # Fallback to 0 if the validation metric value is a NaN. # This is potentially relevant for small datasets like those used in testing where if there's only a # single output label, some metrics like ROC may turn out to be NaN. # However, we want to guarantee that the model will be saved at least once over a full # training-checkpoint-eval-loop. eval_metric_value = 0 if improved_fn(eval_metric_value, progress_tracker.best_eval_metric_value): previous_best_eval_metric_value = progress_tracker.best_eval_metric_value # Save the value, steps, epoch, and checkpoint number. progress_tracker.best_eval_metric_value = eval_metric_value progress_tracker.best_eval_metric_steps = progress_tracker.steps progress_tracker.best_eval_metric_epoch = progress_tracker.epoch progress_tracker.best_eval_metric_checkpoint_number = progress_tracker.checkpoint_number # Save best metrics for all data subsets. progress_tracker.best_eval_train_metrics = get_latest_metrics_dict(progress_tracker.train_metrics) progress_tracker.best_eval_validation_metrics = get_latest_metrics_dict(progress_tracker.validation_metrics) progress_tracker.best_eval_test_metrics = get_latest_metrics_dict(progress_tracker.test_metrics) if self.is_coordinator(): logger.info( f"Evaluation validation metric: '{validation_output_feature_name}' '{validation_metric}' improved." ) absolute_eval_metric_value_change = round( abs(previous_best_eval_metric_value - progress_tracker.best_eval_metric_value), 3 ) if get_metric_objective(validation_metric) == MINIMIZE: logger.info( f"'{validation_output_feature_name}' '{validation_metric}' decreased by " f"{absolute_eval_metric_value_change}." ) else: logger.info( f"'{validation_output_feature_name}' '{validation_metric}' increased by " f"{absolute_eval_metric_value_change}." ) # Save the model. if not skip_save_model: logger.info("New best model saved.\n") checkpoint_manager.save_best(progress_tracker.steps) self.callback(lambda c: c.on_save_best_checkpoint(self, progress_tracker, save_path)) last_improvement_in_steps = progress_tracker.steps - progress_tracker.best_eval_metric_steps progress_tracker.last_improvement_steps = last_improvement_in_steps if last_improvement_in_steps != 0 and self.is_coordinator(): logger.info( f"Last improvement of {validation_output_feature_name} validation {validation_metric} happened " + f"{last_improvement_in_steps} step(s) ago.\n" ) # ========== Learning Rate Schedule evaluation updates ======== self.scheduler.eval_step(progress_tracker, validation_output_feature_name) # ========== Increase Batch Size Plateau logic ========= if increase_batch_size_on_plateau > 0: self.increase_batch_size( progress_tracker, validation_output_feature_name, increase_batch_size_on_plateau, increase_batch_size_on_plateau_patience, increase_batch_size_on_plateau_rate, increase_batch_size_on_plateau_max, increase_batch_size_eval_metric, increase_batch_size_eval_split, ) progress_tracker.last_increase_batch_size = ( progress_tracker.steps - progress_tracker.last_increase_batch_size_steps ) if ( progress_tracker.last_increase_batch_size > 0 and progress_tracker.last_increase_batch_size_eval_metric_improvement > 0 and not progress_tracker.num_increases_batch_size >= increase_batch_size_on_plateau and not progress_tracker.batch_size >= increase_batch_size_on_plateau_max ): logger.info( "Last batch size increase " f"happened {progress_tracker.last_increase_batch_size} step(s) ago, " f"improvement of {validation_output_feature_name} {increase_batch_size_eval_split} " f"{increase_batch_size_eval_metric} happened " f"{progress_tracker.last_increase_batch_size_eval_metric_improvement} step(s) ago." ) # ========== Early Stop logic ========== # If any early stopping condition is satisfied, either lack of improvement for many steps, or via callbacks on # any worker, then trigger early stopping. early_stop_bool = 0 < early_stopping_steps <= last_improvement_in_steps if not early_stop_bool: for callback in self.callbacks: if callback.should_early_stop(self, progress_tracker, self.is_coordinator()): early_stop_bool = True break should_early_stop = torch.as_tensor([early_stop_bool], dtype=torch.int, device=self.device) should_early_stop = self.distributed.allreduce(should_early_stop) if should_early_stop.item(): if self.is_coordinator(): logger.info( f"\nEARLY STOPPING due to lack of validation improvement. It has been {last_improvement_in_steps} " "step(s) since last validation improvement." ) should_break = True return should_break def set_steps_to_1_or_quit(self, signum, frame): """Custom SIGINT handler used to elegantly exit training. A single SIGINT will stop training after the next training step. A second SIGINT will stop training immediately. """ if not self.received_sigint: self.total_steps = 1 self.received_sigint = True logger.critical("\nReceived SIGINT, will finish this training step and then conclude training.") logger.critical("Send another SIGINT to immediately interrupt the process.") else: logger.critical("\nReceived a second SIGINT, will now quit") if self.original_sigint_handler: signal.signal(signal.SIGINT, self.original_sigint_handler) sys.exit(1) @staticmethod def resume_files_exist( training_progress_tracker_path: str, training_checkpoint_path: str, ) -> bool: missing_files = [] # training_progress.json if not path_exists(training_progress_tracker_path): missing_files.append(training_progress_tracker_path) # latest.ckpt in training_checkpoints/ latest_ckpt = os.path.join(training_checkpoint_path, "latest.ckpt") if not path_exists(latest_ckpt): missing_files.append(latest_ckpt) if missing_files: logger.warning(f"Could not find {missing_files} while trying to resume model training.") return False return True def resume_training_progress_tracker(self, training_progress_tracker_path): progress_tracker_dict = None if self.is_coordinator(): logger.info(f"Loading progress tracker for model: {training_progress_tracker_path}") progress_tracker_dict = load_json(training_progress_tracker_path) logger.debug("Broadcasting model progress tracker dict to all workers") progress_tracker_dict = self.distributed.broadcast_object( progress_tracker_dict, name="broadcast_progress_tracker" ) progress_tracker = ProgressTracker.load(progress_tracker_dict) return progress_tracker def resume_weights_and_optimizer( self, model_weights_progress_path: str, checkpoint: Checkpoint, ): CheckpointManager.load_latest_checkpoint(checkpoint, model_weights_progress_path, self.device) def increase_batch_size( self, progress_tracker: ProgressTracker, validation_output_feature_name: str, increase_batch_size_on_plateau: int, increase_batch_size_on_plateau_patience: int, increase_batch_size_on_plateau_rate: float, increase_batch_size_on_plateau_max: int, increase_batch_size_eval_metric: str = LOSS, increase_batch_size_eval_split: str = TRAINING, ): """Uses the progress tracker to determine if the batch size should be increased.""" if ( not progress_tracker.num_increases_batch_size >= increase_batch_size_on_plateau and not progress_tracker.batch_size == increase_batch_size_on_plateau_max ): if increase_batch_size_eval_split == TRAINING: split_metrics = progress_tracker.train_metrics elif increase_batch_size_eval_split == VALIDATION: split_metrics = progress_tracker.validation_metrics else: # if increase_batch_size_eval_split == TEST: split_metrics = progress_tracker.test_metrics validation_metric = increase_batch_size_eval_metric last_metric = split_metrics[validation_output_feature_name][validation_metric][-1] last_metric_value = last_metric[-1] improved_fn = get_improved_fn(validation_metric) is_improved = improved_fn(last_metric_value, progress_tracker.best_increase_batch_size_eval_metric) if is_improved: # We update the best metric value and set it to the current one, and reset last # improvement step count progress_tracker.best_increase_batch_size_eval_metric = last_metric_value progress_tracker.last_increase_batch_size_eval_metric_improvement = 0 else: progress_tracker.last_increase_batch_size_eval_metric_improvement += 1 if not is_improved and ( # Batch size increase happened more than N steps ago progress_tracker.last_increase_batch_size >= increase_batch_size_on_plateau_patience and ( # No improvement of the evaluation metric since more than N steps ago progress_tracker.last_increase_batch_size_eval_metric_improvement >= increase_batch_size_on_plateau_patience ) ): progress_tracker.batch_size = min( int(increase_batch_size_on_plateau_rate * progress_tracker.batch_size), increase_batch_size_on_plateau_max, ) if self.is_coordinator(): logger.info( f"PLATEAU REACHED, increasing batch size to {progress_tracker.batch_size} due to lack of " f"improvement of {validation_output_feature_name} {increase_batch_size_eval_split} " f"{validation_metric}." ) progress_tracker.last_increase_batch_size_steps = progress_tracker.steps progress_tracker.last_increase_batch_size = 0 progress_tracker.num_increases_batch_size += 1 if progress_tracker.num_increases_batch_size >= increase_batch_size_on_plateau: if self.is_coordinator(): logger.info( f"Batch size was already increased {progress_tracker.num_increases_batch_size} times, " "not increasing it anymore." ) elif progress_tracker.batch_size >= increase_batch_size_on_plateau_max: if self.is_coordinator(): logger.info( f"Batch size was already increased {progress_tracker.num_increases_batch_size} times, " f"currently it is {progress_tracker.batch_size}, the maximum allowed." ) def is_coordinator(self): return self.distributed.rank() == 0 @property def local_rank(self) -> int: return self.distributed.local_rank() def barrier(self): self.distributed.barrier() def callback(self, fn, coordinator_only=True): if not coordinator_only or self.is_coordinator(): for callback in self.callbacks: fn(callback) @property def return_device(self): return self.device class RemoteTrainer(Trainer): def __init__(self, gpus=None, gpu_memory_limit=None, allow_parallel_threads=True, **kwargs): super().__init__(**kwargs) # Only return results from rank 0 to reduce network overhead self.train = self.distributed.return_first(self.train) self.train_online = self.distributed.return_first(self.train_online) @property def return_device(self): # When returning the model weights from remote to driver, place them on CPU, # as the driver likely doesn't have a GPU. return "cpu" learning_rate_scale_fns = { "linear": lambda n: n, "sqrt": lambda n: math.sqrt(n), "constant": lambda n: 1, } ================================================ FILE: ludwig/trainers/trainer_llm.py ================================================ import logging import os import time from collections.abc import Callable from typing import Union from torch.utils.tensorboard import SummaryWriter from ludwig.constants import MINIMUM_BATCH_SIZE, TEST, TRAINING, VALIDATION from ludwig.data.dataset.base import Dataset from ludwig.distributed.base import DistributedStrategy, LocalStrategy from ludwig.features.feature_utils import LudwigFeatureDict from ludwig.globals import MODEL_FILE_NAME from ludwig.models.llm import LLM from ludwig.models.predictor import LlmFineTunePredictor, LlmPredictor from ludwig.modules.metric_modules import get_initial_validation_value from ludwig.schema.trainer import BaseTrainerConfig, FineTuneTrainerConfig, NoneTrainerConfig from ludwig.trainers.base import BaseTrainer from ludwig.trainers.registry import register_llm_ray_trainer, register_llm_trainer from ludwig.trainers.trainer import Trainer from ludwig.types import ModelConfigDict from ludwig.utils import time_utils from ludwig.utils.batch_size_tuner import ( BatchSizeEvaluator, LLMFinetunePredictBatchSizeEvaluator, LLMFinetuneTrainerBatchSizeEvaluator, ) from ludwig.utils.defaults import default_random_seed from ludwig.utils.metric_utils import TrainerMetric from ludwig.utils.metrics_printed_table import print_metrics_table from ludwig.utils.misc_utils import set_random_seed from ludwig.utils.torch_utils import get_torch_device from ludwig.utils.trainer_utils import append_metrics, get_new_progress_tracker, ProgressTracker logger = logging.getLogger(__name__) MAX_EVALUATION_EXAMPLES = 1000 MAX_EVALUATION_EXAMPLES_SHOWN = 5 @register_llm_trainer("none") @register_llm_ray_trainer("none") class NoneTrainer(BaseTrainer): """NoneTrainer is a trainer that does not train a model, only runs evaluation.""" def __init__( self, config: NoneTrainerConfig, model: LLM, resume: float = False, skip_save_model: bool = False, skip_save_progress: bool = False, skip_save_log: bool = False, callbacks: list = None, report_tqdm_to_ray=False, random_seed: float = default_random_seed, distributed: DistributedStrategy | None = None, device: str | None = None, **kwargs, ): """ :param config: `ludwig.schema.trainer.NoneTrainerConfig` instance that specifies training hyperparameters (default: `ludwig.schema.trainer.NoneTrainerConfig()`). :param model: Underlying Ludwig model :type model: `ludwig.models.llm.LLM` :param resume: Resume training a model that was being trained. (default: False). :type resume: Boolean :param skip_save_model: Disables saving model weights and hyperparameters each time the model improves. By default Ludwig saves model weights after each round of evaluation the validation metric (improves, but if the model is really big that can be time consuming. If you do not want to keep the weights and just find out what performance a model can get with a set of hyperparameters, use this parameter to skip it, but the model will not be loadable later on. (default: False). :type skip_save_model: Boolean :param skip_save_progress: Disables saving progress each round of evaluation. By default Ludwig saves weights and stats after each round of evaluation for enabling resuming of training, but if the model is really big that can be time consuming and will uses twice as much space, use this parameter to skip it, but training cannot be resumed later on. (default: False). :type skip_save_progress: Boolean :param skip_save_log: Disables saving TensorBoard logs. By default Ludwig saves logs for the TensorBoard, but if it is not needed turning it off can slightly increase the overall speed. (default: False). :type skip_save_log: Boolean :param callbacks: List of `ludwig.callbacks.Callback` objects that provide hooks into the Ludwig pipeline. (default: None). :type callbacks: list :param report_tqdm_to_ray: Enables using the ray based tqdm Callback for progress bar reporting :param random_seed: Default initialization for the random seeds (default: 42). :type random_seed: Float :param distributed: Distributed strategy (default: None). :type distributed: `DistributedStrategy` :param device: Device to load the model on from a saved checkpoint (default: None). :type device: str """ super().__init__() # Ensure distributed strategy is initialized for metric sync_context. # NoneTrainer may run on the head node (not in a Ray Train worker), # so init_dist_strategy may not have been called yet. from ludwig.distributed import init_dist_strategy init_dist_strategy("local") self.config = config self.distributed = distributed if distributed is not None else LocalStrategy() self.skip_save_log = skip_save_log self.resume = resume self.skip_save_model = skip_save_model self.skip_save_progress = skip_save_progress self.random_seed = random_seed self.callbacks = callbacks or [] self.report_tqdm_to_ray = report_tqdm_to_ray self.device = device if device is not None else get_torch_device() self.model = model.to_device(self.device) self.model.metrics_to_device(self.device) # Since we are only running evaluation without training, set the model to evaluation mode. self.model.eval() self.batch_size = self.config.batch_size self.eval_batch_size = self.config.eval_batch_size self.base_learning_rate = self.config.base_learning_rate self.should_shuffle = self.config.should_shuffle self.epochs = self.config.epochs self.train_steps = self.config.train_steps self.steps_per_checkpoint = self.config.steps_per_checkpoint self.checkpoints_per_epoch = self.config.checkpoints_per_epoch self.early_stop = self.config.early_stop self.evaluate_training_set = self.config.evaluate_training_set self.skip_all_evaluation = self.config.skip_all_evaluation def close_writers( self, progress_tracker, save_path, train_summary_writer, validation_summary_writer, test_summary_writer ): # ================ Finished Training ================ self.callback( lambda c: c.on_trainer_train_teardown(self, progress_tracker, save_path, self.is_coordinator()), coordinator_only=False, ) if train_summary_writer is not None: train_summary_writer.close() if validation_summary_writer is not None: validation_summary_writer.close() if test_summary_writer is not None: test_summary_writer.close() def train( self, training_set: Dataset, validation_set: Dataset | None = None, test_set: Dataset | None = None, save_path: str = MODEL_FILE_NAME, return_state_dict: bool = False, **kwargs, ): output_features = self.model.output_features # ====== Setup file names ======= tensorboard_log_dir = None if self.is_coordinator(): os.makedirs(save_path, exist_ok=True) tensorboard_log_dir = os.path.join(save_path, "logs") self.callback( lambda c: c.on_trainer_train_setup(self, save_path, self.is_coordinator()), coordinator_only=False ) train_summary_writer = None validation_summary_writer = None test_summary_writer = None if self.is_coordinator() and not self.skip_save_log and tensorboard_log_dir: train_summary_writer = SummaryWriter(os.path.join(tensorboard_log_dir, TRAINING)) if validation_set is not None and validation_set.size > 0: validation_summary_writer = SummaryWriter(os.path.join(tensorboard_log_dir, VALIDATION)) if test_set is not None and test_set.size > 0: test_summary_writer = SummaryWriter(os.path.join(tensorboard_log_dir, TEST)) set_random_seed(self.random_seed) progress_tracker = get_new_progress_tracker( batch_size=self.batch_size, learning_rate=self.base_learning_rate, best_eval_metric_value=get_initial_validation_value(self.validation_metric), best_increase_batch_size_eval_metric=get_initial_validation_value(self.validation_metric), output_features=output_features, ) # When running with Ray, we only need to return the state dict, as it's faster and cheaper to send the # state dict over the network than to load the model state here, serialize it back to a state dict, then # load it back on the head node. return_value = self.model if not return_state_dict else self.model.cpu().state_dict() if self.skip_all_evaluation: self.close_writers( progress_tracker, save_path, train_summary_writer, validation_summary_writer, test_summary_writer ) return ( return_value, progress_tracker.train_metrics, progress_tracker.validation_metrics, progress_tracker.test_metrics, ) try: self.run_evaluation( training_set, validation_set, test_set, progress_tracker, train_summary_writer, validation_summary_writer, test_summary_writer, output_features, save_path, ) finally: self.close_writers( progress_tracker, save_path, train_summary_writer, validation_summary_writer, test_summary_writer ) return ( return_value, progress_tracker.train_metrics, progress_tracker.validation_metrics, progress_tracker.test_metrics, ) def train_online( self, dataset, ): pass def tune_batch_size( self, config: ModelConfigDict, training_set: Dataset, random_seed: int = default_random_seed, max_trials: int = 20, halving_limit: int = 3, snapshot_weights: bool = True, on_best_batch_size_updated: Callable[[int, float, int], None] | None = None, tune_for_training: bool = True, ) -> int: # TODO: Implement batch size tuning for LLM, currently just returns the default batch size # Compared to ECD, this just requires forward passes till we OOM. # https://github.com/ludwig-ai/ludwig/issues/3525 return MINIMUM_BATCH_SIZE @property def validation_field(self): return self.config.validation_field @property def validation_metric(self): return self.config.validation_metric # Remote implementations may override this def shutdown(self): pass @property def local_rank(self) -> int: return 0 def barrier(self): pass # Functions needed to treat Trainer as a context manager def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): self.shutdown() @staticmethod def get_schema_cls() -> BaseTrainerConfig: return NoneTrainerConfig def is_coordinator(self) -> bool: return self.distributed.rank() == 0 def callback(self, fn, coordinator_only=True): if not coordinator_only or self.is_coordinator(): for callback in self.callbacks: fn(callback) def evaluation( self, dataset: "Dataset", # noqa: F821 dataset_name: str, metrics_log: dict[str, dict[str, list[TrainerMetric]]], batch_size: int, progress_tracker: ProgressTracker, ): predictor = LlmPredictor( self.model, batch_size=batch_size, distributed=self.distributed, report_tqdm_to_ray=self.report_tqdm_to_ray ) metrics, _ = predictor.batch_evaluation(dataset, collect_predictions=False, dataset_name=dataset_name) return append_metrics(self.model, dataset_name, metrics, metrics_log, progress_tracker) @classmethod def write_eval_summary( cls, summary_writer, metrics, step, ): if not summary_writer: return for feature_name, output_feature in metrics.items(): for metric_name, metrics in output_feature.items(): if metrics: metric_tag = f"{feature_name}/epoch_{metric_name}" metric_val = metrics[-1][-1] summary_writer.add_scalar(metric_tag, metric_val, global_step=step) summary_writer.flush() def run_evaluation( self, training_set: Union["Dataset", "RayDataset"], # noqa: F821 validation_set: Union["Dataset", "RayDataset"] | None, # noqa: F821 test_set: Union["Dataset", "RayDataset"] | None, # noqa: F821 progress_tracker: ProgressTracker, train_summary_writer: SummaryWriter, validation_summary_writer: SummaryWriter, test_summary_writer: SummaryWriter, output_features: LudwigFeatureDict, save_path: str, ) -> bool: """Runs evaluation over training, validation, and test sets. Also: - Prints results, saves results to the progress tracker. - Saves the model if the validation score is the best so far - If there is no validation set, the model is always saved. Returns whether the trainer should early stop, based on validation metrics history. """ start_time = time.time() self.callback(lambda c: c.on_eval_start(self, progress_tracker, save_path)) progress_tracker.checkpoint_number += 1 if self.is_coordinator(): logger.info(f"\nRunning evaluation for step: {progress_tracker.steps}, epoch: {progress_tracker.epoch}") # ================ Eval ================ # Run a separate pass over the training data to compute metrics # Appends results to progress_tracker.train_metrics. if self.evaluate_training_set: self.evaluation( training_set, "train", progress_tracker.train_metrics, self.eval_batch_size, progress_tracker ) self.write_eval_summary( summary_writer=train_summary_writer, metrics=progress_tracker.train_metrics, step=progress_tracker.steps, ) if validation_set is not None: self.callback(lambda c: c.on_validation_start(self, progress_tracker, save_path)) # eval metrics on validation set self.evaluation( validation_set, VALIDATION, progress_tracker.validation_metrics, self.eval_batch_size, progress_tracker, ) self.write_eval_summary( summary_writer=validation_summary_writer, metrics=progress_tracker.validation_metrics, step=progress_tracker.steps, ) self.callback(lambda c: c.on_validation_end(self, progress_tracker, save_path)) if test_set is not None: self.callback(lambda c: c.on_test_start(self, progress_tracker, save_path)) # eval metrics on test set self.evaluation(test_set, TEST, progress_tracker.test_metrics, self.eval_batch_size, progress_tracker) self.write_eval_summary( summary_writer=test_summary_writer, metrics=progress_tracker.test_metrics, step=progress_tracker.steps, ) self.callback(lambda c: c.on_test_end(self, progress_tracker, save_path)) elapsed_time = (time.time() - start_time) * 1000.0 if self.is_coordinator(): logger.info(f"Evaluation took {time_utils.strdelta(elapsed_time)}\n") print_metrics_table( output_features, progress_tracker.train_metrics, progress_tracker.validation_metrics, progress_tracker.test_metrics, ) # Trigger eval end callback after any model weights save for complete checkpoint self.callback(lambda c: c.on_eval_end(self, progress_tracker, save_path)) return False @register_llm_trainer("finetune") class FineTuneTrainer(Trainer): @staticmethod def get_schema_cls(): return FineTuneTrainerConfig def __init__( self, config: FineTuneTrainerConfig, model: LLM, resume: float = False, skip_save_model: bool = False, skip_save_progress: bool = False, skip_save_log: bool = False, callbacks: list = None, report_tqdm_to_ray=False, random_seed: int = default_random_seed, distributed: DistributedStrategy | None = None, device: str | None = None, **kwargs, ): super().__init__( config, model, resume, skip_save_model, skip_save_progress, skip_save_log, callbacks, report_tqdm_to_ray, random_seed, distributed, device, **kwargs, ) def evaluation(self, dataset, dataset_name, metrics_log, batch_size, progress_tracker): predictor = LlmFineTunePredictor( self.model, batch_size=batch_size, distributed=self.distributed, report_tqdm_to_ray=self.report_tqdm_to_ray ) metrics, _, input_target_output_dict = predictor.batch_evaluation( dataset, collect_predictions=False, dataset_name=dataset_name ) # Setting collect_predictions=True currently causes an error when doing batch evaluation because the outputs # can be of variable sizes but we try to concatenate them into a single tensor. tokenizer = self.dist_model.tokenizer # There should only be one key in the dict for LLMs input_key = list(input_target_output_dict["inputs"].keys())[0] num_examples = min(len(input_target_output_dict["inputs"][input_key]), MAX_EVALUATION_EXAMPLES) llm_eval_examples = {"inputs": [], "targets": [], "outputs": []} for key in input_target_output_dict["inputs"]: for inp in input_target_output_dict["inputs"][key][:num_examples]: llm_eval_examples["inputs"].append(tokenizer.decode(inp, skip_special_tokens=True)) for key in input_target_output_dict["targets"]: for tar in input_target_output_dict["targets"][key][:num_examples]: llm_eval_examples["targets"].append(tokenizer.decode(tar, skip_special_tokens=True)) for key in input_target_output_dict["outputs"]: for out in input_target_output_dict["outputs"][key][:num_examples]: llm_eval_examples["outputs"].append(tokenizer.decode(out, skip_special_tokens=True)) num_examples_shown = min(len(llm_eval_examples["inputs"]), MAX_EVALUATION_EXAMPLES_SHOWN) for i in range(num_examples_shown): logger.info(f"Input: {llm_eval_examples['inputs'][i].strip()}") logger.info(f"Output: {llm_eval_examples['outputs'][i].strip()}") logger.info("--------------------") progress_tracker.llm_eval_examples = llm_eval_examples return append_metrics(self.model, dataset_name, metrics, metrics_log, progress_tracker) def tune_batch_size( self, config: ModelConfigDict, training_set: Dataset, random_seed: int = default_random_seed, max_trials: int = 20, halving_limit: int = 3, snapshot_weights: bool = True, on_best_batch_size_updated: Callable[[int, float, int], None] | None = None, tune_for_training: bool = True, global_max_sequence_length: int | None = None, ) -> int: if global_max_sequence_length is None: global_max_sequence_length = self.model.global_max_sequence_length return super().tune_batch_size( config, training_set, random_seed, max_trials, halving_limit, snapshot_weights, on_best_batch_size_updated, tune_for_training, global_max_sequence_length, ) def _create_batch_size_evaluator(self) -> BatchSizeEvaluator: return LLMFinetuneTrainerBatchSizeEvaluator(self) def _create_predict_batch_size_evaluator(self) -> BatchSizeEvaluator: return LLMFinetunePredictBatchSizeEvaluator(self) class RemoteLLMTrainer(NoneTrainer): def __init__(self, gpus=None, gpu_memory_limit=None, allow_parallel_threads=True, **kwargs): super().__init__(**kwargs) # Only return results from rank 0 to reduce network overhead self.train = self.distributed.return_first(self.train) self.train_online = self.distributed.return_first(self.train_online) class RemoteLLMFineTuneTrainer(FineTuneTrainer): def __init__(self, gpus=None, gpu_memory_limit=None, allow_parallel_threads=True, **kwargs): super().__init__(**kwargs) # Only return results from rank 0 to reduce network overhead self.train = self.distributed.return_first(self.train) self.train_online = self.distributed.return_first(self.train_online) ================================================ FILE: ludwig/types.py ================================================ """Public API: Common typing for Ludwig dictionary parameters.""" from typing import Any FeatureConfigDict = dict[str, Any] """Dictionary of parameters used to configure an input or output feature. https://ludwig.ai/latest/configuration/features/supported_data_types/ """ ModelConfigDict = dict[str, Any] """Dictionary representation of the ModelConfig object. https://ludwig.ai/latest/configuration/ """ TrainingSetMetadataDict = dict[str, Any] """Training set metadata, which consists of internal configuration parameters.""" PreprocessingConfigDict = dict[str, Any] """Dictionary of parameters used to configure preprocessing. May be type-defaults global preprocessing or feature-specific preprocessing. https://ludwig.ai/latest/configuration/preprocessing/ """ HyperoptConfigDict = dict[str, Any] """Dictionary of parameters used to configure hyperopt. https://ludwig.ai/latest/configuration/hyperparameter_optimization/ """ TrainerConfigDict = dict[str, Any] """Dictionary of parameters used to configure training. https://ludwig.ai/latest/configuration/trainer/ """ FeatureTypeDefaultsDict = dict[str, FeatureConfigDict] """Dictionary of type to parameters that configure the defaults for that feature type. https://ludwig.ai/latest/configuration/defaults/ """ FeatureMetadataDict = dict[str, Any] """Metadata for a specific feature like idx2str.""" FeaturePostProcessingOutputDict = dict[str, Any] """Output from feature post-processing.""" ================================================ FILE: ludwig/upload.py ================================================ import argparse import logging import os import sys from ludwig.globals import MODEL_FILE_NAME, MODEL_HYPERPARAMETERS_FILE_NAME, MODEL_WEIGHTS_FILE_NAME from ludwig.utils.print_utils import get_logging_level_registry from ludwig.utils.upload_utils import HuggingFaceHub, Predibase logger = logging.getLogger(__name__) def get_upload_registry(): return { "hf_hub": HuggingFaceHub, "predibase": Predibase, } def upload_cli( service: str, repo_id: str, model_path: str, repo_type: str = "model", private: bool = False, commit_message: str = "Upload trained [Ludwig](https://ludwig.ai/latest/) model weights", commit_description: str | None = None, dataset_file: str | None = None, dataset_name: str | None = None, **kwargs, ) -> None: """Create an empty repo on the HuggingFace Hub and upload trained model artifacts to that repo. Args: service (`str`): Name of the hosted model service to push the trained artifacts to. Currently, this only supports `hf_hub` and `predibase`. repo_id (`str`): A namespace (user or an organization) and a repo name separated by a `/`. model_path (`str`): The path of the saved model. This is the parent-folder of the folder where the 'model_weights' folder and the 'model_hyperparameters.json' file are stored. private (`bool`, *optional*, defaults to `False`): Whether the model repo should be private. repo_type (`str`, *optional*): Set to `"dataset"` or `"space"` if uploading to a dataset or space, `None` or `"model"` if uploading to a model. Default is `None`. commit_message (`str`, *optional*): The summary / title / first line of the generated commit. Defaults to: `f"Upload {path_in_repo} with huggingface_hub"` commit_description (`str` *optional*): The description of the generated commit dataset_file (`str`, *optional*): The path to the dataset file. Required if `service` is set to `"predibase"` for new model repos. dataset_name (`str`, *optional*): The name of the dataset. Used by the `service` `"predibase"`. """ model_service = get_upload_registry().get(service, "hf_hub") hub: HuggingFaceHub = model_service() if os.path.exists(os.path.join(model_path, MODEL_FILE_NAME, MODEL_WEIGHTS_FILE_NAME)) and os.path.exists( os.path.join(model_path, MODEL_FILE_NAME, MODEL_HYPERPARAMETERS_FILE_NAME) ): experiment_path = model_path elif os.path.exists(os.path.join(model_path, MODEL_WEIGHTS_FILE_NAME)) and os.path.exists( os.path.join(model_path, MODEL_HYPERPARAMETERS_FILE_NAME) ): experiment_path = os.path.normpath(os.path.join(model_path, "..")) else: raise ValueError( f"Can't find 'model_weights' and '{MODEL_HYPERPARAMETERS_FILE_NAME}' either at " f"'{model_path}' or at '{model_path}/model'" ) hub.upload( repo_id=repo_id, model_path=experiment_path, repo_type=repo_type, private=private, commit_message=commit_message, commit_description=commit_description, dataset_file=dataset_file, dataset_name=dataset_name, ) def cli(sys_argv): parser = argparse.ArgumentParser( description="This script pushes a trained model to a hosted model repository service", prog="ludwig upload", usage="%(prog)s [options]", ) # --------------- # Required parameters # --------------- parser.add_argument( "service", help="Name of the model repository service.", default="hf_hub", choices=["hf_hub", "predibase"], ) parser.add_argument( "-r", "--repo_id", help="Name of the repo. This will be created if it doesn't exist. Format: username/repo_name", required=True, ) parser.add_argument("-m", "--model_path", help="Path of the trained model on disk", required=True) # --------------- # Optional parameters # --------------- parser.add_argument("-p", "--private", help="Make the repo private", default=False, choices=[True, False]) parser.add_argument( "-t", "--repo_type", help="Type of repo", default="model", choices=["model", "space", "dataset"] ) parser.add_argument( "-c", "--commit_message", help="The summary / title / first line of the generated commit.", default="Upload trained [Ludwig](https://ludwig.ai/latest/) model weights", ) parser.add_argument("-d", "--commit_description", help="The description of the generated commit", default=None) parser.add_argument( "-l", "--logging_level", default="info", help="The level of logging to use", choices=["critical", "error", "warning", "info", "debug", "notset"], ) parser.add_argument("-df", "--dataset_file", help="The location of the dataset file", default=None) parser.add_argument( "-dn", "--dataset_name", help="(Optional) The name of the dataset in the Provider", default=None ) args = parser.parse_args(sys_argv) args.logging_level = get_logging_level_registry()[args.logging_level] logging.getLogger("ludwig").setLevel(args.logging_level) global logger logger = logging.getLogger("ludwig.upload") upload_cli(**vars(args)) if __name__ == "__main__": cli(sys.argv[1:]) ================================================ FILE: ludwig/utils/__init__.py ================================================ ================================================ FILE: ludwig/utils/algorithms_utils.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== from ludwig.constants import TIED def topological_sort(graph_unsorted): """Repeatedly go through all of the nodes in the graph, moving each of the nodes that has all its edges resolved, onto a sequence that forms our sorted graph. A node has all of its edges resolved and can be moved once all the nodes its edges point to, have been moved from the unsorted graph onto the sorted one. """ # This is the list we'll return, that stores each node/edges pair # in topological order. graph_sorted = [] # Convert the unsorted graph into a hash table. This gives us # constant-time lookup for checking if edges are unresolved, and # for removing nodes from the unsorted graph. graph_unsorted = dict(graph_unsorted) # Run until the unsorted graph is empty. while graph_unsorted: # Go through each of the node/edges pairs in the unsorted # graph. If a set of edges does not contain any nodes that # haven't been resolved, that is, that are still in the # unsorted graph, remove the pair from the unsorted graph, # and append it to the sorted graph. Note here that by using # using the items() method for iterating, a copy of the # unsorted graph is used, allowing us to modify the unsorted # graph as we move through it. We also keep a flag for # checking that that graph is acyclic, which is true if any # nodes are resolved during each pass through the graph. If # not, we need to bail out as the graph therefore can't be # sorted. acyclic = False for node, edges in list(graph_unsorted.items()): if edges is None: edges = [] for edge in edges: if edge in graph_unsorted: break else: acyclic = True del graph_unsorted[node] graph_sorted.append((node, edges)) if not acyclic: # Uh oh, we've passed through all the unsorted nodes and # weren't able to resolve any of them, which means there # are nodes with cyclic edges that will never be resolved, # so we bail out with an error. raise RuntimeError("A cyclic dependency occurred") return graph_sorted def topological_sort_feature_dependencies(features): # topological sorting of output features for resolving dependencies dependencies_graph = {} output_features_dict = {} for feature in features: dependencies = [] if "dependencies" in feature: dependencies.extend(feature["dependencies"]) if TIED in feature: dependencies.append(feature[TIED]) dependencies_graph[feature["name"]] = dependencies output_features_dict[feature["name"]] = feature return [output_features_dict[node[0]] for node in topological_sort(dependencies_graph)] ================================================ FILE: ludwig/utils/audio_utils.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import functools import logging from io import BytesIO from typing import Any import torch import torch.nn.functional as F import torchaudio from packaging import version from ludwig.api_annotations import DeveloperAPI from ludwig.constants import DEFAULT_AUDIO_TENSOR_LENGTH from ludwig.utils.types import TorchAudioTuple logger = logging.getLogger(__name__) # https://github.com/pytorch/audio/blob/main/torchaudio/csrc/sox/types.cpp AUDIO_EXTENSIONS = (".wav", ".amb", ".mp3", ".ogg", ".vorbis", ".flac", ".opus", ".sphere") _TORCH_AUDIO_210 = version.parse(torchaudio.__version__) >= version.parse("2.1.0") _TORCH_AUDIO_201 = version.parse(torchaudio.__version__) >= version.parse("2.0.1") @DeveloperAPI def is_torch_audio_tuple(audio: Any) -> bool: if isinstance(audio, tuple): if len(audio) == 2 and isinstance(audio[0], torch.Tensor) and isinstance(audio[1], int): return True return False @DeveloperAPI def get_default_audio(audio_lst: list[TorchAudioTuple]) -> TorchAudioTuple: if not audio_lst: # Return a silent audio tensor as default when no valid audio is available default_audio_tensor = torch.zeros(1, DEFAULT_AUDIO_TENSOR_LENGTH) return default_audio_tensor, 16000 sampling_rates = [audio[1] for audio in audio_lst] tensor_list = [audio[0] for audio in audio_lst] for i, tensor in enumerate(tensor_list): if tensor.shape[1] > DEFAULT_AUDIO_TENSOR_LENGTH: tensor_list[i] = tensor[:, :DEFAULT_AUDIO_TENSOR_LENGTH] else: pad_size = DEFAULT_AUDIO_TENSOR_LENGTH - tensor.shape[1] tensor_list[i] = F.pad(tensor, (0, pad_size)) default_audio_tensor = torch.mean(torch.stack(tensor_list), dim=0) default_sampling_rate = calculate_mean(sum(sampling_rates), len(sampling_rates)) return default_audio_tensor, default_sampling_rate @DeveloperAPI def read_audio_from_path(path: str) -> TorchAudioTuple | None: """Reads audio from path. Useful for reading from a small number of paths. For more intensive reads, use backend.read_binary_files instead. """ try: if _TORCH_AUDIO_210: return torchaudio.load(path, backend="sox") elif _TORCH_AUDIO_201: return torchaudio.backend.sox_io_backend.load(path) else: return torchaudio.backend.sox_backend.load(path) except Exception as e: logger.warning(e) return None @DeveloperAPI @functools.lru_cache(maxsize=32) def read_audio_from_bytes_obj(bytes_obj: bytes) -> TorchAudioTuple | None: try: f = BytesIO(bytes_obj) return torchaudio.load(f) except Exception as e: logger.warning(e) return None def _pre_emphasize_data(data: torch.Tensor, emphasize_value: float = 0.97): # Increase precision in order to achieve parity with scipy.signal.lfilter implementation filter_window = torch.tensor([1.0, -emphasize_value], dtype=torch.float64, device=data.device) a_coeffs = torch.tensor([1, 0], dtype=torch.float64, device=data.device) pre_emphasized_data = torchaudio.functional.lfilter( data.to(dtype=torch.float64), a_coeffs, filter_window, clamp=False, ).to(torch.float32) return pre_emphasized_data @DeveloperAPI def get_length_in_samp(sampling_rate_in_hz: float | int, length_in_s: float | int) -> int: return int(sampling_rate_in_hz * length_in_s) @DeveloperAPI def get_group_delay( raw_data: torch.Tensor, sampling_rate_in_hz: int, window_length_in_s: float, window_shift_in_s: float, num_fft_points: int, window_type: str, ): X_stft_transform = _get_stft( raw_data, sampling_rate_in_hz, window_length_in_s, window_shift_in_s, num_fft_points, window_type=window_type ) Y_stft_transform = _get_stft( raw_data, sampling_rate_in_hz, window_length_in_s, window_shift_in_s, num_fft_points, window_type=window_type, data_transformation="group_delay", ) X_stft_transform_real = torch.real(X_stft_transform) X_stft_transform_imag = torch.imag(X_stft_transform) Y_stft_transform_real = torch.real(Y_stft_transform) Y_stft_transform_imag = torch.imag(Y_stft_transform) nominator = torch.multiply(X_stft_transform_real, Y_stft_transform_real) + torch.multiply( X_stft_transform_imag, Y_stft_transform_imag ) denominator = torch.square(torch.abs(X_stft_transform)) group_delay = torch.divide(nominator, denominator + 1e-10) assert not torch.isnan(group_delay).any(), "There are NaN values in group delay" return torch.transpose(group_delay, 0, 1) @DeveloperAPI def get_phase_stft_magnitude( raw_data: torch.Tensor, sampling_rate_in_hz: int, window_length_in_s: float, window_shift_in_s: float, num_fft_points: int, window_type: str, ) -> torch.Tensor: stft = _get_stft( raw_data, sampling_rate_in_hz, window_length_in_s, window_shift_in_s, num_fft_points, window_type=window_type ) abs_stft = torch.abs(stft) phase = torch.angle(stft) stft_phase = torch.cat([phase, abs_stft], dim=1) return torch.transpose(stft_phase, 0, 1) @DeveloperAPI def get_stft_magnitude( raw_data: torch.Tensor, sampling_rate_in_hz: int, window_length_in_s: float, window_shift_in_s: float, num_fft_points: int, window_type: str, ): stft = _get_stft( raw_data, sampling_rate_in_hz, window_length_in_s, window_shift_in_s, num_fft_points, window_type=window_type ) stft_magnitude = torch.abs(stft) return torch.transpose(stft_magnitude, 0, 1) ################################################################################ # The following code for FBank is adapted from jameslyons/python_speech_features # MIT licensed implementation # https://github.com/jameslyons/python_speech_features/blob/40c590269b57c64a8c1f1ddaaff2162008d1850c/python_speech_features/base.py#L84################################################################################ ################################################################################ @DeveloperAPI def get_fbank( raw_data: torch.Tensor, sampling_rate_in_hz: int, window_length_in_s: float, window_shift_in_s: float, num_fft_points: int, window_type: str, num_filter_bands: int, ) -> torch.Tensor: stft = _get_stft( raw_data, sampling_rate_in_hz, window_length_in_s, window_shift_in_s, num_fft_points, window_type=window_type, zero_mean_offset=True, ) stft_power = torch.abs(stft) ** 2 upper_limit_freq = int(sampling_rate_in_hz / 2) upper_limit_mel = _convert_hz_to_mel(upper_limit_freq) lower_limit_mel = 0 list_mel_points = torch.linspace(lower_limit_mel, upper_limit_mel, num_filter_bands + 2, device=raw_data.device) mel_fbank_matrix = _get_mel_fbank_matrix(list_mel_points, num_filter_bands, num_fft_points, sampling_rate_in_hz) mel_fbank_feature = torch.matmul(stft_power, torch.transpose(mel_fbank_matrix, 0, 1)) log_mel_fbank_feature = torch.log(mel_fbank_feature + 1.0e-10) return torch.transpose(log_mel_fbank_feature, 0, 1) def _get_mel_fbank_matrix( list_mel_points: torch.Tensor, num_filter_bands: int, num_fft_points: int, sampling_rate_in_hz: int ) -> torch.Tensor: num_ess_fft_points = get_non_symmetric_length(num_fft_points) freq_scale = (num_fft_points + 1) / sampling_rate_in_hz freq_bins_on_mel_scale = torch.floor(freq_scale * _convert_mel_to_hz(list_mel_points)) mel_scaled_fbank = torch.zeros( (num_filter_bands, num_ess_fft_points), dtype=torch.float32, device=list_mel_points.device ) for filt_idx in range(num_filter_bands): start_bin_freq = freq_bins_on_mel_scale[filt_idx] middle_bin_freq = freq_bins_on_mel_scale[filt_idx + 1] end_bin_freq = freq_bins_on_mel_scale[filt_idx + 2] mel_scaled_fbank[filt_idx] = _create_triangular_filter( start_bin_freq, middle_bin_freq, end_bin_freq, num_ess_fft_points ) return mel_scaled_fbank def _create_triangular_filter( start_bin_freq: torch.Tensor, middle_bin_freq: torch.Tensor, end_bin_freq: torch.Tensor, num_ess_fft_points: int ): filter_window = torch.zeros(num_ess_fft_points, dtype=torch.float32, device=start_bin_freq.device) filt_support_begin = middle_bin_freq - start_bin_freq filt_support_end = end_bin_freq - middle_bin_freq for freq in range(int(start_bin_freq), int(middle_bin_freq)): filter_window[freq] = (freq - start_bin_freq) / filt_support_begin for freq in range(int(middle_bin_freq), int(end_bin_freq)): filter_window[freq] = (end_bin_freq - freq) / filt_support_end return filter_window def _convert_hz_to_mel(hz: int) -> float: return float(2595.0 * torch.log10(torch.tensor(1 + hz / 700.0))) def _convert_mel_to_hz(mel): return 700.0 * (10 ** (mel / 2595.0) - 1) def _get_stft( raw_data: torch.Tensor, sampling_rate_in_hz: int, window_length_in_s: float, window_shift_in_s: float, num_fft_points: int, window_type: str, data_transformation: str | None = None, zero_mean_offset: bool = False, ) -> torch.Tensor: pre_emphasized_data = _pre_emphasize_data(raw_data) stft = _short_time_fourier_transform( pre_emphasized_data, sampling_rate_in_hz, window_length_in_s, window_shift_in_s, num_fft_points, window_type, data_transformation, zero_mean_offset, ) non_symmetric_stft = get_non_symmetric_data(stft) return non_symmetric_stft def _short_time_fourier_transform( data: torch.Tensor, sampling_rate_in_hz: int, window_length_in_s: float, window_shift_in_s: float, num_fft_points: int, window_type: str, data_transformation: str | None = None, zero_mean_offset: bool = False, ) -> torch.Tensor: window_length_in_samp: int = get_length_in_samp(window_length_in_s, sampling_rate_in_hz) window_shift_in_samp: int = get_length_in_samp(window_shift_in_s, sampling_rate_in_hz) preprocessed_data_matrix = _preprocess_to_padded_matrix( data[0], window_length_in_samp, window_shift_in_samp, zero_mean_offset=zero_mean_offset ) weighted_data_matrix = _weight_data_matrix( preprocessed_data_matrix, window_type, data_transformation=data_transformation ) fft = torch.fft.fft(weighted_data_matrix, n=num_fft_points) return fft def _preprocess_to_padded_matrix( data: torch.Tensor, window_length_in_samp: int, window_shift_in_samp: int, zero_mean_offset: bool = False ) -> torch.Tensor: num_input = data.shape[0] num_output = get_num_output_padded_to_fit_input(num_input, window_length_in_samp, window_shift_in_samp) zero_padded_matrix = torch.zeros((num_output, window_length_in_samp), dtype=torch.float32, device=data.device) for num_output_idx in range(num_output): start_idx = window_shift_in_samp * num_output_idx is_last_output = num_output_idx == num_output - 1 end_idx = start_idx + window_length_in_samp if not is_last_output else num_input end_padded_idx = window_length_in_samp if not is_last_output else end_idx - start_idx window_data = data[start_idx:end_idx] if zero_mean_offset: window_data = window_data - torch.mean(window_data) zero_padded_matrix[num_output_idx, :end_padded_idx] = window_data return zero_padded_matrix @DeveloperAPI def get_num_output_padded_to_fit_input(num_input: int, window_length_in_samp: int, window_shift_in_samp: int) -> int: num_output_valid = torch.tensor((num_input - window_length_in_samp) / window_shift_in_samp + 1) return int(torch.ceil(num_output_valid)) @DeveloperAPI def get_window(window_type: str, window_length_in_samp: int, device: torch.device | None = None) -> torch.Tensor: # Increase precision in order to achieve parity with scipy.signal.windows.get_window implementation if window_type == "bartlett": return torch.bartlett_window(window_length_in_samp, periodic=False, dtype=torch.float64, device=device).to( torch.float32 ) elif window_type == "blackman": return torch.blackman_window(window_length_in_samp, periodic=False, dtype=torch.float64, device=device).to( torch.float32 ) elif window_type == "hamming": return torch.hamming_window(window_length_in_samp, periodic=False, dtype=torch.float64, device=device).to( torch.float32 ) elif window_type == "hann": return torch.hann_window(window_length_in_samp, periodic=False, dtype=torch.float64, device=device).to( torch.float32 ) else: raise ValueError(f"Unknown window type: {window_type}") @DeveloperAPI def is_audio_score(src_path): # Used for AutoML return int(isinstance(src_path, str) and src_path.lower().endswith(AUDIO_EXTENSIONS)) def _weight_data_matrix( data_matrix: torch.Tensor, window_type: str, data_transformation: str | None = None ) -> torch.Tensor: window_length_in_samp = data_matrix[0].shape[0] window = get_window(window_type, window_length_in_samp, device=data_matrix.device) if data_transformation is not None and data_transformation == "group_delay": window *= torch.arange(window_length_in_samp, device=data_matrix.device).float() return data_matrix * window @DeveloperAPI def get_non_symmetric_length(symmetric_length: int) -> int: return int(symmetric_length / 2) + 1 @DeveloperAPI def get_non_symmetric_data(data: torch.Tensor) -> torch.Tensor: num_fft_points = data.shape[-1] num_ess_fft_points = get_non_symmetric_length(num_fft_points) return data[:, :num_ess_fft_points] @DeveloperAPI def get_max_length_stft_based(length_in_samp, window_length_in_s, window_shift_in_s, sampling_rate_in_hz): window_length_in_samp = get_length_in_samp(window_length_in_s, sampling_rate_in_hz) window_shift_in_samp = get_length_in_samp(window_shift_in_s, sampling_rate_in_hz) return get_num_output_padded_to_fit_input(length_in_samp, window_length_in_samp, window_shift_in_samp) @DeveloperAPI def calculate_incr_var(var_prev, mean_prev, mean, length): return var_prev + (length - mean_prev) * (length - mean) @DeveloperAPI def calculate_incr_mean(count, mean, length): return mean + (length - mean) / float(count) @DeveloperAPI def calculate_var(sum1, sum2, count): return (sum2 - ((sum1 * sum1) / float(count))) / float(count - 1) if count > 1 else 0.0 @DeveloperAPI def calculate_mean(sum1, count): return sum1 / float(count) ================================================ FILE: ludwig/utils/augmentation_utils.py ================================================ from ludwig.api_annotations import DeveloperAPI from ludwig.utils.registry import Registry ### # Registry for augmentation operations # Each augmentation operation is registered with the feature type it is applicable to # and the name of the operation. ### _augmentation_op_registry = Registry() @DeveloperAPI def get_augmentation_op_registry() -> Registry: return _augmentation_op_registry @DeveloperAPI def register_augmentation_op(name: str, features: str | list[str]): if isinstance(features, str): features = [features] def wrap(cls): for feature in features: augmentation_op_registry = get_augmentation_op_registry().get(feature, {}) augmentation_op_registry[name] = cls get_augmentation_op_registry()[feature] = augmentation_op_registry return cls return wrap @DeveloperAPI def get_augmentation_op(feature_type: str, op_name: str): return get_augmentation_op_registry()[feature_type][op_name] class AugmentationPipelines: """Container holding augmentation pipelines defined in the model.""" def __init__(self, augmentation_pipelines: dict): self.augmentation_pipelines = augmentation_pipelines def __getitem__(self, key): return self.augmentation_pipelines[key] def __contains__(self, key): return key in self.augmentation_pipelines def __len__(self): return len(self.augmentation_pipelines) def __iter__(self): return self.augmentation_pipelines.__iter__() def items(self): return self.augmentation_pipelines.items() ================================================ FILE: ludwig/utils/automl/__init__.py ================================================ ================================================ FILE: ludwig/utils/automl/data_source.py ================================================ from abc import ABC, abstractmethod import dask.dataframe as dd import pandas as pd from ludwig.api_annotations import DeveloperAPI from ludwig.utils.audio_utils import is_audio_score from ludwig.utils.automl.utils import avg_num_tokens from ludwig.utils.image_utils import is_image_score from ludwig.utils.misc_utils import memoized_method from ludwig.utils.types import DataFrame @DeveloperAPI class DataSource(ABC): @property @abstractmethod def columns(self) -> list[str]: raise NotImplementedError() @abstractmethod def get_dtype(self, column: str) -> str: raise NotImplementedError() @abstractmethod def get_distinct_values(self, column: str, max_values_to_return: int) -> tuple[int, list[str], float]: raise NotImplementedError() @abstractmethod def get_nonnull_values(self, column: str) -> int: raise NotImplementedError() @abstractmethod def get_avg_num_tokens(self, column: str) -> int: raise NotImplementedError() @abstractmethod def is_string_type(self, dtype: str) -> bool: raise NotImplementedError() @abstractmethod def size_bytes(self) -> int: raise NotImplementedError() @abstractmethod def __len__(self) -> int: raise NotImplementedError() @DeveloperAPI class DataframeSourceMixin: df: DataFrame @property def columns(self) -> list[str]: return self.df.columns def get_dtype(self, column: str) -> str: return self.df[column].dtype.name def get_distinct_values(self, column, max_values_to_return: int) -> tuple[int, list[str], float]: unique_values = self.df[column].dropna().unique() num_unique_values = len(unique_values) unique_values_counts = self.df[column].value_counts() if len(unique_values_counts) != 0: unique_majority_values = unique_values_counts[unique_values_counts.idxmax()] unique_minority_values = unique_values_counts[unique_values_counts.idxmin()] unique_values_balance = unique_minority_values / unique_majority_values else: unique_values_balance = 1.0 return num_unique_values, unique_values[:max_values_to_return], unique_values_balance def get_nonnull_values(self, column: str) -> int: return len(self.df[column].notnull()) def get_image_values(self, column: str, sample_size: int = 10) -> int: return int(sum(is_image_score(x) for x in self.df[column].head(sample_size))) def get_audio_values(self, column: str, sample_size: int = 10) -> int: return int(sum(is_audio_score(x) for x in self.df[column].head(sample_size))) def get_avg_num_tokens(self, column: str) -> int: return avg_num_tokens(self.df[column]) def is_string_type(self, dtype: str) -> bool: return dtype in ["str", "string", "object"] def size_bytes(self) -> int: return sum(self.df.memory_usage(deep=True)) def __len__(self) -> int: return len(self.df) @DeveloperAPI class DataframeSource(DataframeSourceMixin, DataSource): def __init__(self, df): self.df = df @DeveloperAPI class DaskDataSource(DataframeSource): @memoized_method(maxsize=1) def get_sample(self) -> pd.DataFrame: # TODO: uniform random sample return self.df.head(10000) @property def sample(self) -> pd.DataFrame: return self.get_sample() def get_distinct_values(self, column, max_values_to_return) -> tuple[int, list[str], float]: unique_values = self.df[column].drop_duplicates().dropna().persist() num_unique_values = len(unique_values) # TODO(travis): implement imbalance ratio imbalance_ratio = 1.0 return num_unique_values, unique_values.head(max_values_to_return), imbalance_ratio def get_nonnull_values(self, column) -> int: return self.df[column].notnull().sum().compute() def get_image_values(self, column: str, sample_size: int = 10) -> int: return int(sum(is_image_score(x) for x in self.sample[column].head(sample_size))) def get_audio_values(self, column: str, sample_size: int = 10) -> int: return int(sum(is_audio_score(x) for x in self.sample[column].head(sample_size))) def get_avg_num_tokens(self, column) -> int: return avg_num_tokens(self.sample[column]) @DeveloperAPI def wrap_data_source(df: DataFrame) -> DataSource: if isinstance(df, dd.DataFrame): return DaskDataSource(df) return DataframeSource(df) ================================================ FILE: ludwig/utils/automl/field_info.py ================================================ from dataclasses import dataclass from dataclasses_json import dataclass_json, LetterCase from ludwig.api_annotations import DeveloperAPI @DeveloperAPI @dataclass_json(letter_case=LetterCase.CAMEL) @dataclass class FieldInfo: name: str dtype: str key: str = None distinct_values: list = None distinct_values_balance: float = 1.0 num_distinct_values: int = 0 nonnull_values: int = 0 image_values: int = 0 audio_values: int = 0 avg_words: int = None @DeveloperAPI @dataclass_json(letter_case=LetterCase.CAMEL) @dataclass class FieldConfig: name: str column: str type: str @DeveloperAPI @dataclass_json(letter_case=LetterCase.CAMEL) @dataclass class FieldMetadata: name: str config: FieldConfig excluded: bool mode: str missing_values: float imbalance_ratio: float ================================================ FILE: ludwig/utils/automl/ray_utils.py ================================================ import os from ludwig.backend.ray import initialize_ray try: import ray except ImportError: raise ImportError(" ray is not installed. " "In order to use auto_train please run " "pip install ludwig[ray]") def _ray_init(): if ray.is_initialized(): return # Forcibly terminate trial requested to stop after this amount of time passes os.environ.setdefault("TUNE_FORCE_TRIAL_CLEANUP_S", "120") initialize_ray() ================================================ FILE: ludwig/utils/automl/type_inference.py ================================================ import logging from ludwig.api_annotations import DeveloperAPI from ludwig.constants import AUDIO, BINARY, CATEGORY, DATE, IMAGE, NUMBER, TEXT from ludwig.utils import strings_utils from ludwig.utils.automl.field_info import FieldInfo # For a given feature, the highest percentage of distinct values out of the total number of rows that we might still # assign the CATEGORY type. CATEGORY_TYPE_DISTINCT_VALUE_PERCENTAGE_CUTOFF = 0.5 # Consider the field a valid text field if it has at least 5 average words. Fewer than this and it may be a cateogry # or an ID field (like a name or place) of some kind. TEXT_AVG_WORDS_CUTOFF = 5 @DeveloperAPI def infer_type(field: FieldInfo, missing_value_percent: float, row_count: int) -> str: """Perform type inference on field. # Inputs :param field: (FieldInfo) object describing field :param missing_value_percent: (float) percent of missing values in the column :param row_count: (int) total number of entries in original dataset # Return :return: (str) feature type """ if field.dtype == DATE or field.dtype.startswith("datetime"): return DATE num_distinct_values = field.num_distinct_values distinct_values = field.distinct_values if num_distinct_values <= 1: return CATEGORY if num_distinct_values == 2 and missing_value_percent == 0: # Check that all distinct values are conventional bools. if strings_utils.are_conventional_bools(distinct_values): return BINARY if field.image_values >= 3: return IMAGE if field.audio_values >= 3: return AUDIO if strings_utils.are_all_datetimes(distinct_values): return DATE # Use CATEGORY if: # - The number of distinct values is significantly less than the total number of examples. # - The distinct values are not all numbers. # - The distinct values are all numbers but comprise of a perfectly sequential list of integers that suggests the # values represent categories. valid_row_count = row_count * (1.0 - missing_value_percent) if num_distinct_values < valid_row_count * CATEGORY_TYPE_DISTINCT_VALUE_PERCENTAGE_CUTOFF and ( (not strings_utils.are_all_numbers(distinct_values)) or strings_utils.are_sequential_integers(distinct_values) ): return CATEGORY # Use NUMBER if all of the distinct values are numbers. if strings_utils.are_all_numbers(distinct_values): return NUMBER # TODO (ASN): add other modalities (image, etc. ) # Fallback to TEXT. return TEXT @DeveloperAPI def should_exclude( idx: int, field: FieldInfo, dtype: str, column_count: int, row_count: int, targets: set[str] ) -> bool: if field.key == "PRI": logging.info(f"Exclude {field.name} ({dtype}): primary key") return True if field.name in targets: return False if field.num_distinct_values <= 1: logging.info(f"Exclude {field.name} ({dtype}): less than 2 distinct values") return True distinct_value_percent = float(field.num_distinct_values) / row_count if distinct_value_percent == 1.0: upper_name = field.name.upper() if ( (idx == 0 and "INDEX" in upper_name and dtype == NUMBER) or upper_name.endswith("ID") or upper_name.startswith("ID") ): logging.info(f"Exclude {field.name} ({dtype}): unique ID column") return True # For TEXT fields, we only want to use them if they appear "interesting", otherwise we would rather exclude # them and treat the problem as a tabular problem if column_count > 3 and dtype == TEXT and (field.avg_words or 0) < TEXT_AVG_WORDS_CUTOFF: logging.info(f"Exclude {field.name} ({dtype}): too few average words") return True return False ================================================ FILE: ludwig/utils/automl/utils.py ================================================ import bisect import logging from numpy import nan_to_num from pandas import Series from ludwig.api_annotations import DeveloperAPI from ludwig.constants import ( BINARY, CATEGORY, COMBINER, CONFIG, HYPEROPT, IMBALANCE_DETECTION_RATIO, NAME, NUMBER, PARAMETERS, SEARCH_ALG, TRAINER, TYPE, ) from ludwig.features.feature_registries import get_output_type_registry from ludwig.modules.metric_registry import get_metric_objective from ludwig.schema.combiners.utils import get_combiner_jsonschema logger = logging.getLogger(__name__) @DeveloperAPI def avg_num_tokens_decoder(x): if x is None: return None if type(x) is bytes: return x.decode("utf-8") return str(x) @DeveloperAPI def avg_num_tokens(field: Series) -> int: logger.info(f"Calculating average number tokens for field {field.name} using sample of 100 rows.") field_sample = field.head(100).apply(avg_num_tokens_decoder) unique_entries = field_sample.unique() avg_words = round(nan_to_num(Series(unique_entries).str.split().str.len().mean())) return avg_words @DeveloperAPI def get_model_type(config: dict) -> str: if ( "input_features" in config and len(config["input_features"]) == 1 and "type" in config["input_features"][0] and config["input_features"][0]["type"] == "text" ): model_type = "text" elif COMBINER in config and TYPE in config[COMBINER]: model_type = config[COMBINER][TYPE] else: default_combiner_type = get_combiner_jsonschema()["properties"]["type"]["default"] model_type = default_combiner_type return model_type # ref_configs comes from a file storing the config for a high-performing model per reference dataset. # If the automl model type matches that of any reference models, set the initial point_to_evaluate # in the automl hyperparameter search to the config of the reference model with the closest-matching # input number columns ratio. This model config "transfer learning" can improve the automl search. def _add_transfer_config(base_config: dict, ref_configs: dict) -> dict: base_model_type = base_config[COMBINER][TYPE] base_model_numeric_ratio = _get_ratio_numeric_input_features(base_config["input_features"]) min_numeric_ratio_distance = 1.0 min_dataset = None for dataset in ref_configs["datasets"]: dataset_config = dataset[CONFIG] if base_model_type == dataset_config[COMBINER][TYPE]: dataset_numeric_ratio = _get_ratio_numeric_input_features(dataset_config["input_features"]) ratio_distance = abs(base_model_numeric_ratio - dataset_numeric_ratio) if ratio_distance <= min_numeric_ratio_distance: min_numeric_ratio_distance = ratio_distance min_dataset = dataset if min_dataset is not None: logger.info("Transfer config from dataset {}".format(min_dataset["name"])) min_dataset_config = min_dataset[CONFIG] hyperopt_params = base_config[HYPEROPT][PARAMETERS] point_to_evaluate = {} _add_option_to_evaluate(point_to_evaluate, min_dataset_config, hyperopt_params, COMBINER) _add_option_to_evaluate(point_to_evaluate, min_dataset_config, hyperopt_params, TRAINER) base_config[HYPEROPT][SEARCH_ALG]["points_to_evaluate"] = [point_to_evaluate] return base_config def _get_ratio_numeric_input_features(input_features: dict) -> float: num_input_features = len(input_features) num_numeric_input = 0 for input_feature in input_features: if input_feature[TYPE] == NUMBER: num_numeric_input = num_numeric_input + 1 return num_numeric_input / num_input_features # Update point_to_evaluate w/option value from dataset_config for options in hyperopt_params. # Also, add option value to associated categories list if it is not already included. def _add_option_to_evaluate( point_to_evaluate: dict, dataset_config: dict, hyperopt_params: dict, option_type: str ) -> dict: options = dataset_config[option_type] for option in options.keys(): option_param = option_type + "." + option if option_param in hyperopt_params.keys(): option_val = options[option] point_to_evaluate[option_param] = option_val if option_val not in hyperopt_params[option_param]["categories"]: bisect.insort(hyperopt_params[option_param]["categories"], option_val) return point_to_evaluate @DeveloperAPI def set_output_feature_metric(base_config): """If single output feature, set trainer and hyperopt metric and goal for that feature if not set.""" if len(base_config["output_features"]) != 1: # If multiple output features, ludwig uses the goal of minimizing combined loss; # this could be revisited/refined in the future. return base_config output_name = base_config["output_features"][0][NAME] output_type = base_config["output_features"][0][TYPE] output_metric = get_output_type_registry()[output_type].get_schema_cls().default_validation_metric output_goal = get_metric_objective(output_metric) if "validation_field" not in base_config[TRAINER] and "validation_metric" not in base_config[TRAINER]: base_config[TRAINER]["validation_field"] = output_name base_config[TRAINER]["validation_metric"] = output_metric if ( "output_feature" not in base_config[HYPEROPT] and "metric" not in base_config[HYPEROPT] and "goal" not in base_config[HYPEROPT] ): base_config[HYPEROPT]["output_feature"] = output_name base_config[HYPEROPT]["metric"] = output_metric base_config[HYPEROPT]["goal"] = output_goal return base_config @DeveloperAPI def has_imbalanced_output(base_config, features_metadata) -> bool: """Check binary and category output feature(s) for imbalance, i.e., low minority/majority instance count ratio.""" imbalanced_output = False for output_feature in base_config["output_features"]: if output_feature[TYPE] == BINARY or output_feature[TYPE] == CATEGORY: for feature_metadata in features_metadata: if output_feature[NAME] == feature_metadata.name: if feature_metadata.imbalance_ratio < IMBALANCE_DETECTION_RATIO: logger.info( f"Imbalance in {output_feature[NAME]}: minority/majority={feature_metadata.imbalance_ratio}" ) imbalanced_output = True break return imbalanced_output ================================================ FILE: ludwig/utils/backward_compatibility.py ================================================ #! /usr/bin/env python # Copyright (c) 2022 Predibase, 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. # ============================================================================== import copy import logging import warnings from collections.abc import Callable from typing import Any from ludwig.api_annotations import DeveloperAPI from ludwig.constants import ( AUDIO, BIAS, CLASS_WEIGHTS, COLUMN, CONV_BIAS, CONV_USE_BIAS, DECODER, DEFAULT_BIAS, DEFAULT_USE_BIAS, DEFAULTS, ENCODER, EVAL_BATCH_SIZE, EXECUTOR, FORCE_SPLIT, HEIGHT, HYPEROPT, IMAGE, INPUT_FEATURES, LOSS, MISSING_VALUE_STRATEGY, MODEL_ECD, NAME, NUM_SAMPLES, NUMBER, OUTPUT_FEATURES, PARAMETERS, PREPROCESSING, PROBABILITIES, RANDOM, RAY, SAMPLER, SCHEDULER, SEARCH_ALG, SEQUENCE, SPLIT, SPLIT_PROBABILITIES, STRATIFY, TEXT, TIMESERIES, TRAINER, TRAINING, TYPE, USE_BIAS, WIDTH, ) from ludwig.features.feature_registries import get_base_type_registry, get_input_type_registry, get_output_type_registry from ludwig.globals import LUDWIG_VERSION from ludwig.schema.encoders.utils import get_encoder_cls from ludwig.types import ( FeatureConfigDict, FeatureTypeDefaultsDict, HyperoptConfigDict, ModelConfigDict, PreprocessingConfigDict, TrainerConfigDict, TrainingSetMetadataDict, ) from ludwig.utils.metric_utils import TrainerMetric from ludwig.utils.misc_utils import get_from_registry, merge_dict from ludwig.utils.version_transformation import VersionTransformation, VersionTransformationRegistry config_transformation_registry = VersionTransformationRegistry() @DeveloperAPI def register_config_transformation(version: str, prefixes: str | list[str] = []) -> Callable: """This decorator registers a transformation function for a config version. Version is the first version which requires the transform. For example, since "training" is renamed to "trainer" in 0.5, this change should be registered with 0.5. from_version < version <= to_version. Args: version: The version to register this transformation with. The earliest ludwig version which requires this transformation. prefixes: A list of keypath prefixes to apply this transformation to. If not specified, transforms the entire config dict. If a prefix indicates a list, i.e. "input_features", the transformation is applied to each element of the list (each input feature). """ if isinstance(prefixes, str): prefixes = [prefixes] def wrap(fn: Callable[[dict], dict]): config_transformation_registry.register(VersionTransformation(transform=fn, version=version, prefixes=prefixes)) return fn return wrap @DeveloperAPI def upgrade_config_dict_to_latest_version(config: ModelConfigDict) -> ModelConfigDict: """Updates config from an older version of Ludwig to the current version. If config does not have a "ludwig_version" key, all updates are applied. Args: config: A config saved by an older version of Ludwig. Returns A new copy of config, upgraded to the current Ludwig version. Returns config if config has no "ludwig_version". """ return config_transformation_registry.update_config( config, from_version=config.get("ludwig_version", "0.0"), to_version=LUDWIG_VERSION ) def upgrade_model_progress(model_progress: dict) -> dict: """Updates model progress info to be compatible with latest ProgressTracker implementation. Notably, we convert epoch-based stats to their step-based equivalents and reformat metrics into `TrainerMetric` tuples. """ ret = copy.deepcopy(model_progress) if "last_improvement_epoch" in ret: ret["last_improvement_steps"] = ret["last_improvement_epoch"] * ret["batch_size"] del ret["last_improvement_epoch"] if "last_learning_rate_reduction_epoch" in ret: ret["last_learning_rate_reduction_steps"] = ret["last_learning_rate_reduction_epoch"] * ret["batch_size"] del ret["last_learning_rate_reduction_epoch"] if "last_increase_batch_size_epoch" in ret: ret["last_increase_batch_size_steps"] = ret["last_increase_batch_size_epoch"] * ret["batch_size"] del ret["last_increase_batch_size_epoch"] if "vali_metrics" in ret: ret["validation_metrics"] = ret["vali_metrics"] del ret["vali_metrics"] for metric_group in ("train_metrics", "test_metrics", "validation_metrics"): if metric_group not in ret: continue for tgt in ret[metric_group]: for metric in ret[metric_group][tgt]: if len(ret[metric_group][tgt][metric]) == 0 or isinstance( ret[metric_group][tgt][metric][0], (tuple, list) ): continue ret[metric_group][tgt][metric] = [ TrainerMetric(i + 1, (i + 1) * ret["batch_size"], val) for i, val in enumerate(ret[metric_group][tgt][metric]) ] if "tune_checkpoint_num" not in ret: ret["tune_checkpoint_num"] = 0 # Upgrades related to extending progress tracker with explicit bests. if "checkpoint_number" not in ret: ret["checkpoint_number"] = 0 if "best_eval_metric_steps" not in ret: ret["best_eval_metric_steps"] = 0 if "best_eval_metric_epoch" not in ret: ret["best_eval_metric_epoch"] = 0 if "best_eval_metric_checkpoint_number" not in ret: ret["best_eval_metric_checkpoint_number"] = 0 if "best_eval_train_metrics" not in ret: ret["best_eval_train_metrics"] = {} if "best_eval_validation_metrics" not in ret: ret["best_eval_validation_metrics"] = {} if "best_eval_test_metrics" not in ret: ret["best_eval_test_metrics"] = {} if "best_eval_metric" in ret: ret["best_eval_metric_value"] = ret["best_eval_metric"] del ret["best_eval_metric"] if "last_improvement" in ret: del ret["last_improvement"] # Delete learning-rate related fields removed in https://github.com/ludwig-ai/ludwig/pull/2877. if "best_reduce_learning_rate_eval_metric" in ret: del ret["best_reduce_learning_rate_eval_metric"] if "last_reduce_learning_rate_eval_metric_improvement" in ret: del ret["last_reduce_learning_rate_eval_metric_improvement"] return ret def _traverse_dicts(config: Any, f: Callable[[dict], None]): """Recursively applies function f to every dictionary contained in config. f should in-place modify the config dict. f will be called on leaves first, root last. """ if isinstance(config, dict): for k, v in config.items(): _traverse_dicts(v, f) f(config) elif isinstance(config, list): for v in config: _traverse_dicts(v, f) @register_config_transformation("0.6", "backend") def _update_backend_cache_credentials(backend: dict[str, Any]) -> dict[str, Any]: if "cache_credentials" in backend: credentials = backend.get("credentials", {}) if "cache" in credentials: warnings.warn("`cache` already found in `backend.credentials`, ignoring `cache_credentials`") else: warnings.warn( "`backend.cache_credentials` has been renamed `backend.credentials.cache`", DeprecationWarning ) credentials["cache"] = backend.pop("cache_credentials") backend["credentials"] = credentials return backend @register_config_transformation("0.6", ["output_features"]) def update_class_weights_in_features(feature: FeatureConfigDict) -> FeatureConfigDict: if LOSS in feature: class_weights = feature[LOSS].get(CLASS_WEIGHTS, None) if not isinstance(class_weights, (list, dict)): class_weights = None feature[LOSS][CLASS_WEIGHTS] = class_weights return feature @register_config_transformation("0.4") def _update_level_metadata(config: ModelConfigDict) -> ModelConfigDict: # Replace parameters represented as keys with params represented as values. # Precedence is defined by first in the dictionary order, so if multiple # provided keys map to the same value, the one that appears earlier in this # dictionary will take priority. drop_params = { "sequence_length_limit": "max_sequence_length", "word_most_common": "most_common", "word_sequence_length_limit": "max_sequence_length", "word_tokenizer": "tokenizer", "word_vocab_file": "vocab_file", "char_most_common": "most_common", "char_sequence_length_limit": "max_sequence_length", "char_tokenizer": "tokenizer", "char_vocab_file": "vocab_file", } def upgrade_params(params): for key, value in drop_params.items(): if key in params: if value in params: warnings.warn( f"Removing deprecated config preprocessing parameter {key} as new param {value} already " f"present in the config", DeprecationWarning, ) else: warnings.warn( f"Renaming deprecated config preprocessing parameter {key} to {value}", DeprecationWarning, ) params[value] = params[key] del params[key] sequence_types = [SEQUENCE, TEXT, AUDIO, TIMESERIES] for dtype in sequence_types: params = config.get(PREPROCESSING, {}).get(dtype, {}) upgrade_params(params) for feature in config[INPUT_FEATURES]: if feature.get(TYPE) not in sequence_types: continue params = feature.get(PREPROCESSING, {}) upgrade_params(params) return config @register_config_transformation("0.5") def rename_training_to_trainer(config: ModelConfigDict) -> ModelConfigDict: if TRAINING in config: warnings.warn( 'Config section "training" renamed to "trainer" and will be removed in a future version', DeprecationWarning ) config[TRAINER] = config[TRAINING] del config[TRAINING] return config @register_config_transformation("0.5", ["input_features", "output_features"]) def _upgrade_use_bias_in_features(feature: FeatureConfigDict) -> FeatureConfigDict: def upgrade_use_bias(config): if BIAS in config: warnings.warn( 'Parameter "bias" renamed to "use_bias" and will be removed in a future version', DeprecationWarning ) config[USE_BIAS] = config[BIAS] del config[BIAS] if CONV_BIAS in config: warnings.warn( 'Parameter "conv_bias" renamed to "conv_use_bias" and will be removed in a future version', DeprecationWarning, ) config[CONV_USE_BIAS] = config[CONV_BIAS] del config[CONV_BIAS] if DEFAULT_BIAS in config: warnings.warn( 'Parameter "default_bias" renamed to "default_use_bias" and will be removed in a future version', DeprecationWarning, ) config[DEFAULT_USE_BIAS] = config[DEFAULT_BIAS] del config[DEFAULT_BIAS] _traverse_dicts(feature, upgrade_use_bias) return feature @register_config_transformation("0.5", ["input_features", "output_features"]) def _upgrade_feature(feature: FeatureConfigDict) -> FeatureConfigDict: """Upgrades feature config (in-place)""" if feature.get(TYPE) == "numerical": warnings.warn( 'Feature type "numerical" renamed to "number" and will be removed in a future version', DeprecationWarning ) feature[TYPE] = NUMBER if feature.get(TYPE) == AUDIO: if PREPROCESSING in feature: feature[PREPROCESSING] = upgrade_audio_preprocessing(feature[PREPROCESSING]) warnings.warn( "Parameters specified at the `audio_feature` parameter level have been unnested and should now " "be specified at the preprocessing level. Support for `audio_feature` will be removed in a future version", DeprecationWarning, ) return feature def upgrade_audio_preprocessing(preproc_dict: PreprocessingConfigDict) -> PreprocessingConfigDict: if "audio_feature" in preproc_dict: for k, v in preproc_dict["audio_feature"].items(): preproc_dict[k] = v del preproc_dict["audio_feature"] return preproc_dict @register_config_transformation("0.6", ["input_features"]) def _upgrade_encoder_params(feature: FeatureConfigDict) -> FeatureConfigDict: return _upgrade_encoder_decoder_params(feature, True) @register_config_transformation("0.6", ["output_features"]) def _upgrade_decoder_params(feature: FeatureConfigDict) -> FeatureConfigDict: return _upgrade_encoder_decoder_params(feature, False) def _upgrade_encoder_decoder_params(feature: FeatureConfigDict, input_feature: bool) -> FeatureConfigDict: """ This function nests un-nested encoder/decoder parameters to conform with the new config structure for 0.6 Args: feature (Dict): Feature to nest encoder/decoder params for. input_feature (Bool): Whether this feature is an input feature or not. """ if TYPE not in feature: return feature try: if input_feature: module_type = ENCODER feature_cls = get_from_registry(feature[TYPE], get_input_type_registry()) else: module_type = DECODER feature_cls = get_from_registry(feature[TYPE], get_output_type_registry()) except ValueError: logging.exception("Failed to obtain encoder / decoder from registry") return feature feature_schema_cls = feature_cls.get_schema_cls() feature_keys = feature_schema_cls.get_valid_field_names() # These keys have been renamed from the form below to `fc_` in the new config fc_layer_keys = [ "fc_layers", "output_size", "use_bias", "weights_initializer", "bias_initializer", "norm", "norm_params", "activation", "dropout", ] module = feature.get(module_type, {}) warn = False if isinstance(module, str): module = {TYPE: module} feature[module_type] = module warn = True nested_params = [] for k, v in feature.items(): if k not in feature_keys: module[k] = v if k in fc_layer_keys and module_type == DECODER: module[f"fc_{k}"] = v nested_params.append(k) warn = True if module: if module_type in feature: feature[module_type].update(module) else: feature[module_type] = module for k in nested_params: del feature[k] if warn: warnings.warn( f"{module_type} specific parameters should now be nested within a dictionary under the '{module_type}' " f"parameter. Support for un-nested {module_type} specific parameters will be removed in a future version", DeprecationWarning, ) return feature @register_config_transformation("0.5", ["hyperopt"]) def _upgrade_hyperopt(hyperopt: HyperoptConfigDict) -> HyperoptConfigDict: """Upgrades hyperopt config (in-place)""" # check for use of legacy "training" reference, if any found convert to "trainer" if PARAMETERS in hyperopt: hparams = hyperopt[PARAMETERS] for k, v in list(hparams.items()): substr = "training." if k.startswith(substr): warnings.warn( 'Config section "training" renamed to "trainer" and will be removed in a future version', DeprecationWarning, ) hparams["trainer." + k[len(substr) :]] = v del hparams[k] # check for legacy parameters in "executor" if EXECUTOR in hyperopt: hpexecutor = hyperopt[EXECUTOR] executor_type = hpexecutor.get(TYPE, None) if executor_type is not None and executor_type != RAY: warnings.warn( f'executor type "{executor_type}" not supported, converted to "ray" will be flagged as error ' "in a future version", DeprecationWarning, ) hpexecutor[TYPE] = RAY # if search_alg not at top level and is present in executor, promote to top level if SEARCH_ALG in hpexecutor: # promote only if not in top-level, otherwise use current top-level if SEARCH_ALG not in hyperopt: hyperopt[SEARCH_ALG] = hpexecutor[SEARCH_ALG] if isinstance(hyperopt[SEARCH_ALG], str): hyperopt[SEARCH_ALG] = {TYPE: hyperopt[SEARCH_ALG]} del hpexecutor[SEARCH_ALG] else: warnings.warn( 'Missing "executor" section, adding "ray" executor will be flagged as error in a future version', DeprecationWarning, ) hyperopt[EXECUTOR] = {TYPE: RAY} # check for legacy "sampler" section if SAMPLER in hyperopt: warnings.warn( f'"{SAMPLER}" is no longer supported, converted to "{SEARCH_ALG}". "{SAMPLER}" will be flagged as ' "error in a future version", DeprecationWarning, ) if SEARCH_ALG in hyperopt[SAMPLER]: if SEARCH_ALG not in hyperopt: hyperopt[SEARCH_ALG] = hyperopt[SAMPLER][SEARCH_ALG] if isinstance(hyperopt[SEARCH_ALG], str): hyperopt[SEARCH_ALG] = {TYPE: hyperopt[SEARCH_ALG]} warnings.warn('Moved "search_alg" to hyperopt config top-level', DeprecationWarning) # if num_samples or scheduler exist in SAMPLER move to EXECUTOR Section if NUM_SAMPLES in hyperopt[SAMPLER] and NUM_SAMPLES not in hyperopt[EXECUTOR]: hyperopt[EXECUTOR][NUM_SAMPLES] = hyperopt[SAMPLER][NUM_SAMPLES] warnings.warn('Moved "num_samples" from "sampler" to "executor"', DeprecationWarning) if SCHEDULER in hyperopt[SAMPLER] and SCHEDULER not in hyperopt[EXECUTOR]: hyperopt[EXECUTOR][SCHEDULER] = hyperopt[SAMPLER][SCHEDULER] warnings.warn('Moved "scheduler" from "sampler" to "executor"', DeprecationWarning) if SCHEDULER in hyperopt[EXECUTOR] and len(hyperopt[EXECUTOR][SCHEDULER].keys()) == 0: del hyperopt[EXECUTOR][SCHEDULER] # remove legacy section del hyperopt[SAMPLER] if SEARCH_ALG not in hyperopt: # make top-level as search_alg, if missing put in default value hyperopt[SEARCH_ALG] = {TYPE: "variant_generator"} warnings.warn( 'Missing "search_alg" at hyperopt top-level, adding in default value, will be flagged as error ' "in a future version", DeprecationWarning, ) return hyperopt @register_config_transformation("0.5", ["trainer"]) def _upgrade_trainer(trainer: TrainerConfigDict) -> TrainerConfigDict: """Upgrades trainer config (in-place)""" eval_batch_size = trainer.get(EVAL_BATCH_SIZE) if eval_batch_size == 0: warnings.warn( "`trainer.eval_batch_size` value `0` changed to `None`, will be unsupported in a future version", DeprecationWarning, ) trainer[EVAL_BATCH_SIZE] = None return trainer @register_config_transformation("0.5") def _upgrade_preprocessing_defaults(config: ModelConfigDict) -> ModelConfigDict: """Move feature-specific preprocessing parameters into defaults in config (in-place)""" type_specific_preprocessing_params = dict() # If preprocessing section specified and it contains feature specific preprocessing parameters, # make a copy and delete it from the preprocessing section for parameter in list(config.get(PREPROCESSING, {})): if parameter in get_base_type_registry(): warnings.warn( f"Moving preprocessing configuration for `{parameter}` feature type from `preprocessing` section" " to `defaults` section in Ludwig config. This will be unsupported in a future version.", DeprecationWarning, ) type_specific_preprocessing_params[parameter] = config[PREPROCESSING].pop(parameter) if parameter == "numerical": warnings.warn( f"Moving preprocessing configuration for `{parameter}` feature type from `preprocessing` section" " to `defaults` section in Ludwig config. This will be unsupported in a future version.", DeprecationWarning, ) type_specific_preprocessing_params[NUMBER] = config[PREPROCESSING].pop(parameter) # Delete empty preprocessing section if no other preprocessing parameters specified if PREPROCESSING in config and not config[PREPROCESSING]: del config[PREPROCESSING] # Update defaults with the default feature specific preprocessing parameters defaults = config.get(DEFAULTS, {}) for feature_type, preprocessing_param in type_specific_preprocessing_params.items(): if PREPROCESSING in preprocessing_param: preprocessing_param = preprocessing_param[PREPROCESSING] if feature_type == AUDIO: preprocessing_param = upgrade_audio_preprocessing(preprocessing_param) # If defaults was empty, then create a new key with feature type if feature_type not in defaults: defaults[feature_type] = {PREPROCESSING: preprocessing_param} # Feature type exists but preprocessing hasn't be specified elif PREPROCESSING not in defaults[feature_type]: defaults[feature_type][PREPROCESSING] = preprocessing_param # Update default feature specific preprocessing with parameters from config else: defaults[feature_type][PREPROCESSING].update( merge_dict(defaults[feature_type][PREPROCESSING], preprocessing_param) ) if defaults: config[DEFAULTS] = defaults return config @register_config_transformation("0.5", "preprocessing") def _upgrade_preprocessing_split(preprocessing: PreprocessingConfigDict) -> PreprocessingConfigDict: """Upgrade split related parameters in preprocessing.""" split_params = {} force_split = preprocessing.pop(FORCE_SPLIT, None) split_probabilities = preprocessing.pop(SPLIT_PROBABILITIES, None) stratify = preprocessing.pop(STRATIFY, None) if split_probabilities is not None: split_params[PROBABILITIES] = split_probabilities warnings.warn( "`preprocessing.split_probabilities` has been replaced by `preprocessing.split.probabilities`, " "will be flagged as error in a future version", DeprecationWarning, ) if stratify is not None: split_params[TYPE] = STRATIFY split_params[COLUMN] = stratify warnings.warn( "`preprocessing.stratify` has been replaced by `preprocessing.split.column` " 'when setting `preprocessing.split.type` to "stratify", ' "will be flagged as error in a future version", DeprecationWarning, ) if force_split is not None: warnings.warn( "`preprocessing.force_split` has been replaced by `preprocessing.split.type`, " "will be flagged as error in a future version", DeprecationWarning, ) if TYPE not in split_params: split_params[TYPE] = RANDOM if split_params: preprocessing[SPLIT] = split_params if AUDIO in preprocessing: if "audio_feature" in preprocessing[AUDIO]: for k, v in preprocessing[AUDIO]["audio_feature"].items(): preprocessing[AUDIO][k] = v del preprocessing[AUDIO]["audio_feature"] warnings.warn( "Parameters specified at the `audio_feature` parameter level have been unnested and should now " "be specified at the preprocessing level. Support for `audio_feature` will be removed in a future version", DeprecationWarning, ) return preprocessing @register_config_transformation("0.5") def update_training(config: ModelConfigDict) -> ModelConfigDict: if TRAINING in config: warnings.warn( 'Config section "training" renamed to "trainer" and will be removed in a future version', DeprecationWarning ) config[TRAINER] = config[TRAINING] del config[TRAINING] return config @register_config_transformation("0.6") def upgrade_missing_value_strategy(config: ModelConfigDict) -> ModelConfigDict: for input_feature in config.get(INPUT_FEATURES, []): if _is_old_missing_value_strategy(input_feature): _update_old_missing_value_strategy(input_feature) for output_feature in config.get(OUTPUT_FEATURES, []): if _is_old_missing_value_strategy(output_feature): _update_old_missing_value_strategy(output_feature) for feature, feature_defaults in config.get(DEFAULTS, {}).items(): if _is_old_missing_value_strategy(feature_defaults): _update_old_missing_value_strategy(config.get(DEFAULTS).get(feature)) return config @register_config_transformation("0.6", ["trainer"]) def _upgrade_max_batch_size(trainer: TrainerConfigDict) -> TrainerConfigDict: if "increase_batch_size_on_plateau_max" in trainer: warnings.warn( 'Config param "increase_batch_size_on_plateau_max" renamed to "max_batch_size" and will be ' "removed in a future version", DeprecationWarning, ) increase_batch_size_on_plateau_max_val = trainer.pop("increase_batch_size_on_plateau_max") if "max_batch_size" in trainer: warnings.warn('"max_batch_size" config param already set. Discarding "increase_batch_size_on_plateau_max".') else: warnings.warn( f'Setting "max_batch_size" config param to "increase_batch_size_on_plateau_max" value ' f'({increase_batch_size_on_plateau_max_val}) and discarding "increase_batch_size_on_plateau_max"' ) trainer["max_batch_size"] = increase_batch_size_on_plateau_max_val return trainer @register_config_transformation("0.6") def remove_trainer_type(config: ModelConfigDict) -> ModelConfigDict: # LLM Model types support different trainer types if config.get("model_type", None) == "llm": return config if TYPE in config.get("trainer", {}): warnings.warn( "Config param `type` has been removed from the trainer. The trainer type is determined by the top level " " `model_type` parameter. Support for the `type` params in trainer will be removed in a future version", DeprecationWarning, ) del config["trainer"][TYPE] return config @register_config_transformation("0.7", ["trainer"]) def learning_rate_scheduler(trainer: TrainerConfigDict) -> TrainerConfigDict: key_mapping = { "reduce_learning_rate_on_plateau": "reduce_on_plateau", "reduce_learning_rate_on_plateau_patience": "reduce_on_plateau_patience", "reduce_learning_rate_on_plateau_rate": "reduce_on_plateau_rate", "reduce_learning_rate_eval_metric": "reduce_eval_metric", "reduce_learning_rate_eval_split": "reduce_eval_split", "decay": "decay", "decay_steps": "decay_steps", "decay_rate": "decay_rate", "staircase": "staircase", "learning_rate_warmup_epochs": "warmup_evaluations", } lr_scheduler = trainer.get("learning_rate_scheduler", {}) for old_key, new_key in key_mapping.items(): if old_key in trainer: warnings.warn( f"Config param `trainer.{old_key}` has been moved to `trainer.learning_rate_scheduler.{new_key}`.", DeprecationWarning, ) if new_key in lr_scheduler: warnings.warn( f"`trainer.learning_rate_scheduler.{new_key}` config param already set. " f"Discarding `trainer.{old_key}`." ) else: value = trainer[old_key] if old_key == "decay" and isinstance(value, bool): # Decay has changed from a bool to an optional enum lr_scheduler[new_key] = "exponential" if value else None elif old_key == "reduce_learning_rate_on_plateau": lr_scheduler[new_key] = int(value) else: lr_scheduler[new_key] = value del trainer[old_key] if lr_scheduler: trainer["learning_rate_scheduler"] = lr_scheduler return trainer @register_config_transformation("0.7", ["input_features"]) def _upgrade_legacy_image_encoders(feature: FeatureConfigDict) -> FeatureConfigDict: if feature.get(TYPE) != IMAGE: return feature encoder_mapping = { "resnet": "_resnet_legacy", "vit": "_vit_legacy", } encoder = feature.get(ENCODER, {}) encoder_type = encoder.get(TYPE) if encoder_type not in encoder_mapping: return feature # For this version of Ludwig, only ECD supported these encoders. new_encoder_cls = get_encoder_cls(MODEL_ECD, feature[TYPE], encoder_type) new_encoder_fields = new_encoder_cls.get_valid_field_names() legacy_encoder_cls = get_encoder_cls(MODEL_ECD, feature[TYPE], encoder_mapping[encoder_type]) legacy_encoder_fields = legacy_encoder_cls.get_valid_field_names() user_fields = set(encoder.keys()) user_fields.remove(TYPE) removed_fields = legacy_encoder_fields.difference(new_encoder_fields) added_fields = new_encoder_fields.difference(legacy_encoder_fields) user_legacy_fields = user_fields.intersection(removed_fields) user_new_fields = user_fields.intersection(added_fields) if len(user_legacy_fields) > 0: if len(user_new_fields) > 0: raise ValueError( f"Intended encoder type is ambiguous. " f"Provided encoder fields matching encoder '{encoder_type}' {user_new_fields} and " f"legacy encoder '{encoder_mapping[encoder_type]}' {user_legacy_fields}. " f"Please remove features unique to one of these encoder types from your configuration." ) warnings.warn( f"Encoder '{encoder_type}' with params '{user_legacy_fields}' has been renamed to " f"'{encoder_mapping[encoder_type]}'. Please upgrade your config to use the new '{encoder_type}' as " f"support for '{encoder_mapping[encoder_type]}' is not guaranteed in future versions.", DeprecationWarning, ) # User provided legacy fields and no new fields, so we assume they intended to use the legacy encoder encoder[TYPE] = encoder_mapping[encoder_type] return feature @register_config_transformation("0.7") def upgrade_missing_hyperopt(config: ModelConfigDict) -> ModelConfigDict: hyperopt = config.get(HYPEROPT) if hyperopt == {}: # This is a deprecated form of providing a missing hyperopt section, as it violates the schema definition warnings.warn( "Config section `hyperopt: {}` is deprecated, please set `hyperopt: null` to disable hyperopt.", DeprecationWarning, ) del config[HYPEROPT] return config @register_config_transformation("0.7", "defaults") def remove_extra_type_param_in_defaults_config(defaults: FeatureTypeDefaultsDict) -> FeatureTypeDefaultsDict: """Fixes a bug introduced before 0.7.3. [1] and subsequent refactors accidentally introduced a bug where a `type` param was added to every feature in the defaults config. It was removed by [2], but made it into one of the patch releases. This transformation removes that `type` param from each section of the defaults config if it exists. [1]: https://github.com/ludwig-ai/ludwig/pull/3223 [2]: https://github.com/ludwig-ai/ludwig/pull/3258 """ defaults_copy = copy.deepcopy(defaults) for feature_type, feature_config in defaults.items(): if TYPE in feature_config: del defaults_copy[feature_type][TYPE] return defaults_copy def upgrade_metadata(metadata: TrainingSetMetadataDict) -> TrainingSetMetadataDict: # TODO(travis): stopgap solution, we should make it so we don't need to do this # by decoupling config and metadata metadata = copy.deepcopy(metadata) _upgrade_metadata_missing_values(metadata) return metadata def _upgrade_metadata_missing_values(metadata: TrainingSetMetadataDict): for k, v in metadata.items(): if isinstance(v, dict) and _is_old_missing_value_strategy(v): _update_old_missing_value_strategy(v) elif isinstance(v, dict) and _is_image_feature(v): _update_old_image_preprocessing(v) def _update_old_missing_value_strategy(feature_config: FeatureConfigDict): missing_value_strategy = feature_config.get(PREPROCESSING).get(MISSING_VALUE_STRATEGY) replacement_strategy = "bfill" if missing_value_strategy == "backfill" else "ffill" feature_name = feature_config.get(NAME) warnings.warn( f"Using `{replacement_strategy}` instead of `{missing_value_strategy}` as the missing value strategy" f" for `{feature_name}`. These are identical. `{missing_value_strategy}` will be removed in a future version", DeprecationWarning, ) feature_config[PREPROCESSING].update({MISSING_VALUE_STRATEGY: replacement_strategy}) def _is_old_missing_value_strategy(feature_config: FeatureConfigDict): if PREPROCESSING not in feature_config: return False missing_value_strategy = feature_config.get(PREPROCESSING).get(MISSING_VALUE_STRATEGY, None) if not missing_value_strategy or missing_value_strategy not in ("backfill", "pad"): return False return True def _is_image_feature(feature_config: FeatureConfigDict): preproc = feature_config.get(PREPROCESSING, {}) return HEIGHT in preproc and WIDTH in preproc def _update_old_image_preprocessing(feature_config: FeatureConfigDict): preprocessing = feature_config.get(PREPROCESSING) if not preprocessing: return preprocessing["standardize_image"] = preprocessing.get("standardize_image") ================================================ FILE: ludwig/utils/batch_size_tuner.py ================================================ import gc import logging import statistics import time from abc import ABC import torch from ludwig.api_annotations import DeveloperAPI from ludwig.constants import MAX_BATCH_SIZE_DATASET_FRACTION, MIN_POSSIBLE_BATCH_SIZE logger = logging.getLogger(__name__) TOTAL_STEPS = 5 @DeveloperAPI class BatchSizeEvaluator(ABC): def select_best_batch_size( self, dataset_len: int, max_batch_size: int | None = None, max_trials: int = 20, is_coordinator: bool | None = True, global_max_sequence_length: int | None = None, ) -> int: """Returns optimal batch size as measured by throughput (samples / sec).""" logger.info("Tuning batch size...") max_batch_size = max_batch_size or dataset_len def _is_valid_batch_size(batch_size): # make sure that batch size is valid (e.g. less than 20% of ds size and max_batch_size) is_smaller_than_training_set = batch_size <= MAX_BATCH_SIZE_DATASET_FRACTION * dataset_len is_under_max_batch_size = batch_size <= max_batch_size is_valid = is_smaller_than_training_set and is_under_max_batch_size if not is_valid and is_coordinator: logger.info( f"Batch size {batch_size} is invalid, must be less than or equal to " f"{MAX_BATCH_SIZE_DATASET_FRACTION * 100}% dataset size " f"({int(MAX_BATCH_SIZE_DATASET_FRACTION * dataset_len)} samples " f"of {dataset_len}) and less than or equal to max batch size {max_batch_size}" ) return is_valid batch_size = MIN_POSSIBLE_BATCH_SIZE best_samples_per_sec = 0 best_batch_size = None count = 0 while count < max_trials and _is_valid_batch_size(batch_size): if is_coordinator: logger.info(f"Exploring batch_size={batch_size}") gc.collect() try: samples_per_sec = self.evaluate( batch_size, total_steps=TOTAL_STEPS, global_max_sequence_length=global_max_sequence_length ) if is_coordinator: logger.info(f"Throughput at batch_size={batch_size}: {samples_per_sec:.5f} samples/s") if samples_per_sec < best_samples_per_sec: # We assume that once the throughput starts degrading, it won't go up again if is_coordinator: logger.info(f"Throughput decrease at batch_size={batch_size}") break best_samples_per_sec = samples_per_sec best_batch_size = batch_size count += 1 # double batch size batch_size *= 2 except RuntimeError as e: # PyTorch only generates Runtime errors for CUDA OOM. gc.collect() if "CUDA out of memory" in str(e) or isinstance(e, torch.cuda.OutOfMemoryError): if is_coordinator: logger.info(f"OOM at batch_size={batch_size}") else: # Not a CUDA error raise break # Ensure that some batch size is found. # `best_batch_size` can be None if the first batch size is invalid. if best_batch_size is None: if is_coordinator: logger.info(f"Could not tune batch size, using minimum batch size of {MIN_POSSIBLE_BATCH_SIZE}") best_batch_size = MIN_POSSIBLE_BATCH_SIZE if is_coordinator: logger.info(f"Selected batch_size={best_batch_size}") return best_batch_size def evaluate(self, batch_size: int, total_steps: int = 5, global_max_sequence_length: int | None = None) -> float: """Evaluates throughput of the given batch size. Return: Median throughput in samples / sec. """ durations = [] for _ in range(total_steps): self.reset() start_ts = time.time() self.step(batch_size, global_max_sequence_length=global_max_sequence_length) durations.append(time.time() - start_ts) med_duration_s = statistics.median(durations) if med_duration_s == 0.0: return float("inf") return batch_size / med_duration_s def reset(self): """Called at the beginning of each evaluation step.""" def step(self, batch_size: int, global_max_sequence_length: int | None = None): """Called each step to evaluate the given batch size.""" raise NotImplementedError("`step` must be implemented by concrete evaluator.") class BaseLLMBatchSizeEvaluator(BatchSizeEvaluator): """Base class for batch size evaluators for LLM models.""" def __init__(self, trainer): self.trainer = trainer self.input_feature_name, self.input_feature = list(trainer.model.input_features.items())[0] self.output_feature_name, self.output_feature = list(trainer.model.output_features.items())[0] # Get the length of the longest input sequence from the training data self.input_msl = self.input_feature.input_shape[0] if trainer.model.config_obj.input_features[0].preprocessing.max_sequence_length: self.input_msl = trainer.model.config_obj.input_features[0].preprocessing.max_sequence_length # Get the length of the longest output sequence from the training data self.output_msl = self.output_feature.output_shape[0] if trainer.model.config_obj.output_features[0].preprocessing.max_sequence_length: self.output_msl = trainer.model.config_obj.output_features[0].preprocessing.max_sequence_length # This is useful to create the synthetic input and target data which will be a # random sequence of integers between 0 and vocab_size self.vocab_size = len(trainer.model.config_obj.input_features[0].encoder.vocab) def reset(self): self.trainer.model.reset_metrics() self.trainer.optimizer.zero_grad() def step(self, batch_size: int, global_max_sequence_length: int | None = None): if global_max_sequence_length and self.input_msl + self.output_msl > global_max_sequence_length: # In this case, we just need to make sure that the length of the synthetic data exceeds # max_sequence_length by at most a small amount self.input_msl = global_max_sequence_length // 2 + 1 self.output_msl = global_max_sequence_length // 2 + 1 inputs = { self.input_feature_name: torch.randint(0, self.vocab_size, size=(batch_size, self.input_msl)) .to(self.input_feature.input_dtype) .to(self.trainer.device) } targets = { self.output_feature_name: torch.randint(0, self.vocab_size, size=(batch_size, self.output_msl)) .to(self.output_feature.get_output_dtype()) .to(self.trainer.device) } self.perform_step(inputs, targets) def perform_step(self, inputs, targets): raise NotImplementedError("perform_step method must be implemented in subclasses") class LLMFinetuneTrainerBatchSizeEvaluator(BaseLLMBatchSizeEvaluator): """Batch size evaluator for training batch size for LLM finetuning.""" def perform_step(self, inputs, targets): self.trainer.train_step(inputs, targets) class LLMFinetunePredictBatchSizeEvaluator(BaseLLMBatchSizeEvaluator): """Batch size evaluator for prediction/evaluation batch size for LLM finetuning.""" def perform_step(self, inputs, targets): with torch.no_grad(): self.trainer.dist_model((inputs, targets)) ================================================ FILE: ludwig/utils/calibration.py ================================================ #! /usr/bin/env python # Copyright (c) 2022 Predibase, 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. # ============================================================================== import logging from abc import ABC, abstractmethod from dataclasses import dataclass import numpy as np import torch import torch.nn as nn from ludwig.api_annotations import DeveloperAPI from ludwig.constants import BINARY, CATEGORY from ludwig.utils.registry import DEFAULT_KEYS, Registry logger = logging.getLogger(__name__) calibration_registry = Registry() @DeveloperAPI def register_calibration(name: str, features: str | list[str], default=False): """Registers a calibration implementation for a list of features.""" if isinstance(features, str): features = [features] def wrap(cls): for feature in features: feature_registry = calibration_registry.get(feature, {}) feature_registry[name] = cls if default: for key in DEFAULT_KEYS: feature_registry[key] = cls calibration_registry[feature] = feature_registry return cls return wrap @DeveloperAPI def get_calibration_cls(feature: str, calibration_method: str) -> type["CalibrationModule"]: """Get calibration class for specified feature type and calibration method.""" if not calibration_method: return None if feature in calibration_registry: if calibration_method in calibration_registry[feature]: return calibration_registry[feature][calibration_method] else: raise ValueError(f"Calibration method {calibration_method} not supported for {feature} output features") else: raise ValueError(f"Calibration not yet supported for {feature} output features") return None @DeveloperAPI class ECELoss(nn.Module): """Calculates the Expected Calibration Error of a model. The input to this loss is the logits of a model, NOT the softmax scores. This divides the confidence outputs into equally-sized interval bins. In each bin, we compute the confidence gap: bin_gap = | avg_confidence_in_bin - accuracy_in_bin | We then return an average of the gaps, weighted by the number of samples in each bin. References: Naeini, Mahdi Pakdaman, Gregory F. Cooper, and Milos Hauskrecht "Obtaining Well Calibrated Probabilities Using Bayesian Binning." AAAI. 2015. Chuan Guo, Geoff Pleiss, Yu Sun, Kilian Q. Weinberger "On Calibration of Modern Neural Networks." PMLR 2017. """ def __init__(self, n_bins: int = 15): """n_bins (int): number of confidence interval bins.""" super().__init__() bin_boundaries = torch.linspace(0, 1, n_bins + 1) self.bin_lowers = bin_boundaries[:-1] self.bin_uppers = bin_boundaries[1:] def forward(self, logits: torch.Tensor, one_hot_labels: torch.Tensor) -> torch.Tensor: softmaxes = nn.functional.softmax(logits, dim=1) confidences, predictions = torch.max(softmaxes, 1) labels = torch.argmax(one_hot_labels, 1) accuracies = predictions.eq(labels) ece = torch.zeros(1, device=logits.device) for bin_lower, bin_upper in zip(self.bin_lowers, self.bin_uppers): # Calculates |confidence - accuracy| in each bin in_bin = confidences.gt(bin_lower.item()) * confidences.le(bin_upper.item()) prop_in_bin = in_bin.float().mean() if prop_in_bin.item() > 0: accuracy_in_bin = accuracies[in_bin].float().mean() avg_confidence_in_bin = confidences[in_bin].mean() ece += torch.abs(avg_confidence_in_bin - accuracy_in_bin) * prop_in_bin return ece @DeveloperAPI @dataclass class CalibrationResult: """Tracks results of probability calibration.""" before_calibration_nll: float before_calibration_ece: float after_calibration_nll: float after_calibration_ece: float @DeveloperAPI class CalibrationModule(nn.Module, ABC): @abstractmethod def train_calibration( self, logits: torch.Tensor | np.ndarray, labels: torch.Tensor | np.ndarray ) -> CalibrationResult: """Calibrate output probabilities using logits and labels from validation set.""" return NotImplementedError() @DeveloperAPI @register_calibration("temperature_scaling", [BINARY, CATEGORY], default=True) class TemperatureScaling(CalibrationModule): """Implements temperature scaling of logits. Based on results from "On Calibration of Modern Neural Networks": https://arxiv.org/abs/1706.04599. Temperature scaling scales all logits by the same constant factor. Though it may modify output probabilities it will never change argmax or categorical top-n predictions. In the case of binary classification with a threshold, however, calibration may change predictions. Implementation inspired by https://github.com/gpleiss/temperature_scaling Args: num_classes: The number of classes. Must be 2 if binary is True. binary: If binary is true, logits is expected to be a 1-dimensional array. If false, logits is a 2-dimensional array of shape (num_examples, num_classes). """ def __init__(self, num_classes: int = 2, binary: bool = False): super().__init__() self.num_classes = 2 if binary else num_classes self.binary = binary self.device = "cuda" if torch.cuda.is_available() and torch.cuda.device_count() > 0 else "cpu" self.temperature = nn.Parameter(torch.ones(1), requires_grad=False).to(self.device) def train_calibration( self, logits: torch.Tensor | np.ndarray, labels: torch.Tensor | np.ndarray ) -> CalibrationResult: logits = torch.as_tensor(logits, dtype=torch.float32, device=self.device) labels = torch.as_tensor(labels, dtype=torch.int64, device=self.device) one_hot_labels = nn.functional.one_hot(labels, self.num_classes).float() if self.binary: # Treat binary classification as multi-class with 2 classes to re-use code. # The math works out the same: softmax([0, a])[1] == sigmoid(a) logits = torch.stack([torch.zeros_like(logits), logits], axis=-1) nll_criterion = nn.CrossEntropyLoss().to(self.device) ece_criterion = ECELoss().to(self.device) # Saves the original temperature parameter, in case something goes wrong in optimization. original_temperature = self.temperature.clone().detach() self.temperature.requires_grad = True # Calculate NLL and ECE before temperature scaling before_calibration_nll = nll_criterion(logits, one_hot_labels).item() before_calibration_ece = ece_criterion(logits, one_hot_labels).item() logger.info( "Before temperature scaling:\n" " Negative log-likelihood: %.3f\n" " Expected Calibration Error: %.3f" % (before_calibration_nll, before_calibration_ece) ) # Optimizes the temperature to minimize NLL optimizer = torch.optim.LBFGS([self.temperature], lr=0.01, max_iter=50, line_search_fn="strong_wolfe") def eval(): optimizer.zero_grad() loss = nll_criterion(self.scale_logits(logits), one_hot_labels) loss.backward() return loss optimizer.step(eval) # Calculate NLL and ECE after temperature scaling after_calibration_nll = nll_criterion(self.scale_logits(logits), one_hot_labels).item() after_calibration_ece = ece_criterion(self.scale_logits(logits), one_hot_labels).item() logger.info("Optimal temperature: %.3f" % self.temperature.item()) logger.info( "After temperature scaling:\n" " Negative log-likelihood: %.3f\n" " Expected Calibration Error: %.3f" % (after_calibration_nll, after_calibration_ece) ) self.temperature.requires_grad = False # This should never happen, but if expected calibration error is higher after optimizing temperature, revert. if after_calibration_ece > before_calibration_ece: logger.warning( "Expected calibration error higher after scaling, " "reverting to temperature=%.3f." % original_temperature.item() ) with torch.no_grad(): self.temperature.data = original_temperature.data return CalibrationResult( before_calibration_nll, before_calibration_ece, after_calibration_nll, after_calibration_ece ) def scale_logits(self, logits: torch.Tensor) -> torch.Tensor: return torch.div(logits, self.temperature) def forward(self, logits: torch.Tensor) -> torch.Tensor: """Converts logits to probabilities.""" scaled_logits = self.scale_logits(logits) if self.binary: return torch.sigmoid(scaled_logits) else: return torch.softmax(scaled_logits, -1) @DeveloperAPI @register_calibration("matrix_scaling", CATEGORY, default=False) class MatrixScaling(CalibrationModule): """Implements matrix scaling of logits, as described in Beyond temperature scaling: Obtaining well-calibrated multiclass probabilities with Dirichlet calibration https://arxiv.org/abs/1910.12656. Unlike temperature scaling which has only one free parameter, matrix scaling has n_classes x (n_classes + 1) parameters. Use this only with a large validation set, as matrix scaling has a tendency to overfit small datasets. Also, unlike temperature scaling, matrix scaling can change the argmax or top-n predictions. NOTE: Matrix Scaling is not exposed in the UI or config yet, though it may be in a future release after testing. Args: num_classes: The number of classes. off_diagonal_l2: The regularization weight for off-diagonal matrix entries. mu: The regularization weight for bias vector. Defaults to off_diagonal_l2 if not specified. """ def __init__(self, num_classes: int = 2, off_diagonal_l2: float = 0.01, mu: float = None): super().__init__() self.num_classes = num_classes self.device = "cuda" if torch.cuda.is_available() and torch.cuda.device_count() > 0 else "cpu" self.w = nn.Parameter(torch.eye(self.num_classes), requires_grad=False).to(self.device) self.b = nn.Parameter(torch.zeros(self.num_classes), requires_grad=False).to(self.device) self.off_diagonal_l2 = off_diagonal_l2 self.mu = off_diagonal_l2 if mu is None else mu def train_calibration( self, logits: torch.Tensor | np.ndarray, labels: torch.Tensor | np.ndarray ) -> CalibrationResult: logits = torch.as_tensor(logits, dtype=torch.float32, device=self.device) labels = torch.as_tensor(labels, dtype=torch.int64, device=self.device) one_hot_labels = nn.functional.one_hot(labels, self.num_classes).float() nll_criterion = nn.CrossEntropyLoss().to(self.device) ece_criterion = ECELoss().to(self.device) self.w.requires_grad = True self.b.requires_grad = True # Calculate NLL and ECE before temperature scaling before_calibration_nll = nll_criterion(logits, one_hot_labels).item() before_calibration_ece = ece_criterion(logits, one_hot_labels).item() logger.info( "Before matrix scaling:\n" " Negative log-likelihood: %.3f\n" " Expected Calibration Error: %.3f" % (before_calibration_nll, before_calibration_ece) ) # Optimizes the linear transform to minimize NLL optimizer = torch.optim.LBFGS([self.w, self.b], lr=0.001, max_iter=200, line_search_fn="strong_wolfe") def eval(): optimizer.zero_grad() loss = nll_criterion(self.scale_logits(logits), one_hot_labels) + self.regularization_terms() loss.backward() return loss optimizer.step(eval) # Calculate NLL and ECE after matrix scaling after_calibration_nll = nll_criterion(self.scale_logits(logits), one_hot_labels).item() after_calibration_ece = ece_criterion(self.scale_logits(logits), one_hot_labels).item() logger.info( "After matrix scaling:\n" " Negative log-likelihood: %.3f\n" " Expected Calibration Error: %.3f" % (after_calibration_nll, after_calibration_ece) ) self.w.requires_grad = False self.b.requires_grad = False # This should never happen, but if expected calibration error is higher after optimizing matrix, revert. if after_calibration_ece > before_calibration_ece: logger.warning("Expected calibration error higher after matrix scaling, reverting to identity.") with torch.no_grad(): self.w.data = torch.eye(self.num_classes) self.b.data = torch.zeros(self.num_classes) return CalibrationResult( before_calibration_nll, before_calibration_ece, after_calibration_nll, after_calibration_ece ) def regularization_terms(self) -> torch.Tensor: """Off-Diagonal and Intercept Regularisation (ODIR). Described in "Beyond temperature scaling: Obtaining well-calibrated multiclass probabilities with Dirichlet calibration" https://proceedings.neurips.cc/paper/2019/file/8ca01ea920679a0fe3728441494041b9-Paper.pdf """ off_diagonal_entries = torch.masked_select( self.w, ~torch.eye(self.num_classes, dtype=bool, device=self.w.device) ) weight_matrix_loss = self.off_diagonal_l2 * torch.linalg.vector_norm(off_diagonal_entries) bias_vector_loss = self.mu * torch.linalg.vector_norm(self.b, 2) return bias_vector_loss + weight_matrix_loss def scale_logits(self, logits: torch.Tensor) -> torch.Tensor: return torch.matmul(self.w, logits.T).T + self.b def forward(self, logits: torch.Tensor) -> torch.Tensor: """Converts logits to probabilities.""" return torch.softmax(self.scale_logits(logits), -1) ================================================ FILE: ludwig/utils/carton_utils.py ================================================ import asyncio import importlib.util import logging import os import shutil import sys import tempfile import traceback from typing import Any import torch from ludwig.api import LudwigModel from ludwig.api_annotations import DeveloperAPI from ludwig.constants import NAME from ludwig.types import ModelConfigDict from ludwig.utils.fs_utils import open_file logger = logging.getLogger(__name__) INFERENCE_MODULE_TEMPLATE = """ from typing import Any, Dict, List, Tuple, Union import torch from ludwig.utils.types import TorchscriptPreprocessingInput class GeneratedInferenceModule(torch.nn.Module): def __init__(self, inference_module): super().__init__() self.inference_module = inference_module def forward(self, inputs: Dict[str, Any]): retyped_inputs: Dict[str, TorchscriptPreprocessingInput] = {{}} for k, v in inputs.items(): assert isinstance(v, TorchscriptPreprocessingInput) retyped_inputs[k] = v results = self.inference_module(retyped_inputs) return {output_dicts} """ def _get_output_dicts(config: ModelConfigDict) -> str: results = [] for feature in config["output_features"]: name = feature[NAME] results.append(f'"{name}": results["{name}"]["predictions"]') return "{" + ", ".join(results) + "}" @DeveloperAPI def generate_carton_torchscript(model: LudwigModel): config = model.config inference_module = model.to_torchscript() with tempfile.TemporaryDirectory() as tmpdir: ts_path = os.path.join(tmpdir, "generated.py") with open_file(ts_path, "w") as f: f.write( INFERENCE_MODULE_TEMPLATE.format( output_dicts=_get_output_dicts(config), ) ) spec = importlib.util.spec_from_file_location("generated.ts", ts_path) gen_ts = importlib.util.module_from_spec(spec) spec.loader.exec_module(gen_ts) gen_module = gen_ts.GeneratedInferenceModule(inference_module) scripted_module = torch.jit.script(gen_module) return scripted_module def _get_input_spec(model: LudwigModel) -> list[dict[str, Any]]: from cartonml import TensorSpec spec = [] for feature_name, feature in model.model.input_features.items(): metadata = model.training_set_metadata[feature_name] spec.append( TensorSpec( name=feature.feature_name, dtype=feature.get_preproc_input_dtype(metadata), shape=("batch_size",) ) ) return spec def _get_output_spec(model: LudwigModel) -> list[dict[str, Any]]: from cartonml import TensorSpec spec = [] for feature_name, feature in model.model.output_features.items(): metadata = model.training_set_metadata[feature_name] spec.append( TensorSpec( name=feature.feature_name, dtype=feature.get_postproc_output_dtype(metadata), shape=("batch_size",) ) ) return spec @DeveloperAPI def export_carton(model: LudwigModel, carton_path: str, carton_model_name="ludwig_model"): try: import cartonml as carton except ImportError: raise RuntimeError('The "cartonml-nightly" package is not installed in your environment.') # Generate a torchscript model model_ts = generate_carton_torchscript(model) with tempfile.TemporaryDirectory() as tmpdir: # Save the model to a temp dir input_model_path: str = os.path.join(tmpdir, "model.pt") torch.jit.save(model_ts, input_model_path) # carton.pack is an async function so we run it and wait until it's complete # See https://pyo3.rs/v0.20.0/ecosystem/async-await#a-note-about-asynciorun for why we wrap it # in another function async def pack() -> str: try: return await carton.pack( path=input_model_path, runner_name="torchscript", # Any 2.x.x version is okay # TODO: improve this required_framework_version="=2", model_name=carton_model_name, inputs=_get_input_spec(model), outputs=_get_output_spec(model), ) except Exception as e: exception_message: str = 'An Exception inside "pack()" occurred.\n' exception_traceback: str = traceback.format_exc() exception_message += f'{type(e).__name__}: "{str(e)}". Traceback: "{exception_traceback}".' sys.stderr.write(exception_message) sys.stderr.flush() raise ValueError(exception_message) from e # Re-raise error for calling function to handle. try: tmp_out_path: str = asyncio.run(pack()) # Move it to the output path shutil.move(tmp_out_path, carton_path) except Exception as e: exception_message: str = 'An Exception inside "export_carton()" occurred.\n' exception_traceback: str = traceback.format_exc() exception_message += f'{type(e).__name__}: "{str(e)}". Traceback: "{exception_traceback}".' sys.stderr.write(exception_message) sys.stderr.flush() raise SystemExit(exception_message) from e # Make sure error is fatal. ================================================ FILE: ludwig/utils/checkpoint_utils.py ================================================ """Implements similar functionality as tf.train.Checkpoint and tf.train.CheckpointManager. https://gist.github.com/kevinzakka/5d345421f7abefd5dbaf6a77f829e70a. """ import errno import logging import os import re import shutil import signal import tempfile import uuid from abc import ABC, abstractmethod from collections.abc import Mapping from glob import glob from typing import Any, TYPE_CHECKING import torch from torch.optim import Optimizer from ludwig.api_annotations import DeveloperAPI from ludwig.globals import MODEL_WEIGHTS_FILE_NAME from ludwig.modules.lr_scheduler import LRScheduler if TYPE_CHECKING: from ludwig.distributed.base import DistributedStrategy from ludwig.models.base import BaseModel logger = logging.getLogger(__name__) LATEST = "latest" BEST = "best" @DeveloperAPI def mkdir(s): """Create a directory if it doesn't already exist.""" if not os.path.exists(s): os.makedirs(s) @DeveloperAPI def get_files(d, pattern, sort=True): """Return a list of files in a given directory. Args: d (str): The path to the directory. pattern (str): The wildcard to filter files with. sort (bool): Whether to sort the returned list. Assumes filenames contain a number value to sort by (tmp-001). """ files = glob(os.path.join(d, pattern)) files = [f for f in files if os.path.isfile(f)] if sort: def filter_numeric(s): return re.sub("[^0-9]", "", s) files.sort(key=lambda x: int(filter_numeric(os.path.basename(x).split(".")[0]))) return files @DeveloperAPI def get_latest_checkpoint_path(directory: str) -> str: latest_path = os.path.join(directory, f"{LATEST}.ckpt") if os.path.exists(latest_path): return latest_path # Legacy codepath for checkpoints saved by global step number ckpts = get_files(directory, "*.ckpt") if ckpts: return ckpts[-1] return None @DeveloperAPI class Checkpoint(ABC): """Save and restore model and optimizer states.""" def __init__( self, distributed: "DistributedStrategy", model: "BaseModel", optimizer: Optimizer | None = None, scheduler: LRScheduler | None = None, ): """Constructor.""" self.distributed = distributed self.model = model self.optimizer = optimizer self.scheduler = scheduler self.global_step = 0 def prepare(self, directory: str): # create checkpoint directory if it doesn't # already exist mkdir(directory) @abstractmethod def load(self, save_path: str, device: torch.device | None = None) -> bool: pass @abstractmethod def get_state_for_inference(self, save_path: str, device: torch.device | None = None) -> Mapping[str, Any]: pass @abstractmethod def save(self, save_path: str, global_step: int): pass def _get_global_step(self, state: dict[str, Any], save_path: str) -> int: global_step = state.get("global_step") if global_step is None: # Legacy step detection for older checkpoint format which encoded the # step number in the checkpoint filename. return int(os.path.basename(save_path).split(".")[0]) return global_step @DeveloperAPI class MultiNodeCheckpoint(Checkpoint): def prepare(self, directory: str): if self.is_local_rank_0(): super().prepare(directory) self.distributed.barrier() def load(self, save_path: str, device: torch.device | None = None) -> bool: """Load state from a saved checkpoint. Args: save_path (str): The filepath to the saved checkpoint. device (torch.device): The device on which to load the state. Returns: True if the checkpoint was sucessfully loaded, False if the checkpoint file could not be found. """ try: state = torch.load(save_path, map_location=device) try: self.global_step = self._get_global_step(state, save_path) _, unexpected_keys = self.model.load_state_dict(state[MODEL_WEIGHTS_FILE_NAME], strict=False) assert unexpected_keys == [], f"Unexpected keys found in state dict: {unexpected_keys}" if self.optimizer is not None: self.optimizer.load_state_dict(state["optim_state"]) if self.scheduler is not None and "scheduler_state" in state: self.scheduler.load_state_dict(state["scheduler_state"]) logger.info(f"Successfully loaded model weights from {save_path}.") return True except Exception as e: # there was an issue loading the state which means # either the model definition and saved weights # do not agree or they were not saved in the first # place. # since this is a severe issue, we raise an error # rather than allowing the program to proceed. raise e except FileNotFoundError as e: logger.error(e) return False def get_state_for_inference(self, save_path: str, device: torch.device | None = None) -> Mapping[str, Any]: state = torch.load(save_path, map_location=device) return state[MODEL_WEIGHTS_FILE_NAME] def save(self, save_path: str, global_step: int): """Save a state to disk. Modified from brentyi/fannypack. Args: save_path (str): The name of the checkpoint to save. global_step (int): The iteration number which will be used to name the checkpoint. """ if self.is_local_rank_0(): state = { "global_step": global_step, MODEL_WEIGHTS_FILE_NAME: self.get_model_state_dict(), } if self.optimizer is not None: state["optim_state"] = self.optimizer.state_dict() if self.scheduler is not None: state["scheduler_state"] = self.scheduler.state_dict() # ignore ctrl+c while saving try: orig_handler = signal.getsignal(signal.SIGINT) signal.signal(signal.SIGINT, lambda _sig, _frame: None) except ValueError: # signal throws a ValueError if we're not in the main thread orig_handler = None try: # atomic save with tempfile.TemporaryDirectory() as tmpdir: # Save to a temporary directory outside of the checkpoint dir so # async processes do not try and copy a partially-written checkpoint. # See Ray Tune and MLFlow for examples of background processes that # are affected by this. tmp_path = os.path.join(tmpdir, "temp.ckpt") torch.save(state, tmp_path) self.safe_move_file(tmp_path, save_path) logger.debug(f"Saved checkpoint at {save_path}.") finally: # restore SIGINT handler if orig_handler is not None: signal.signal(signal.SIGINT, orig_handler) self.distributed.barrier() def get_model_state_dict(self) -> dict[str, Any]: state = self.model.state_dict() # Remove frozen parameter weights from state_dict for adapters and pretrained models for n, p in self.model.named_parameters(): if n in state and not p.requires_grad: del state[n] return state def is_local_rank_0(self) -> bool: return self.distributed.local_rank() == 0 def safe_move_file(self, src: str, dst: str): """Move a file from one directory to another, possibly across filesystems. This implementation specifically addresses the following issue with distributed training: 1. The `save_path` is a directory local to the node, in which case every node should write checkpoints separately. 2. The `save_path` is a remote / global filesystem like NFS, in which case only the coordinator should write checkpoints. """ try: os.replace(src, dst) except OSError as err: if err.errno == errno.EXDEV: # Tried to move to an external filesystem. This means we should only run this on the coordinator if not self.distributed.is_coordinator(): logger.info( f"Skipping writing checkpoint from rank {self.distributed.rank()} as it is not the coordinator " f"and the destination filesystem is remote." ) return # Generate a unique ID, and copy `` to the target directory with a temporary name `..tmp`. # Because we're copying across a filesystem boundary, this initial copy may not be atomic. We insert a # random UUID so if different processes are copying into ``, they don't overlap in their tmp # copies. copy_id = uuid.uuid4() tmp_dst = f"{dst}.{copy_id}.tmp" shutil.copyfile(src, tmp_dst) # Atomic replace file onto the new name, and clean up original source file. os.replace(tmp_dst, dst) os.unlink(src) else: raise @DeveloperAPI class CheckpointManager: """A model and optimizer checkpoint manager.""" def __init__(self, checkpoint: Checkpoint, directory: str, device: torch.device): """Constructor. Args: checkpoint (Checkpoint): An instance of `Checkpoint`. directory (str): The directory in which checkpoints will be saved. device (torch.device): The computing device on which to restore checkpoints. """ self.checkpoint = checkpoint self.directory = directory self.device = device self.latest_checkpoint = None self.checkpoint.prepare(self.directory) def restore_or_initialize(self) -> int: """Restore items in checkpoint from the latest checkpoint file. Returns: The global iteration step. This is parsed from the latest checkpoint file if one is found, else 0 is returned. """ last_ckpt = get_latest_checkpoint_path(self.directory) if last_ckpt: status = self.checkpoint.load(last_ckpt, self.device) if not status: logger.warning("Could not restore latest checkpoint file.") return 0 self.latest_checkpoint = last_ckpt return self.checkpoint.global_step return 0 def save(self, global_step: int, tag: str = LATEST): """Create a new checkpoint. Args: global_step (int): The iteration number which will be used to name the checkpoint. """ save_path = os.path.join(self.directory, f"{tag}.ckpt") self.checkpoint.save(save_path, global_step) self.latest_checkpoint = save_path def save_best(self, global_step: int): self.save(global_step, BEST) def load(self, tag: str = LATEST): """Load a checkpoint. Args: tag (str): The tag of the checkpoint to load. """ save_path = os.path.join(self.directory, f"{tag}.ckpt") self.checkpoint.load(save_path, self.device) def get_best_checkpoint_state_for_inference(self, device: torch.device) -> tuple[Mapping[str, Any], None]: save_path = os.path.join(self.directory, f"{BEST}.ckpt") try: return self.checkpoint.get_state_for_inference(save_path, device) except Exception: # This exception may be hit if the best checkpoint does not exist. This can happen if the model runs into # NaN loss because of NaN or inf values in the weights before the first checkpoint is saved. In this case, logger.error(f"Could not load best checkpoint state from {save_path}. Best checkpoint may not exist.") return None def close(self): pass @staticmethod def load_latest_checkpoint(checkpoint: Checkpoint, directory: str, device: torch.device): last_ckpt = get_latest_checkpoint_path(directory) if last_ckpt: checkpoint.load(last_ckpt, device) else: raise FileNotFoundError(f"No checkpoints found in {directory}.") ================================================ FILE: ludwig/utils/config_utils.py ================================================ from typing import Any from ludwig.api_annotations import DeveloperAPI from ludwig.constants import ( DECODER, ENCODER, IMAGE, INPUT_FEATURES, MODEL_ECD, MODEL_LLM, MODEL_TYPE, PREPROCESSING, SEQUENCE, TEXT, TIMESERIES, TYPE, ) from ludwig.features.feature_registries import get_input_type_registry from ludwig.schema.model_config import ModelConfig from ludwig.types import FeatureConfigDict, FeatureTypeDefaultsDict, PreprocessingConfigDict @DeveloperAPI def get_feature_type_parameter_values_from_section( config: ModelConfig, features_section: str, feature_type: str, parameter_name: str ) -> set: """Returns the set of all parameter values used for the given features_section, feature_type, and parameter_name.""" parameter_values = set() for feature in config[features_section]: if feature[TYPE] == feature_type: if parameter_name in feature: parameter_values.add(feature[parameter_name]) elif parameter_name in feature[ENCODER]: parameter_values.add(feature[ENCODER][parameter_name]) elif parameter_name in feature[DECODER]: parameter_values.add(feature[DECODER][parameter_name]) return parameter_values @DeveloperAPI def get_defaults_section_for_feature_type( feature_type: str, config_defaults: FeatureTypeDefaultsDict, config_defaults_section: str, ) -> FeatureConfigDict: """Returns a dictionary of all default parameter values specified in the global defaults section for the config_defaults_section of the feature_type.""" if feature_type not in config_defaults: return {} if config_defaults_section not in config_defaults[feature_type]: return {} return config_defaults[feature_type][config_defaults_section] def _to_dict(obj) -> dict: """Convert a config object or dict to a plain dict.""" if isinstance(obj, dict): return obj return obj.to_dict() def get_preprocessing_params(config_obj: ModelConfig) -> PreprocessingConfigDict: """Returns a new dictionary that merges preprocessing section of config with type-specific preprocessing parameters from config defaults.""" preprocessing_params = {} preprocessing_params.update(_to_dict(config_obj.preprocessing)) for feat_type in get_input_type_registry().keys(): if hasattr(config_obj.defaults, feat_type): feat_defaults = getattr(config_obj.defaults, feat_type) preprocessing = ( feat_defaults.preprocessing if not isinstance(feat_defaults, dict) else feat_defaults.get("preprocessing", {}) ) preprocessing_params[feat_type] = _to_dict(preprocessing) return preprocessing_params @DeveloperAPI def merge_config_preprocessing_with_feature_specific_defaults( config_preprocessing: PreprocessingConfigDict, config_defaults: FeatureTypeDefaultsDict ) -> PreprocessingConfigDict: """Returns a new dictionary that merges preprocessing section of config with type-specific preprocessing parameters from config defaults.""" preprocessing_params = {} preprocessing_params.update(config_preprocessing) for feature_type in config_defaults: preprocessing_params[feature_type] = config_defaults[feature_type].get(PREPROCESSING, {}) return preprocessing_params def has_trainable_encoder(config: ModelConfig) -> bool: for feature in config.input_features.to_list(): encoder = feature.get("encoder", {}) if encoder.get("trainable", False): # TODO(travis): we assume here that False is always the default, which may not be true. We should dervice # this from the schema. return True return False def has_unstructured_input_feature(config: ModelConfig) -> bool: for feature in config.input_features.to_list(): if feature.get("type", None) in {TEXT, IMAGE, SEQUENCE, TIMESERIES}: return True return False def has_pretrained_encoder(config: ModelConfig) -> bool: for feature in config.input_features: if feature.encoder.is_pretrained(): return True return False def config_uses_llm(config: dict[str, Any] | ModelConfig) -> bool: """Determine if a config uses an LLM. Args: config: Ludwig config object or dictionary Returns: True if the model type is LLM or if the model uses and LLM encoder, otherwise False. """ uses_llm = False # For a valid config, model_type LLM is automatically True # ECD models need to be checked for at least one LLM text encoder if isinstance(config, ModelConfig): if config.model_type == MODEL_LLM: uses_llm = True else: for feature in config.input_features: if feature.encoder and feature.encoder.type == MODEL_LLM: uses_llm = True break elif isinstance(config, dict) and config: if config.get(MODEL_TYPE, MODEL_ECD) == MODEL_LLM: uses_llm = True elif INPUT_FEATURES in config: for feature in config.get(INPUT_FEATURES, []): if feature.get(ENCODER, {}).get(TYPE) == MODEL_LLM: uses_llm = True break else: raise ValueError( "Invalid config cannot be checked for LLM usage because it has no input features." f"Config: {config}" ) else: raise ValueError(f"Invalid config cannot be checked for LLM usage. Config: {config}") return uses_llm def get_quantization(config: dict[str, Any] | ModelConfig) -> list[int | None]: """Get the quantization specified in a config at any level. Args: config: Ludwig config object or dictionary Returns: For LLM models, the value of quantization.bits or None if it is not specified. For ECD models, the list of values of quantization.bits for each encoder. If the encoder does not support quantization or no quantization config is specified, the list entry is None. """ if isinstance(config, ModelConfig): if config.model_type == MODEL_LLM: return [config.quantization.bits] if config.quantization else [None] else: quantization_bits = [] for feature in config.input_features: try: quantization = feature.encoder.quantization.bits except AttributeError: quantization = None quantization_bits.append(quantization) return quantization_bits elif isinstance(config, dict) and config: if config.get(MODEL_TYPE, MODEL_ECD) == MODEL_LLM: return [config.get("quantization", {}).get("bits")] elif INPUT_FEATURES in config: quantization_bits = [] for feature in config.get(INPUT_FEATURES, []): quantization_bits.append(feature.get(ENCODER, {}).get("quantization", {}).get("bits")) return quantization_bits else: raise ValueError( "Invalid config cannot be checked for quantization because it has no input features." f"Config: {config}" ) else: raise ValueError(f"Invalid config cannot be checked for quantization. Config: {config}") ================================================ FILE: ludwig/utils/data_utils.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import base64 import collections.abc import contextlib import csv import dataclasses import functools import hashlib import json import logging import os import os.path import pickle import random import re import tempfile import threading from itertools import islice from typing import Any import numpy as np import pandas as pd import pyarrow as pa import yaml from fsspec.config import conf, set_conf_files from pandas.errors import ParserError from sklearn.model_selection import KFold from ludwig.api_annotations import DeveloperAPI from ludwig.constants import PREPROCESSING, SPLIT from ludwig.data.cache.types import CacheableDataset from ludwig.globals import MODEL_HYPERPARAMETERS_FILE_NAME, MODEL_WEIGHTS_FILE_NAME, TRAIN_SET_METADATA_FILE_NAME from ludwig.utils.dataframe_utils import from_numpy_dataset, is_dask_lib, to_numpy_dataset from ludwig.utils.fs_utils import download_h5, has_remote_protocol, open_file, upload_h5 from ludwig.utils.math_utils import cumsum from ludwig.utils.misc_utils import get_from_registry from ludwig.utils.types import DataFrame try: import dask import dask.dataframe as dd DASK_DF_FORMATS = {dd.DataFrame} except ImportError: DASK_DF_FORMATS = set() dd = None logger = logging.getLogger(__name__) DATASET_SPLIT_URL = "dataset_{}_fp" DATA_PROCESSED_CACHE_DIR = "data_processed_cache_dir" DATA_TRAIN_HDF5_FP = "data_train_hdf5_fp" DATA_TRAIN_PARQUET_FP = "data_train_parquet_fp" DATA_VALIDATION_PARQUET_FP = "data_validation_parquet_fp" DATA_TEST_PARQUET_FP = "data_test_parquet_fp" HDF5_COLUMNS_KEY = "columns" DICT_FORMATS = {"dict", "dictionary", dict} DATAFRAME_FORMATS = {"dataframe", "df", pd.DataFrame} | DASK_DF_FORMATS CSV_FORMATS = {"csv"} TSV_FORMATS = {"tsv"} JSON_FORMATS = {"json"} JSONL_FORMATS = {"jsonl"} EXCEL_FORMATS = {"excel"} PARQUET_FORMATS = {"parquet"} PICKLE_FORMATS = {"pickle"} FEATHER_FORMATS = {"feather"} FWF_FORMATS = {"fwf"} HTML_FORMATS = {"html"} ORC_FORMATS = {"orc"} SAS_FORMATS = {"sas"} SPSS_FORMATS = {"spss"} STATA_FORMATS = {"stata"} HDF5_FORMATS = {"hdf5", "h5"} CACHEABLE_FORMATS = set.union( *( CSV_FORMATS, TSV_FORMATS, JSON_FORMATS, JSONL_FORMATS, EXCEL_FORMATS, PARQUET_FORMATS, PICKLE_FORMATS, FEATHER_FORMATS, FWF_FORMATS, HTML_FORMATS, ORC_FORMATS, SAS_FORMATS, SPSS_FORMATS, STATA_FORMATS, DATAFRAME_FORMATS, ) ) PANDAS_DF = pd # Lock over the entire interpreter as we can only have one set # of credentials scoped to the interpreter at once. GLOBAL_CRED_LOCK = threading.Lock() @DeveloperAPI def get_parquet_filename(n: int): """Left pads the partition number with zeros to preserve order in downstream reads. Downstream reads use the filename to determine the lexical order of the partitions. """ return f"part.{str(n).zfill(8)}.parquet" @DeveloperAPI def get_split_path(dataset_fp): return os.path.splitext(dataset_fp)[0] + ".split.parquet" @DeveloperAPI def get_abs_path(src_path, file_path): if has_remote_protocol(file_path): return file_path elif src_path is not None: return os.path.join(src_path, file_path) else: return file_path @DeveloperAPI def load_csv(data_fp): with open_file(data_fp, "rb") as f: data = list(csv.reader(f)) return data # Decorator used to encourage Dask on Ray to spread out data loading across workers @DeveloperAPI def spread(fn): def wrapped_fn(*args, **kwargs): if dd is None or not hasattr(dask, "annotate"): return fn(*args, **kwargs) with dask.annotate(ray_remote_args=dict(scheduling_strategy="SPREAD")): return fn(*args, **kwargs) return wrapped_fn def read_nrows_via_chunksize(fp, read_fn, **kwargs): chunksize = kwargs.pop("nrows", None) ret = read_fn(fp, chunksize=chunksize, **kwargs) if isinstance(ret, collections.abc.Iterator): return next(ret) return ret @DeveloperAPI @spread def read_xsv(data_fp, df_lib=PANDAS_DF, separator=",", header=0, nrows=None, skiprows=None, dtype=object, **kwargs): """Helper method to read a csv file. Wraps around pd.read_csv to handle some exceptions. Can extend to cover cases as necessary. :param data_fp: path to the xsv file :param df_lib: DataFrame library used to read in the CSV :param separator: defaults separator to use for splitting :param header: header argument for pandas to read the csv :param nrows: number of rows to read from the csv, None means all :param skiprows: number of rows to skip from the csv, None means no skips :param dtype: dtype to use for columns. Defaults to object to disable type inference. :return: Pandas dataframe with the data """ with open_file(data_fp, "r", encoding="utf8") as csvfile: try: dialect = csv.Sniffer().sniff(csvfile.read(1024 * 100), delimiters=[",", "\t", "|"]) separator = dialect.delimiter except csv.Error: # Could not conclude the delimiter, defaulting to user provided pass # NOTE: by default we read all XSV columns in as dtype=object, bypassing all type inference. This is to avoid silent # issues related to incorrect type inference (e.g. NaNs in bool columns). Convert data to correct types after # reading in. kwargs = dict(sep=separator, header=header, skiprows=skiprows, dtype=dtype, **kwargs) if nrows is not None: kwargs["nrows"] = nrows try: df = df_lib.read_csv(data_fp, **kwargs) except ParserError: logger.warning("Failed to parse the CSV with pandas default way, trying \\ as escape character.") df = df_lib.read_csv(data_fp, escapechar="\\", **kwargs) return df read_csv = functools.partial(read_xsv, separator=",") read_tsv = functools.partial(read_xsv, separator="\t") @DeveloperAPI @spread def read_json(data_fp, df_lib, normalize=False, **kwargs): # Not supported unless lines=True kwargs.pop("nrows", None) if normalize: return df_lib.json_normalize(load_json(data_fp)) else: return df_lib.read_json(data_fp, **kwargs) @DeveloperAPI @spread def read_jsonl(data_fp, df_lib, **kwargs): return df_lib.read_json(data_fp, lines=True, **kwargs) @DeveloperAPI @spread def read_excel(data_fp, df_lib, **kwargs): fp_split = os.path.splitext(data_fp) if fp_split[1] == ".xls": excel_engine = "xlrd" else: excel_engine = "openpyxl" # https://github.com/dask/dask/issues/9055 if is_dask_lib(df_lib): logger.warning("Falling back to pd.read_excel() since dask backend does not support it") return dd.from_pandas(pd.read_excel(data_fp, engine=excel_engine, **kwargs), npartitions=1) return df_lib.read_excel(data_fp, engine=excel_engine, **kwargs) @DeveloperAPI @spread def read_parquet(data_fp, df_lib, nrows=None, **kwargs): if nrows is not None: import pyarrow.parquet as pq from ludwig.utils.fs_utils import get_fs_and_path fs, _ = get_fs_and_path(data_fp) dataset = pq.ParquetDataset(data_fp, filesystem=fs).fragments[0] preview = dataset.head(nrows).to_pandas() if is_dask_lib(df_lib): return df_lib.from_pandas(preview, npartitions=1) return preview return df_lib.read_parquet(data_fp, **kwargs) @DeveloperAPI @spread def read_pickle(data_fp, df_lib, **kwargs): # Chunking is not supported for pickle files: kwargs.pop("nrows", None) # https://github.com/dask/dask/issues/9055 if is_dask_lib(df_lib): logger.warning("Falling back to pd.read_pickle() since dask backend does not support it") return dd.from_pandas(pd.read_pickle(data_fp), npartitions=1) return df_lib.read_pickle(data_fp) @DeveloperAPI @spread def read_fwf(data_fp, df_lib, **kwargs): return df_lib.read_fwf(data_fp, **kwargs) @DeveloperAPI @spread def read_feather(data_fp, df_lib, **kwargs): # Chunking is not supported for feather files: kwargs.pop("nrows", None) # https://github.com/dask/dask/issues/9055 if is_dask_lib(df_lib): logger.warning("Falling back to pd.read_feather() since dask backend does not support it") return dd.from_pandas(pd.read_feather(data_fp), npartitions=1) return df_lib.read_feather(data_fp) @DeveloperAPI @spread def read_html(data_fp, df_lib, **kwargs): # Chunking is not supported for html files: kwargs.pop("nrows", None) # Wrap literal HTML strings in StringIO to avoid pandas FutureWarning from io import StringIO if isinstance(data_fp, str) and not os.path.isfile(data_fp): data_fp = StringIO(data_fp) # https://github.com/dask/dask/issues/9055 if is_dask_lib(df_lib): logger.warning("Falling back to pd.read_html() since dask backend does not support it") return dd.from_pandas(pd.read_html(data_fp)[0], npartitions=1) return df_lib.read_html(data_fp)[0] @DeveloperAPI @spread def read_orc(data_fp, df_lib, **kwargs): # Chunking is not supported for orc files: kwargs.pop("nrows", None) return df_lib.read_orc(data_fp, **kwargs) @DeveloperAPI @spread def read_sas(data_fp, df_lib, **kwargs): # https://github.com/dask/dask/issues/9055 if is_dask_lib(df_lib): logger.warning("Falling back to pd.read_sas() since dask backend does not support it") return dd.from_pandas(read_nrows_via_chunksize(data_fp, df_lib.read_sas, **kwargs), npartitions=1) return read_nrows_via_chunksize(data_fp, df_lib.read_sas, **kwargs) @DeveloperAPI @spread def read_spss(data_fp, df_lib, **kwargs): # Chunking is not supported for spss files: kwargs.pop("nrows", None) # https://github.com/dask/dask/issues/9055 if is_dask_lib(df_lib): logger.warning("Falling back to pd.read_spss() since dask backend does not support it") return dd.from_pandas(pd.read_spss(data_fp), npartitions=1) return df_lib.read_spss(data_fp) @DeveloperAPI @spread def read_stata(data_fp, df_lib, **kwargs): # https://github.com/dask/dask/issues/9055 if is_dask_lib(df_lib): logger.warning("Falling back to pd.read_stata() since dask backend does not support it") return dd.from_pandas(read_nrows_via_chunksize(data_fp, df_lib.read_stata, **kwargs), npartitions=1) return read_nrows_via_chunksize(data_fp, df_lib.read_stata, **kwargs) @DeveloperAPI @spread def read_hdf5(data_fp, **_kwargs): return load_hdf5(data_fp, clean_cols=True) @DeveloperAPI @spread def read_buffer(buf, fname): """Reads data in from a binary buffer by first writing the data to a temporary file, and then processes it based on its format (hdf5, csv, tsv etc). Useful if object is a binary buffer coming from streaming data. """ data_format = figure_data_format_dataset(fname) reader_fn = data_reader_registry[data_format] with tempfile.TemporaryDirectory() as tmpdir: temp_name = os.path.join(tmpdir, "dataset") with open(temp_name, "wb") as f: f.write(buf.read()) return reader_fn(temp_name, pd) @DeveloperAPI @spread def read_fname(fname, data_format=None, df_lib=pd, **kwargs): """This function reads data from fname using the df_lib data processing library (defaults to pandas). Useful if you don't know the file type extension in advance. """ data_format = data_format or figure_data_format_dataset(fname) reader_fn = data_reader_registry[data_format] return reader_fn(fname, df_lib, **kwargs) @DeveloperAPI def save_csv(data_fp, data): with open_file(data_fp, "w", encoding="utf-8") as csv_file: writer = csv.writer(csv_file) for row in data: if not isinstance(row, collections.abc.Iterable) or isinstance(row, str): row = [row] writer.writerow(row) @DeveloperAPI def csv_contains_column(data_fp, column_name): return column_name in read_csv(data_fp, nrows=0) # only loads header @DeveloperAPI def load_yaml(yaml_fp): with open_file(yaml_fp, "r") as f: return yaml.safe_load(f) @DeveloperAPI def load_config_from_str(config): """Load the config as either a serialized string or a path to a YAML file.""" config = yaml.safe_load(config) if isinstance(config, str): # Assume the caller provided a path name with open(config, encoding="utf-8") as f: config = yaml.safe_load(f) return config @DeveloperAPI def load_json(data_fp): with open_file(data_fp, "r") as input_file: data = json.load(input_file) return data @DeveloperAPI def save_json(data_fp, data, sort_keys=True, indent=4): with open_file(data_fp, "w") as output_file: json.dump(data, output_file, cls=NumpyEncoder, sort_keys=sort_keys, indent=indent) @DeveloperAPI def hash_dict(d: dict, max_length: int | None = 6) -> bytes: """Function that maps a dictionary into a unique hash. Known limitation: All values and keys of the dict must have an ordering. If not, there's no guarantee to obtain the same hash. For instance, values that are sets will potentially lead to different hashed when run on different machines or in different python sessions. Replacing them with sorted lists is suggested. """ s = json.dumps(d, cls=NumpyEncoder, sort_keys=True, ensure_ascii=True) h = hashlib.md5(s.encode()) d = h.digest() b = base64.b64encode(d, altchars=b"__") return b[:max_length] @DeveloperAPI def to_json_dict(d): """Converts Python dict to pure JSON ready format.""" return json.loads(json.dumps(d, cls=NumpyEncoder)) @DeveloperAPI def chunk_dict(data, chunk_size=100): """Split large dictionary into chunks. Source: https://stackoverflow.com/a/22878842 """ it = iter(data) for _ in range(0, len(data), chunk_size): yield {k: data[k] for k in islice(it, chunk_size)} @DeveloperAPI def flatten_dict(d, parent_key="", sep="."): """Based on https://www.geeksforgeeks.org/python-convert-nested-dictionary-into-flattened-dictionary/""" items = [] for k, v in d.items(): new_key = parent_key + sep + k if parent_key else k if isinstance(v, collections.abc.MutableMapping): items.extend(flatten_dict(v, new_key, sep=sep).items()) elif isinstance(v, list): list_mapping = {str(i): item for i, item in enumerate(v)} items.extend(flatten_dict(list_mapping, new_key, sep=sep).items()) else: items.append((new_key, v)) return dict(items) @DeveloperAPI def save_hdf5(data_fp, data): numpy_dataset = to_numpy_dataset(data) with upload_h5(data_fp) as h5_file: h5_file.create_dataset(HDF5_COLUMNS_KEY, data=np.array(data.columns.values, dtype="S")) for column in data.columns: h5_file.create_dataset(column, data=numpy_dataset[column]) @DeveloperAPI def load_hdf5(data_fp, clean_cols: bool = False): with download_h5(data_fp) as hdf5_data: columns = [s.decode("utf-8") for s in hdf5_data[HDF5_COLUMNS_KEY][()].tolist()] numpy_dataset = {} for column in columns: # Column names from training hdf5 will be in the form 'Survived_a2fv4' np_col = column.rsplit("_", 1)[0] if clean_cols else column numpy_dataset[np_col] = hdf5_data[column][()] return from_numpy_dataset(numpy_dataset) @DeveloperAPI def load_object(object_fp): with open_file(object_fp, "rb") as f: return pickle.load(f) @DeveloperAPI def save_object(object_fp, obj): with open_file(object_fp, "wb") as f: pickle.dump(obj, f) @DeveloperAPI def load_array(data_fp, dtype=float): list_num = [] with open_file(data_fp, "r") as input_file: for x in input_file: list_num.append(dtype(x.strip())) return np.array(list_num) @DeveloperAPI def load_matrix(data_fp, dtype=float): list_num = [] with open_file(data_fp, "r") as input_file: for row in input_file: list_num.append([dtype(elem) for elem in row.strip().split()]) return np.squeeze(np.array(list_num)) @DeveloperAPI def save_array(data_fp, array): with open_file(data_fp, "w") as output_file: for x in np.nditer(array): output_file.write(str(x) + "\n") # TODO(shreya): Confirm types of args @DeveloperAPI def load_pretrained_embeddings(embeddings_path: str, vocab: list[str]) -> np.ndarray: """Create an embedding matrix of all words in vocab.""" embeddings, embeddings_size = load_glove(embeddings_path, return_embedding_size=True) # calculate an average embedding, to use for initializing missing words avg_embedding = [embeddings[w] for w in vocab if w in embeddings] avg_embedding = sum(avg_embedding) / len(avg_embedding) # create the embedding matrix embeddings_vectors = [] for word in vocab: if word in embeddings: embeddings_vectors.append(embeddings[word]) else: embeddings_vectors.append(avg_embedding + np.random.uniform(-0.01, 0.01, embeddings_size)) embeddings_matrix = np.stack(embeddings_vectors) # let's help the garbage collector free some memory embeddings = None return embeddings_matrix @DeveloperAPI @functools.lru_cache(1) def load_glove(file_path: str, return_embedding_size: bool = False) -> dict[str, np.ndarray]: """Loads Glove embeddings for each word. Returns: Mapping between word and numpy array of size embedding_size as set by first line of file. """ logger.info(f" Loading Glove format file {file_path}") embeddings = {} embedding_size = 0 # collect embeddings size assuming the first line is correct with open_file(file_path, "r", encoding="utf-8") as f: found_line = False while not found_line: line = f.readline() if line: embedding_size = len(line.split()) - 1 found_line = True # collect embeddings with open_file(file_path, "r", encoding="utf-8") as f: for line_number, line in enumerate(f): if line: try: split = line.split() if len(split) != embedding_size + 1: raise ValueError( f"Line {line_number} is of length {len(split)}, " f"while expected length is {embedding_size + 1}." ) word = split[0] embedding = np.array([float(val) for val in split[-embedding_size:]]) embeddings[word] = embedding except ValueError: logger.warning(f"Line {line_number} in the GloVe file {file_path} is malformed, skipping it") logger.info(f" {len(embeddings)} embeddings loaded") if return_embedding_size: return embeddings, embedding_size return embeddings @DeveloperAPI def split_data(split: float, data: list) -> tuple[list, list]: split_length = int(round(split * len(data))) random.shuffle(data) return data[:split_length], data[split_length:] @DeveloperAPI def split_by_slices(slices: list[Any], n: int, probabilities: list[float]) -> list[Any]: splits = [] indices = cumsum([int(x * n) for x in probabilities]) start = 0 for end in indices: splits.append(slices[start:end]) start = end return splits @DeveloperAPI def shuffle_unison_inplace(list_of_lists, random_state=None): if list_of_lists: assert all(len(single_list) == len(list_of_lists[0]) for single_list in list_of_lists) if random_state is not None: p = random_state.permutation(len(list_of_lists[0])) else: p = np.random.permutation(len(list_of_lists[0])) return [single_list[p] for single_list in list_of_lists] return None @DeveloperAPI def shuffle_dict_unison_inplace(np_dict, random_state=None): keys = list(np_dict.keys()) list_of_lists = list(np_dict.values()) # shuffle up the list of lists according to previous fct shuffled_list = shuffle_unison_inplace(list_of_lists, random_state) recon = {} for ii, dkey in enumerate(keys): recon[dkey] = shuffled_list[ii] # we've shuffled the dictionary in place! return recon @DeveloperAPI def split_dataset_ttv(dataset, split): # Obtain distinct splits from the split column. If # a split is not present in this set, then we can skip generating # the dataframe for that split. if dataset[split].dtype != int: dataset[split] = dataset[split].astype(int) distinct_values = dataset[split].drop_duplicates() if hasattr(distinct_values, "compute"): distinct_values = distinct_values.compute() distinct_values = set(distinct_values.values.tolist()) training_set = split_dataset(dataset, split, 0) if 0 in distinct_values else None validation_set = split_dataset(dataset, split, 1) if 1 in distinct_values else None test_set = split_dataset(dataset, split, 2) if 2 in distinct_values else None return training_set, test_set, validation_set @DeveloperAPI def split_dataset(dataset, split, value_to_split=0): split_df = dataset[dataset[split] == value_to_split] return split_df @DeveloperAPI def collapse_rare_labels(labels, labels_limit): if labels_limit > 0: labels[labels >= labels_limit] = labels_limit return labels @DeveloperAPI def class_counts(dataset, labels_field): return np.bincount(dataset[labels_field].flatten()).tolist() @DeveloperAPI def load_from_file(file_name, field=None, dtype=int, ground_truth_split=2): """Load experiment data from supported file formats. Experiment data can be test/train statistics, model predictions, probability, ground truth, ground truth metadata. :param file_name: Path to file to be loaded :param field: Target Prediction field. :param dtype: :param ground_truth_split: Ground truth split filter where 0 is train 1 is validation and 2 is test split. By default test split is used when loading ground truth from hdf5. :return: Experiment data as array """ if file_name.endswith(".hdf5") and field is not None: dataset = pd.read_hdf(file_name, key=HDF5_COLUMNS_KEY) column = dataset[field] array = column[dataset[SPLIT] == ground_truth_split].values # ground truth elif file_name.endswith(".npy"): array = np.load(file_name) elif file_name.endswith(".csv"): array = read_csv(file_name, header=None).values else: array = load_matrix(file_name, dtype) return array @DeveloperAPI def replace_file_extension(file_path, extension): """Return a file path for a file with same name but different format. a.csv, json -> a.json a.csv, hdf5 -> a.hdf5. :param file_path: original file path :param extension: file extension :return: file path with same name but different format """ if file_path is None: return None extension = extension.strip() if extension.startswith("."): # Handle the case if the user calls with '.hdf5' instead of 'hdf5' extension = extension[1:] return os.path.splitext(file_path)[0] + "." + extension @DeveloperAPI def file_exists_with_diff_extension(file_path, extension): return file_path is None or os.path.isfile(replace_file_extension(file_path, extension)) @DeveloperAPI def add_sequence_feature_column(df, col_name, seq_length): """Adds a new column to the dataframe computed from an existing column. Values in the new column are space- delimited strings composed of preceding values of the same column up to seq_length. For example values of the i-th row of the new column will be a space-delimited string of df[col_name][i-seq_length]. :param df: input dataframe :param col_name: column name containing sequential data :param seq_length: length of an array of preceeding column values to use """ if col_name not in df.columns.values: logger.error(f"{col_name} column does not exist") return new_col_name = col_name + "_feature" if new_col_name in df.columns.values: logger.warning(f"{new_col_name} column already exists, values will be overridden") new_data = [None] * seq_length old_data = np.array(df[col_name]) for i in range(seq_length, len(df)): new_data.append(" ".join(str(j) for j in old_data[i - seq_length : i])) df[new_col_name] = new_data df[new_col_name] = df[new_col_name].bfill() @DeveloperAPI def override_in_memory_flag(input_features, override_value): num_overrides = 0 for feature in input_features: if PREPROCESSING in feature: if "in_memory" in feature[PREPROCESSING]: feature[PREPROCESSING]["in_memory"] = override_value num_overrides += 1 return num_overrides @DeveloperAPI def normalize_numpy(obj): if isinstance(obj, np.integer): return int(obj) elif isinstance(obj, np.floating): return float(obj) elif isinstance(obj, np.ndarray): return normalize_numpy(obj.tolist()) elif isinstance(obj, list): return [normalize_numpy(v) for v in obj] else: return obj @DeveloperAPI class NumpyEncoder(json.JSONEncoder): """Custom JSON encoder for handling NumPy objects. This encoder extends the `json.JSONEncoder` class and provides custom serialization for NumPy objects. It converts NumPy arrays, sets, tuples, integers, floating-point numbers, booleans, and dataclasses to their JSON serializable equivalents. Attributes: None Methods: default: Overrides the default method of `json.JSONEncoder` to provide custom serialization for NumPy objects. Usage: Use this encoder when serializing objects that contain NumPy arrays or other NumPy objects to JSON. Example: encoder = NumpyEncoder() json_data = encoder.encode(data) """ def default(self, o): if isinstance(o, (set, tuple)): return list(o) elif isinstance(o, np.bool_): return bool(o) elif isinstance(o, np.integer): return int(o) elif isinstance(o, np.floating): return float(o) elif isinstance(o, np.ndarray): return o.tolist() elif dataclasses.is_dataclass(o): return dataclasses.asdict(o) elif hasattr(o, "to_dict"): return o.to_dict() else: return json.JSONEncoder.default(self, o) @DeveloperAPI def generate_kfold_splits(data_df, num_folds, random_state): kf = KFold(n_splits=num_folds, shuffle=True, random_state=random_state) fold_num = 0 for train_indices, test_indices in kf.split(data_df): fold_num += 1 yield train_indices, test_indices, fold_num @DeveloperAPI def get_path_size(start_path, regex_accept=None, regex_reject=None): total_size = 0 pattern_accept = re.compile(regex_accept) if regex_accept else None pattern_reject = re.compile(regex_reject) if regex_reject else None for dirpath, _, filenames in os.walk(start_path): for filename in filenames: filepath = os.path.join(dirpath, filename) if not os.path.islink(filepath): accepted = True if pattern_accept: accepted = accepted and pattern_accept.match(filename) if pattern_reject: accepted = accepted and not pattern_reject.match(filename) if accepted: total_size += os.path.getsize(filepath) return total_size @DeveloperAPI def clear_data_cache(): """Clears any cached data objects (e.g., embeddings)""" load_glove.cache_clear() @DeveloperAPI def figure_data_format_dataset(dataset): if isinstance(dataset, CacheableDataset): return figure_data_format_dataset(dataset.unwrap()) elif isinstance(dataset, pd.DataFrame): return pd.DataFrame elif dd and isinstance(dataset, dd.DataFrame): return dd.DataFrame elif isinstance(dataset, dict): return dict elif isinstance(dataset, str): dataset = dataset.strip() if dataset.startswith("ludwig://"): return "ludwig" if dataset.startswith("hf://"): return "hf" dataset = dataset.lower() if dataset.endswith(".csv"): return "csv" elif dataset.endswith(".tsv"): return "tsv" elif dataset.endswith(".json"): return "json" elif dataset.endswith(".jsonl"): return "jsonl" elif ( dataset.endswith(".xls") or dataset.endswith(".xlsx") or dataset.endswith(".xlsm") or dataset.endswith(".xlsb") or dataset.endswith(".odf") or dataset.endswith(".ods") or dataset.endswith(".odt") ): return "excel" elif dataset.endswith(".parquet"): return "parquet" elif dataset.endswith(".pickle") or dataset.endswith(".p"): return "pickle" elif dataset.endswith(".feather"): return "feather" elif dataset.endswith(".fwf"): return "fwf" elif dataset.endswith(".html"): return "html" elif dataset.endswith(".orc"): return "orc" elif dataset.endswith(".sas"): return "sas" elif dataset.endswith(".spss"): return "spss" elif dataset.endswith(".dta") or dataset.endswith(".stata"): return "stata" elif dataset.endswith(".h5") or dataset.endswith(".hdf5"): return "hdf5" else: raise ValueError(f"Dataset path string {dataset} does not contain a valid extension") else: raise ValueError(f"Cannot figure out the format of dataset {dataset}") @DeveloperAPI def figure_data_format(dataset=None, training_set=None, validation_set=None, test_set=None): if dataset is not None: data_format = figure_data_format_dataset(dataset) elif training_set is not None: data_formats = [figure_data_format_dataset(training_set)] if validation_set is not None: data_formats.append(figure_data_format_dataset(validation_set)) if test_set is not None: data_formats.append(figure_data_format_dataset(test_set)) data_formats_set = set(data_formats) if len(data_formats_set) > 1: error_message = "Datasets have different formats. Training: " error_message += str(data_formats[0]) if validation_set: error_message = ", Validation: " error_message += str(data_formats[1]) if test_set: error_message = ", Test: " error_message += str(data_formats[-1]) raise ValueError(error_message) else: data_format = next(iter(data_formats_set)) else: raise ValueError("At least one between dataset and training_set must be not None") return data_format @DeveloperAPI def is_model_dir(path: str) -> bool: hyperparameters_fn = os.path.join(path, MODEL_HYPERPARAMETERS_FILE_NAME) ts_metadata_fn = os.path.join(path, TRAIN_SET_METADATA_FILE_NAME) is_a_model_dir = False if os.path.isdir(path) and os.path.isfile(hyperparameters_fn) and os.path.isfile(ts_metadata_fn): weights_files_count = 0 for file_name in os.listdir(path): if file_name.startswith(MODEL_WEIGHTS_FILE_NAME): weights_files_count += 1 if weights_files_count >= 2: is_a_model_dir = True return is_a_model_dir @DeveloperAPI def ndarray2string(parm_array): # convert numpy.ndarray to ludwig custom string format if isinstance(parm_array, np.ndarray): return f"__ndarray__{json.dumps(parm_array.tolist())}" else: raise ValueError(f"Argument must be numpy.ndarray. Instead argument found to be {type(parm_array)}") @DeveloperAPI def string2ndarray(parm_string): # convert ludwig custom ndarray string to numpy.ndarray if isinstance(parm_string, str) and parm_string[:11] == "__ndarray__": return np.array(json.loads(parm_string[11:])) else: raise ValueError("Argument must be Ludwig custom string format for numpy.ndarray") @DeveloperAPI def is_ludwig_ndarray_string(parm_string): # tests if parameter is a Ludwig custom ndarray string return isinstance(parm_string, str) and parm_string[:11] == "__ndarray__" @DeveloperAPI def get_pa_dtype(obj: Any): if np.isscalar(obj): return pa.from_numpy_dtype(np.array(obj).dtype) elif isinstance(obj, np.ndarray) or isinstance(obj, list) or isinstance(obj, tuple): return pa.list_(get_pa_dtype(obj[0])) else: raise ValueError(f"Unsupported type for pyarrow dtype: {type(obj)}") @DeveloperAPI def get_pa_schema(df: DataFrame): """Gets the pyarrow schema associated with a given DataFrame. This will fail in very specific conditions worth enumerating: 1. If the DataFrame is a Dask DataFrame which has a partition of size 1 and its only sample is a NaN, then the `schema` dict will not contain the associated key. The value in this case will be inferred (likely incorrectly) as a float64 downstream. 2. If the DataFrame contains NaNs in some column and the presence of NaNs changes the overall dtype of the column. For example, if a number feature column contains some NaN-like value, then its dtype will be changed by the below `fillna` call from float32 to float64. This will cause `to_parquet` to fail downstream. """ head = df.head(100) schema = {} for k, v in head.items(): if sum(v.isna()) > 0: v = v.fillna(np.nan).replace([np.nan], [None]) # Only fill NaNs if they are present v = v.values for val in v: if val is not None and k not in schema: schema[k] = get_pa_dtype(val) break return pa.schema(list(schema.items())) data_reader_registry = { **{fmt: read_csv for fmt in CSV_FORMATS}, **{fmt: read_tsv for fmt in TSV_FORMATS}, **{fmt: read_json for fmt in JSON_FORMATS}, **{fmt: read_jsonl for fmt in JSONL_FORMATS}, **{fmt: read_excel for fmt in EXCEL_FORMATS}, **{fmt: read_parquet for fmt in PARQUET_FORMATS}, **{fmt: read_pickle for fmt in PICKLE_FORMATS}, **{fmt: read_fwf for fmt in FWF_FORMATS}, **{fmt: read_feather for fmt in FEATHER_FORMATS}, **{fmt: read_html for fmt in HTML_FORMATS}, **{fmt: read_orc for fmt in ORC_FORMATS}, **{fmt: read_sas for fmt in SAS_FORMATS}, **{fmt: read_spss for fmt in SPSS_FORMATS}, **{fmt: read_stata for fmt in STATA_FORMATS}, **{fmt: read_hdf5 for fmt in HDF5_FORMATS}, } @DeveloperAPI def load_dataset(dataset, data_format=None, df_lib=PANDAS_DF): if not data_format or data_format == "auto": data_format = figure_data_format(dataset) # use appropriate reader to create dataframe if data_format in DATAFRAME_FORMATS: return dataset elif data_format in DICT_FORMATS: return pd.DataFrame(dataset) elif data_format in CACHEABLE_FORMATS: data_reader = get_from_registry(data_format, data_reader_registry) return data_reader(dataset, df_lib) else: raise ValueError(f"{data_format} format is not supported") @DeveloperAPI @contextlib.contextmanager def use_credentials(creds): if creds is None: with contextlib.nullcontext(): yield return # https://filesystem-spec.readthedocs.io/en/latest/features.html#configuration # This allows us to avoid having to plumb the `storage_options` kwargs through # every remote FS call in Ludwig. This implementation is restricted to one thread # in the process acquiring the lock at once. with GLOBAL_CRED_LOCK: with tempfile.TemporaryDirectory() as tmpdir: fname = os.path.join(tmpdir, "conf.json") with open(fname, "w", encoding="utf-8") as f: json.dump(creds, f) # Backup any existing credentials old_conf = dict(**conf) conf.clear() set_conf_files(tmpdir, conf) try: yield finally: # Restore previous credentials with open(fname, "w", encoding="utf-8") as f: json.dump(old_conf, f) conf.clear() set_conf_files(tmpdir, conf) def get_sanitized_feature_name(feature_name: str) -> str: """Replaces non-word characters (anything other than alphanumeric or _) with _. Used in model config initialization and sanitize_column_names(), which is called during dataset building. """ return re.sub(r"[(){}.:\"\"\'\'\[\]]", "_", feature_name) def sanitize_column_names(df: DataFrame) -> DataFrame: """Renames df columns with non-word characters (anything other than alphanumeric or _) to _.""" safe_column_names = [get_sanitized_feature_name(col) for col in df.columns] return df.rename(columns=dict(zip(df.columns, safe_column_names))) ================================================ FILE: ludwig/utils/dataframe_utils.py ================================================ from typing import Optional import numpy as np import pandas as pd from ludwig.api_annotations import DeveloperAPI from ludwig.constants import DASK_MODULE_NAME from ludwig.data.dataframe.base import DataFrameEngine from ludwig.utils.types import DataFrame @DeveloperAPI def is_dask_lib(df_lib) -> bool: """Returns whether the dataframe library is dask.""" return df_lib.__name__ == DASK_MODULE_NAME @DeveloperAPI def is_dask_backend(backend: Optional["Backend"]) -> bool: # noqa: F821 """Returns whether the backend's dataframe is dask.""" return backend is not None and is_dask_lib(backend.df_engine.df_lib) @DeveloperAPI def is_dask_series_or_df(df: DataFrame, backend: Optional["Backend"]) -> bool: # noqa: F821 if is_dask_backend(backend): import dask.dataframe as dd return isinstance(df, dd.Series) or isinstance(df, dd.DataFrame) return False @DeveloperAPI def flatten_df(df: DataFrame, df_engine: DataFrameEngine) -> tuple[DataFrame, dict[str, tuple]]: # noqa: F821 """Returns a flattened dataframe with a dictionary of the original shapes, keyed by dataframe columns.""" # Workaround for: https://issues.apache.org/jira/browse/ARROW-5645 column_shapes = {} for c in df.columns: df = df_engine.persist(df) shape = df_engine.compute( df_engine.map_objects( df[c], lambda x: np.array(x).shape, ).max() ) if len(shape) > 1: column_shapes[c] = shape df[c] = df_engine.map_objects(df[c], lambda x: np.array(x).reshape(-1)) return df, column_shapes @DeveloperAPI def unflatten_df(df: DataFrame, column_shapes: dict[str, tuple], df_engine: DataFrameEngine) -> DataFrame: # noqa: F821 """Returns an unflattened dataframe, the reverse of flatten_df.""" for c in df.columns: shape = column_shapes.get(c) if shape: df[c] = df_engine.map_objects(df[c], lambda x: np.array(x).reshape(shape)) return df @DeveloperAPI def to_numpy_dataset(df: DataFrame, backend: Optional["Backend"] = None) -> dict[str, np.ndarray]: # noqa: F821 """Returns a dictionary of numpy arrays, keyed by the columns of the given dataframe.""" # Compute Dask DataFrames to pandas first to avoid issues with extension dtypes # (e.g. TensorDtype) that Dask-expr's metadata system cannot handle. if backend and is_dask_backend(backend): df = backend.df_engine.compute(df) dataset = {} for col in df.columns: if len(df.index) != 0: dataset[col] = np.stack(df[col].to_numpy()) else: # Dataframe is empty. # Use to_list() directly, as np.stack() requires at least one array to stack. dataset[col] = df[col].to_list() return dataset @DeveloperAPI def from_numpy_dataset(dataset) -> pd.DataFrame: """Returns a pandas dataframe from the dataset.""" col_mapping = {} for k, v in dataset.items(): if len(v.shape) > 1: # unstacking, needed for ndarrays of dimension 2 and more (*vals,) = v else: # not unstacking. Needed because otherwise pandas casts types # the way it wants, like converting a list of float32 scalats # to a column of float64 vals = v col_mapping[k] = vals return pd.DataFrame.from_dict(col_mapping) @DeveloperAPI def set_index_name(pd_df: pd.DataFrame, name: str) -> pd.DataFrame: pd_df.index.name = name return pd_df @DeveloperAPI def to_batches(df: pd.DataFrame, batch_size: int) -> list[pd.DataFrame]: n_rows = len(df) return [df[i : i + batch_size].copy() for i in range(0, n_rows, batch_size)] @DeveloperAPI def from_batches(batches: list[pd.DataFrame]) -> pd.DataFrame: return pd.concat(batches) @DeveloperAPI def to_scalar_df(df: pd.DataFrame) -> pd.DataFrame: """Converts all columns in a pd.DataFrame to be scalar types. For object columns of lists, each element of the list is expanded into its own column named {column}_{index}. We assume all object columns are lists of the same length (i.e., tensor format output from preprocessing). It's also important that the relative order of the columns is preserved, to maintain consistency with other conversions like the one for Hummingbird. """ scalar_df = df column_ordering = [] for c, s in df.items(): if s.dtype == "object": s_list = s.to_list() try: ncols = s_list[0].shape[0] split_cols = [f"{c}_{k}" for k in range(ncols)] sdf = pd.DataFrame(s_list, columns=split_cols) scalar_df = pd.concat([scalar_df, sdf], axis=1) column_ordering += split_cols except AttributeError as e: raise ValueError(f"Expected series of lists, but found {s_list[0]}") from e else: column_ordering.append(c) return scalar_df[column_ordering] ================================================ FILE: ludwig/utils/dataset_utils.py ================================================ import pandas as pd from sklearn.model_selection import train_test_split from ludwig.api_annotations import PublicAPI from ludwig.constants import TEST_SPLIT, TRAIN_SPLIT, VALIDATION_SPLIT from ludwig.data.dataset.base import Dataset from ludwig.utils.defaults import default_random_seed @PublicAPI def get_repeatable_train_val_test_split( df_input, stratify_colname="", random_seed=default_random_seed, frac_train=0.7, frac_val=0.1, frac_test=0.2 ): """Return df_input with split column containing (if possible) non-zero rows in the train, validation, and test data subset categories. If the input dataframe does not contain an existing split column or if the number of rows in both the validation and test split is 0 and non-empty stratify_colname specified, return df_input with split column set according to frac_ and stratify_colname. Else stratify_colname is ignored, and: If the input dataframe contains an existing split column and non-zero row counts for all three split types, return df_input. If the input dataframe contains an existing split column but only one of validation and test split has non-zero row counts, return df_input with missing split getting rows from train split as per frac_. Parameters ---------- df_input : Pandas dataframe Input dataframe to be split. stratify_colname : str The column used for stratification (if desired); usually the label column. random_seed : int Seed used to get repeatable split. frac_train : float frac_val : float frac_test : float The ratios with which to split the dataframe into train, val, and test data; should sum to 1.0. Returns ------- df_split : Dataframe containing the three splits. """ if frac_train + frac_val + frac_test != 1.0: raise ValueError(f"fractions {frac_train:f}, {frac_val:f}, {frac_test:f} do not add up to 1.0") if stratify_colname: do_stratify_split = True if stratify_colname not in df_input.columns: raise ValueError("%s is not a column in the dataframe" % (stratify_colname)) else: do_stratify_split = False if "split" not in df_input.columns: df_input["split"] = 0 # set up for non-stratified split path if "split" in df_input.columns: df_train = df_input[df_input["split"] == TRAIN_SPLIT].copy() df_val = df_input[df_input["split"] == VALIDATION_SPLIT].copy() df_test = df_input[df_input["split"] == TEST_SPLIT].copy() if not do_stratify_split or len(df_val) != 0 or len(df_test) != 0: if len(df_val) == 0: df_val = df_train.sample(frac=frac_val, replace=False, random_state=random_seed) df_train = df_train.drop(df_val.index) if len(df_test) == 0: df_test = df_train.sample(frac=frac_test, replace=False, random_state=random_seed) df_train = df_train.drop(df_test.index) do_stratify_split = False if do_stratify_split: # Make sure the `stratify_colname` doesn't have any NaNs. df_input = df_input[df_input[stratify_colname].notna()] # Split original dataframe into train and temp dataframes. y = df_input[[stratify_colname]] # Dataframe of just the column on which to stratify. df_train, df_temp, y_train, y_temp = train_test_split( df_input, y, stratify=y, test_size=(1.0 - frac_train), random_state=random_seed ) # Split the temp dataframe into val and test dataframes. relative_frac_test = frac_test / (frac_val + frac_test) df_val, df_test, y_val, y_test = train_test_split( df_temp, y_temp, stratify=y_temp, test_size=relative_frac_test, random_state=random_seed ) assert len(df_input) == len(df_train) + len(df_val) + len(df_test) df_train["split"] = TRAIN_SPLIT df_val["split"] = VALIDATION_SPLIT df_test["split"] = TEST_SPLIT df_split = pd.concat([df_train, df_val, df_test], ignore_index=True) return df_split def generate_dataset_statistics( training_set: Dataset, validation_set: str | dict | pd.DataFrame | Dataset | None, test_set: str | dict | pd.DataFrame | Dataset | None, ) -> list[tuple[str, int, int]]: from ludwig.benchmarking.utils import format_memory dataset_statistics = [ ["Dataset", "Size (Rows)", "Size (In Memory)"], ["Training", len(training_set), format_memory(training_set.in_memory_size_bytes)], ] if validation_set is not None: dataset_statistics.append( ["Validation", len(validation_set), format_memory(validation_set.in_memory_size_bytes)] ) if test_set is not None: dataset_statistics.append(["Test", len(test_set), format_memory(test_set.in_memory_size_bytes)]) return dataset_statistics ================================================ FILE: ludwig/utils/date_utils.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import time from datetime import date, datetime, timezone import numpy as np from dateutil.parser import parse, ParserError from ludwig.api_annotations import DeveloperAPI SCALE_S = np.floor(np.log10(time.time())) @DeveloperAPI def create_vector_from_datetime_obj(datetime_obj): yearday = datetime_obj.toordinal() - date(datetime_obj.year, 1, 1).toordinal() + 1 midnight = datetime_obj.replace(hour=0, minute=0, second=0, microsecond=0) second_of_day = (datetime_obj - midnight).seconds return [ datetime_obj.year, datetime_obj.month, datetime_obj.day, datetime_obj.weekday(), yearday, datetime_obj.hour, datetime_obj.minute, datetime_obj.second, second_of_day, ] @DeveloperAPI def parse_datetime(timestamp: float | int | str) -> datetime: """Parse a datetime from a string or a numeric timestamp. Args: timestamp: A datetime string or numeric timestamp. Returns: A datetime representation of `timestamp`. """ try: dt = parse(timestamp) except (OverflowError, ParserError, TypeError): dt = convert_number_to_datetime(timestamp) return dt @DeveloperAPI def convert_number_to_datetime(timestamp: float | int | str) -> datetime: """Convert a numeric timestamp to a datetime object. `datetime` objects can be created from POSIX timestamps like those returned by `time.time()`. Args: timestamp: A numeric timestamp. Returns: A datetime representation of `timestamp`. Raises: ValueError: Raised if `timestamp` is not a number or not a valid datetime. """ try: timestamp = float(timestamp) except TypeError: raise ValueError(f"Provided value {timestamp} is not a valid numeric timestamp") # Determine the unit of the timestamp ts_scale = np.floor(np.log10(timestamp)) # `datetime.datetime.fromtimestamp` expects a timestamp in seconds. Rescale the timestamp if it is not in seconds. if SCALE_S < ts_scale: delta = ts_scale - SCALE_S timestamp = timestamp / np.power(10, delta) # Convert the timestamp to a datetime object. If it is not a valid timestamp, `ValueError` is raised. dt = datetime.fromtimestamp(timestamp, tz=timezone.utc).replace(tzinfo=None) return dt ================================================ FILE: ludwig/utils/defaults.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import argparse import copy import logging import yaml from ludwig.api_annotations import DeveloperAPI from ludwig.contrib import add_contrib_callback_args from ludwig.features.feature_registries import get_input_type_registry from ludwig.globals import LUDWIG_VERSION from ludwig.schema.model_config import ModelConfig from ludwig.schema.preprocessing import PreprocessingConfig from ludwig.utils.backward_compatibility import upgrade_config_dict_to_latest_version from ludwig.utils.data_utils import load_config_from_str, load_yaml from ludwig.utils.fs_utils import open_file from ludwig.utils.print_utils import print_ludwig logger = logging.getLogger(__name__) default_random_seed = 42 # Still needed for preprocessing TODO(Connor): Refactor ludwig/data/preprocessing to use schema # TODO(travis): remove this, make type a protected string for each subclass default_feature_specific_preprocessing_parameters = { name: preproc_sect.get_schema_cls()(name="__tmp__", type=name).preprocessing.to_dict() for name, preproc_sect in get_input_type_registry().items() } default_training_preprocessing_parameters = copy.deepcopy(default_feature_specific_preprocessing_parameters) default_training_preprocessing_parameters.update(PreprocessingConfig().to_dict()) default_prediction_preprocessing_parameters = copy.deepcopy(default_feature_specific_preprocessing_parameters) @DeveloperAPI def render_config(config=None, output=None, **kwargs): upgraded_config = upgrade_config_dict_to_latest_version(config) output_config = ModelConfig.from_dict(upgraded_config).to_dict() if output is None: print(yaml.safe_dump(output_config, None, sort_keys=False)) else: with open_file(output, "w") as f: yaml.safe_dump(output_config, f, sort_keys=False) @DeveloperAPI def cli_render_config(sys_argv): parser = argparse.ArgumentParser( description="This script renders the full config from a user config.", prog="ludwig render_config", usage="%(prog)s [options]", ) parser.add_argument( "-c", "--config", type=load_yaml, help="Path to the YAML file containing the model configuration", ) parser.add_argument( "-cs", "--config_str", dest="config", type=load_config_from_str, help="JSON or YAML serialized string of the model configuration", ) parser.add_argument( "-o", "--output", type=str, help="output rendered YAML config path", required=False, ) add_contrib_callback_args(parser) args = parser.parse_args(sys_argv) args.callbacks = args.callbacks or [] for callback in args.callbacks: callback.on_cmdline("render_config", *sys_argv) print_ludwig("Render Config", LUDWIG_VERSION) render_config(**vars(args)) ================================================ FILE: ludwig/utils/entmax/LICENSE ================================================ MIT License Copyright (c) 2019 DeepSPIN Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: ludwig/utils/entmax/README.md ================================================ # entmax ______________________________________________________________________ This package provides a pytorch implementation of entmax and entmax losses: a sparse family of probability mappings and corresponding loss functions, generalizing softmax / cross-entropy. *Features:* - Exact partial-sort algorithms for 1.5-entmax and 2-entmax (sparsemax). - A bisection-based algorithm for generic alpha-entmax. - Gradients w.r.t. alpha for adaptive, learned sparsity! *Requirements:* python 3, pytorch >= 1.0 (and pytest for unit tests) ## Example ```python import torch from torch.nn.functional import softmax from entmax import sparsemax, entmax15 x = torch.tensor([-2, 0, 0.5]) print(softmax(x, dim=0)) # tensor([0.0486, 0.3592, 0.5922]) print(sparsemax(x, dim=0)) # tensor([0.0000, 0.2500, 0.7500]) print(entmax15(x, dim=0)) # tensor([0.0000, 0.3260, 0.6740]) ``` Gradients w.r.t. alpha (continued): ```python import torch from torch.autograd import grad from entmax import entmax_bisect x = torch.tensor([[-1, 0, 0.5], [1, 2, 3.5]]) alpha = torch.tensor(1.33, requires_grad=True) p = entmax_bisect(x, alpha) print(p) # tensor([[0.0460, 0.3276, 0.6264], # [0.0026, 0.1012, 0.8963]], grad_fn=) print(grad(p[0, 0], alpha)) # (tensor(-0.2562),) ``` ## Installation ``` pip install entmax ``` ## Citations [Sparse Sequence-to-Sequence Models](https://www.aclweb.org/anthology/P19-1146) ``` @inproceedings{entmax, author = {Peters, Ben and Niculae, Vlad and Martins, Andr{\'e} FT}, title = {Sparse Sequence-to-Sequence Models}, booktitle = {Proc. ACL}, year = {2019}, url = {https://www.aclweb.org/anthology/P19-1146} } ``` [Adaptively Sparse Transformers](https://arxiv.org/pdf/1909.00015.pdf) ``` @inproceedings{correia19adaptively, author = {Correia, Gon\c{c}alo M and Niculae, Vlad and Martins, Andr{\'e} FT}, title = {Adaptively Sparse Transformers}, booktitle = {Proc. EMNLP-IJCNLP (to appear)}, year = {2019}, } ``` Further reading: - Blondel, Martins, and Niculae, 2019. [Learning with Fenchel-Young Losses](https://arxiv.org/abs/1901.02324). - Martins and Astudillo, 2016. [From Softmax to Sparsemax: A Sparse Model of Attention and Multi-Label Classification](https://arxiv.org/abs/1602.02068). - Peters and Martins, 2019 [IT-IST at the SIGMORPHON 2019 Shared Task: Sparse Two-headed Models for Inflection](https://www.aclweb.org/anthology/W19-4207). ================================================ FILE: ludwig/utils/entmax/__init__.py ================================================ __version__ = "1.1.dev0" from ludwig.utils.entmax.activations import entmax15, Entmax15, sparsemax, Sparsemax from ludwig.utils.entmax.losses import ( entmax15_loss, Entmax15Loss, entmax_bisect_loss, EntmaxBisectLoss, sparsemax_bisect_loss, sparsemax_loss, SparsemaxBisectLoss, SparsemaxLoss, ) from ludwig.utils.entmax.root_finding import entmax_bisect, EntmaxBisect, sparsemax_bisect, SparsemaxBisect __all__ = [ "entmax15", "Entmax15", "sparsemax", "Sparsemax", "entmax15_loss", "Entmax15Loss", "entmax_bisect_loss", "EntmaxBisectLoss", "sparsemax_bisect_loss", "sparsemax_loss", "SparsemaxBisectLoss", "SparsemaxLoss", "entmax_bisect", "EntmaxBisect", "sparsemax_bisect", "SparsemaxBisect", ] ================================================ FILE: ludwig/utils/entmax/activations.py ================================================ """An implementation of entmax (Peters et al., 2019). See https://arxiv.org/pdf/1905.05702 for detailed description. This builds on previous work with sparsemax (Martins & Astudillo, 2016). See https://arxiv.org/pdf/1602.02068. """ # Author: Ben Peters # Author: Vlad Niculae # License: MIT import torch import torch.nn as nn from torch.autograd import Function def _make_ix_like(X, dim): d = X.size(dim) rho = torch.arange(1, d + 1, device=X.device, dtype=X.dtype) view = [1] * X.dim() view[0] = -1 return rho.view(view).transpose(0, dim) def _roll_last(X, dim): if dim == -1: return X elif dim < 0: dim = X.dim() - dim perm = [i for i in range(X.dim()) if i != dim] + [dim] return X.permute(perm) def _sparsemax_threshold_and_support(X, dim=-1, k=None): """Core computation for sparsemax: optimal threshold and support size. Parameters ---------- X : torch.Tensor The input tensor to compute thresholds over. dim : int The dimension along which to apply sparsemax. k : int or None number of largest elements to partial-sort over. For optimal performance, should be slightly bigger than the expected number of nonzeros in the solution. If the solution is more than k-sparse, this function is recursively called with a 2*k schedule. If `None`, full sorting is performed from the beginning. Returns ------- tau : torch.Tensor like `X`, with all but the `dim` dimension intact the threshold value for each vector support_size : torch LongTensor, shape like `tau` the number of nonzeros in each vector. """ if k is None or k >= X.shape[dim]: # do full sort topk, _ = torch.sort(X, dim=dim, descending=True) else: topk, _ = torch.topk(X, k=k, dim=dim) topk_cumsum = topk.cumsum(dim) - 1 rhos = _make_ix_like(topk, dim) support = rhos * topk > topk_cumsum support_size = support.sum(dim=dim).unsqueeze(dim) tau = topk_cumsum.gather(dim, support_size - 1) tau /= support_size.to(X.dtype) if k is not None and k < X.shape[dim]: unsolved = (support_size == k).squeeze(dim) if torch.any(unsolved): in_ = _roll_last(X, dim)[unsolved] tau_, ss_ = _sparsemax_threshold_and_support(in_, dim=-1, k=2 * k) _roll_last(tau, dim)[unsolved] = tau_ _roll_last(support_size, dim)[unsolved] = ss_ return tau, support_size def _entmax_threshold_and_support(X, dim=-1, k=None): """Core computation for 1.5-entmax: optimal threshold and support size. Parameters ---------- X : torch.Tensor The input tensor to compute thresholds over. dim : int The dimension along which to apply 1.5-entmax. k : int or None number of largest elements to partial-sort over. For optimal performance, should be slightly bigger than the expected number of nonzeros in the solution. If the solution is more than k-sparse, this function is recursively called with a 2*k schedule. If `None`, full sorting is performed from the beginning. Returns ------- tau : torch.Tensor like `X`, with all but the `dim` dimension intact the threshold value for each vector support_size : torch LongTensor, shape like `tau` the number of nonzeros in each vector. """ if k is None or k >= X.shape[dim]: # do full sort Xsrt, _ = torch.sort(X, dim=dim, descending=True) else: Xsrt, _ = torch.topk(X, k=k, dim=dim) rho = _make_ix_like(Xsrt, dim) mean = Xsrt.cumsum(dim) / rho mean_sq = (Xsrt**2).cumsum(dim) / rho ss = rho * (mean_sq - mean**2) delta = (1 - ss) / rho # NOTE this is not exactly the same as in reference algo # Fortunately it seems the clamped values never wrongly # get selected by tau <= sorted_z. Prove this! delta_nz = torch.clamp(delta, 0) tau = mean - torch.sqrt(delta_nz) support_size = (tau <= Xsrt).sum(dim).unsqueeze(dim) tau_star = tau.gather(dim, support_size - 1) if k is not None and k < X.shape[dim]: unsolved = (support_size == k).squeeze(dim) if torch.any(unsolved): X_ = _roll_last(X, dim)[unsolved] tau_, ss_ = _entmax_threshold_and_support(X_, dim=-1, k=2 * k) _roll_last(tau_star, dim)[unsolved] = tau_ _roll_last(support_size, dim)[unsolved] = ss_ return tau_star, support_size class SparsemaxFunction(Function): @classmethod def forward(cls, ctx, X, dim=-1, k=None): ctx.dim = dim output, backwards_kwargs = _sparsemax_forward(X, dim, k) ctx.save_for_backward(backwards_kwargs["supp_size"], output) return output @classmethod def backward(cls, ctx, grad_output): supp_size, output = ctx.saved_tensors dim = ctx.dim grad_input = grad_output.clone() grad_input[output == 0] = 0 v_hat = grad_input.sum(dim=dim) / supp_size.to(output.dtype).squeeze(dim) v_hat = v_hat.unsqueeze(dim) grad_input = torch.where(output != 0, grad_input - v_hat, grad_input) return grad_input, None, None def _sparsemax_forward(X, dim, k): max_val, _ = X.max(dim=dim, keepdim=True) X = X - max_val # same numerical stability trick as softmax tau, supp_size = _sparsemax_threshold_and_support(X, dim=dim, k=k) output = torch.clamp(X - tau, min=0) return output, {"supp_size": supp_size} class Entmax15Function(Function): @classmethod def forward(cls, ctx, X, dim=0, k=None): ctx.dim = dim Y, _ = _entmax15_forward(X, dim, k) ctx.save_for_backward(Y) return Y @classmethod def backward(cls, ctx, dY): (Y,) = ctx.saved_tensors gppr = Y.sqrt() # = 1 / g'' (Y) dX = dY * gppr q = dX.sum(ctx.dim) / gppr.sum(ctx.dim) q = q.unsqueeze(ctx.dim) dX -= q * gppr return dX, None, None def _entmax15_forward(X, dim, k): max_val, _ = X.max(dim=dim, keepdim=True) X = X - max_val # same numerical stability trick as for softmax X = X / 2 # divide by 2 to solve actual Entmax tau_star, _ = _entmax_threshold_and_support(X, dim=dim, k=k) Y = torch.clamp(X - tau_star, min=0) ** 2 return Y, {} def sparsemax(X, dim=-1, k=None, training=True): """sparsemax: normalizing sparse transform (a la softmax). Solves the projection: min_p ||x - p||_2 s.t. p >= 0, sum(p) == 1. Parameters ---------- X : torch.Tensor The input tensor. dim : int The dimension along which to apply sparsemax. k : int or None number of largest elements to partial-sort over. For optimal performance, should be slightly bigger than the expected number of nonzeros in the solution. If the solution is more than k-sparse, this function is recursively called with a 2*k schedule. If `None`, full sorting is performed from the beginning. Returns ------- P : torch tensor, same shape as X The projection result, such that P.sum(dim=dim) == 1 elementwise. """ # Avoids call to custom autograd.Function during eval to ensure torchscript compatibility # custom autograd.Function is not scriptable: https://github.com/pytorch/pytorch/issues/22329#issuecomment-506608053 if not training: output, _ = _sparsemax_forward(X, dim, k) return output return SparsemaxFunction.apply(X, dim, k) def entmax15(X, dim=-1, k=None, training=True): """1.5-entmax: normalizing sparse transform (a la softmax). Solves the optimization problem: max_p - H_1.5(p) s.t. p >= 0, sum(p) == 1. where H_1.5(p) is the Tsallis alpha-entropy with alpha=1.5. Parameters ---------- X : torch.Tensor The input tensor. dim : int The dimension along which to apply 1.5-entmax. k : int or None number of largest elements to partial-sort over. For optimal performance, should be slightly bigger than the expected number of nonzeros in the solution. If the solution is more than k-sparse, this function is recursively called with a 2*k schedule. If `None`, full sorting is performed from the beginning. Returns ------- P : torch tensor, same shape as X The projection result, such that P.sum(dim=dim) == 1 elementwise. """ # Avoids call to custom autograd.Function during eval to ensure torchscript compatibility # custom autograd.Function is not scriptable: https://github.com/pytorch/pytorch/issues/22329#issuecomment-506608053 if not training: output, _ = _entmax15_forward(X, dim, k) return output return Entmax15Function.apply(X, dim, k) class Sparsemax(nn.Module): def __init__(self, dim=-1, k=None): """sparsemax: normalizing sparse transform (a la softmax). Solves the projection: min_p ||x - p||_2 s.t. p >= 0, sum(p) == 1. Parameters ---------- dim : int The dimension along which to apply sparsemax. k : int or None number of largest elements to partial-sort over. For optimal performance, should be slightly bigger than the expected number of nonzeros in the solution. If the solution is more than k-sparse, this function is recursively called with a 2*k schedule. If `None`, full sorting is performed from the beginning. """ self.dim = dim self.k = k super().__init__() def forward(self, X): return sparsemax(X, dim=self.dim, k=self.k, training=self.training) class Entmax15(nn.Module): def __init__(self, dim=-1, k=None): """1.5-entmax: normalizing sparse transform (a la softmax). Solves the optimization problem: max_p - H_1.5(p) s.t. p >= 0, sum(p) == 1. where H_1.5(p) is the Tsallis alpha-entropy with alpha=1.5. Parameters ---------- dim : int The dimension along which to apply 1.5-entmax. k : int or None number of largest elements to partial-sort over. For optimal performance, should be slightly bigger than the expected number of nonzeros in the solution. If the solution is more than k-sparse, this function is recursively called with a 2*k schedule. If `None`, full sorting is performed from the beginning. """ self.dim = dim self.k = k super().__init__() def forward(self, X): return entmax15(X, dim=self.dim, k=self.k, training=self.training) ================================================ FILE: ludwig/utils/entmax/losses.py ================================================ import torch import torch.nn as nn from torch.autograd import Function from ludwig.constants import IGNORE_INDEX_TOKEN_ID from ludwig.utils.entmax.activations import entmax15, sparsemax from ludwig.utils.entmax.root_finding import entmax_bisect, sparsemax_bisect class _GenericLoss(nn.Module): def __init__(self, ignore_index=IGNORE_INDEX_TOKEN_ID, reduction="elementwise_mean"): assert reduction in ["elementwise_mean", "sum", "none"] self.reduction = reduction self.ignore_index = ignore_index super().__init__() def forward(self, X, target): loss = self.loss(X, target) if self.ignore_index >= 0: ignored_positions = target == self.ignore_index size = (target.size(0) - ignored_positions.sum()).item() loss.masked_fill_(ignored_positions, 0.0) else: size = target.size(0) if self.reduction == "sum": loss = loss.sum() elif self.reduction == "elementwise_mean": if size == 0: # Returns zero loss and zero gradient in the rare case that all row targets are ignored. loss = loss.sum() * 0.0 else: loss = loss.sum() / float(size) return loss class _GenericLossFunction(Function): @classmethod def forward(cls, ctx, X, target, alpha, proj_args): """X (FloatTensor): n x num_classes target (LongTensor): n, the indices of the target classes.""" assert X.shape[0] == target.shape[0] p_star = cls.project(X, alpha, **proj_args) loss = cls.omega(p_star, alpha) p_star.scatter_add_(1, target.unsqueeze(1), torch.full_like(p_star, -1)) loss += torch.einsum("ij,ij->i", p_star, X) ctx.save_for_backward(p_star) return loss @classmethod def backward(cls, ctx, grad_output): (p_star,) = ctx.saved_tensors grad = grad_output.unsqueeze(1) * p_star ret = (grad,) # pad with as many Nones as needed return ret + (None,) * (1 + cls.n_fwd_args) class SparsemaxLossFunction(_GenericLossFunction): n_fwd_args = 1 @classmethod def project(cls, X, alpha, k): return sparsemax(X, dim=-1, k=k) @classmethod def omega(cls, p_star, alpha): return (1 - (p_star**2).sum(dim=1)) / 2 @classmethod def forward(cls, ctx, X, target, k=None): return super().forward(ctx, X, target, alpha=2, proj_args=dict(k=k)) class SparsemaxBisectLossFunction(_GenericLossFunction): n_fwd_args = 1 @classmethod def project(cls, X, alpha, n_iter): return sparsemax_bisect(X, n_iter=n_iter) @classmethod def omega(cls, p_star, alpha): return (1 - (p_star**2).sum(dim=1)) / 2 @classmethod def forward(cls, ctx, X, target, n_iter=50): return super().forward(ctx, X, target, alpha=2, proj_args=dict(n_iter=n_iter)) class Entmax15LossFunction(_GenericLossFunction): n_fwd_args = 1 @classmethod def project(cls, X, alpha, k=None): return entmax15(X, dim=-1, k=k) @classmethod def omega(cls, p_star, alpha): return (1 - (p_star * torch.sqrt(p_star)).sum(dim=1)) / 0.75 @classmethod def forward(cls, ctx, X, target, k=None): return super().forward(ctx, X, target, alpha=1.5, proj_args=dict(k=k)) class EntmaxBisectLossFunction(_GenericLossFunction): n_fwd_args = 2 @classmethod def project(cls, X, alpha, n_iter): return entmax_bisect(X, alpha=alpha, n_iter=n_iter, ensure_sum_one=True) @classmethod def omega(cls, p_star, alpha): return (1 - (p_star**alpha).sum(dim=1)) / (alpha * (alpha - 1)) @classmethod def forward(cls, ctx, X, target, alpha=1.5, n_iter=50): return super().forward(ctx, X, target, alpha, proj_args=dict(n_iter=n_iter)) def sparsemax_loss(X, target, k=None): """sparsemax loss: sparse alternative to cross-entropy. Computed using a partial sorting strategy. Parameters ---------- X : torch.Tensor, shape=(n_samples, n_classes) The input 2D tensor of predicted scores target : torch.LongTensor, shape=(n_samples,) The ground truth labels, 0 <= target < n_classes. k : int or None number of largest elements to partial-sort over. For optimal performance, should be slightly bigger than the expected number of nonzeros in the solution. If the solution is more than k-sparse, this function is recursively called with a 2*k schedule. If `None`, full sorting is performed from the beginning. Returns ------- losses, torch.Tensor, shape=(n_samples,) The loss incurred at each sample. """ return SparsemaxLossFunction.apply(X, target, k) def sparsemax_bisect_loss(X, target, n_iter=50): """sparsemax loss: sparse alternative to cross-entropy. Computed using bisection. Parameters ---------- X : torch.Tensor, shape=(n_samples, n_classes) The input 2D tensor of predicted scores target : torch.LongTensor, shape=(n_samples,) The ground truth labels, 0 <= target < n_classes. n_iter : int Number of bisection iterations. For float32, 24 iterations should suffice for machine precision. Returns ------- losses, torch.Tensor, shape=(n_samples,) The loss incurred at each sample. """ return SparsemaxBisectLossFunction.apply(X, target, n_iter) def entmax15_loss(X, target, k=None): """1.5-entmax loss: sparse alternative to cross-entropy Computed using a partial sorting strategy. Parameters ---------- X : torch.Tensor, shape=(n_samples, n_classes) The input 2D tensor of predicted scores target : torch.LongTensor, shape=(n_samples,) The ground truth labels, 0 <= target < n_classes. k : int or None number of largest elements to partial-sort over. For optimal performance, should be slightly bigger than the expected number of nonzeros in the solution. If the solution is more than k-sparse, this function is recursively called with a 2*k schedule. If `None`, full sorting is performed from the beginning. Returns ------- losses, torch.Tensor, shape=(n_samples,) The loss incurred at each sample. """ return Entmax15LossFunction.apply(X, target, k) def entmax_bisect_loss(X, target, alpha=1.5, n_iter=50): """alpha-entmax loss: sparse alternative to cross-entropy. Computed using bisection, supporting arbitrary alpha > 1. Parameters ---------- X : torch.Tensor, shape=(n_samples, n_classes) The input 2D tensor of predicted scores target : torch.LongTensor, shape=(n_samples,) The ground truth labels, 0 <= target < n_classes. alpha : float or torch.Tensor Tensor of alpha parameters (> 1) to use for each row of X. If scalar or python float, the same value is used for all rows. A value of alpha=2 corresponds to sparsemax, and alpha=1 would in theory recover softmax. For numeric reasons, this algorithm does not work with `alpha=1`: if you want softmax, we recommend `torch.nn.softmax` n_iter : int Number of bisection iterations. For float32, 24 iterations should suffice for machine precision. Returns ------- losses, torch.Tensor, shape=(n_samples,) The loss incurred at each sample. """ return EntmaxBisectLossFunction.apply(X, target, alpha, n_iter) class SparsemaxBisectLoss(_GenericLoss): def __init__(self, n_iter=50, ignore_index=IGNORE_INDEX_TOKEN_ID, reduction="elementwise_mean"): self.n_iter = n_iter super().__init__(ignore_index, reduction) def loss(self, X, target): return sparsemax_bisect_loss(X, target, self.n_iter) class SparsemaxLoss(_GenericLoss): def __init__(self, k=None, ignore_index=IGNORE_INDEX_TOKEN_ID, reduction="elementwise_mean"): self.k = k super().__init__(ignore_index, reduction) def loss(self, X, target): return sparsemax_loss(X, target, self.k) class EntmaxBisectLoss(_GenericLoss): def __init__( self, alpha=1.5, n_iter=50, ignore_index=IGNORE_INDEX_TOKEN_ID, reduction="elementwise_mean", ): self.alpha = alpha self.n_iter = n_iter super().__init__(ignore_index, reduction) def loss(self, X, target): return entmax_bisect_loss(X, target, self.alpha, self.n_iter) class Entmax15Loss(_GenericLoss): def __init__(self, k=100, ignore_index=IGNORE_INDEX_TOKEN_ID, reduction="elementwise_mean"): self.k = k super().__init__(ignore_index, reduction) def loss(self, X, target): return entmax15_loss(X, target, self.k) ================================================ FILE: ludwig/utils/entmax/root_finding.py ================================================ """Bisection implementation of alpha-entmax (Peters et al., 2019). Backward pass wrt alpha per (Correia et al., 2019). See https://arxiv.org/pdf/1905.05702 for detailed description. """ # Author: Goncalo M Correia # Author: Ben Peters # Author: Vlad Niculae import torch import torch.nn as nn from torch.autograd import Function class EntmaxBisectFunction(Function): @classmethod def _gp(cls, x, alpha): return x ** (alpha - 1) @classmethod def _gp_inv(cls, y, alpha): return y ** (1 / (alpha - 1)) @classmethod def _p(cls, X, alpha): return cls._gp_inv(torch.clamp(X, min=0), alpha) @classmethod def forward(cls, ctx, X, alpha=1.5, dim=-1, n_iter=50, ensure_sum_one=True): p_m, backward_kwargs = _entmax_bisect_forward(X, alpha, dim, n_iter, ensure_sum_one, cls) ctx.alpha = backward_kwargs["alpha"] ctx.dim = backward_kwargs["dim"] ctx.save_for_backward(p_m) return p_m @classmethod def backward(cls, ctx, dY): (Y,) = ctx.saved_tensors gppr = torch.where(Y > 0, Y ** (2 - ctx.alpha), Y.new_zeros(1)) dX = dY * gppr q = dX.sum(ctx.dim) / gppr.sum(ctx.dim) q = q.unsqueeze(ctx.dim) dX -= q * gppr d_alpha = None if ctx.needs_input_grad[1]: # alpha gradient computation # d_alpha = (partial_y / partial_alpha) * dY # NOTE: ensure alpha is not close to 1 # since there is an indetermination # batch_size, _ = dY.shape # shannon terms S = torch.where(Y > 0, Y * torch.log(Y), Y.new_zeros(1)) # shannon entropy ent = S.sum(ctx.dim).unsqueeze(ctx.dim) Y_skewed = gppr / gppr.sum(ctx.dim).unsqueeze(ctx.dim) d_alpha = dY * (Y - Y_skewed) / ((ctx.alpha - 1) ** 2) d_alpha -= dY * (S - Y_skewed * ent) / (ctx.alpha - 1) d_alpha = d_alpha.sum(ctx.dim).unsqueeze(ctx.dim) return dX, d_alpha, None, None, None def _entmax_bisect_forward(X, alpha, dim, n_iter, ensure_sum_one, cls=EntmaxBisectFunction): if not isinstance(alpha, torch.Tensor): alpha = torch.tensor(alpha, dtype=X.dtype, device=X.device) alpha_shape = list(X.shape) alpha_shape[dim] = 1 alpha = alpha.expand(*alpha_shape) d = X.shape[dim] max_val, _ = X.max(dim=dim, keepdim=True) X = X * (alpha - 1) max_val = max_val * (alpha - 1) # Note: when alpha < 1, tau_lo > tau_hi. This still works since dm < 0. tau_lo = max_val - cls._gp(1, alpha) tau_hi = max_val - cls._gp(1 / d, alpha) f_lo = cls._p(X - tau_lo, alpha).sum(dim) - 1 dm = tau_hi - tau_lo for it in range(n_iter): dm /= 2 tau_m = tau_lo + dm p_m = cls._p(X - tau_m, alpha) f_m = p_m.sum(dim) - 1 mask = (f_m * f_lo >= 0).unsqueeze(dim) tau_lo = torch.where(mask, tau_m, tau_lo) if ensure_sum_one: p_m /= p_m.sum(dim=dim).unsqueeze(dim=dim) return p_m, {"alpha": alpha, "dim": dim} # slightly more efficient special case for sparsemax class SparsemaxBisectFunction(EntmaxBisectFunction): @classmethod def _gp(cls, x, alpha): return x @classmethod def _gp_inv(cls, y, alpha): return y @classmethod def _p(cls, x, alpha): return torch.clamp(x, min=0) @classmethod def forward(cls, ctx, X, dim=-1, n_iter=50, ensure_sum_one=True): p_m, backward_kwargs = _sparsemax_bisect_forward(X, dim, n_iter, ensure_sum_one) ctx.alpha = backward_kwargs["alpha"] ctx.dim = backward_kwargs["dim"] ctx.save_for_backward(p_m) return p_m @classmethod def backward(cls, ctx, dY): (Y,) = ctx.saved_tensors gppr = (Y > 0).to(dtype=dY.dtype) dX = dY * gppr q = dX.sum(ctx.dim) / gppr.sum(ctx.dim) q = q.unsqueeze(ctx.dim) dX -= q * gppr return dX, None, None, None def _sparsemax_bisect_forward(X, dim, n_iter, ensure_sum_one): return _entmax_bisect_forward(X, alpha=2, dim=dim, n_iter=50, ensure_sum_one=True, cls=SparsemaxBisectFunction) def entmax_bisect(X, alpha=1.5, dim=-1, n_iter=50, ensure_sum_one=True, training=True): """alpha-entmax: normalizing sparse transform (a la softmax). Solves the optimization problem: max_p - H_a(p) s.t. p >= 0, sum(p) == 1. where H_a(p) is the Tsallis alpha-entropy with custom alpha >= 1, using a bisection (root finding, binary search) algorithm. This function is differentiable with respect to both X and alpha. Parameters ---------- X : torch.Tensor The input tensor. alpha : float or torch.Tensor Tensor of alpha parameters (> 1) to use. If scalar or python float, the same value is used for all rows, otherwise, it must have shape (or be expandable to) alpha.shape[j] == (X.shape[j] if j != dim else 1) A value of alpha=2 corresponds to sparsemax, and alpha=1 would in theory recover softmax. For numeric reasons, this algorithm does not work with `alpha=1`: if you want softmax, we recommend `torch.nn.softmax`. dim : int The dimension along which to apply alpha-entmax. n_iter : int Number of bisection iterations. For float32, 24 iterations should suffice for machine precision. ensure_sum_one : bool, Whether to divide the result by its sum. If false, the result might sum to close but not exactly 1, which might cause downstream problems. Returns ------- P : torch tensor, same shape as X The projection result, such that P.sum(dim=dim) == 1 elementwise. """ # Avoids call to custom autograd.Function during eval to ensure torchscript compatibility # custom autograd.Function is not scriptable: https://github.com/pytorch/pytorch/issues/22329#issuecomment-506608053 if not training: output, _ = _entmax_bisect_forward(X, alpha, dim, n_iter, ensure_sum_one) return output return EntmaxBisectFunction.apply(X, alpha, dim, n_iter, ensure_sum_one) def sparsemax_bisect(X, dim=-1, n_iter=50, ensure_sum_one=True, training=True): """sparsemax: normalizing sparse transform (a la softmax), via bisection. Solves the projection: min_p ||x - p||_2 s.t. p >= 0, sum(p) == 1. Parameters ---------- X : torch.Tensor The input tensor. dim : int The dimension along which to apply sparsemax. n_iter : int Number of bisection iterations. For float32, 24 iterations should suffice for machine precision. ensure_sum_one : bool, Whether to divide the result by its sum. If false, the result might sum to close but not exactly 1, which might cause downstream problems. Note: This function does not yet support normalizing along anything except the last dimension. Please use transposing and views to achieve more general behavior. Returns ------- P : torch tensor, same shape as X The projection result, such that P.sum(dim=dim) == 1 elementwise. """ # Avoids call to custom autograd.Function during eval to ensure torchscript compatibility # custom autograd.Function is not scriptable: https://github.com/pytorch/pytorch/issues/22329#issuecomment-506608053 if not training: output, _ = _sparsemax_bisect_forward(X, dim, n_iter, ensure_sum_one) return output return SparsemaxBisectFunction.apply(X, dim, n_iter, ensure_sum_one) class SparsemaxBisect(nn.Module): def __init__(self, dim=-1, n_iter=None): """sparsemax: normalizing sparse transform (a la softmax) via bisection Solves the projection: min_p ||x - p||_2 s.t. p >= 0, sum(p) == 1. Parameters ---------- dim : int The dimension along which to apply sparsemax. n_iter : int Number of bisection iterations. For float32, 24 iterations should suffice for machine precision. """ self.dim = dim self.n_iter = n_iter super().__init__() def forward(self, X): return sparsemax_bisect(X, dim=self.dim, n_iter=self.n_iter, training=self.training) class EntmaxBisect(nn.Module): def __init__(self, alpha=1.5, dim=-1, n_iter=50): """alpha-entmax: normalizing sparse map (a la softmax) via bisection. Solves the optimization problem: max_p - H_a(p) s.t. p >= 0, sum(p) == 1. where H_a(p) is the Tsallis alpha-entropy with custom alpha >= 1, using a bisection (root finding, binary search) algorithm. Parameters ---------- alpha : float or torch.Tensor Tensor of alpha parameters (> 1) to use. If scalar or python float, the same value is used for all rows, otherwise, it must have shape (or be expandable to) alpha.shape[j] == (X.shape[j] if j != dim else 1) A value of alpha=2 corresponds to sparsemax; and alpha=1 would in theory recover softmax. For numeric reasons, this algorithm does not work with `alpha=1`; if you want softmax, we recommend `torch.nn.softmax`. dim : int The dimension along which to apply alpha-entmax. n_iter : int Number of bisection iterations. For float32, 24 iterations should suffice for machine precision. """ super().__init__() self.dim = dim self.n_iter = n_iter if isinstance(alpha, torch.Tensor): self.register_buffer("alpha", alpha) else: self.alpha = alpha def forward(self, X): return entmax_bisect(X, alpha=self.alpha, dim=self.dim, n_iter=self.n_iter, training=self.training) ================================================ FILE: ludwig/utils/error_handling_utils.py ================================================ import logging from functools import partial from retry.api import retry, retry_call import ludwig.constants as const logger = logging.getLogger(__name__) default_retry_call = partial( retry_call, tries=const.TRIES, backoff=const.BACKOFF, delay=const.DELAY, jitter=const.JITTER, logger=logger ) default_retry = partial( retry, tries=const.TRIES, backoff=const.BACKOFF, delay=const.DELAY, jitter=const.JITTER, logger=logger ) ================================================ FILE: ludwig/utils/eval_utils.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import logging from collections import OrderedDict import numpy as np from sklearn import metrics from sklearn.metrics import confusion_matrix logger = logging.getLogger(__name__) class ConfusionMatrix: def __init__(self, conditions, predictions, labels=None, sample_weight=None): # assert (len(predictions) == len(conditions)) min_length = min(len(predictions), len(conditions)) self.predictions = predictions[:min_length] self.conditions = conditions[:min_length] if labels is not None: self.label2idx = {label: idx for idx, label in enumerate(labels)} self.idx2label = {idx: label for idx, label in enumerate(labels)} labels = list(range(len(labels))) else: self.label2idx = { str(label): idx for idx, label in enumerate(np.unique([self.predictions, self.conditions])) } self.idx2label = { idx: str(label) for idx, label in enumerate(np.unique([self.predictions, self.conditions])) } self.cm = confusion_matrix(self.conditions, self.predictions, labels=labels, sample_weight=sample_weight) # if labels is not None: # self.labels_dict = {label: idx for idx, label in enumerate(labels)} # else: # if conditions.dtype.char == 'S': # it's an array of strings # self.labels_dict = {str(label): idx for idx, label in # enumerate(np.unique([predictions, conditions]))} # else: # number # max_label = np.concatenate([predictions, conditions]).max() # self.labels_dict = {str(i): i for i in range(max_label + 1)} # labels = [str(i) for i in range(max_label + 1)] # self.cm = confusion_matrix(conditions, predictions, labels, sample_weight) self.sum_predictions = np.sum(self.cm, axis=0) self.sum_conditions = np.sum(self.cm, axis=1) self.all = np.sum(self.cm) def label_to_idx(self, label): return self.label2idx[label] def true_positives(self, idx): return self.cm[idx, idx] def true_negatives(self, idx): return self.all - self.sum_predictions[idx] - self.sum_conditions[idx] + self.true_positives(idx) def false_positives(self, idx): return self.sum_predictions[idx] - self.true_positives(idx) def false_negatives(self, idx): return self.sum_conditions[idx] - self.true_positives(idx) def true_positive_rate(self, idx): nom = self.true_positives(idx) den = self.sum_conditions[idx] if den == 0 or den == np.nan: return 0 else: return nom / den def true_negative_rate(self, idx): nom = tn = self.true_negatives(idx) den = tn + self.false_positives(idx) if den == 0 or den == np.nan: return 0 else: return nom / den def positive_predictive_value(self, idx): nom = self.true_positives(idx) den = self.sum_predictions[idx] if den == 0 or den == np.nan: return 0 else: return nom / den def negative_predictive_value(self, idx): nom = tn = self.true_negatives(idx) den = tn + self.false_negatives(idx) if den == 0 or den == np.nan: return 0 else: return nom / den def false_negative_rate(self, idx): return 1.0 - self.true_positive_rate(idx) def false_positive_rate(self, idx): return 1.0 - self.true_negative_rate(idx) def false_discovery_rate(self, idx): return 1.0 - self.positive_predictive_value(idx) def false_omission_rate(self, idx): return 1.0 - self.negative_predictive_value(idx) def accuracy(self, idx): nom = self.true_positives(idx) + self.true_negatives(idx) den = self.all if den == 0 or den == np.nan: return 0 else: return nom / den def precision(self, idx): return self.positive_predictive_value(idx) def recall(self, idx): return self.true_positive_rate(idx) def fbeta_score(self, beta, idx): beta_2 = np.power(beta, 2) precision = self.precision(idx) recall = self.recall(idx) nom = (1 + beta_2) * precision * recall den = (beta_2 * precision) + recall if den == 0 or den == np.nan: return 0 else: return nom / den def f1_score(self, idx): return self.fbeta_score(1, idx) def sensitivity(self, idx): return self.true_positive_rate(idx) def specificity(self, idx): return self.true_negative_rate(idx) def hit_rate(self, idx): return self.true_positive_rate(idx) def miss_rate(self, idx): return self.false_negative_rate(idx) def fall_out(self, idx): return self.false_positive_rate(idx) def matthews_correlation_coefficient(self, idx): tp = self.true_positives(idx) tn = self.true_negatives(idx) fp = self.false_positives(idx) fn = self.false_negatives(idx) nom = tp * tn - fp * fn den = np.sqrt((tp + fp) * (tp + fn) * (tn + fp) * (tn + fn)) if den == 0 or den == np.nan: return 0 else: return nom / den def informedness(self, idx): return self.true_positive_rate(idx) + self.true_negative_rate(idx) - 1 def markedness(self, idx): return self.positive_predictive_value(idx) + self.negative_predictive_value(idx) - 1 def token_accuracy(self): return metrics.accuracy_score(self.conditions, self.predictions) def avg_precision(self, average="macro"): return metrics.precision_score(self.conditions, self.predictions, average=average) def avg_recall(self, average="macro"): return metrics.recall_score(self.conditions, self.predictions, average=average) def avg_f1_score(self, average="macro"): return metrics.f1_score(self.conditions, self.predictions, average=average) def avg_fbeta_score(self, beta, average="macro"): return metrics.fbeta_score(self.conditions, self.predictions, beta=beta, average=average) def kappa_score(self): return metrics.cohen_kappa_score(self.conditions, self.predictions) def class_stats(self, idx): return { "true_positives": self.true_positives(idx), "true_negatives": self.true_negatives(idx), "false_positives": self.false_positives(idx), "false_negatives": self.false_negatives(idx), "true_positive_rate": self.true_positive_rate(idx), "true_negative_rate": self.true_negative_rate(idx), "positive_predictive_value": self.positive_predictive_value(idx), "negative_predictive_value": self.negative_predictive_value(idx), "false_negative_rate": self.false_negative_rate(idx), "false_positive_rate": self.false_positive_rate(idx), "false_discovery_rate": self.false_discovery_rate(idx), "false_omission_rate": self.false_omission_rate(idx), "accuracy": self.accuracy(idx), "precision": self.precision(idx), "recall": self.recall(idx), "f1_score": self.f1_score(idx), "sensitivity": self.sensitivity(idx), "specificity": self.specificity(idx), "hit_rate": self.hit_rate(idx), "miss_rate": self.miss_rate(idx), "fall_out": self.fall_out(idx), "matthews_correlation_coefficient": self.matthews_correlation_coefficient(idx), "informedness": self.informedness(idx), "markedness": self.markedness(idx), } def per_class_stats(self): stats = OrderedDict() for idx in sorted(self.idx2label.keys()): stats[self.idx2label[idx]] = self.class_stats(idx) return stats def stats(self): return { "token_accuracy": self.token_accuracy(), "avg_precision_macro": self.avg_precision(average="macro"), "avg_recall_macro": self.avg_recall(average="macro"), "avg_f1_score_macro": self.avg_f1_score(average="macro"), "avg_precision_micro": self.avg_precision(average="micro"), "avg_recall_micro": self.avg_recall(average="micro"), "avg_f1_score_micro": self.avg_f1_score(average="micro"), "avg_precision_weighted": self.avg_precision(average="micro"), "avg_recall_weighted": self.avg_recall(average="micro"), "avg_f1_score_weighted": self.avg_f1_score(average="weighted"), "kappa_score": self.kappa_score(), } def roc_curve(conditions, prediction_scores, pos_label=None, sample_weight=None): return metrics.roc_curve(conditions, prediction_scores, pos_label=pos_label, sample_weight=sample_weight) def roc_auc_score(conditions, prediction_scores, average="micro", sample_weight=None): try: return metrics.roc_auc_score(conditions, prediction_scores, average=average, sample_weight=sample_weight) except ValueError as ve: logger.info(ve) def precision_recall_curve(conditions, prediction_scores, pos_label=None, sample_weight=None): return metrics.precision_recall_curve( conditions, prediction_scores, pos_label=pos_label, sample_weight=sample_weight ) def average_precision_score(conditions, prediction_scores, average="micro", sample_weight=None): # average == [micro, macro, sampled, weidhted] return metrics.average_precision_score(conditions, prediction_scores, average=average, sample_weight=sample_weight) ================================================ FILE: ludwig/utils/fs_utils.py ================================================ #! /usr/bin/env python # Copyright (c) 2021 Linux Foundation. # # 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. # ============================================================================== import contextlib import errno import functools import logging import os import pathlib import shutil import tempfile import uuid from urllib.parse import unquote, urlparse import certifi import fsspec import h5py import pyarrow.fs import urllib3 from filelock import FileLock from fsspec.core import split_protocol from ludwig.api_annotations import DeveloperAPI logger = logging.getLogger(__name__) @DeveloperAPI def get_default_cache_location() -> str: """Returns a path to the default LUDWIG_CACHE location, or $HOME/.ludwig_cache.""" cache_path = None if "LUDWIG_CACHE" in os.environ and os.environ["LUDWIG_CACHE"]: cache_path = os.environ["LUDWIG_CACHE"] else: cache_path = str(pathlib.Path.home().joinpath(".ludwig_cache")) # Check if the cache path exists, if not create it if not os.path.exists(cache_path): os.makedirs(cache_path) return cache_path @DeveloperAPI def get_fs_and_path(url): protocol, path = split_protocol(url) # Parse the url to get only the escaped url path path = unquote(urlparse(path).path) # Create a windows compatible path from url path path = os.fspath(pathlib.PurePosixPath(path)) fs = fsspec.filesystem(protocol) return fs, path @DeveloperAPI def has_remote_protocol(url): protocol, _ = split_protocol(url) return protocol and protocol != "file" @DeveloperAPI def is_http(urlpath): protocol, _ = split_protocol(urlpath) return protocol == "http" or protocol == "https" @DeveloperAPI def upgrade_http(urlpath): protocol, url = split_protocol(urlpath) if protocol == "http": return "https://" + url return None @DeveloperAPI @functools.lru_cache(maxsize=32) def get_bytes_obj_from_path(path: str) -> bytes | None: if is_http(path): try: return get_bytes_obj_from_http_path(path) except Exception as e: logger.warning(e) return None else: try: with open_file(path) as f: return f.read() except OSError as e: logger.warning(e) return None @DeveloperAPI def stream_http_get_request(path: str) -> urllib3.response.HTTPResponse: if upgrade_http(path): http = urllib3.PoolManager() else: http = urllib3.PoolManager(ca_certs=certifi.where()) resp = http.request("GET", path, preload_content=False) return resp @DeveloperAPI @functools.lru_cache(maxsize=32) def get_bytes_obj_from_http_path(path: str) -> bytes: resp = stream_http_get_request(path) if resp.status == 404: upgraded = upgrade_http(path) if upgraded: logger.info(f"reading url {path} failed. upgrading to https and retrying") return get_bytes_obj_from_http_path(upgraded) else: raise urllib3.exceptions.HTTPError(f"reading url {path} failed and cannot be upgraded to https") # stream data data = b"" for chunk in resp.stream(1024): data += chunk return data @DeveloperAPI def find_non_existing_dir_by_adding_suffix(directory_name): fs, _ = get_fs_and_path(directory_name) suffix = 0 curr_directory_name = directory_name while fs.exists(curr_directory_name): curr_directory_name = directory_name + "_" + str(suffix) suffix += 1 return curr_directory_name @DeveloperAPI def abspath(url): protocol, _ = split_protocol(url) if protocol is not None: # we assume any path containing an explicit protovol is fully qualified return url return os.path.abspath(url) @DeveloperAPI def path_exists(url): fs, path = get_fs_and_path(url) return fs.exists(path) @DeveloperAPI def listdir(url): fs, path = get_fs_and_path(url) return fs.listdir(path) @DeveloperAPI def safe_move_file(src, dst): """Rename a file from `src` to `dst`. Inspired by: https://alexwlchan.net/2019/03/atomic-cross-filesystem- moves-in-python/ * Moves must be atomic. `shutil.move()` is not atomic. * Moves must work across filesystems. Sometimes temp directories and the model directories live on different filesystems. `os.replace()` will throw errors if run across filesystems. So we try `os.replace()`, but if we detect a cross-filesystem copy, we switch to `shutil.move()` with some wrappers to make it atomic. """ try: os.replace(src, dst) except OSError as err: if err.errno == errno.EXDEV: # Generate a unique ID, and copy `` to the target directory with a temporary name `..tmp`. # Because we're copying across a filesystem boundary, this initial copy may not be atomic. We insert a # random UUID so if different processes are copying into ``, they don't overlap in their tmp copies. copy_id = uuid.uuid4() tmp_dst = f"{dst}.{copy_id}.tmp" shutil.copyfile(src, tmp_dst) # Atomic replace file onto the new name, and clean up original source file. os.replace(tmp_dst, dst) os.unlink(src) else: raise @DeveloperAPI def safe_move_directory(src, dst): """Recursively moves files from src directory to dst directory and removes src directory. If dst directory does not exist, it will be created. """ try: os.replace(src, dst) except OSError as err: if err.errno == errno.EXDEV: # Generate a unique ID, and copy `` to the target directory with a temporary name `..tmp`. # Because we're copying across a filesystem boundary, this initial copy may not be atomic. We insert a # random UUID so if different processes are copying into ``, they don't overlap in their tmp copies. copy_id = uuid.uuid4() tmp_dst = f"{dst}.{copy_id}.tmp" shutil.copytree(src, tmp_dst) # Atomic replace directory name onto the new name, and clean up original source directory. os.replace(tmp_dst, dst) os.unlink(src) else: raise @DeveloperAPI def rename(src, tgt): protocol, _ = split_protocol(tgt) if protocol is not None: fs = fsspec.filesystem(protocol) fs.mv(src, tgt, recursive=True) else: safe_move_file(src, tgt) @DeveloperAPI def upload_file(src, tgt): protocol, _ = split_protocol(tgt) fs = fsspec.filesystem(protocol) fs.put(src, tgt) @DeveloperAPI def copy(src, tgt, recursive=False): protocol, _ = split_protocol(tgt) fs = fsspec.filesystem(protocol) fs.copy(src, tgt, recursive=recursive) @DeveloperAPI def makedirs(url, exist_ok=False): fs, path = get_fs_and_path(url) fs.makedirs(path, exist_ok=exist_ok) @DeveloperAPI def delete(url, recursive=False): fs, path = get_fs_and_path(url) return fs.delete(path, recursive=recursive) @DeveloperAPI def upload(lpath, rpath): fs, path = get_fs_and_path(rpath) pyarrow.fs.copy_files(lpath, path, destination_filesystem=pyarrow.fs.PyFileSystem(pyarrow.fs.FSSpecHandler(fs))) @DeveloperAPI def download(rpath, lpath): fs, path = get_fs_and_path(rpath) pyarrow.fs.copy_files(path, lpath, source_filesystem=pyarrow.fs.PyFileSystem(pyarrow.fs.FSSpecHandler(fs))) @DeveloperAPI def checksum(url): fs, path = get_fs_and_path(url) return fs.checksum(path) @DeveloperAPI def to_url(path): protocol, _ = split_protocol(path) if protocol is not None: return path return pathlib.Path(os.path.abspath(path)).as_uri() @DeveloperAPI @contextlib.contextmanager def upload_output_directory(url): if url is None: yield None, None return if has_remote_protocol(url): # To avoid extra network load, write all output files locally at runtime, # then upload to the remote fs at the end. with tempfile.TemporaryDirectory() as tmpdir: fs, remote_path = get_fs_and_path(url) # In cases where we are resuming from a previous run, we first need to download # the artifacts from the remote filesystem if path_exists(url): fs.get(url, tmpdir + "/", recursive=True) def put_fn(): # Use pyarrow API here as fs.put() is inconsistent in where it uploads the file # See: https://github.com/fsspec/filesystem_spec/issues/1062 pyarrow.fs.copy_files( tmpdir, remote_path, destination_filesystem=pyarrow.fs.PyFileSystem(pyarrow.fs.FSSpecHandler(fs)) ) # Write to temp directory locally yield tmpdir, put_fn # Upload to remote when finished put_fn() else: # For local paths (including file:// URIs), use the path directly. _, local_path = get_fs_and_path(url) makedirs(local_path, exist_ok=True) yield local_path, None @DeveloperAPI @contextlib.contextmanager def open_file(url, *args, **kwargs): fs, path = get_fs_and_path(url) with fs.open(path, *args, **kwargs) as f: yield f @DeveloperAPI @contextlib.contextmanager def download_h5(url): with tempfile.TemporaryDirectory() as tmpdir: local_path = os.path.join(tmpdir, os.path.basename(url)) fs, path = get_fs_and_path(url) fs.get(path, local_path) with h5py.File(local_path, "r") as f: yield f @DeveloperAPI @contextlib.contextmanager def upload_h5(url): with upload_output_file(url) as local_fname: mode = "w" if url == local_fname and path_exists(url): mode = "r+" with h5py.File(local_fname, mode) as f: yield f @DeveloperAPI @contextlib.contextmanager def upload_output_file(url): """Takes a remote URL as input, returns a temp filename, then uploads it when done.""" protocol, _ = split_protocol(url) if protocol is not None: fs = fsspec.filesystem(protocol) with tempfile.TemporaryDirectory() as tmpdir: local_fname = os.path.join(tmpdir, "tmpfile") yield local_fname fs.put(local_fname, url, recursive=True) else: yield url @DeveloperAPI class file_lock(contextlib.AbstractContextManager): """File lock based on filelock package.""" def __init__(self, path: str, ignore_remote_protocol: bool = True, lock_file: str = ".lock") -> None: if not isinstance(path, (str, os.PathLike, pathlib.Path)): self.lock = None else: path = os.path.join(path, lock_file) if os.path.isdir(path) else f"{path}./{lock_file}" if ignore_remote_protocol and has_remote_protocol(path): self.lock = None else: self.lock = FileLock(path, timeout=-1) def __enter__(self, *args, **kwargs): if self.lock: return self.lock.__enter__(*args, **kwargs) def __exit__(self, *args, **kwargs): if self.lock: return self.lock.__exit__(*args, **kwargs) @DeveloperAPI def list_file_names_in_directory(directory_name: str) -> list[str]: file_path: pathlib.Path # noqa [F842] # incorrect flagging of "local variable is annotated but never used" file_names: list[str] = [ file_path.name for file_path in pathlib.Path(directory_name).iterdir() if file_path.is_file() ] return file_names ================================================ FILE: ludwig/utils/h3_util.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== from typing import NamedTuple class H3Data(NamedTuple): mode: int edge: int resolution: int base_cell: int cells: list[int] def set_bit(v, index, x): """Set the index:th bit of v to 1 if x is truthy, else to 0, and return the new value.""" mask = 1 << index # Compute mask, an integer with just bit 'index' set. v &= ~mask # Clear the bit indicated by the mask (if x is False) if x: v |= mask # If x was True, set the bit indicated by the mask. return v # Return the result, we're done. def set_bits(v, start_bit, slice_length, x): bin_x = bin(x) for i, index in enumerate(range(start_bit, start_bit + slice_length)): val = int(bin_x[-(i + 1)]) if 2 + i < len(bin_x) else 0 v = set_bit(v, index, val) return v def components_to_h3(components): h3 = 18446744073709551615 h3 = set_bits(h3, 64 - 5, 4, components["mode"]) h3 = set_bits(h3, 64 - 8, 3, components["edge"]) h3 = set_bits(h3, 64 - 12, 4, components["resolution"]) h3 = set_bits(h3, 64 - 19, 7, components["base_cell"]) for i, cell in enumerate(components["cells"]): h3 = set_bits(h3, 64 - 19 - (i + 1) * 3, 3, cell) h3 = set_bits(h3, 64 - 1, 4, 0) return h3 def bitslice(x: int, start_bit: int, slice_length: int) -> int: ones_mask: int = int(2**slice_length - 1) return (x & (ones_mask << start_bit)) >> start_bit def h3_index_mode(h3_long: int) -> int: return bitslice(h3_long, 64 - 5, 4) def h3_edge(h3_long: int) -> int: return bitslice(h3_long, 64 - 8, 3) def h3_resolution(h3_long: int) -> int: return bitslice(h3_long, 64 - 12, 4) def h3_base_cell(h3_long: int) -> int: return bitslice(h3_long, 64 - 19, 7) def h3_octal_components(h3_long): res = h3_resolution(h3_long) return "{0:0{w}o}".format(bitslice(h3_long + 2**63, 64 - 19 - 3 * res, 3 * res), w=res) def h3_component(h3_long: int, i: int) -> int: return bitslice(h3_long, 64 - 19 - 3 * i, 3) def h3_components(h3_long: int) -> list[int]: return [h3_component(h3_long, i) for i in range(1, h3_resolution(h3_long) + 1)] def h3_to_components(h3_value: int) -> H3Data: """Extract the values from an H3 hexadecimal value Refer to this for the bit layout: https://uber.github.io/h3/#/documentation/core-library/h3-index-representations """ # lat_long = (0, 0) # h3ToGeo(h3_value) return H3Data( mode=h3_index_mode(h3_value), edge=h3_edge(h3_value), resolution=h3_resolution(h3_value), base_cell=h3_base_cell(h3_value), cells=h3_components(h3_value), ) if __name__ == "__main__": value = 622236723497533439 components = h3_to_components(value) h3 = components_to_h3(components) components2 = h3_to_components(h3) print(value) print(components) print(h3) print(components2) ================================================ FILE: ludwig/utils/heuristics.py ================================================ from ludwig.schema.model_config import ModelConfig from ludwig.utils.config_utils import has_pretrained_encoder, has_trainable_encoder, has_unstructured_input_feature def get_auto_learning_rate(config: ModelConfig) -> float: """Uses config heuristics to determine an appropriate learning rate. The main idea behind the following heuristics is that smaller learning rates are more suitable for features with larger encoders, which are typically used with unstructured features. Note that these are meant to be rough heuristics that are solely based on feature types and the type of the corresponding encoder. More factors could be taken into consideration such as model size, dataset size, batch size, number of features, etc. Args: config: Ludwig config used to train the model. """ if not has_unstructured_input_feature(config): return 0.001 if not has_pretrained_encoder(config): return 0.0001 if has_trainable_encoder(config): return 0.00001 return 0.00002 ================================================ FILE: ludwig/utils/hf_utils.py ================================================ import logging import os import tempfile from os import PathLike from transformers import AutoTokenizer, PreTrainedModel from transformers.tokenization_utils import PreTrainedTokenizer from ludwig.api_annotations import DeveloperAPI from ludwig.utils.error_handling_utils import default_retry from ludwig.utils.fs_utils import download, path_exists from ludwig.utils.upload_utils import hf_hub_login logger = logging.getLogger(__name__) @default_retry() def load_pretrained_hf_model_from_hub( model_class: type, pretrained_model_name_or_path: str | PathLike | None, **pretrained_kwargs, ) -> PreTrainedModel: """Download a HuggingFace model. Downloads a model from the HuggingFace zoo with retry on failure. Args: model_class: Class of the model to download. pretrained_model_name_or_path: Name of the model to download. pretrained_kwargs: Additional arguments to pass to the model constructor. Returns: The pretrained model object. """ return model_class.from_pretrained(pretrained_model_name_or_path, **pretrained_kwargs) @default_retry() def load_pretrained_hf_tokenizer( pretrained_model_name_or_path: str | PathLike | None, **pretrained_kwargs ) -> PreTrainedTokenizer: """Download a HuggingFace tokenizer. Args: pretrained_model_name_or_path: Name of the tokenizer to download. pretrained_kwargs: Additional arguments to pass to the tokenizer constructor. Returns: The pretrained tokenizer object. """ return AutoTokenizer.from_pretrained(pretrained_model_name_or_path, **pretrained_kwargs) def _load_pretrained_hf_model_from_dir( model_class: type, pretrained_model_name_or_path: str | PathLike | None, **pretrained_kwargs, ) -> PreTrainedModel: """Downloads a model to a local temporary directory, and Loads a pretrained HF model from a local directory.""" with tempfile.TemporaryDirectory() as tmpdir: download(pretrained_model_name_or_path, tmpdir) return model_class.from_pretrained(tmpdir, **pretrained_kwargs) @DeveloperAPI def load_pretrained_hf_model_with_hub_fallback( model_class: type, pretrained_model_name_or_path: str | PathLike | None, **pretrained_kwargs, ) -> tuple[PreTrainedModel, bool]: """Returns the model and a boolean indicating whether the model was downloaded from the HuggingFace hub. If the `LUDWIG_PRETRAINED_MODELS_DIR` environment variable is set, we attempt to load the HF model from this directory, falling back to downloading from the HF hub if the model is not found, downloading fails, or if model initialization fails. `LUDWIG_PRETRAINED_MODELS_DIR` can be an s3 path. Weights are copied to a local temporary directory, and the model is loaded from there. The expected structure of the `LUDWIG_PRETRAINED_MODELS_DIR` directory is: {LUDWIG_PRETRAINED_MODELS_DIR}/{pretrained_model_name_or_path}/pytorch_model.bin {LUDWIG_PRETRAINED_MODELS_DIR}/{pretrained_model_name_or_path}/config.json For example, if `LUDWIG_PRETRAINED_MODELS_DIR` is set to `s3://my-bucket/pretrained-models`, and `pretrained_model_name_or_path` is set to `bert-base-uncased`, we expect to find the following files: s3://my-bucket/bert-base-uncased/ - pytorch_model.bin - config.json If the `LUDWIG_PRETRAINED_MODELS_DIR` environment variable is not set, we download the model from the HF hub. """ pretrained_models_dir = os.environ.get("LUDWIG_PRETRAINED_MODELS_DIR") if pretrained_models_dir: pretrained_model_path = os.path.join(pretrained_models_dir, pretrained_model_name_or_path) if path_exists(pretrained_model_path): try: logger.info( f"Found existing pretrained model artifact {pretrained_model_name_or_path} in directory " f"{pretrained_models_dir}. Downloading." ) return ( _load_pretrained_hf_model_from_dir(model_class, pretrained_model_path, **pretrained_kwargs), False, ) except Exception as e: logger.warning( f"Failed to download pretrained model from {pretrained_models_dir} with error {e}. " "Falling back to HuggingFace model hub." ) # Fallback to HF hub. return load_pretrained_hf_model_from_hub(model_class, pretrained_model_name_or_path, **pretrained_kwargs), True def upload_folder_to_hfhub( repo_id: str, folder_path: str, repo_type: str | None = "model", private: bool | None = False, path_in_repo: str | None = None, # defaults to root of repo commit_message: str | None = None, commit_description: str | None = None, ) -> None: """Uploads a local folder to the Hugging Face Model Hub. Args: repo_id (str): The ID of the target repository on the Hugging Face Model Hub. folder_path (str): The local path to the folder to be uploaded. repo_type (str, optional): The type of the repository ('model', 'dataset', or 'space'). Defaults to 'model'. private (bool, optional): If True, the repository will be private; otherwise, it will be public. Defaults to False. path_in_repo (str, optional): The relative path within the repository where the folder should be uploaded. Defaults to None, which means the root of the repository. commit_message (str, optional): A message for the commit associated with the upload. commit_description (str, optional): A description for the commit associated with the upload. Raises: FileNotFoundError: If the specified folder does not exist. ValueError: If the specified folder is empty, a file, or if an invalid 'repo_type' is provided. ValueError: If the upload process fails for any reason. Returns: None """ # Make sure the folder exists if not os.path.exists(folder_path): raise FileNotFoundError(f"Folder {folder_path} does not exist.") # Make sure the folder is not a file if os.path.isfile(folder_path): raise ValueError(f"Folder {folder_path} is a file. Please provide a folder.") # Make sure the folder is not empty if not os.listdir(folder_path): raise ValueError(f"Folder {folder_path} is empty.") if repo_type not in {"model", "dataset", "space"}: raise ValueError(f"Invalid repo_type {repo_type}. Valid values are 'model', 'dataset', and 'space'.") # Login to the hub api = hf_hub_login() # Create the repo if it doesn't exist. This is a no-op if the repo already exists # This is required because the API doesn't allow uploading to a non-existent repo if not api.repo_exists(repo_id, repo_type=repo_type): logger.info(f"{repo_id} does not exist. Creating.") api.create_repo(repo_id, private=private, exist_ok=True, repo_type=repo_type) # Upload the folder try: logger.info(f"Uploading folder {folder_path} to repo {repo_id}.") api.upload_folder( repo_id=repo_id, folder_path=folder_path, repo_type=repo_type, path_in_repo=path_in_repo, commit_message=commit_message, commit_description=commit_description, ) except Exception as e: raise ValueError(f"Failed to upload folder {folder_path} to repo {repo_id}") from e ================================================ FILE: ludwig/utils/html_utils.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import logging import re from html.parser import HTMLParser from ludwig.utils import strings_utils logger = logging.getLogger(__name__) class HTMLStripper(HTMLParser): def __init__(self): super().__init__() self.reset() self.strict = False self.convert_charrefs = True self.fed = [] def handle_data(self, data): self.fed.append(data) def get_data(self): return "".join(self.fed) def error(self, message): logger.error(message) def strip_tags(html): stripper = HTMLStripper() stripper.feed(html) return stripper.get_data() # regular expressions for cleaning text res_pre = [(re.compile(r"([^.:;\?\!>])(
)"), r"\1.\2"), (re.compile(r"
"), r" ")] res_post = [ (re.compile(r"[ \t\0]"), r" "), (re.compile(r"[–_]"), r"-"), ( re.compile(r"[\’\‘]"), r"""), (re.compile(r'[”“]]'), r""", ), (re.compile(r"℅"), r"%"), (re.compile(r"([^.>])(
)"), r"\1.\2"), (re.compile(r"\\\\[NnRr]"), r" "), (re.compile(r"\\[NnRr]"), r" "), (re.compile(r"[\n\r]"), r" "), (re.compile(r"\\\\"), r" / "), (re.compile(r"
"), r" "), (re.compile(r"\\\\" ""), r"\'"), (re.compile(r"^\'([^\']+)$"), r"\1"), (re.compile(r"([\<\>\{\}\[\]\(\)\-\+\=:;,\./\?\!\$%&£#@\'₹ ])\1+"), r"\1"), ( re.compile( r"[^qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM1234567890\<\>\{\}\[\]\(\)\-\+\=:;,\./\?\!\$%&£#@\'₹ ]" # noqa ), r" ", ), (re.compile(r"\s{2,}"), r" "), ] def clean_html(html_text): # print() # print(html_text) html_text, matched = strings_utils.match_replace(html_text, res_pre) # print(html_text) html_text = strip_tags(html_text) # print(html_text) html_text = strings_utils.strip_accents(html_text) # print(html_text) # result = html_text.strip( # 'qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM1234567890\<\>\{\}\[\]\(\)\-\+\=:;,\./\?\!\$%&€£#@'₹\' ') # if result: # print(result) html_text, matched = strings_utils.match_replace(html_text, res_post) # print(matched) # print(html_text) return html_text ================================================ FILE: ludwig/utils/image_utils.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import logging import warnings from collections.abc import Callable, Iterable from dataclasses import dataclass from io import BytesIO import numpy as np import torch import torchvision.transforms.functional as F from torchvision.io import decode_image, ImageReadMode from torchvision.models._api import WeightsEnum from ludwig.api_annotations import DeveloperAPI from ludwig.constants import CROP_OR_PAD, IMAGE_MAX_CLASSES, INTERPOLATE from ludwig.utils.data_utils import get_abs_path from ludwig.utils.fs_utils import get_bytes_obj_from_path from ludwig.utils.registry import Registry @dataclass class TVModelVariant: # Model variant identifier variant_id: str | int # TorchVision function to create model class create_model_function: Callable # Torchvision class for model weights model_weights: WeightsEnum logger = logging.getLogger(__name__) IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg", ".tiff", ".tif", ".bmp", ".gif") @DeveloperAPI class ResizeChannels(torch.nn.Module): def __init__(self, num_channels: int): super().__init__() self.num_channels = num_channels def forward(self, imgs: torch.Tensor): original_imgs_shape = imgs.shape if len(original_imgs_shape) == 3: # if shape is (C, H, W), add batch dimension imgs = imgs.unsqueeze(0) channels = imgs.shape[1] if channels > self.num_channels: # take the first `self.num_channels` channels imgs = imgs[:, : self.num_channels, :, :] elif channels < self.num_channels: # repeat and use the first `self.num_channels` channels imgs = imgs.repeat(1, (self.num_channels // channels) + 1, 1, 1)[:, : self.num_channels, :, :] if len(original_imgs_shape) == 3: # if shape was (C, H, W), remove batch dimension return imgs[0] return imgs @DeveloperAPI def get_gray_default_image(num_channels: int, height: int, width: int) -> np.ndarray: return np.full((num_channels, height, width), 128, dtype=np.float32) @DeveloperAPI def get_average_image(image_lst: list[np.ndarray]) -> np.array: return np.mean([x for x in image_lst if x is not None], axis=(0), dtype=np.float32) @DeveloperAPI def is_bytes_image(bytes_obj) -> bool: """Check if a bytes object is an image using PIL.""" try: from io import BytesIO from PIL import Image if isinstance(bytes_obj, bytes): bytes_obj = BytesIO(bytes_obj) Image.open(bytes_obj).verify() return True except Exception: return False def is_image(src_path: str, img_entry: bytes | str, column: str) -> bool: if not isinstance(img_entry, str): return False try: from io import BytesIO from PIL import Image path = get_abs_path(src_path, img_entry) bytes_obj = get_bytes_obj_from_path(path) if isinstance(bytes_obj, bytes): bytes_obj = BytesIO(bytes_obj) Image.open(bytes_obj).verify() return True except Exception as e: logger.warning(f"While assessing potential image in is_image() for column {column}, encountered exception: {e}") return False @DeveloperAPI def is_image_score(path): return int(isinstance(path, str) and path.lower().endswith(IMAGE_EXTENSIONS)) @DeveloperAPI def get_image_read_mode_from_num_channels(num_channels: int) -> ImageReadMode: """Returns the torchvision.io.ImageReadMode corresponding to the number of channels. If num_channels is not recognized, returns ImageReadMode.UNCHANGED. """ mode = ImageReadMode.UNCHANGED if num_channels == 1: mode = ImageReadMode.GRAY elif num_channels == 2: mode = ImageReadMode.GRAY_ALPHA elif num_channels == 3: mode = ImageReadMode.RGB elif num_channels == 4: mode = ImageReadMode.RGB_ALPHA return mode @DeveloperAPI def read_image_from_path( path: str, num_channels: int | None = None, return_num_bytes=False ) -> torch.Tensor | None | tuple[torch.Tensor | None, int]: """Reads image from path. Useful for reading from a small number of paths. For more intensive reads, use backend.read_binary_files instead. If `return_num_bytes` is True, returns a tuple of (image, num_bytes). """ bytes_obj = get_bytes_obj_from_path(path) image = read_image_from_bytes_obj(bytes_obj, num_channels) if return_num_bytes: if bytes_obj is not None: num_bytes = len(bytes_obj) else: num_bytes = None return image, num_bytes else: return image @DeveloperAPI def read_image_from_bytes_obj(bytes_obj: bytes | None = None, num_channels: int | None = None) -> torch.Tensor | None: """Tries to read image as a tensor from the path. If the path is not decodable as a PNG, attempts to read as a numpy file. If neither of these work, returns None. """ if bytes_obj is None: return None mode = get_image_read_mode_from_num_channels(num_channels) image = read_image_as_png(bytes_obj, mode) if image is None: image = read_image_as_numpy(bytes_obj) if image is None: image = read_image_as_tif(bytes_obj) if image is None: warnings.warn("Unable to read image from bytes object.") return image @DeveloperAPI def read_image_as_png(bytes_obj: bytes, mode: ImageReadMode = ImageReadMode.UNCHANGED) -> torch.Tensor | None: """Reads image from bytes object from a PNG file.""" try: with BytesIO(bytes_obj) as buffer: buffer_view = buffer.getbuffer() if len(buffer_view) == 0: del buffer_view raise Exception("Bytes object is empty. This could be due to a failed load from storage.") image = decode_image(torch.frombuffer(buffer_view, dtype=torch.uint8), mode=mode) del buffer_view return image except Exception as e: warnings.warn(f"Failed to read image from PNG file. Original exception: {e}") return None @DeveloperAPI def read_image_as_numpy(bytes_obj: bytes) -> torch.Tensor | None: """Reads image from bytes object from a numpy file.""" try: with BytesIO(bytes_obj) as buffer: image = np.load(buffer) return torch.from_numpy(image) except Exception as e: warnings.warn(f"Failed to read image from numpy file. Original exception: {e}") return None @DeveloperAPI def read_image_as_tif(bytes_obj: bytes) -> torch.Tensor | None: """Reads image from bytes object from a tif file.""" try: import tifffile with BytesIO(bytes_obj) as buffer: image = tifffile.imread(buffer) if image.dtype == np.uint16: image = image.astype(np.int32) image = torch.from_numpy(image) if len(image.shape) == 2: image = torch.unsqueeze(image, dim=0) return image except Exception as e: warnings.warn(f"Failed to read image from tif file. Original exception: {e}") return None @DeveloperAPI def pad( img: torch.Tensor, new_size: int | tuple[int, int], ) -> torch.Tensor: """Torchscript-compatible implementation of pad. Args: img (torch.Tensor): image with shape [..., height, width] to pad new_size (Union[int, Tuple[int, int]]): size to pad to. If int, resizes to square image of that size. Returns: torch.Tensor: padded image of size [..., size[0], size[1]] or [..., size, size] if size is int. """ new_size = to_tuple(new_size) old_size = img.shape[-2:] pad_size = (torch.tensor(new_size) - torch.tensor(old_size)) / 2 padding = torch.cat((torch.floor(pad_size), torch.ceil(pad_size))) padding[padding < 0] = 0 padding = [int(x) for x in padding] return F.pad(img, padding=padding, padding_mode="edge") @DeveloperAPI def crop( img: torch.Tensor, new_size: int | tuple[int, int], ) -> torch.Tensor: """Torchscript-compatible implementation of crop. Args: img (torch.Tensor): image with shape [..., height, width] to crop size (Union[int, Tuple[int, int]]): size to crop to. If int, crops to square image of that size. Returns: torch.Tensor: cropped image of size [..., size[0], size[1]] or [..., size, size] if size is int. """ new_size = to_tuple(new_size) return F.center_crop(img, output_size=new_size) @DeveloperAPI def crop_or_pad(img: torch.Tensor, new_size: int | tuple[int, int]): """Torchscript-compatible implementation of resize using constants.CROP_OR_PAD. Args: img (torch.Tensor): image with shape [..., height, width] to resize new_size (Union[int, Tuple[int, int]]): size to resize to. If int, resizes to square image of that size. Returns: torch.Tensor: resized image of size [..., size[0], size[1]] or [..., size, size] if size is int. """ new_size = to_tuple(new_size) if list(new_size) == list(img.shape[-2:]): return img img = pad(img, new_size) img = crop(img, new_size) return img @DeveloperAPI def resize_image( img: torch.Tensor, new_size: int | tuple[int, int], resize_method: str, crop_or_pad_constant: str = CROP_OR_PAD, interpolate_constant: str = INTERPOLATE, ) -> torch.Tensor: """Torchscript-compatible implementation of resize. Args: img (torch.Tensor): image with shape [..., height, width] to resize new_size (Union[int, Tuple[int, int]]): size to resize to. If int, resizes to square image of that size. resize_method (str): method to use for resizing. Either constants.CROP_OR_PAD or constants.INTERPOLATE. Returns: torch.Tensor: resized image of size [..., size[0], size[1]] or [..., size, size] if size is int. """ new_size = to_tuple(new_size) if list(img.shape[-2:]) != list(new_size): if resize_method == crop_or_pad_constant: return crop_or_pad(img, new_size) elif resize_method == interpolate_constant: return F.resize(img, new_size) raise ValueError(f"Invalid image resize method: {resize_method}") return img @DeveloperAPI def grayscale(img: torch.Tensor) -> torch.Tensor: """Grayscales RGB image.""" return F.rgb_to_grayscale(img) @DeveloperAPI def num_channels_in_image(img: torch.Tensor): """Returns number of channels in image.""" if img is None or img.ndim < 2: raise ValueError("Invalid image data") if img.ndim == 2: return 1 else: return img.shape[0] @DeveloperAPI def get_unique_channels( image_sample: list[torch.Tensor], num_channels: int, num_classes: int = None, ) -> torch.Tensor: """Returns a tensor of unique channel values from a list of images. Args: image_sample: A list of images of dimensions [C x H x W] or [H x W], where C is the channel dimension num_channels: The expected number of channels num_classes: The expected number of classes or None Return: channel_class_map: A tensor mapping channel values to classes, where dim=0 is the class. """ n_images = 0 no_new_class = 0 channel_class_map = None for img in image_sample: if img.ndim < 2: raise ValueError("Invalid image dimensions {img.ndim}") if img.ndim == 2: img = img.unsqueeze(0) if num_channels == 1 and num_channels_in_image(img) != 1: img = grayscale(img) if num_classes == 2 and num_channels_in_image(img) == 1: img = img.type(torch.float32) / 255 img = img.round() * 255 img = img.type(torch.uint8) img = img.flatten(1, 2) img = img.permute(1, 0) uniq_chans = img.unique(dim=0) if channel_class_map is None: channel_class_map = uniq_chans else: channel_class_map = torch.concat((channel_class_map, uniq_chans)).unique(dim=0) if channel_class_map.shape[0] > IMAGE_MAX_CLASSES: raise ValueError( f"Images inferred num classes {channel_class_map.shape[0]} exceeds " f"max classes {IMAGE_MAX_CLASSES}." ) n_images += 1 if n_images % 25 == 0: logger.info(f"Processed the first {n_images} images inferring {channel_class_map.shape[0]} classes...") if channel_class_map.shape[0] == uniq_chans.shape[0]: no_new_class += 1 if no_new_class >= 4 and channel_class_map.shape[0] == num_classes: break # early loop exit else: no_new_class = 0 logger.info(f"Inferred {channel_class_map.shape[0]} classes from the first {n_images} images.") return channel_class_map.type(torch.uint8) @DeveloperAPI def get_class_mask_from_image( channel_class_map: torch.Tensor, img: torch.Tensor, ) -> torch.Tensor: """Returns a masked image where each mask value is the channel class of the input. Args: channel_class_map: A tensor mapping channel values to classes, where dim=0 is the class. img: An input image of dimensions [C x H x W] or [H x W], where C is the channel dimension Return: [mask] A masked image of dimensions [H x W] where each value is the channel class of the input """ num_classes = channel_class_map.shape[0] mask = torch.full((img.shape[-2], img.shape[-1]), num_classes, dtype=torch.uint8) if img.ndim == 2: img = img.unsqueeze(0) if num_classes == 2 and num_channels_in_image(img) == 1: img = img.type(torch.float32) / 255 img = img.round() * 255 img = img.type(torch.uint8) img = img.permute(1, 2, 0) for nclass, value in enumerate(channel_class_map): mask[(img == value).all(-1)] = nclass if torch.any(mask.ge(num_classes)): raise ValueError( f"Image channel could not be mapped to a class because an unknown channel value was detected. " f"{num_classes} classes were inferred from the first set of images. This image has a channel " f"value that was not previously seen in the first set of images. Check preprocessing parameters " f"for image resizing, num channels, num classes and num samples. Image resizing may affect " f"channel values. " ) return mask @DeveloperAPI def get_image_from_class_mask( channel_class_map: torch.Tensor, mask: np.ndarray, ) -> np.ndarray: """Returns an image with channel values determined from a corresponding mask. Args: channel_class_map: An tensor mapping channel values to classes, where dim=0 is the class. mask: A masked image of dimensions [H x W] where each value is the channel class of the final image Return: [img] An image of dimensions [C x H x W], where C is the channel dimension """ mask = torch.from_numpy(mask) img = torch.zeros(channel_class_map.shape[1], mask.shape[-2], mask.shape[-1], dtype=torch.uint8) img = img.permute(1, 2, 0) mask = mask.unsqueeze(0) mask = mask.permute(1, 2, 0) for nclass, value in enumerate(channel_class_map): img[(mask == nclass).all(-1)] = value img = img.permute(2, 0, 1) return img.numpy() @DeveloperAPI def to_tuple(v: int | tuple[int, int]) -> tuple[int, int]: """Converts int or tuple to tuple of ints.""" if torch.jit.isinstance(v, int): return v, v else: return v @DeveloperAPI def to_np_tuple(prop: int | Iterable) -> np.ndarray: """Creates a np array of length 2 from a Conv2D property. E.g., stride=(2, 3) gets converted into np.array([2, 3]), where the height_stride = 2 and width_stride = 3. stride=2 gets converted into np.array([2, 2]). """ if isinstance(prop, int): return np.ones(2).astype(int) * prop elif isinstance(prop, np.ndarray) and prop.size == 2: return prop.astype(int) elif isinstance(prop, Iterable) and len(prop) == 2: return np.array(list(prop)).astype(int) else: raise TypeError(f"prop must be int or iterable of length 2, but is {prop}.") @DeveloperAPI def get_img_output_shape( img_height: int, img_width: int, kernel_size: int | tuple[int], stride: int | tuple[int], padding: int | tuple[int] | str, dilation: int | tuple[int], ) -> tuple[int]: """Returns the height and width of an image after a 2D img op. Currently supported for Conv2D, MaxPool2D and AvgPool2d ops. """ if padding == "same": return (img_height, img_width) elif padding == "valid": padding = np.zeros(2) else: padding = to_np_tuple(padding) kernel_size = to_np_tuple(kernel_size) stride = to_np_tuple(stride) dilation = to_np_tuple(dilation) shape = np.array([img_height, img_width]) out_shape = np.floor(((shape + 2 * padding - dilation * (kernel_size - 1) - 1) / stride) + 1) return tuple(out_shape.astype(int)) torchvision_model_registry = Registry() def register_torchvision_model_variants(variants: list[TVModelVariant]): def wrap(cls): # prime with empty placeholder torchvision_model_registry[cls.torchvision_model_type] = {} # register each variant for variant in variants: torchvision_model_registry[cls.torchvision_model_type][variant.variant_id] = variant return cls return wrap ================================================ FILE: ludwig/utils/inference_utils.py ================================================ from datetime import datetime import pandas as pd import torch from ludwig.constants import ( AUDIO, BAG, BINARY, CATEGORY, COLUMN, DATE, IMAGE, NAME, POSTPROCESSOR, PREDICTOR, PREPROCESSOR, SEQUENCE, SET, TEXT, TIMESERIES, TYPE, VECTOR, ) from ludwig.types import FeatureConfigDict, ModelConfigDict from ludwig.utils.audio_utils import read_audio_from_path from ludwig.utils.date_utils import create_vector_from_datetime_obj from ludwig.utils.image_utils import read_image_from_path from ludwig.utils.torch_utils import place_on_device from ludwig.utils.types import TorchDevice, TorchscriptPreprocessingInput FEATURES_TO_CAST_AS_STRINGS = {BINARY, CATEGORY, BAG, SET, TEXT, SEQUENCE, TIMESERIES, VECTOR} def get_filename_from_stage(stage: str, device: TorchDevice) -> str: """Returns the filename for a stage of inference.""" if stage not in [PREPROCESSOR, PREDICTOR, POSTPROCESSOR]: raise ValueError(f"Invalid stage: {stage}.") # device is only tracked for predictor stage if stage == PREDICTOR: return f"inference_{stage}-{device}.pt" else: return f"inference_{stage}.pt" def to_inference_module_input_from_dataframe( dataset: pd.DataFrame, config: ModelConfigDict, load_paths: bool = False, device: torch.device | None = None ) -> dict[str, TorchscriptPreprocessingInput]: """Converts a pandas DataFrame to be compatible with a torchscripted InferenceModule forward pass.""" inputs = {} for if_config in config["input_features"]: feature_inputs = to_inference_model_input_from_series( dataset[if_config[COLUMN]], if_config[TYPE], load_paths=load_paths, feature_config=if_config, ) feature_inputs = place_on_device(feature_inputs, device) inputs[if_config[NAME]] = feature_inputs return inputs def to_inference_model_input_from_series( s: pd.Series, feature_type: str, load_paths: bool = False, feature_config: FeatureConfigDict | None = None ) -> TorchscriptPreprocessingInput: """Converts a pandas Series to be compatible with a torchscripted InferenceModule forward pass.""" if feature_type == IMAGE: if load_paths: return [read_image_from_path(v) if isinstance(v, str) else v for v in s] elif feature_type == AUDIO: if load_paths: return [read_audio_from_path(v) if isinstance(v, str) else v for v in s] elif feature_type == DATE: if feature_config is None: raise ValueError('"date" feature type requires the associated feature config to be provided.') datetime_format = feature_config["preprocessing"]["datetime_format"] return [torch.tensor(create_vector_from_datetime_obj(datetime.strptime(v, datetime_format))) for v in s] elif feature_type in FEATURES_TO_CAST_AS_STRINGS: return s.astype(str).to_list() return torch.from_numpy(s.to_numpy()) ================================================ FILE: ludwig/utils/llm_quantization_utils.py ================================================ import torch from torch import nn try: from bitsandbytes.functional import dequantize_4bit from bitsandbytes.nn.modules import Linear4bit except Exception: dequantize_4bit = None Linear4bit = None from ludwig.api_annotations import DeveloperAPI @DeveloperAPI def linear4bit_to_linear(linear4bit_layer): """Converts a Linear4Bit layer to a standard Linear layer by dequantizing the weight values and copying the dequantized weights to a new Linear layer. Args: linear4bit_layer (Linear4bit): The input Linear4Bit layer. Returns: nn.Linear: A new Linear layer with dequantized weights and biases. """ # Create a new Linear layer with the same shape new_linear_layer = nn.Linear( linear4bit_layer.in_features, linear4bit_layer.out_features, bias=linear4bit_layer.bias is not None, dtype=torch.float16, ) # Dequantize the weight and bias from the Linear4bit layer and perform an in-place tensor replacement # to update the weights and bias in the new Linear layer. This is done to avoid creating a new tensor # and copying the data, which is slow. new_linear_layer.weight.data.copy_( dequantize_4bit(linear4bit_layer.weight.data, linear4bit_layer.weight.quant_state) ) if linear4bit_layer.bias is not None: new_linear_layer.bias.data.copy_(linear4bit_layer.bias.data) return new_linear_layer @DeveloperAPI def convert_quantized_linear_to_linear(module): """Recursively converts Linear4Bit layers to standard Linear layers in a given module. Args: module (nn.Module): The input module containing potentially nested Linear4Bit layers. Returns: None """ for name, child in module.named_children(): if isinstance(child, Linear4bit): # Replace Linear4Bit layer with a new Linear layer setattr(module, name, linear4bit_to_linear(child)) else: # Recursively apply the conversion for nested modules convert_quantized_linear_to_linear(child) ================================================ FILE: ludwig/utils/llm_utils.py ================================================ import copy import logging import tempfile from typing import TYPE_CHECKING, Union import torch import torch.nn.functional as F import transformers from packaging import version try: from bitsandbytes.nn.modules import Embedding as BnbEmbedding except Exception: BnbEmbedding = None from transformers import AutoConfig, AutoModelForCausalLM, PreTrainedModel, PreTrainedTokenizer, TextStreamer from ludwig.constants import IGNORE_INDEX_TOKEN_ID, LOGITS, PREDICTIONS, PROBABILITIES from ludwig.schema.trainer import LLMTrainerConfig from ludwig.utils.error_handling_utils import default_retry from ludwig.utils.logging_utils import log_once from ludwig.utils.model_utils import find_embedding_layer_with_path if TYPE_CHECKING: from ludwig.schema.encoders.text_encoders import LLMEncoderConfig from ludwig.schema.model_types.llm import LLMModelConfig logger = logging.getLogger(__name__) transformers_436 = version.parse(transformers.__version__) >= version.parse("4.36.0") FALLBACK_CONTEXT_LEN = 2048 _MODELS_WITH_DEVICE_MAP_AUTO_EXCLUSION = set() @default_retry(tries=8) def load_pretrained_from_config( config_obj: Union["LLMModelConfig", "LLMEncoderConfig"], model_config: AutoConfig | None = None, weights_save_path: str | None = None, ) -> PreTrainedModel: load_kwargs = {} if config_obj.quantization: # Apply quantization configuration at model load time load_kwargs["dtype"] = getattr(torch, config_obj.quantization.bnb_4bit_compute_dtype) load_kwargs["quantization_config"] = config_obj.quantization.to_bitsandbytes() load_kwargs["device_map"] = "auto" if transformers_436: load_kwargs["attn_implementation"] = "eager" else: # Load in float32 by default to avoid CUBLAS errors with small hidden sizes # and to ensure numerical stability during training without mixed-precision. load_kwargs["dtype"] = torch.float32 config_modified = False if config_obj.model_parameters: # Add any model specific parameters to the load kwargs for param_name, param_value in config_obj.model_parameters.to_dict().items(): # Not all parameters are supported by all models, so we only add the parameter to the load kwargs # if it is supported by the model. if param_value is None: continue if hasattr(model_config, param_name): if isinstance(param_value, dict): # For nested dict params (e.g. rope_scaling), merge with existing # config values to preserve defaults like rope_theta. existing = getattr(model_config, param_name, {}) or {} existing.update(param_value) setattr(model_config, param_name, existing) config_modified = True else: load_kwargs[param_name] = param_value else: logger.warning(f"Parameter {param_name} is not supported by {config_obj.base_model}. Skipping.") # Only pass config= when we've directly modified it (e.g. rope_scaling merge). if config_modified: load_kwargs["config"] = model_config logger.info("Loading large language model...") pretrained_model_name_or_path = weights_save_path or config_obj.base_model trust_remote_code = getattr(config_obj, "trust_remote_code", False) model: PreTrainedModel = AutoModelForCausalLM.from_pretrained( pretrained_model_name_or_path, trust_remote_code=trust_remote_code, **load_kwargs ) return model def to_device( model: PreTrainedModel, device: str | torch.DeviceObjType, config_obj: "LLMModelConfig", # noqa F821 curr_device: torch.DeviceObjType, ) -> tuple[PreTrainedModel, torch.DeviceObjType]: """Move an LLM to the requested device, accounting for sharding and adapters. Args: model: Pretrained model to put on device config_obj: LLM config curr_device: The current device that the model is on Returns: `model` moved to `device` """ device = torch.device(device) if device.type == curr_device.type: log_once(f"Model already on device'{device}'.") return model, device else: log_once(f"Moving LLM from '{curr_device}' to '{device}'.") model_kwargs = {} num_gpus = torch.cuda.device_count() if device == torch.device("cuda") and num_gpus > 1: # TODO: make this configurable in the future. These parameters are from FastChat: # https://github.com/lm-sys/FastChat/blob/0e958b852a14f4bef5f0e9d7a5e7373477329cf2/fastchat/serve/inference.py#L90 # noqa # TODO: Wrap device_map="auto" in a try-except block since it may not be supported for all models (E.g. BertLMHead) # noqa # We don't add quantization here (float16 or bfloat16) since we may not always want to quantize. We should # make quantization configurable in the future via the trainer config. model_kwargs.update( dict( low_cpu_mem_usage=True, max_memory={i: "13GiB" for i in range(num_gpus)}, ) ) if config_obj.base_model not in _MODELS_WITH_DEVICE_MAP_AUTO_EXCLUSION: model_kwargs["device_map"] = "auto" if config_obj.quantization: model_kwargs["quantization_config"] = config_obj.quantization.to_bitsandbytes() # we save and reload the weights to ensure that they can be sharded across the GPUs using `from_pretrained` with tempfile.TemporaryDirectory() as tmpdir: model.save_pretrained(tmpdir) if config_obj.adapter: model = AutoModelForCausalLM.from_pretrained( config_obj.base_model, trust_remote_code=getattr(config_obj, "trust_remote_code", False), **model_kwargs, ) # Leave this import inline to support a minimal install of Ludwig from peft import PeftModel # noqa model = PeftModel.from_pretrained( model, tmpdir, torch_dtype=torch.float16, ) else: model = AutoModelForCausalLM.from_pretrained( tmpdir, trust_remote_code=getattr(config_obj, "trust_remote_code", False), **model_kwargs, ) else: model = model.to(device) return model, device def _load_peft_config(pretrained_adapter_weights: str): """Load a PeftConfig, fixing known compatibility issues with newer PEFT versions.""" import json from huggingface_hub import hf_hub_download from peft import PeftConfig config_file = hf_hub_download(pretrained_adapter_weights, "adapter_config.json") with open(config_file) as f: config_dict = json.load(f) # AdaLoRA requires total_step > 0 in newer PEFT versions, but pretrained # configs may have total_step=None. if config_dict.get("peft_type") == "ADALORA" and not config_dict.get("total_step"): config_dict["total_step"] = 10000 return PeftConfig.from_peft_type(**config_dict) def initialize_adapter( model: PreTrainedModel, config_obj: "LLMModelConfig" # noqa F821 ) -> Union["PeftModel", PreTrainedModel]: # noqa F821 """Wrap a pretrained model with a PEFT model for fine-tuning. Args: model: Pretrained model to fine-tune with an adapter. config_obj: LLM config Returns: `model` wrapped in a PEFT model if an adapter config was provided, otherwise `model`. """ # Only load a PEFT model if the config specifies an adapter, otherwise return the model unaltered. if config_obj.adapter: if config_obj.adapter.pretrained_adapter_weights: # Load pretrained adapter weights if specified. logger.info(f"Using pretrained adapter weights: {config_obj.adapter.pretrained_adapter_weights}") # Leave this import inline to support a minimal install of Ludwig from peft import MODEL_TYPE_TO_PEFT_MODEL_MAPPING, PeftConfig # noqa peft_config = _load_peft_config(config_obj.adapter.pretrained_adapter_weights) model = MODEL_TYPE_TO_PEFT_MODEL_MAPPING[peft_config.task_type].from_pretrained( model, config_obj.adapter.pretrained_adapter_weights, config=peft_config ) else: # Leave this import inline to support a minimal install of Ludwig from peft import get_peft_model, TaskType # noqa # If no pretrained adapter is provided, we want to load untrained weights into the model peft_config = config_obj.adapter.to_config( task_type=TaskType.CAUSAL_LM, tokenizer_name_or_path=config_obj.base_model ) model = get_peft_model(model, peft_config) return model def get_context_len(model_config: AutoConfig): """Determines the maximum length of the context (input + output tokens) based on the provided model configuration. Args: model_config (AutoConfig): The model configuration object containing information about the model's properties. Returns: int: The maximum context length, which can be derived from the model configuration. If no relevant attribute is found, the default value of 2048 is returned. This function examines the provided model configuration object to identify the attribute that specifies the maximum context length. It checks for attributes in the following order of preference: 1. 'max_sequence_length': If this attribute is present in the model configuration, its value is returned. 2. 'max_position_embeddings': If 'max_sequence_length' is not found but 'max_position_embeddings' is present, its value is returned. 3. 'n_positions': If neither 'max_sequence_length' nor 'max_position_embeddings' are found, and 'n_positions' is present, its value is returned. 4. Default: If none of the relevant attributes are present, the function returns a default value of 2048. Note: - The maximum context length is important for defining the size of input and output sequences in a model. Example Usage: >>> config = AutoConfig.from_pretrained("bert-base-uncased") >>> context_len = get_context_len(config) >>> print(context_len) 512 """ if hasattr(model_config, "max_sequence_length"): return model_config.max_sequence_length elif hasattr(model_config, "max_position_embeddings"): return model_config.max_position_embeddings elif hasattr(model_config, "n_positions"): return model_config.n_positions else: return FALLBACK_CONTEXT_LEN def has_padding_token(input_tensor: torch.Tensor, tokenizer: PreTrainedTokenizer): """Checks if the input tensor contains any padding tokens. Args: input_tensor (torch.Tensor): The input tensor. tokenizer (PreTrainedTokenizer): The tokenizer used to encode the input. Returns: bool: True if the input tensor contains any padding tokens, False otherwise. Example: >>> import torch >>> from transformers import PreTrainedTokenizer >>> tokenizer = PreTrainedTokenizer.from_pretrained('bert-base-uncased') >>> input_sentence = "This is an example sentence." >>> input_ids = tokenizer.encode(input_sentence, add_special_tokens=True) >>> padded_input_ids = torch.nn.functional.pad(input_ids, (0, 10 - len(input_ids))) >>> has_padding = has_padding_token(padded_input_ids, tokenizer) >>> has_padding True """ if input_tensor.dim() == 1: return torch.any(input_tensor == tokenizer.pad_token_id).item() elif input_tensor.dim() == 2: return torch.any(input_tensor == tokenizer.pad_token_id, dim=-1).item() else: raise ValueError("Input tensor must be 1D or 2D") def remove_left_padding(input_ids_sample: torch.Tensor, tokenizer: PreTrainedTokenizer): """Removes left padding and other tokens until the first BOS token from the input_ids tensor. Args: input_ids_sample (torch.Tensor): The input tensor with padding and other tokens. tokenizer (PreTrainedTokenizer): The tokenizer used to encode the input. Returns: torch.Tensor: The input tensor without left padding and other tokens until the first BOS token. Example: >>> import torch >>> from transformers import PreTrainedTokenizer >>> tokenizer = PreTrainedTokenizer.from_pretrained('bert-base-uncased') >>> input_sentence = "This is an example sentence." >>> input_ids = tokenizer.encode(input_sentence, add_special_tokens=True) >>> padded_input_ids = torch.nn.functional.pad(input_ids, (10 - len(input_ids), 0)) >>> input_ids_no_padding = remove_left_padding(padded_input_ids, tokenizer) >>> input_ids_no_padding tensor([[1, 2, 3]]) """ # Remove all PAD tokens pad_idxs = torch.where(input_ids_sample == tokenizer.pad_token_id)[0] # all PAD token locations input_ids_no_padding = input_ids_sample if len(pad_idxs) != 0: pad_idx = pad_idxs[-1] # get last PAD token location input_ids_no_padding = input_ids_sample[pad_idx + 1 :] # Start from the first BOS token bos_idxs = torch.where(input_ids_no_padding == tokenizer.bos_token_id)[0] # all BOS token locations if len(bos_idxs) != 0: bos_idx = bos_idxs[0] # get first BOS token location else: bos_idx = 0 input_ids_no_bos = input_ids_no_padding[bos_idx:].unsqueeze(0) return input_ids_no_bos def add_left_padding(input_ids, max_length, pad_value=0): """Adds left padding to the input_ids tensor. Args: input_ids (torch.Tensor): The input tensor. max_length (int): The maximum length of the tensor after padding. pad_value (int, optional): The value used for padding. Defaults to 0. Returns: torch.Tensor: The input_ids tensor with left padding. Example: >>> input_ids = torch.tensor([1, 2, 3]) >>> max_length = 5 >>> padded_tensor = add_left_padding(input_ids, max_length) >>> padded_tensor tensor([0, 0, 1, 2, 3]) """ padding = torch.tensor([pad_value] * (max_length - input_ids.shape[0]), dtype=torch.int64, device=input_ids.device) return torch.cat((padding, input_ids), dim=-1) def create_attention_mask(input_ids: torch.Tensor, tokenizer: PreTrainedTokenizer): """Creates an attention mask for the input_ids tensor. This also sets the last padding token ID to 1 if it exists. Args: input_ids (torch.Tensor): The input tensor. tokenizer (PreTrainedTokenizer): The tokenizer used to encode the input. Returns: torch.Tensor: The attention mask tensor. Example: >>> import torch # noqa >>> from transformers import PreTrainedTokenizer >>> tokenizer = PreTrainedTokenizer.from_pretrained('bert-base-uncased') >>> input_sentence = "This is an example sentence." >>> input_ids = tokenizer.encode(input_sentence, add_special_tokens=True) >>> attention_mask = create_attention_mask(input_ids, tokenizer) >>> attention_mask tensor([1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]) """ attention_mask = input_ids != tokenizer.pad_token_id # Last token may not be padding if we've already hit the max sequence length if not attention_mask[-1]: # last token is padding, always attended to even if it is padding attention_mask[-1] = 1 attention_mask = attention_mask.to(torch.int64) return attention_mask def find_last_matching_index(tensor_a: torch.Tensor, tensor_b: torch.Tensor): """Returns the last index of `tensor_a` that matches `tensor_b`. Specifically, this checks whether the tensor_b is in the last tensor_b.shape[0] elements of tensor_a. Args: tensor_a (torch.Tensor): The first tensor. tensor_b (torch.Tensor): The second tensor. Returns: int: The last index of `tensor_a` that matches `tensor_b`. Returns -1 if there is no matching index. Example: >>> import torch >>> tensor_a = torch.tensor([1, 2, 3, 4, 5, 6, 7, 8]) >>> tensor_b = torch.tensor([6, 7, 8]) >>> last_matching_index = find_last_matching_index(tensor_a, tensor_b) >>> last_matching_index 5 """ last_index = -1 tensor_a_length = tensor_a.shape[0] tensor_b_length = tensor_b.shape[0] # Get the last tensor_b_length elements of tensor_a. tensor_a_truncated = tensor_a[-tensor_b_length:] # Find the last matching index. for i in range(tensor_b_length): if torch.equal(tensor_a_truncated[i:], tensor_b[: tensor_b_length - i]): last_index = tensor_a_length - tensor_b_length + i break return last_index def pad_target_tensor_for_fine_tuning( targets: dict[str, torch.Tensor], predictions: dict[str, torch.Tensor], model_inputs: torch.Tensor, of_name: str, ) -> dict[str, torch.Tensor]: """Pad and adjust target tensors for fine-tuning LLMS models. This function is used to pad and adjust the target tensors with IGNORE_INDEX_TOKEN_ID based on the model inputs and predictions during the fine-tuning process of Language Models. Here's what this function does: 1. If none of the tokens from the target were in the model inputs, we create a tensor of the length of model inputs with value IGNORE_INDEX_TOKEN_IDs. This ignores this row from affecting loss. 2. If the target tokens were entirely inside the model inputs, we want to pad all the tokens in model_inputs coming from the input with IGNORE_INDEX_TOKEN_IDs and leave the target tokens as is. This ensures that all of the target tokens are used during loss computation. 3. In the scenario that only some part of the target tokens were in the model inputs, we want to pad the model inputs until that point and only leave the partial tokens of the target as is. This ensures that we will only compute loss on the target tokens that were in the model inputs. Args: targets (Dict[str, torch.Tensor]): A dictionary containing the target tensors. predictions (Dict[str, torch.Tensor]): A dictionary containing the predicted tensors. model_inputs (torch.Tensor): The input tensor passed into the model's forward pass. of_name (str): The name of the target tensor to be padded and adjusted. Returns: Dict[str, torch.Tensor]: A dictionary containing the updated target dictionaries. """ target_length = targets.get(of_name).size()[1] prediction_length = predictions[of_name].get(PREDICTIONS).size()[1] if target_length == prediction_length: return targets updated_targets = [] for idx, target in enumerate(targets[of_name]): # Remove any leading IGNORE_INDEX_TOKEN_IDs in the target that were temporarily added for alignment end_index = (target != IGNORE_INDEX_TOKEN_ID).nonzero()[0] target = target[end_index:] target_device = target.device # See if any part of the target was in the tensor passed into the model's forward pass last_matching_index = find_last_matching_index(model_inputs[idx], target) # If the last matching index is -1, it means that the input tensor passed into the model was truncated # and did not contain the target tensor. In this case, we need to truncate the target tensors as well # and just set it to a tensor of IGNORE_INDEX_TOKEN_ID so that we don't compute loss on this target tensor. if last_matching_index == -1: updated_targets.append(torch.full((prediction_length,), IGNORE_INDEX_TOKEN_ID).to(device=target_device)) # If the last matching index is not -1, it means that the input tensor passed into the model was not # truncated and contained either a part of the target tensor or the entire target tensor. In this case, # we need to set the target tensor to the part of the target tensor that was passed into the model while # also padding it to the correct length with IGNORE_INDEX_TOKEN_ID. else: padding = torch.full((last_matching_index,), IGNORE_INDEX_TOKEN_ID).to(device=target_device) updated_targets.append(torch.cat((padding, target), dim=-1)[:prediction_length]) targets[of_name] = torch.stack(updated_targets).to(device=targets.get(of_name).device, dtype=torch.int64) return targets def generate_merged_ids( input_ids: torch.tensor, target_ids: torch.tensor, tokenizer: PreTrainedTokenizer, max_sequence_length: int = None ): """Generate merged input and target IDs tensor. This function merges the input_ids and target_ids together to create a unified tensor to pass into the model. It also returns attention masks for the merged tensors. Args: input_ids (torch.Tensor): The input IDs tensor. target_ids (torch.Tensor or None): The target IDs tensor or None. max_sequence_length (int or None): The maximum sequence length to pad or truncate to. tokenizer (PreTrainedTokenizer): The tokenizer used to encode the input_ids and target_ids. Returns: torch.Tensor: The merged input and target IDs tensor. torch.Tensor: The attention masks for the merged tensor. """ merged_input_and_targets = [] lengths = [] eos_tensor = torch.tensor([tokenizer.eos_token_id]).to(target_ids[0].device) # Merge input_ids and target_ids by concatenating them together. # We remove the left padding from both input_ids and target_ids before concatenating them. for input_id_sample, target_id_sample in zip(input_ids, target_ids): input_id_sample_no_padding = remove_left_padding(input_id_sample, tokenizer)[0] target_id_sample_no_padding = remove_left_padding(target_id_sample, tokenizer)[0] target_id_sample_no_padding = torch.cat((target_id_sample_no_padding, eos_tensor), dim=-1) merged_sample_ids = torch.cat((input_id_sample_no_padding, target_id_sample_no_padding), dim=-1) # If the merged tensor is longer than the maximum sequence length, we truncate it. if max_sequence_length and merged_sample_ids.shape[0] > max_sequence_length: merged_sample_ids = merged_sample_ids[:max_sequence_length] merged_input_and_targets.append(merged_sample_ids) lengths.append(merged_sample_ids.shape[0]) # Since we remove the left padding from the target_ids, the merged input_ids and target_ids # may not have the same lengths. We need to align them to the same length by adding left padding # and generate an attention mask for just the part of the input that is not padding. max_length = max(lengths) attention_masks = [] for i, merged_sample_ids in enumerate(merged_input_and_targets): merged_input_and_targets[i] = add_left_padding(merged_sample_ids, max_length) attention_masks.append(create_attention_mask(merged_input_and_targets[i], tokenizer)) return torch.stack(merged_input_and_targets), torch.stack(attention_masks) def _get_decoded_targets_and_predictions( targets: dict[str, torch.Tensor], predictions: dict[str, dict[str, torch.Tensor]], tokenizer: PreTrainedTokenizer, of_name: str, ): """Returns the decoded targets and predictions, accounting for IGNORE_INDEX_TOKEN_ID.""" target_tensor = targets[of_name] pred_tensor = predictions[of_name][PREDICTIONS] # Ensure targets and predictions are on the same device if target_tensor.device != pred_tensor.device: target_tensor = target_tensor.to(pred_tensor.device) sanitized_targets = torch.where(target_tensor != IGNORE_INDEX_TOKEN_ID, target_tensor, tokenizer.pad_token_id) sanitized_predictions = torch.where( pred_tensor != IGNORE_INDEX_TOKEN_ID, pred_tensor, tokenizer.pad_token_id, ) decoded_targets = tokenizer.batch_decode(sanitized_targets, skip_special_tokens=True) decoded_predictions = tokenizer.batch_decode(sanitized_predictions, skip_special_tokens=True) return decoded_targets, decoded_predictions def get_realigned_target_and_prediction_tensors_for_inference( targets: dict[str, torch.Tensor], predictions: dict[str, dict[str, torch.Tensor]], of_name: str, tokenizer: PreTrainedTokenizer, pad_value: int = None, ) -> tuple[dict[str, torch.Tensor], dict[str, torch.Tensor]]: """Realigns the target tensor with the predictions. This is necessary for text metrics that require the target and prediction to be of the same length. Args: targets: The target tensor. predictions: The prediction tensor. of_name: The output feature's name. tokenizer: The HF tokenizer. pad_direction: The direction to pad the tensors. Can be 'left' or 'right'. Defaults to 'right'. Returns: Tuple of realigned (targets, decoded_targets, predictions, decoded_predictions). - targets is a map of feature name -> tensor of token ids. - predictions is a map from output feature name -> map of tensors with the following items: - "predictions": tensor of token ids. - "probabilities": tensor of probabilities. - "logits": tensor of logits. """ target_length = targets.get(of_name).size()[1] prediction_length = predictions[of_name].get(PREDICTIONS).size()[1] if target_length == prediction_length: return targets, predictions if not pad_value: pad_value = tokenizer.pad_token_id if tokenizer.pad_token_id is not None else tokenizer.eos_token_id zeros_to_add = ( target_length - prediction_length if target_length > prediction_length else prediction_length - target_length ) # We don't want to modify the original targets and predictions tensors, so we create a copy of them. _targets = copy.deepcopy(targets) _predictions = copy.deepcopy(predictions) # Align target and prediction tensors for text to text metric computation if target_length > prediction_length: # Pad the predictions. _predictions[of_name][PREDICTIONS] = F.pad( _predictions[of_name][PREDICTIONS], (0, zeros_to_add), value=pad_value ).to(torch.int64) _predictions[of_name][PROBABILITIES] = F.pad(_predictions[of_name][PROBABILITIES], (0, 0, 0, zeros_to_add)).to( torch.float32 ) _predictions[of_name][LOGITS] = F.pad(_predictions[of_name][LOGITS], (0, 0, 0, zeros_to_add)).to(torch.float32) else: _targets[of_name] = F.pad(_targets[of_name], (0, zeros_to_add), value=pad_value).to(torch.int64) return _targets, _predictions def update_embedding_layer(model: AutoModelForCausalLM, config_obj: LLMTrainerConfig) -> AutoModelForCausalLM: """Updates the embedding layer of the model to use the 8-bit embedding layer from bitsandbytes.nn.modules. This is necessary when using 8-bit optimizers from bitsandbytes. See: https://github.com/TimDettmers/bitsandbytes#tldr """ # If we're using an 8-bit optimizer, we need to replace the embedding layer with a custom embedding layer from # bnb.nn.modules.Embedding. if hasattr(config_obj, "optimizer") and config_obj.optimizer.is_8bit: embedding_layer, module_path = find_embedding_layer_with_path(model) if embedding_layer is None: raise ValueError( "Could not find an embedding layer in the model. This is required when using 8-bit optimizers" " since a custom 8-bit embedding layer is used in place of the original embedding layer." ) # Initialize the BNB embedding layer with the same parameters and weights as the original embedding layer. bnb_embedding = BnbEmbedding( num_embeddings=embedding_layer.num_embeddings, embedding_dim=embedding_layer.embedding_dim, padding_idx=embedding_layer.padding_idx, max_norm=embedding_layer.max_norm, norm_type=embedding_layer.norm_type, scale_grad_by_freq=embedding_layer.scale_grad_by_freq, sparse=embedding_layer.sparse, _weight=embedding_layer.weight, device=model.device, ) # Update the model's original embedding layer to use the BNB embedding layer using the module_path # returned by find_embedding_layer_with_path. module_path = module_path.split(".") module = model for module_name in module_path[:-1]: module = getattr(module, module_name) setattr(module, module_path[-1], bnb_embedding) # Set the get input embeddings lambda function to return the BNB embedding layer model.get_input_embeddings = lambda: bnb_embedding logger.info("Updated the pretrained embedding layer to use the embedding layer from bitsandbytes.") return model def create_text_streamer(tokenizer: PreTrainedTokenizer) -> TextStreamer: """Creates a TextStreamer object for streaming text to stdout during generation.""" return TextStreamer(tokenizer=tokenizer, skip_prompt=True) ================================================ FILE: ludwig/utils/logging_utils.py ================================================ _logged = set() def log_once(key: str) -> bool: """Returns True if this is the "first" call for a given key. Example: if log_once("some_key"): logger.info("Some verbose logging statement") # noqa """ if key not in _logged: _logged.add(key) return True return False ================================================ FILE: ludwig/utils/loss_utils.py ================================================ import torch def rmspe_loss(targets: torch.Tensor, predictions: torch.Tensor) -> torch.Tensor: """Root mean square percentage error. Bad predictions can lead to arbitrarily large RMSPE values, especially if some values of targets are very close to zero. We return a large value instead of inf when (some) targets are zero. """ epsilon = 1e-4 # add epsilon if targets are zero to avoid division by zero denominator = targets + epsilon * (targets == 0).float() loss = torch.sqrt(torch.mean(((targets - predictions).float() / denominator) ** 2)) return loss def mean_confidence_penalty(probabilities: torch.Tensor, num_classes: int) -> torch.Tensor: max_entropy = torch.log(torch.tensor(num_classes)) # clipping needed for avoiding log(0) = -inf entropy_per_class, _ = torch.max(-probabilities * torch.log(torch.clamp(probabilities, 1e-10, 1)), dim=0) entropy = torch.sum(entropy_per_class, -1) penalty = (max_entropy - entropy) / max_entropy return torch.mean(penalty) ================================================ FILE: ludwig/utils/math_utils.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import math import numpy as np def softmax(x, temperature=1.0): e_x = np.exp((x - np.max(x)) / temperature) return e_x / e_x.sum() def int_type(number): if number <= np.iinfo(np.int8).max: return np.int8 elif number <= np.iinfo(np.int16).max: return np.int16 elif number <= np.iinfo(np.int32).max: return np.int32 else: # if number <= np.iinfo(np.int64).max: return np.int64 def convert_size(size_bytes): if size_bytes == 0: return "0B" size_name = ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB") i = int(math.floor(math.log(size_bytes, 1024))) p = math.pow(1024, i) s = round(size_bytes / p, 2) return f"{s} {size_name[i]}" def round2precision(val, precision: int = 0, which: str = ""): assert precision >= 0 val *= 10**precision round_callback = round if which.lower() == "up": round_callback = math.ceil if which.lower() == "down": round_callback = math.floor return "{1:.{0}f}".format(precision, round_callback(val) / 10**precision) def cumsum(x: list[int]) -> list[int]: results = [] j = 0 for i in range(0, len(x)): j += x[i] results.append(j) return results ================================================ FILE: ludwig/utils/metric_utils.py ================================================ from collections import defaultdict, namedtuple import torch from torch import Tensor from torchmetrics.metric import Metric from ludwig.constants import COMBINED, LOSS, NAME, TYPE from ludwig.modules.metric_registry import get_metric_names_for_type from ludwig.types import FeatureConfigDict def sequence_mask(lengths: Tensor, maxlen: int | None = None, dtype=torch.bool) -> Tensor: """Implements tf.sequence_mask in torch. From https://discuss.pytorch.org/t/pytorch-equivalent-for-tf-sequence-mask/39036/2. """ if maxlen is None: maxlen = lengths.max() row_vector = torch.arange(0, maxlen, 1).to(lengths.device) matrix = torch.unsqueeze(lengths, dim=-1) mask = row_vector < matrix return mask.type(dtype) def dynamic_partition(data: Tensor, partitions: Tensor, num_partitions: int) -> list[Tensor]: """Implements tf.dynamic_partition in torch. From https://discuss.pytorch.org/t/equivalent-of-tf-dynamic-partition/53735. """ assert data.size() == partitions.size() # Flatten data into 1D vectors to do partitioning correctly. data = data.view(-1) partitions = partitions.view(-1) result = [] for i in range(num_partitions): result += [data[(partitions == i).nonzero().squeeze(1)]] return result def masked_correct_predictions(targets: Tensor, preds: Tensor, targets_sequence_lengths: Tensor) -> Tensor: """Masks out special symbols, and returns tensor of correct predictions. Args: targets: 2D tensor [batch_size, sequence_length] preds: 2D tensor [batch_size, sequence_length] Returns: 1D tensor of all correct predictions. """ correct_preds = preds == targets mask = sequence_mask(lengths=targets_sequence_lengths, maxlen=correct_preds.shape[1], dtype=torch.int32) _, masked_correct_preds = dynamic_partition(data=correct_preds, partitions=mask, num_partitions=2) return masked_correct_preds.type(torch.float32) def get_scalar_from_ludwig_metric(metric: Metric) -> float: """Returns the scalar value of a Ludwig metric. Params: metric: Metric object Returns: float: scalar value of the metric """ return metric.compute().detach().cpu().numpy().item() # Data for training and evaluation metrics. TrainerMetric = namedtuple("TrainerMetric", ("epoch", "step", "value")) def reduce_trainer_metrics_dict( dict_dict_trainer_metrics: dict[str, dict[str, list[TrainerMetric]]], ) -> dict[str, dict[str, list[float]]]: """Reduces Dict[feature_name, Dict[metric_name, List[TrainerMetric]]] to Dict[feature_name, Dict[metric_name, List[float]]]. Used for flattening the results returned by trainer.py::train(), which come from ProgressTracker. """ flattened_dict = defaultdict(lambda: defaultdict(list)) for feature_name, trainer_metric_dict in dict_dict_trainer_metrics.items(): for metric_name, trainer_metrics in trainer_metric_dict.items(): for trainer_metric in trainer_metrics: flattened_dict[feature_name][metric_name].append(trainer_metric[-1]) # Convert defaultdict to dict so JSON serialization works with dataclasses.asdict(). return {k: dict(v) for k, v in flattened_dict.items()} def get_metric_names(output_features: dict[str, "OutputFeature"]) -> dict[str, list[str]]: # noqa """Returns a dict of output_feature_name -> list of metric names.""" metrics_names = {} for output_feature_name, output_feature in output_features.items(): metrics_names[output_feature_name] = sorted(list(get_metric_names_for_type(output_feature.type()))) # Add combined loss. metrics_names[COMBINED] = [LOSS] return metrics_names def get_feature_to_metric_names_map(output_features: list[FeatureConfigDict]) -> dict[str, list[str]]: """Returns a dict of output_feature_name -> list of metric names.""" metrics_names = {} for output_feature in output_features: output_feature_name = output_feature[NAME] output_feature_type = output_feature[TYPE] metrics_names[output_feature_name] = get_metric_names_for_type(output_feature_type) metrics_names[COMBINED] = [LOSS] return metrics_names def get_feature_to_metric_names_map_from_feature_collection( output_features: "FeatureCollection", # noqa ) -> dict[str, list[str]]: """Returns a dict of output_feature_name -> list of metric names.""" metrics_names = { output_feature.name: get_metric_names_for_type(output_feature.type) for output_feature in output_features } metrics_names[COMBINED] = [LOSS] return metrics_names ================================================ FILE: ludwig/utils/metrics_printed_table.py ================================================ import logging from tabulate import tabulate from ludwig.constants import COMBINED, LOSS from ludwig.utils.metric_utils import TrainerMetric logger = logging.getLogger(__name__) def get_metric_value_or_empty(metrics_log: dict[str, list[TrainerMetric]], metric_name: str): """Returns the metric value if it exists or empty.""" if metric_name not in metrics_log: return "" return metrics_log[metric_name][-1][-1] def print_table_for_single_output_feature( train_metrics_log: dict[str, list[TrainerMetric]], validation_metrics_log: dict[str, list[TrainerMetric]], test_metrics_log: dict[str, list[TrainerMetric]], combined_loss_for_each_split: list[float], ) -> None: """Prints the metrics table for a single output feature. Args: train_metrics_log: Dict from metric name to list of TrainerMetric. validation_metrics_log: Dict from metric name to list of TrainerMetric. test_metrics_log: Dict from metric name to list of TrainerMetric. """ # Get the superset of metric names across all splits. all_metric_names = set() all_metric_names.update(train_metrics_log.keys()) all_metric_names.update(validation_metrics_log.keys()) all_metric_names.update(test_metrics_log.keys()) all_metric_names = sorted(list(all_metric_names)) # Assemble the printed table. # Each item in the printed_table corresponds to a row in the printed table. printed_table = [["train", "validation", "test"]] for metric_name in all_metric_names: metrics_for_each_split = [ get_metric_value_or_empty(train_metrics_log, metric_name), get_metric_value_or_empty(validation_metrics_log, metric_name), get_metric_value_or_empty(test_metrics_log, metric_name), ] printed_table.append([metric_name] + metrics_for_each_split) # Add combined loss. printed_table.append(["combined_loss"] + combined_loss_for_each_split) logger.info(tabulate(printed_table, headers="firstrow", tablefmt="fancy_grid", floatfmt=".4f")) def print_metrics_table( output_features: dict[str, "OutputFeature"], # noqa train_metrics_log: dict[str, dict[str, list[TrainerMetric]]], validation_metrics_log: dict[str, dict[str, list[TrainerMetric]]], test_metrics_log: dict[str, dict[str, list[TrainerMetric]]], ): """Prints a table of metrics table for each output feature, for each split. Example: ╒═══════════════╤═════════╤══════════════╤════════╕ │ │ train │ validation │ test │ ╞═══════════════╪═════════╪══════════════╪════════╡ │ accuracy │ 0.8157 │ 0.6966 │ 0.8090 │ ├───────────────┼─────────┼──────────────┼────────┤ │ loss │ 0.4619 │ 0.5039 │ 0.4488 │ ├───────────────┼─────────┼──────────────┼────────┤ │ precision │ 0.8274 │ 0.6250 │ 0.7818 │ ├───────────────┼─────────┼──────────────┼────────┤ │ recall │ 0.6680 │ 0.4545 │ 0.6615 │ ├───────────────┼─────────┼──────────────┼────────┤ │ roc_auc │ 0.8471 │ 0.7706 │ 0.8592 │ ├───────────────┼─────────┼──────────────┼────────┤ │ specificity │ 0.9105 │ 0.8393 │ 0.8938 │ ├───────────────┼─────────┼──────────────┼────────┤ │ combined_loss │ 0.4619 │ 0.5039 │ 0.4488 │ ╘═══════════════╧═════════╧══════════════╧════════╛ """ # Obtain the combined loss, which is the same across all output features. combined_loss_for_each_split = [ get_metric_value_or_empty(train_metrics_log[COMBINED], LOSS), get_metric_value_or_empty(validation_metrics_log[COMBINED], LOSS), get_metric_value_or_empty(test_metrics_log[COMBINED], LOSS), ] for output_feature_name in sorted(output_features.keys()): if output_feature_name == COMBINED: # Skip the combined output feature. The combined loss will be added to each output feature's table. continue print_table_for_single_output_feature( train_metrics_log[output_feature_name], validation_metrics_log[output_feature_name], test_metrics_log[output_feature_name], combined_loss_for_each_split, ) ================================================ FILE: ludwig/utils/misc_utils.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import copy import functools import os import random import subprocess import weakref from collections import OrderedDict from collections.abc import Mapping from typing import Any, TYPE_CHECKING import numpy import torch from ludwig.api_annotations import DeveloperAPI from ludwig.constants import PROC_COLUMN from ludwig.globals import DESCRIPTION_FILE_NAME, MODEL_FILE_NAME from ludwig.utils import fs_utils from ludwig.utils.fs_utils import find_non_existing_dir_by_adding_suffix if TYPE_CHECKING: from ludwig.schema.model_types.base import ModelConfig @DeveloperAPI def set_random_seed(random_seed): os.environ["PYTHONHASHSEED"] = str(random_seed) random.seed(random_seed) numpy.random.seed(random_seed) torch.manual_seed(random_seed) if torch.cuda.is_available() and torch.cuda.device_count() > 0: torch.cuda.manual_seed(random_seed) @DeveloperAPI def merge_dict(dct, merge_dct): """Recursive dict merge. Inspired by :meth:``dict.update()``, instead of updating only top-level keys, dict_merge recurses down into dicts nested to an arbitrary depth, updating keys. The ``merge_dct`` is merged into ``dct``. :param dct: dict onto which the merge is executed :param merge_dct: dct merged into dct :return: None """ dct = copy.deepcopy(dct) for k, v in merge_dct.items(): if k in dct and isinstance(dct[k], dict) and isinstance(merge_dct[k], Mapping): dct[k] = merge_dict(dct[k], merge_dct[k]) else: dct[k] = merge_dct[k] return dct @DeveloperAPI def sum_dicts(dicts, dict_type=dict): summed_dict = dict_type() for d in dicts: for key, value in d.items(): if key in summed_dict: prev_value = summed_dict[key] if isinstance(value, (dict, OrderedDict)): summed_dict[key] = sum_dicts([prev_value, value], dict_type=type(value)) elif isinstance(value, numpy.ndarray): summed_dict[key] = numpy.concatenate((prev_value, value)) else: summed_dict[key] = prev_value + value else: summed_dict[key] = value return summed_dict @DeveloperAPI def get_from_registry(key, registry): if hasattr(key, "lower"): key = key.lower() if key in registry: return registry[key] else: raise ValueError(f"Key '{key}' not in registry, available options: {registry.keys()}") @DeveloperAPI def set_default_value(dictionary, key, value): if key not in dictionary: dictionary[key] = value @DeveloperAPI def set_default_values(dictionary: dict, default_value_dictionary: dict): """This function sets multiple default values recursively for various areas of the config. By using the helper function set_default_value, It parses input values that contain nested dictionaries, only setting values for parameters that have not already been defined by the user. Args: dictionary (dict): The dictionary to set default values for, generally a section of the config. default_value_dictionary (dict): The dictionary containing the default values for the config. """ for key, value in default_value_dictionary.items(): if key not in dictionary: # Event where the key is not in the dictionary yet dictionary[key] = value elif value == {}: # Event where dict is empty set_default_value(dictionary, key, value) elif isinstance(value, dict) and value: # Event where dictionary is nested - recursive call set_default_values(dictionary[key], value) else: set_default_value(dictionary, key, value) @DeveloperAPI def get_class_attributes(c): return {i for i in dir(c) if not callable(getattr(c, i)) and not i.startswith("_")} @DeveloperAPI def get_output_directory(output_directory, experiment_name, model_name="run"): base_dir_name = os.path.join(output_directory, experiment_name + ("_" if model_name else "") + (model_name or "")) return fs_utils.abspath(find_non_existing_dir_by_adding_suffix(base_dir_name)) @DeveloperAPI def get_file_names(output_directory): description_fn = os.path.join(output_directory, DESCRIPTION_FILE_NAME) training_stats_fn = os.path.join(output_directory, "training_statistics.json") model_dir = os.path.join(output_directory, MODEL_FILE_NAME) return description_fn, training_stats_fn, model_dir @DeveloperAPI def get_combined_features(config): return config["input_features"] + config["output_features"] @DeveloperAPI def get_proc_features(config): return get_proc_features_from_lists(config["input_features"], config["output_features"]) @DeveloperAPI def get_proc_features_from_lists(*args): return {feature[PROC_COLUMN]: feature for features in args for feature in features} @DeveloperAPI def set_saved_weights_in_checkpoint_flag(config_obj: "ModelConfig"): """Adds a flag to all input feature encoder configs indicating that the weights are saved in the checkpoint. Next time the model is loaded we will restore pre-trained encoder weights from ludwig model (and not load from cache or model hub). """ for input_feature in config_obj.input_features: encoder_obj = input_feature.encoder encoder_obj.saved_weights_in_checkpoint = True @DeveloperAPI def remove_empty_lines(str): return "\n".join([line.rstrip() for line in str.split("\n") if line.rstrip()]) @DeveloperAPI def memoized_method(*lru_args, **lru_kwargs): def decorator(func): @functools.wraps(func) def wrapped_func(self, *args, **kwargs): # We're storing the wrapped method inside the instance. If we had # a strong reference to self the instance would never die. self_weak = weakref.ref(self) @functools.wraps(func) @functools.lru_cache(*lru_args, **lru_kwargs) def cached_method(*args, **kwargs): return func(self_weak(), *args, **kwargs) setattr(self, func.__name__, cached_method) return cached_method(*args, **kwargs) return wrapped_func return decorator @DeveloperAPI def get_commit_hash(): """If Ludwig is run from a git repository, get the commit hash of the current HEAD. Returns None if git is not executable in the current environment or Ludwig is not run in a git repo. """ try: with open(os.devnull, "w") as devnull: is_a_git_repo = subprocess.call(["git", "branch"], stderr=subprocess.STDOUT, stdout=devnull) == 0 if is_a_git_repo: commit_hash = subprocess.check_output(["git", "rev-parse", "HEAD"]).decode("utf-8") return commit_hash except: # noqa: E722 pass return None @DeveloperAPI def scrub_creds(config_dict: dict[str, Any]) -> dict[str, Any]: """Returns a copy of a config dict with all sensitive fields scrubbed.""" if config_dict.get("backend", {}) and "credentials" in config_dict.get("backend", {}): config_dict["backend"]["credentials"] = {} return config_dict ================================================ FILE: ludwig/utils/model_utils.py ================================================ import logging from collections import OrderedDict import numpy as np import torch logger = logging.getLogger(__name__) NUMPY_TO_TORCH_DTYPE = { bool: torch.bool, np.bool_: torch.bool, np.uint8: torch.uint8, np.int8: torch.int8, np.int16: torch.int16, np.int32: torch.int32, np.int64: torch.int64, np.float16: torch.float16, np.float32: torch.float32, np.float64: torch.float64, np.complex64: torch.complex64, np.complex128: torch.complex128, } def extract_tensors(model: torch.nn.Module) -> tuple[torch.nn.Module, list[dict]]: """Remove the tensors from a PyTorch model, convert them to NumPy arrays, and return the stripped model and tensors. Reference implementation: https://medium.com/ibm-data-ai/how-to-load-pytorch-models-340-times-faster-with- ray-8be751a6944c # noqa """ tensors = [] for _, module in model.named_modules(): # Store the tensors as numpy arrays in Python dictionaries # Delete the same tensors since we no longer need them and we want to reduce memory pressure. # This ensures that throughout this process, we keep memory nearly linear w.r.t model parameters. params = OrderedDict() buffers = OrderedDict() for name, param in module.named_parameters(recurse=False): params[name] = torch.clone(param).detach().numpy() del param for name, buf in module.named_buffers(recurse=False): buffers[name] = torch.clone(buf).detach().numpy() del buf tensors.append({"params": params, "buffers": buffers}) # Strip all tensors and buffers out of the original model. for _, module in model.named_modules(): for name in [name for name, _ in module.named_parameters(recurse=False)] + [ name for name, _ in module.named_buffers(recurse=False) ]: setattr(module, name, None) return model, tensors def replace_tensors(m: torch.nn.Module, tensors: list[dict], device: torch.device): """Restore the tensors that extract_tensors() stripped out of a PyTorch model. This operation is performed in place. Reference implementation: https://medium.com/ibm-data-ai/how-to-load-pytorch-models-340-times-faster-with- ray-8be751a6944c # noqa """ modules = [module for _, module in m.named_modules()] for module, tensor_dict in zip(modules, tensors): # There are separate APIs to set parameters and buffers. for name, array in tensor_dict["params"].items(): module.register_parameter( name, torch.nn.Parameter(torch.as_tensor(array, device=device, dtype=NUMPY_TO_TORCH_DTYPE.get(array.dtype))), ) for name, array in tensor_dict["buffers"].items(): module.register_buffer( name, torch.as_tensor(array, device=device, dtype=NUMPY_TO_TORCH_DTYPE.get(array.dtype)), ) def find_embedding_layer_with_path(module, module_names=[]): """Recursively search through a module to find an embedding layer and its module path. Returns a tuple containing the embedding layer and its module path. """ for name, child_module in module.named_children(): if isinstance(child_module, torch.nn.Embedding): # If an embedding layer is found, return it along with the module path return child_module, ".".join(module_names + [name]) else: # Recursively search in the child module and update the module_names list found, path = find_embedding_layer_with_path(child_module, module_names + [name]) if found is not None: return found, path return None, None def contains_nan_or_inf_tensors(module: torch.nn.Module) -> bool: """Check for NaN or infinity (inf) values in the tensors (parameters and buffers) of a PyTorch module. This function recursively inspects the module's parameters and buffers to identify NaN or inf values. It is designed to ensure the numerical stability of the model by detecting any irregularities in the tensor values. Parameters: module (torch.nn.Module): The PyTorch module to check for NaN or inf values. Returns: bool: Returns True if any NaN or inf values are found in the module's tensors. Otherwise, returns False. """ for name, param in module.named_parameters(): if param.requires_grad and (torch.isnan(param).any() or torch.isinf(param).any()): logger.info(f"Found NaN or inf values in parameter '{name}' of module '{module.__class__.__name__}'") return True for name, buffer in module.named_buffers(): if torch.isnan(buffer).any() or torch.isinf(buffer).any(): logger.info(f"Found NaN or inf values in buffer '{name}' of module '{module.__class__.__name__}'") return True for name, submodule in module.named_children(): if contains_nan_or_inf_tensors(submodule): logger.info(f"Found NaN or inf values in submodule '{name}' of module '{module.__class__.__name__}'") return True return False ================================================ FILE: ludwig/utils/nlp_utils.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import logging import sys logger = logging.getLogger(__name__) nlp_pipelines = { "en": None, "it": None, "es": None, "de": None, "fr": None, "pt": None, "nl": None, "el": None, "nb": None, "lt": None, "da": None, "pl": None, "ro": None, "ja": None, "zh": None, "xx": None, } language_module_registry = { "en": "en_core_web_sm", "it": "it_core_news_sm", "es": "es_core_news_sm", "de": "de_core_news_sm", "fr": "fr_core_news_sm", "pt": "pt_core_news_sm", "nl": "nl_core_news_sm", "el": "el_core_news_sm", "nb": "nb_core_news_sm", "lt": "lt_core_news_sm", "da": "da_core_news_sm", "pl": "pl_core_news_sm", "ro": "ro_core_news_sm", "ja": "ja_core_news_sm", "zh": "zh_core_web_sm", "xx": "xx_ent_wiki_sm", } default_characters = [ " ", "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", "0", "1", "2", "3", "4", "5", "6", "8", "9", "-", ",", ";", ".", "!", "?", ":", "'", "'", "/", "\\", "|", "_", "@", "#", "$", "%", "^", "&", "*", "~", "`", "+", "-", "=", "<", ">", "(", ")", "[", "]", "{", "}", ] punctuation = {".", ",", "@", "$", "%", "/", ":", ";", "+", "="} def load_nlp_pipeline(language="xx"): if language not in language_module_registry: logger.error( "Language {} is not supported." "Suported languages are: {}".format(language, language_module_registry.keys()) ) raise ValueError else: spacy_module_name = language_module_registry[language] if nlp_pipelines[language] is None: logger.info("Loading NLP pipeline") try: import spacy except ImportError: logger.error( " spacy is not installed. " "In order to install all text feature dependencies run " "pip install ludwig[text]" ) sys.exit(-1) try: nlp_pipelines[language] = spacy.load(spacy_module_name, disable=["parser", "tagger", "ner"]) except OSError: logger.info(" spaCy {} model is missing, downloading it " "(this will only happen once)") from spacy.cli import download download(spacy_module_name) nlp_pipelines[language] = spacy.load(spacy_module_name, disable=["parser", "tagger", "ner"]) return nlp_pipelines[language] def pass_filters( token, filter_numbers=False, filter_punctuation=False, filter_short_tokens=False, filter_stopwords=False ): passes_filters = True if filter_numbers: passes_filters = not token.like_num if passes_filters and filter_punctuation: passes_filters = not bool(set(token.orth_) & punctuation) if passes_filters and filter_short_tokens: passes_filters = len(token) > 2 if passes_filters and filter_stopwords: passes_filters = not token.is_stop return passes_filters def process_text( text, nlp_pipeline, return_lemma=False, filter_numbers=False, filter_punctuation=False, filter_short_tokens=False, filter_stopwords=False, ): doc = nlp_pipeline(text) return [ token.lemma_ if return_lemma else token.text for token in doc if pass_filters(token, filter_numbers, filter_punctuation, filter_short_tokens, filter_stopwords) ] if __name__ == "__main__": text = ( "Hello John, how are you doing my good old friend? Are you still number 732 in the list? Did you pay $32.43 or " "54.21 for the book?" ) print(process_text(text, load_nlp_pipeline())) print( process_text(text, load_nlp_pipeline(), filter_numbers=True, filter_punctuation=True, filter_short_tokens=True) ) print(process_text(text, load_nlp_pipeline(), filter_stopwords=True)) print(process_text(text, load_nlp_pipeline(), return_lemma=True)) print( process_text( text, load_nlp_pipeline(), return_lemma=True, filter_numbers=True, filter_punctuation=True, filter_short_tokens=True, ) ) print(process_text(text, load_nlp_pipeline(), return_lemma=True, filter_stopwords=True)) ================================================ FILE: ludwig/utils/numerical_test_utils.py ================================================ # Copyright (c) 2022 Predibase, 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. # ============================================================================== from typing import Any import numpy as np def _dict_like(x): """Returns true if an object is a dict or convertible to one, false if not.""" try: _ = dict(x) except (TypeError, ValueError): return False return True def _enumerable(x): """Returns true if an object is enumerable, false if not.""" try: _ = enumerate(x) except (TypeError, ValueError): return False return True def assert_all_finite(x: Any, keypath=""): """Ensures that all scalars at all levels of the dictionary, list, array, or scalar are finite. keypath is only used for logging error messages, to indicate where the non-finite value was detected. """ path_description = f" at {keypath} " if keypath else " " if np.isscalar(x): assert np.isfinite(x), f"Value{path_description}should be finite, but is {str(x)}." elif isinstance(x, np.ndarray): non_finite_indices = np.nonzero(~np.isfinite(x)) non_finite_values = x[non_finite_indices] assert np.all(np.isfinite(x)), ( f"All values{path_description}should be finite, but found {str(non_finite_values)} " "at positions {str(np.array(non_finite_indices).flatten())}." ) elif _dict_like(x): # x is either a dict or convertible to one for k, v in dict(x).items(): assert_all_finite(v, keypath=keypath + "." + str(k) if keypath else str(k)) elif _enumerable(x): # x is a list, set or other enumerable type, but not a string, dict, or numpy array. for i, v in enumerate(x): assert_all_finite(v, keypath=keypath + f"[{i}]") else: assert False, f"Unhandled type {str(type(x))} for value{path_description}" ================================================ FILE: ludwig/utils/output_feature_utils.py ================================================ """Utilities used for managing output feature dicts.""" import numpy as np import torch from ludwig.utils.torch_utils import sequence_length_3D, sequence_mask def get_feature_concat_name(feature_name: str, tensor_name: str) -> str: return feature_name + "::" + tensor_name def get_tensor_name_from_concat_name(concat_name: str) -> str: return concat_name.split("::")[-1] def get_feature_name_from_concat_name(concat_name: str) -> str: return "::".join(concat_name.split("::")[:-1]) def get_single_output_feature_tensors( output_feature_dict: dict[str, torch.Tensor], feature_name: str ) -> dict[str, torch.Tensor]: """Returns a map of tensors related to the given feature_name.""" single_output_feature_tensors = {} for concat_name, tensor in output_feature_dict.items(): if get_feature_name_from_concat_name(concat_name) == feature_name: single_output_feature_tensors[get_tensor_name_from_concat_name(concat_name)] = tensor return single_output_feature_tensors def get_output_feature_tensor( output_dict: dict[str, torch.Tensor], feature_name: str, tensor_name: str ) -> torch.Tensor: """Returns a tensor related for the given feature_name and tensor_name.""" concat_name = get_feature_concat_name(feature_name, tensor_name) if concat_name not in output_dict: raise ValueError( f"Could not find {tensor_name} for {feature_name} in the output_dict with keys: {output_dict.keys()}" ) return output_dict[get_feature_concat_name(feature_name, tensor_name)] def set_output_feature_tensor( output_dict: dict[str, torch.Tensor], feature_name: str, tensor_name: str, tensor: torch.Tensor ): """Adds tensor for the given feature_name and tensor_name to the tensor dict.""" output_dict[get_feature_concat_name(feature_name, tensor_name)] = tensor def concat_dependencies( feature_name: str, dependencies: list[str], dependency_reducers: torch.ModuleDict, combiner_hidden_state: torch.Tensor, other_output_feature_states: dict[str, torch.Tensor], ) -> torch.Tensor: """Concatenates combiner_hidden_state with other output feature hidden states based on listed dependencies.""" # No dependencies. if not dependencies: return combiner_hidden_state dependency_hidden_states = [] for feature_name in dependencies: # The dependent feature should be present since ECD does a topological sort over output features. feature_hidden_state = other_output_feature_states[feature_name] # This feature is sequential. if len(combiner_hidden_state.shape) > 2: if len(feature_hidden_state.shape) > 2: # The dependent feature is also sequential. # matrix matrix -> concat assert combiner_hidden_state.shape[1] == feature_hidden_state.shape[1] dependency_hidden_states.append(feature_hidden_state) else: # The dependent feature is not sequential. # matrix vector -> tile concat sequence_max_length = combiner_hidden_state.shape[1] multipliers = (1, sequence_max_length, 1) tiled_representation = torch.tile(torch.unsqueeze(feature_hidden_state, 1), multipliers) sequence_length = sequence_length_3D(combiner_hidden_state) mask = sequence_mask(sequence_length, sequence_max_length) tiled_representation = torch.mul( tiled_representation, mask[:, :, np.newaxis].type(torch.float32), ) dependency_hidden_states.append(tiled_representation) else: # This feature is not sequential. if len(feature_hidden_state.shape) > 2: # The dependent feature is sequential. # vector matrix -> reduce concat reducer = dependency_reducers[feature_name] dependency_hidden_states.append(reducer(feature_hidden_state)) else: # The dependent feature is not sequential. # vector vector -> concat dependency_hidden_states.append(feature_hidden_state) try: hidden = torch.cat([combiner_hidden_state] + dependency_hidden_states, dim=-1) except Exception as e: raise ValueError( f"Shape mismatch {e} while concatenating dependent features of {feature_name}: " f"{dependencies}. Concatenating the feature activations tensor {combiner_hidden_state} " f"with activation tensors of dependencies: {dependency_hidden_states}. The error is " "likely due to a mismatch of the second dimension (sequence length) or a " "difference in ranks. Likely solutions are setting the maximum_sequence_length " "of all sequential features to be the same, or reduce the output of some " "features, or disabling the bucketing setting bucketing_field to None / null, " "as activating it will reduce the length of the field the bucketing is " "performed on." ) return hidden ================================================ FILE: ludwig/utils/package_utils.py ================================================ import importlib import types class LazyLoader(types.ModuleType): """Lazily import a module, mainly to avoid pulling in large dependencies. `contrib`, and `ffmpeg` are examples of modules that are large and not always needed, and this allows them to only be loaded when they are used. Copied from: https://github.com/tensorflow/tensorflow/blob/master/tensorflow/python/util/lazy_loader.py """ # The lint error here is incorrect. def __init__(self, local_name, parent_module_globals, name): # pylint: disable=super-on-old-class self._local_name = local_name self._parent_module_globals = parent_module_globals super().__init__(name) def _load(self): # Import the target module and insert it into the parent's namespace module = importlib.import_module(self.__name__) self._parent_module_globals[self._local_name] = module # Update this object's dict so that if someone keeps a reference to the # LazyLoader, lookups are efficient (__getattr__ is only called on lookups # that fail). self.__dict__.update(module.__dict__) return module def __getattr__(self, item): module = self._load() return getattr(module, item) def __dir__(self): module = self._load() return dir(module) ================================================ FILE: ludwig/utils/print_utils.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import logging from collections import OrderedDict from pprint import pformat from ludwig.api_annotations import DeveloperAPI logger = logging.getLogger(__name__) @DeveloperAPI def get_logging_level_registry() -> dict[str, int]: return { "critical": logging.CRITICAL, "error": logging.ERROR, "warning": logging.WARNING, "info": logging.INFO, "debug": logging.DEBUG, "notset": logging.NOTSET, } @DeveloperAPI def get_logo(message, ludwig_version): return "\n".join( [ "███████████████████████", "█ █ █ █ ▜█ █ █ █ █ █", "█ █ █ █ █ █ █ █ █ █ ███", "█ █ █ █ █ █ █ █ █ ▌ █", "█ █████ █ █ █ █ █ █ █ █", "█ █ ▟█ █ █ █", "███████████████████████", f"ludwig v{ludwig_version} - {message}", "", ] ) @DeveloperAPI def print_ludwig(message, ludwig_version): logger.info(get_logo(message, ludwig_version)) @DeveloperAPI def print_boxed(text, print_fun=logger.info): box_width = len(text) + 2 print_fun("") print_fun("╒{}╕".format("═" * box_width)) print_fun(f"│ {text.upper()} │") print_fun("╘{}╛".format("═" * box_width)) print_fun("") @DeveloperAPI def repr_ordered_dict(d: OrderedDict): return "{" + ",\n ".join(f"{x}: {pformat(y, indent=4)}" for x, y in d.items()) + "}" @DeveloperAPI def query_yes_no(question: str, default: str | None = "yes"): """Ask a yes/no question via raw_input() and return their answer. Args: question: String presented to the user default: The presumed answer from the user. Must be "yes", "no", or None (Answer is required) Returns: Boolean based on prompt response """ valid = {"yes": True, "y": True, "ye": True, "no": False, "n": False} if default is None: prompt = " [y/n] " elif default == "yes": prompt = " [Y/n] " elif default == "no": prompt = " [y/N] " else: raise ValueError("invalid default answer: '%s'" % default) while True: logger.info(question + prompt) choice = input().lower() if default is not None and choice == "": return valid[default] elif choice in valid: return valid[choice] else: logger.info("Please respond with 'yes' or 'no' " "(or 'y' or 'n').\n") ================================================ FILE: ludwig/utils/registry.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 2020 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. # ============================================================================== from collections import UserDict from typing import Generic, TypeVar DEFAULT_KEYS = ["None", "none", "null", None] T = TypeVar("T") class Registry(UserDict, Generic[T]): """Registry is like a normal dict, but with an optional parent dict. Items are considered to exist in the registry if they are added to either the registry itself, or its parent. """ def __init__(self, source=None): init_data = None parent = {} if isinstance(source, Registry): parent = source else: init_data = source self.parent = parent super().__init__(init_data) def __getitem__(self, key: str) -> T: if self.parent and key not in self.data: return self.parent.__getitem__(key) return self.data.__getitem__(key) def __contains__(self, key: str): return key in self.data or key in self.parent def __len__(self) -> int: return len(self.data) + len(self.parent) def __iter__(self): return self._merged().__iter__() def keys(self): return self._merged().keys() def values(self): return self._merged().values() def items(self): return self._merged().items() def _merged(self): return {**self.parent, **self.data} def register(self, name: str, default: bool = False): def wrap(cls): self[name] = cls if default: for key in DEFAULT_KEYS: self[key] = cls return cls return wrap ================================================ FILE: ludwig/utils/server_utils.py ================================================ import json import os import tempfile from typing import Any import numpy as np import pandas as pd from starlette.datastructures import UploadFile from starlette.responses import JSONResponse from ludwig.utils.data_utils import NumpyEncoder def serialize_payload(data_source: pd.DataFrame | pd.Series) -> tuple: """ Generates two dictionaries to be sent via REST API for Ludwig prediction service. First dictionary created is payload_dict. Keys found in payload_dict: raw_data: this is json string created by pandas to_json() method source_type: indicates if the data_source is either a pandas dataframe or pandas series. This is needed to know how to rebuild the structure. ndarray_dtype: this is a dictionary where each entry is for any ndarray data found in the data_source. This could be an empty dictioinary if no ndarray objects are present in data_source. Key for this dictionary is column name if data_source is dataframe or index name if data_source is series. The value portion of the dictionary is the dtype of the ndarray. This value is used to set the correct dtype when rebuilding the entry. Second dictionary created is called payload_files, this contains information and content for files to be sent to the server. NOTE: if no files are to be sent, this will be an empty dictionary. Entries in this dictionary: Key: file path string for file to be sent to server Value: tuple(file path string, byte encoded file content, 'application/octet-stream') Args: data_source: input features to be sent to Ludwig server Returns: tuple(payload_dict, payload_files) """ payload_dict = {} payload_dict["ndarray_dtype"] = {} payload_files = {} if isinstance(data_source, pd.DataFrame): payload_dict["raw_data"] = data_source.to_json(orient="columns") payload_dict["source_type"] = "dataframe" for col in data_source.columns: if isinstance(data_source[col].iloc[0], np.ndarray): # if we have any ndarray columns, record dtype payload_dict["ndarray_dtype"][col] = str(data_source[col].iloc[0].dtype) elif isinstance(data_source[col].iloc[0], str) and os.path.exists(data_source[col].iloc[0]): # if we have file path feature, prepare file for transport for v in data_source[col]: payload_files[v] = (v, open(v, "rb"), "application/octet-stream") elif isinstance(data_source, pd.Series): payload_dict["raw_data"] = data_source.to_json(orient="index") payload_dict["source_type"] = "series" for col in data_source.index: if isinstance(data_source[col], np.ndarray): # for ndarrays record dtype for reconstruction payload_dict["ndarray_dtype"][col] = str(data_source[col].dtype) elif isinstance(data_source[col], str) and os.path.exists(data_source[col]): # if we have file path feature, prepare file for transport v = data_source[col] payload_files[v] = (v, open(v, "rb"), "application/octet-stream") else: ValueError( '"data_source" must be either a pandas DataFrame or Series, ' "format found to be {}".format(type(data_source)) ) return payload_dict, payload_files def _write_file(v, files): # Convert UploadFile to a NamedTemporaryFile to ensure it's on the disk suffix = os.path.splitext(v.filename)[1] named_file = tempfile.NamedTemporaryFile(delete=False, suffix=suffix) files.append(named_file) named_file.write(v.file.read()) named_file.close() return named_file.name def deserialize_payload(json_string: str) -> pd.DataFrame: """This function performs the inverse of the serialize_payload function and rebuilds the object represented in json_string to a pandas DataFrame. Args: json_string: representing object to be rebuilt. Returns: pandas.DataFrame """ payload_dict = json.loads(json_string) # extract raw data from json string raw_data_dict = json.loads(payload_dict["raw_data"]) # rebuild based on original data source if payload_dict["source_type"] == "dataframe": # reconstitute the pandas dataframe df = pd.DataFrame.from_dict(raw_data_dict, orient="columns") elif payload_dict["source_type"] == "series": # reconstitute series into single row dataframe df = pd.DataFrame(pd.Series(raw_data_dict)).T else: ValueError( 'Unknown "source_type" found. Valid values are "dataframe" or ' '"series". Instead found {}'.format(payload_dict["source_type"]) ) # if source has ndarrays, rebuild those from list and set # original dtype. if payload_dict["ndarray_dtype"]: # yes, now covert list representation to ndarray representation for col in payload_dict["ndarray_dtype"]: dtype = payload_dict["ndarray_dtype"][col] df[col] = df[col].apply(lambda x: np.array(x).astype(dtype)) return df def deserialize_request(form) -> tuple: """This function will deserialize the REST API request packet to create a pandas dataframe that is input to the Ludwig predict method and a list of files that will be cleaned up at the end of processing. Args: form: REST API provide form data Returns: tuple(pandas.DataFrame, list of temporary files to clean up) """ files = [] file_index = {} for k, v in form.multi_items(): if type(v) is UploadFile: file_index[v.filename] = _write_file(v, files) # reconstruct the dataframe df = deserialize_payload(form["payload"]) # insert files paths of the temporary files in place of the original # file paths specified by the user. # pd.DataFrame.replace() method is used to replace file path string # specified by the user context with the file path string where a # temporary file containing the same content. # parameters for replace() method: # to_replace: list of file path strings that the user provided # value: list of temporary files created for each input file # # IMPORTANT: There is a one-to-one correspondence of the to_replace list # and the value list. Each list must be the same size. df.replace(to_replace=list(file_index.keys()), value=list(file_index.values()), inplace=True) return df, files class NumpyJSONResponse(JSONResponse): def render(self, content: dict[str, Any]) -> str: """Override the default JSONResponse behavior to encode numpy arrays. Args: content: JSON object to be serialized. Returns: str """ return json.dumps( content, ensure_ascii=False, allow_nan=False, indent=None, separators=(",", ":"), cls=NumpyEncoder ).encode("utf-8") ================================================ FILE: ludwig/utils/state_dict_backward_compatibility.py ================================================ # Copyright (c) 2023 Predibase, 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. # ============================================================================== def _update_transformers_to_freeze_module(state_dict): """Updates pre-trained encoders which were saved prior to the addition of FreezeModule.""" return { ( k.replace("encoder_obj.transformer.", "encoder_obj.transformer.module.") if "encoder_obj.transformer.module" not in k else k ): v for k, v in state_dict.items() } def _update_combiner_no_input_features(state_dict): """Removed combiner.input_features from state_dict following DeepSpeed integration.""" return {k: v for k, v in state_dict.items() if not k.startswith("combiner.input_features.")} def _update_combiner_no_device_tensor(state_dict): """Removed device_tensor from state_dict following DeepSpeed integration.""" return {k: v for k, v in state_dict.items() if not k.endswith("device_tensor")} def update_state_dict(state_dict): """Checks state_dict on load, updates state dict if needed.""" state_dict = _update_transformers_to_freeze_module(state_dict) state_dict = _update_combiner_no_input_features(state_dict) state_dict = _update_combiner_no_device_tensor(state_dict) return state_dict ================================================ FILE: ludwig/utils/strings_utils.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import logging import re import unicodedata from collections import Counter from dataclasses import dataclass from enum import Enum import numpy as np from dateutil.parser import parse as parse_datetime from ludwig.constants import PADDING_SYMBOL, START_SYMBOL, STOP_SYMBOL, UNKNOWN_SYMBOL from ludwig.data.dataframe.base import DataFrameEngine from ludwig.data.dataframe.pandas import PANDAS from ludwig.utils.fs_utils import open_file from ludwig.utils.math_utils import int_type from ludwig.utils.tokenizers import get_tokenizer_from_registry from ludwig.utils.types import Series PANDAS_TRUE_STRS = {"true"} PANDAS_FALSE_STRS = {"false"} BOOL_TRUE_STRS = {"yes", "y", "true", "t", "1", "1.0"} BOOL_FALSE_STRS = {"no", "n", "false", "f", "0", "0.0", "-1", "-1.0"} logger = logging.getLogger(__name__) class SpecialSymbol(Enum): """Special symbols used for text features.""" STOP = 0 START = 1 PADDING = 2 UNKNOWN = 3 def all_bool_strs(): """Returns all valid boolean strings, with varied capitalization.""" fns = [lambda x: x, lambda x: x.upper(), lambda x: x.capitalize()] return sorted({fn(x) for fn in fns for x in BOOL_TRUE_STRS | BOOL_FALSE_STRS}) def make_safe_filename(s): def safe_char(c): if c.isalnum(): return c else: return "_" return "".join(safe_char(c) for c in s).rstrip("_") def strip_accents(s): return "".join(c for c in unicodedata.normalize("NFD", s) if unicodedata.category(c) != "Mn") def str2bool(v: str, fallback_true_label=None) -> bool: """Returns bool representation of the given value v. Check the value against global bool string lists. Fallback to using fallback_true_label as True if the value isn't in the global bool lists. args: v: Value to get the bool representation for. fallback_true_label: (str) label to use as 'True'. """ v_str = str(v).lower() if v_str in BOOL_TRUE_STRS: return True if v_str in BOOL_FALSE_STRS: return False if fallback_true_label is None: raise ValueError( f"Cannot automatically map value '{v}' to a boolean and no `preprocessing.fallback_true_label` specified" ) return v == fallback_true_label def values_are_pandas_numbers(values: list[str]): """Returns True if values would be read by pandas as dtype float or int.""" for v in values: try: float(v) except ValueError: return False return True def values_are_pandas_bools(values: list[str]): """Returns True if values would be read by pandas as dtype bool.""" lowercase_values_set = {str(v).lower() for v in values} return lowercase_values_set.issubset(PANDAS_FALSE_STRS | PANDAS_TRUE_STRS) def are_conventional_bools(values: list[str | bool]) -> bool: """Returns whether all values are conventional booleans.""" for value in values: lower_value = str(value).lower() if lower_value not in BOOL_TRUE_STRS and lower_value not in BOOL_FALSE_STRS: return False return True def is_number(s: str | int | float): """Returns whether specified value is number.""" if isinstance(s, str) and s.lower() == "nan": return True try: float(s) return True except ValueError: return False def is_datetime(s: str | int | float): """Returns whether specified value is datetime.""" if is_number(s): return False try: parse_datetime(s) return True except Exception: return False def are_all_datetimes(values: list[str | int | float]): """Returns whether all values are datetimes.""" for value in values: if not is_datetime(value): return False return True def are_all_numbers(values: list[str | int | float]): """Returns whether all values are numbers.""" for value in values: if not is_number(value): return False return True def is_integer(s: str | int | float): """Returns whether specified value is an integer.""" try: float(s) except ValueError: return False else: return float(s).is_integer() and not np.isnan(float(s)) def are_sequential_integers(values: list[str | int | float]): """Returns whether distinct values form sequential integer list.""" int_list = [] for value in values: if not is_integer(value): return False int_list.append(int(float(value))) return (max(int_list) - min(int_list) + 1) == len(int_list) def match_replace(string_to_match, list_regex): """Matches strings against regular expressions. arguments: string_to_match -- the string to match returns: string_to_match -- the cleaned string matched -- the list of regular expressions that matched """ matched = [] for regex in list_regex: match = re.search(regex[0], string_to_match) if match: string_to_match = re.sub(regex[0], regex[1], string_to_match) matched.append(regex[0].pattern) return string_to_match, matched def load_vocabulary(vocab_file): with open_file(vocab_file, "r", encoding="utf-8") as f: vocabulary = [] for line in f: line = line.strip() if " " in line: line = line.split(" ")[0] vocabulary.append(line) return vocabulary def add_or_move_symbol(vocab_list: list[str], vocab_set: set[str], symbol: str, index: int): """Inserts or moves the symbol to the specified index.""" if symbol in vocab_set: vocab_list.remove(symbol) vocab_list.insert(index, symbol) @dataclass class Vocabulary: vocab: list[str] """List of strings representing the computed vocabulary.""" str2idx: dict[str, int] """Map of symbol to index.""" str2freq: dict[str, int] """Map of symbol to frequency.""" str2idf: dict[str, int] | None """Map of symbol to inverse document frequency.""" max_sequence_length: int """Maximum sequence length.""" sequence_length_99ptile: int """99th percentile of maximum sequence length.""" pad_idx: int """Index to padding symbol.""" padding_symbol: str """Actual padding symbol.""" unknown_symbol: str """Actual unknown symbol.""" prompt_template_num_tokens: int = 0 """The number of tokens in the prompt template. If -1, then there is no prompt template. """ def _get_vocab_from_dict(vocab: dict[str, int]) -> list[str]: """Returns a vocab in list format from a vocab token=>idx dictionary.""" vocab_values = list(vocab.values()) if len(set(vocab_values)) != len(vocab_values): raise ValueError("Vocabulary has duplicate mappings in its vocabulary. This should never happen.") # construct a vocab that is a list that reflects the token=>index mapping in HF's vocab # pre-allocate a list to make sure each index is inited to prevent OBO errors caused by missing indices max_idx = max(vocab_values) vocab_list = [None for _ in range(max_idx + 1)] for token, idx in vocab.items(): vocab_list[idx] = token return vocab_list def _get_vocabulary( tokenizer_type: str, tokenizer, vocab_file: str, unknown_symbol: str, add_special_symbols: bool, padding_symbol: str, unit_counts: Counter, num_most_frequent: int, ) -> list[str] | None: """Returns the vocabulary from the tokenizer_type, tokenizer, or vocab_file. If the `tokenizer_type` is 'hf_tokenizer', then the set vocabulary from the tokenizer is used. If there's no vocab_file or if the tokenizer has no set vocabulary (e.g. space_punct), then the vocabulary is determined from the tokenized data (unit_counts). The UNKNOWN special symbol is always included in the final vocabulary. Additional special symbols (PADDING, START, STOP) are added if add_special_symbols=True. If the tokenizer is a pre-trained huggingface tokenizer, then the special symbols are taken from the tokenizer's vocabulary. """ # Pre-trained huggingface tokenizer. Use the pre-existing vocabulary and special symbols. if tokenizer_type == "hf_tokenizer": try: return _get_vocab_from_dict(tokenizer.get_vocab()) except NotImplementedError: logger.warning( "HuggingFace tokenizer does not have a get_vocab() method. " + "Using tokenizer.tokenizer.vocab_size and tokenizer.tokenizer._convert_id_to_token " + "to build the vocabulary." ) vocab = [] for idx in range(tokenizer.tokenizer.vocab_size): vocab.append(tokenizer.tokenizer._convert_id_to_token(idx)) vocab += tokenizer.tokenizer.added_tokens_encoder.keys() return vocab # The tokenizer has a preset vocabulary. if hasattr(tokenizer, "get_vocab"): return _get_vocab_from_dict(tokenizer.get_vocab()) # Load the vocabulary from the vocab file. if vocab_file is not None: return load_vocabulary(vocab_file) # The tokenizer had no preset vocabulary, for example space_punct. # Compute the vocabulary from tokenized data. return [unit for unit, _ in unit_counts.most_common(num_most_frequent)] def remove_bracketed_elements(prompt_template: str) -> str: """Example: -> .""" pattern = r"\{.*?\}" return re.sub(pattern, "", prompt_template) def create_vocabulary( data: Series, tokenizer_type: str = "space", lowercase: bool = True, num_most_frequent: int = None, vocab_file: str = None, add_special_symbols: bool = True, unknown_symbol: str = UNKNOWN_SYMBOL, padding_symbol: str = PADDING_SYMBOL, start_symbol: str = START_SYMBOL, stop_symbol: str = STOP_SYMBOL, pretrained_model_name_or_path: str = None, ngram_size: int | None = None, compute_idf: bool = False, processor: DataFrameEngine = PANDAS, prompt_template: str = "", ) -> Vocabulary: """Computes a vocabulary over the provided data frame. This function is used when the data consists of multiple tokens within one example. E.g., words in a text feature, items in a set feature, etc. If the feature only contains a single token like for category features, `create_vocabulary_single_token` should be used instead, as it is more efficient. A tokenizer is specified using the `tokenizer_type`. The tokenizer will be used to process all of the data provided, producing an indexed vocabulary with frequency counts. If the `tokenizer_type` is 'hf_tokenizer', then a pre-trained huggingface tokenizer is loaded from `pretrained_model_name_or_path` and that vocabulary is used directly. The UNKNOWN special symbol is always included in the final vocabulary. Additional special symbols (PADDING, START, STOP) are added if add_special_symbols=True. If the tokenizer is a pre-trained huggingface tokenizer, then the special symbols are taken from the tokenizer's vocabulary. Args: prompt_template: The prompt template for the model. Applicable only to LLMs. data: Series of string data. tokenizer_type: Tokenizer type. Can be a tokenizer registry value or 'hf_tokenizer' for huggingface. lowercase: Whether to lowercase all strings. num_most_frequent: Upper limit on vocabulary size., add_special_symbols: If True, START, STOP, PADDING special symbols are added to the vocabulary. UNKNOWN is always added. unknown_symbol: String representation for the UNKNOWN symbol. padding_symbol: String representation for the PADDING symbol. start_symbol: String representation for the START symbol. stop_symbol: String representation for the STOP symbol. pretrained_model_name_or_path: Name/path to huggingface model. ngram_size: Size of the n-gram when using `ngram` tokenizer. compute_idf: If True, computes the inverse document frequency for each token. processor: Which processor to use to process data. Returns: Vocabulary object containing metadata about the vocab. TODO(Justin): Clean up pad_idx, padding_symbol, unknown_symbol return, as no one seems to be using it. """ tokenizer = get_tokenizer_from_registry(tokenizer_type)( vocab_file=vocab_file, pretrained_model_name_or_path=pretrained_model_name_or_path, ngram_size=ngram_size, ) # Number of tokens in template. prompt_template_num_tokens = -1 if prompt_template: prompt_without_bracketed_elements = remove_bracketed_elements(prompt_template) prompt_template_num_tokens = len(tokenizer(prompt_without_bracketed_elements)) # Tokenize the data. def process_line(line): return tokenizer(line.lower() if lowercase else line) processed_lines = processor.map_objects(data, process_line) processed_counts = processed_lines.explode().value_counts(sort=False) processed_counts = processor.compute(processed_counts) unit_counts = Counter(dict(processed_counts)) max_sequence_length = processor.compute(processed_lines.map(len).max()) sequence_length_99ptile = processor.compute(processed_lines.map(len).quantile(0.99)) if tokenizer_type != "hf_tokenizer": # For non-HF tokenizers, add 2 for start and stop symbols. max_sequence_length += 2 sequence_length_99ptile += 2 pad_idx = None if tokenizer_type == "hf_tokenizer": # Replace the special symbols with the ones from the tokenizer. unknown_symbol = tokenizer.get_unk_token() padding_symbol = tokenizer.get_pad_token() pad_idx = tokenizer.convert_token_to_id(padding_symbol) vocab: list[str] = _get_vocabulary( tokenizer_type, tokenizer, vocab_file, unknown_symbol, add_special_symbols, padding_symbol, unit_counts, num_most_frequent, ) vocab_set = set(vocab) doc_unit_counts = None if compute_idf: # The document frequency used for TF-IDF. Similar to unit_counts, but de-duped by document. document_counts = processed_lines.map(lambda x: set(x)).explode().value_counts(sort=False) document_counts = processor.compute(document_counts) doc_unit_counts = Counter(dict(document_counts)) if tokenizer_type != "hf_tokenizer": if add_special_symbols: add_or_move_symbol(vocab, vocab_set, stop_symbol, SpecialSymbol.STOP.value) add_or_move_symbol(vocab, vocab_set, start_symbol, SpecialSymbol.START.value) add_or_move_symbol(vocab, vocab_set, padding_symbol, SpecialSymbol.PADDING.value) # Always add the UNKNOWN symbol if we're using our own tokenizer. add_or_move_symbol(vocab, vocab_set, unknown_symbol, SpecialSymbol.UNKNOWN.value) str2idx = {unit: i for i, unit in enumerate(vocab)} str2freq = {unit: unit_counts.get(unit) if unit in unit_counts else 0 for unit in vocab} str2idf = ( {unit: np.log(len(vocab) / (1 + doc_unit_counts.get(unit))) if unit in doc_unit_counts else 0 for unit in vocab} if compute_idf else None ) if pad_idx is None and padding_symbol in str2idx.keys(): pad_idx = str2idx[padding_symbol] return Vocabulary( vocab=vocab, str2idx=str2idx, str2freq=str2freq, str2idf=str2idf, max_sequence_length=max_sequence_length, sequence_length_99ptile=sequence_length_99ptile, pad_idx=pad_idx, padding_symbol=padding_symbol, unknown_symbol=unknown_symbol, prompt_template_num_tokens=prompt_template_num_tokens, ) def create_vocabulary_single_token( data: Series, num_most_frequent: int | None = None, processor: DataFrameEngine = PANDAS, unknown_symbol: str = UNKNOWN_SYMBOL, ): """Computes a vocabulary over the provided data frame. This function should be used iff the values in each row of data should be considered as a single token, e.g., category features ("interested", "not interested", "somewhat interested"). This assumption allows us to be more efficient than `create_vocabulary()` as we can skip tokenization and computing the maximum sequence length, which are unnecessary for category features. Args: data: Series of string data. num_most_frequent: Upper limit on vocabulary size. unknown_symbol: String representation for the UNKNOWN symbol. processor: Which processor to use to process data. Returns: Tuple of: vocab: List of strings representing the computed vocabulary. str2idx: Map of symbol to index. str2freq: Map of symbol to frequency. """ processed_counts = data.str.strip().value_counts(sort=True) processed_counts = processor.compute(processed_counts) full_vocab = processed_counts.index.tolist() # Only add unknown symbol if num most frequent tokens is less than total number of unique tokens if num_most_frequent < len(full_vocab): vocab = [unknown_symbol] + full_vocab[:num_most_frequent] else: vocab = full_vocab str2idx = {unit: i for i, unit in enumerate(vocab)} str2freq = processed_counts.to_dict() str2freq = {k: str2freq.get(k, 0) for k in vocab} return vocab, str2idx, str2freq def _get_sequence_vector( sequence, tokenizer, tokenizer_type, format_dtype, unit_to_id, lowercase=True, unknown_symbol=UNKNOWN_SYMBOL ) -> np.ndarray: unit_sequence = tokenizer(sequence.lower() if lowercase else sequence) unit_indices_vector = np.empty(len(unit_sequence), dtype=format_dtype) for i in range(len(unit_sequence)): curr_unit = unit_sequence[i] if tokenizer_type == "hf_tokenizer": unit_indices_vector[i] = curr_unit else: if curr_unit in unit_to_id: unit_indices_vector[i] = unit_to_id[curr_unit] else: unit_indices_vector[i] = unit_to_id[unknown_symbol] # Add start and stop symbols. # Huggingface's pretrained tokenizers take care of this implicitly: # https://huggingface.co/docs/transformers/preprocessing if tokenizer_type != "hf_tokenizer": unit_indices_vector = np.append(unit_indices_vector, unit_to_id[STOP_SYMBOL]) unit_indices_vector = np.insert(unit_indices_vector, 0, unit_to_id[START_SYMBOL]) return unit_indices_vector def build_sequence_matrix( sequences, # pd.core.series.Series inverse_vocabulary, tokenizer_type, length_limit, padding_symbol=PADDING_SYMBOL, padding="right", unknown_symbol=UNKNOWN_SYMBOL, lowercase=True, tokenizer_vocab_file=None, pretrained_model_name_or_path=None, processor=PANDAS, ) -> np.ndarray: tokenizer = get_tokenizer_from_registry(tokenizer_type)( vocab_file=tokenizer_vocab_file, pretrained_model_name_or_path=pretrained_model_name_or_path, ) format_dtype = int_type(len(inverse_vocabulary) - 1) unit_vectors = sequences.map( lambda sequence: _get_sequence_vector( sequence, tokenizer, tokenizer_type, format_dtype, inverse_vocabulary, lowercase=lowercase, unknown_symbol=unknown_symbol, ) ) max_length = processor.compute(unit_vectors.map(len).max()) if max_length < length_limit: logger.debug(f"max length of {format}: {max_length} < limit: {length_limit}") max_length = length_limit if tokenizer_type == "hf_tokenizer": padding_symbol = tokenizer.get_pad_token() pad_token_id = tokenizer.convert_token_to_id(padding_symbol) else: pad_token_id = inverse_vocabulary[padding_symbol] def pad(vector): sequence = np.full((int(max_length),), pad_token_id, dtype=format_dtype) limit = min(vector.shape[0], max_length) if padding == "right": sequence[:limit] = vector[:limit] else: # if padding == 'left sequence[max_length - limit :] = vector[:limit] return sequence padded = processor.map_objects(unit_vectors, pad) return padded def get_tokenizer(tokenizer_type: str, tokenizer_vocab_file: str, pretrained_model_name_or_path: str): """Returns a tokenizer object based on the tokenizer type.""" return get_tokenizer_from_registry(tokenizer_type)( vocab_file=tokenizer_vocab_file, pretrained_model_name_or_path=pretrained_model_name_or_path, ) ================================================ FILE: ludwig/utils/structural_warning.py ================================================ import warnings from ludwig.utils.logging_utils import log_once def warn_structure_refactor(old_module: str, new_module: str, direct: bool = True) -> None: """Create structure refactor warning to indicate modules new location post. Only creates a warning once per module. """ old_module = old_module.replace(".py", "") if log_once(old_module): warning = ( f"The module `{old_module}` has been moved to `{new_module}` and the old " f"location will be deprecated soon. Please adjust your imports to point " f"to the new location." ) if direct: warning += f" Example: Do a global search and " f"replace `{old_module}` with `{new_module}`." else: warning += ( f"\nATTENTION: This module may have been split or refactored. Please " f"check the contents of `{new_module}` before making changes." ) with warnings.catch_warnings(): warnings.simplefilter("always") warnings.warn(warning, DeprecationWarning, stacklevel=3) ================================================ FILE: ludwig/utils/system_utils.py ================================================ #! /usr/bin/env python # Copyright (c) 2022 Predibase, 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. # ============================================================================== from dataclasses import dataclass from ludwig.api_annotations import DeveloperAPI @DeveloperAPI @dataclass class Resources: cpus: int gpus: int ================================================ FILE: ludwig/utils/time_utils.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import logging import time from datetime import datetime, timedelta logger = logging.getLogger(__name__) class WithTimer: def __init__(self, title="", quiet=False): self.title = title self.quiet = quiet def elapsed(self): return time.time() - self.wall, time.process_time() - self.proc def enter(self): """Manually trigger enter.""" self.__enter__() def __enter__(self): self.proc = time.process_time() self.wall = time.time() return self def __exit__(self, *args): if not self.quiet: elapsed_wp = self.elapsed() logger.info(f"Elapsed {self.title}: wall {elapsed_wp[0]:.06f}, sys {elapsed_wp[1]:.06f}") class Timer: def __init__(self): self.reset() def reset(self): self._proc = time.process_time() self._wall = time.time() def elapsed(self): return self.wall(), self.proc() def elapsed_str(self): return strdelta(self.wall() * 1000.0), strdelta(self.proc() * 1000.0) def wall(self): return time.time() - self._wall def proc(self): return time.process_time() - self._proc def tic(self): """Like Matlab tic/toc for wall time and processor time.""" self.reset() def toc(self): """Like Matlab tic/toc for wall time.""" return self.wall() def tocproc(self): """Like Matlab tic/toc, but for processor time.""" return self.proc() def timestamp(): return f"{datetime.now():%Y_%m_%d_%H_%M_%S}" def strdelta(tdelta): if isinstance(tdelta, (int, float)): tdelta = timedelta(milliseconds=tdelta) d = {"D": tdelta.days} d["H"], rem = divmod(tdelta.seconds, 3600) d["M"], d["S"] = divmod(rem, 60) d["f"] = str(tdelta.microseconds)[0:4] if d["D"] > 0: t = "{D}d {H}h {M}m {S}.{f}s" elif d["H"] > 0: t = "{H}h {M}m {S}.{f}s" elif d["M"] > 0: t = "{M}m {S}.{f}s" else: t = "{S}.{f}s" return t.format(**d) ================================================ FILE: ludwig/utils/tokenizers.py ================================================ """Ludwig string tokenizers including string-based, spacy-based, and huggingface-based implementations. To add a new tokenizer, 1) implement a subclass of BaseTokenizer and 2) add it to the tokenizer_registry. Once it's in the registry, tokenizers can be used in a ludwig config, e.g.. ``` input_features: - name: title type: text preprocessing: tokenizer: ``` """ import logging import re from abc import abstractmethod from typing import Any import torch from ludwig.utils.nlp_utils import load_nlp_pipeline, process_text logger = logging.getLogger(__name__) SPACE_PUNCTUATION_REGEX = re.compile(r"\w+|[^\w\s]") COMMA_REGEX = re.compile(r"\s*,\s*") UNDERSCORE_REGEX = re.compile(r"\s*_\s*") TORCHSCRIPT_COMPATIBLE_TOKENIZERS = {"space", "space_punct"} class BaseTokenizer: @abstractmethod def __init__(self, **kwargs): pass @abstractmethod def __call__(self, text: str): pass class CharactersToListTokenizer(BaseTokenizer): def __call__(self, text): return [char for char in text] class SpaceStringToListTokenizer(torch.nn.Module): """Implements torchscript-compatible whitespace tokenization.""" def __init__(self, **kwargs): super().__init__() def forward(self, v: str | list[str] | torch.Tensor) -> Any: if isinstance(v, torch.Tensor): raise ValueError(f"Unsupported input: {v}") inputs: list[str] = [] # Ludwig calls map on List[str] objects, so we need to handle individual strings as well. if isinstance(v, str): inputs.append(v) else: inputs.extend(v) tokens: list[list[str]] = [] for sequence in inputs: split_sequence = sequence.strip().split(" ") token_sequence: list[str] = [] for token in split_sequence: if len(token) > 0: token_sequence.append(token) tokens.append(token_sequence) return tokens[0] if isinstance(v, str) else tokens class SpacePunctuationStringToListTokenizer(torch.nn.Module): """Implements torchscript-compatible space_punct tokenization.""" def __init__(self, **kwargs): super().__init__() def is_regex_w(self, c: str) -> bool: return c.isalnum() or c == "_" def forward(self, v: str | list[str] | torch.Tensor) -> Any: if isinstance(v, torch.Tensor): raise ValueError(f"Unsupported input: {v}") inputs: list[str] = [] # Ludwig calls map on List[str] objects, so we need to handle individual strings as well. if isinstance(v, str): inputs.append(v) else: inputs.extend(v) tokens: list[list[str]] = [] for sequence in inputs: token_sequence: list[str] = [] word: list[str] = [] for c in sequence: if self.is_regex_w(c): word.append(c) elif len(word) > 0: # if non-empty word and non-alphanumeric char, append word to token sequence token_sequence.append("".join(word)) word.clear() if not self.is_regex_w(c) and not c.isspace(): # non-alphanumeric, non-space char is punctuation token_sequence.append(c) if len(word) > 0: # add last word token_sequence.append("".join(word)) tokens.append(token_sequence) return tokens[0] if isinstance(v, str) else tokens class StringSplitTokenizer(BaseTokenizer): """Splits a string by a given separator.""" def __init__(self, separator: str = " ", **kwargs): self.separator = separator def __call__(self, text): return text.split(self.separator) class NgramTokenizer(BaseTokenizer): """Tokenizes text into unigrams + ngrams up to n.""" def __init__(self, n: int = 2, **kwargs): self.n = n def __call__(self, text): tokens = text.strip().split() result = list(tokens) for i in range(2, self.n + 1): for j in range(len(tokens) - i + 1): result.append(" ".join(tokens[j : j + i])) return result class UnderscoreStringToListTokenizer(BaseTokenizer): def __call__(self, text): return UNDERSCORE_REGEX.split(text.strip()) class CommaStringToListTokenizer(BaseTokenizer): def __call__(self, text): return COMMA_REGEX.split(text.strip()) class UntokenizedStringToListTokenizer(BaseTokenizer): def __call__(self, text): return [text] class StrippedStringToListTokenizer(BaseTokenizer): def __call__(self, text): return [text.strip()] class EnglishTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("en")) class EnglishFilterTokenizer(BaseTokenizer): def __call__(self, text): return process_text( text, load_nlp_pipeline("en"), filter_numbers=True, filter_punctuation=True, filter_short_tokens=True ) class EnglishRemoveStopwordsTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("en"), filter_stopwords=True) class EnglishLemmatizeTokenizer(BaseTokenizer): def __call__(self, text): process_text(text, load_nlp_pipeline("en"), return_lemma=True) class EnglishLemmatizeFilterTokenizer(BaseTokenizer): def __call__(self, text): return process_text( text, load_nlp_pipeline("en"), return_lemma=True, filter_numbers=True, filter_punctuation=True, filter_short_tokens=True, ) class EnglishLemmatizeRemoveStopwordsTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("en"), return_lemma=True, filter_stopwords=True) class ItalianTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("it")) class ItalianFilterTokenizer(BaseTokenizer): def __call__(self, text): return process_text( text, load_nlp_pipeline("it"), filter_numbers=True, filter_punctuation=True, filter_short_tokens=True ) class ItalianRemoveStopwordsTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("it"), filter_stopwords=True) class ItalianLemmatizeTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("it"), return_lemma=True) class ItalianLemmatizeFilterTokenizer(BaseTokenizer): def __call__(self, text): return process_text( text, load_nlp_pipeline("it"), return_lemma=True, filter_numbers=True, filter_punctuation=True, filter_short_tokens=True, ) class ItalianLemmatizeRemoveStopwordsTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("it"), return_lemma=True, filter_stopwords=True) class SpanishTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("es")) class SpanishFilterTokenizer(BaseTokenizer): def __call__(self, text): return process_text( text, load_nlp_pipeline("es"), filter_numbers=True, filter_punctuation=True, filter_short_tokens=True ) class SpanishRemoveStopwordsTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("es"), filter_stopwords=True) class SpanishLemmatizeTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("es"), return_lemma=True) class SpanishLemmatizeFilterTokenizer(BaseTokenizer): def __call__(self, text): return process_text( text, load_nlp_pipeline("es"), return_lemma=True, filter_numbers=True, filter_punctuation=True, filter_short_tokens=True, ) class SpanishLemmatizeRemoveStopwordsTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("es"), return_lemma=True, filter_stopwords=True) class GermanTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("de")) class GermanFilterTokenizer(BaseTokenizer): def __call__(self, text): return process_text( text, load_nlp_pipeline("de"), filter_numbers=True, filter_punctuation=True, filter_short_tokens=True ) class GermanRemoveStopwordsTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("de"), filter_stopwords=True) class GermanLemmatizeTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("de"), return_lemma=True) class GermanLemmatizeFilterTokenizer(BaseTokenizer): def __call__(self, text): return process_text( text, load_nlp_pipeline("de"), return_lemma=True, filter_numbers=True, filter_punctuation=True, filter_short_tokens=True, ) class GermanLemmatizeRemoveStopwordsTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("de"), return_lemma=True, filter_stopwords=True) class FrenchTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("fr")) class FrenchFilterTokenizer(BaseTokenizer): def __call__(self, text): return process_text( text, load_nlp_pipeline("fr"), filter_numbers=True, filter_punctuation=True, filter_short_tokens=True ) class FrenchRemoveStopwordsTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("fr"), filter_stopwords=True) class FrenchLemmatizeTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("fr"), return_lemma=True) class FrenchLemmatizeFilterTokenizer(BaseTokenizer): def __call__(self, text): return process_text( text, load_nlp_pipeline("fr"), return_lemma=True, filter_numbers=True, filter_punctuation=True, filter_short_tokens=True, ) class FrenchLemmatizeRemoveStopwordsTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("fr"), return_lemma=True, filter_stopwords=True) class PortugueseTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("pt")) class PortugueseFilterTokenizer(BaseTokenizer): def __call__(self, text): return process_text( text, load_nlp_pipeline("pt"), filter_numbers=True, filter_punctuation=True, filter_short_tokens=True ) class PortugueseRemoveStopwordsTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("pt"), filter_stopwords=True) class PortugueseLemmatizeTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("pt"), return_lemma=True) class PortugueseLemmatizeFilterTokenizer(BaseTokenizer): def __call__(self, text): return process_text( text, load_nlp_pipeline("pt"), return_lemma=True, filter_numbers=True, filter_punctuation=True, filter_short_tokens=True, ) class PortugueseLemmatizeRemoveStopwordsTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("pt"), return_lemma=True, filter_stopwords=True) class DutchTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("nl")) class DutchFilterTokenizer(BaseTokenizer): def __call__(self, text): return process_text( text, load_nlp_pipeline("nl"), filter_numbers=True, filter_punctuation=True, filter_short_tokens=True ) class DutchRemoveStopwordsTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("nl"), filter_stopwords=True) class DutchLemmatizeTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("nl"), return_lemma=True) class DutchLemmatizeFilterTokenizer(BaseTokenizer): def __call__(self, text): return process_text( text, load_nlp_pipeline("nl"), return_lemma=True, filter_numbers=True, filter_punctuation=True, filter_short_tokens=True, ) class DutchLemmatizeRemoveStopwordsTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("nl"), return_lemma=True, filter_stopwords=True) class GreekTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("el")) class GreekFilterTokenizer(BaseTokenizer): def __call__(self, text): return process_text( text, load_nlp_pipeline("el"), filter_numbers=True, filter_punctuation=True, filter_short_tokens=True ) class GreekRemoveStopwordsTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("el"), filter_stopwords=True) class GreekLemmatizeTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("el"), return_lemma=True) class GreekLemmatizeFilterTokenizer(BaseTokenizer): def __call__(self, text): return process_text( text, load_nlp_pipeline("el"), return_lemma=True, filter_numbers=True, filter_punctuation=True, filter_short_tokens=True, ) class GreekLemmatizeRemoveStopwordsFilterTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("el"), return_lemma=True, filter_stopwords=True) class NorwegianTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("nb")) class NorwegianFilterTokenizer(BaseTokenizer): def __call__(self, text): return process_text( text, load_nlp_pipeline("nb"), filter_numbers=True, filter_punctuation=True, filter_short_tokens=True ) class NorwegianRemoveStopwordsTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("nb"), filter_stopwords=True) class NorwegianLemmatizeTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("nb"), return_lemma=True) class NorwegianLemmatizeFilterTokenizer(BaseTokenizer): def __call__(self, text): return process_text( text, load_nlp_pipeline("nb"), return_lemma=True, filter_numbers=True, filter_punctuation=True, filter_short_tokens=True, ) class NorwegianLemmatizeRemoveStopwordsFilterTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("nb"), return_lemma=True, filter_stopwords=True) class LithuanianTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("lt")) class LithuanianFilterTokenizer(BaseTokenizer): def __call__(self, text): return process_text( text, load_nlp_pipeline("lt"), filter_numbers=True, filter_punctuation=True, filter_short_tokens=True ) class LithuanianRemoveStopwordsTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("lt"), filter_stopwords=True) class LithuanianLemmatizeTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("lt"), return_lemma=True) class LithuanianLemmatizeFilterTokenizer(BaseTokenizer): def __call__(self, text): return process_text( text, load_nlp_pipeline("lt"), return_lemma=True, filter_numbers=True, filter_punctuation=True, filter_short_tokens=True, ) class LithuanianLemmatizeRemoveStopwordsFilterTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("lt"), return_lemma=True, filter_stopwords=True) class DanishTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("da")) class DanishFilterTokenizer(BaseTokenizer): def __call__(self, text): return process_text( text, load_nlp_pipeline("da"), filter_numbers=True, filter_punctuation=True, filter_short_tokens=True ) class DanishRemoveStopwordsTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("da"), filter_stopwords=True) class DanishLemmatizeTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("da"), return_lemma=True) class DanishLemmatizeFilterTokenizer(BaseTokenizer): def __call__(self, text): return process_text( text, load_nlp_pipeline("da"), return_lemma=True, filter_numbers=True, filter_punctuation=True, filter_short_tokens=True, ) class DanishLemmatizeRemoveStopwordsFilterTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("da"), return_lemma=True, filter_stopwords=True) class PolishTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("pl")) class PolishFilterTokenizer(BaseTokenizer): def __call__(self, text): return process_text( text, load_nlp_pipeline("pl"), filter_numbers=True, filter_punctuation=True, filter_short_tokens=True ) class PolishRemoveStopwordsTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("pl"), filter_stopwords=True) class PolishLemmatizeTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("pl"), return_lemma=True) class PolishLemmatizeFilterTokenizer(BaseTokenizer): def __call__(self, text): return process_text( text, load_nlp_pipeline("pl"), return_lemma=True, filter_numbers=True, filter_punctuation=True, filter_short_tokens=True, ) class PolishLemmatizeRemoveStopwordsFilterTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("pl"), return_lemma=True, filter_stopwords=True) class RomanianTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("ro")) class RomanianFilterTokenizer(BaseTokenizer): def __call__(self, text): return process_text( text, load_nlp_pipeline("ro"), filter_numbers=True, filter_punctuation=True, filter_short_tokens=True ) class RomanianRemoveStopwordsTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("ro"), filter_stopwords=True) class RomanianLemmatizeTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("ro"), return_lemma=True) class RomanianLemmatizeFilterTokenizer(BaseTokenizer): def __call__(self, text): return process_text( text, load_nlp_pipeline("ro"), return_lemma=True, filter_numbers=True, filter_punctuation=True, filter_short_tokens=True, ) class RomanianLemmatizeRemoveStopwordsFilterTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("ro"), return_lemma=True, filter_stopwords=True) class JapaneseTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("jp")) class JapaneseFilterTokenizer(BaseTokenizer): def __call__(self, text): return process_text( text, load_nlp_pipeline("jp"), filter_numbers=True, filter_punctuation=True, filter_short_tokens=True ) class JapaneseRemoveStopwordsTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("jp"), filter_stopwords=True) class JapaneseLemmatizeTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("jp"), return_lemma=True) class JapaneseLemmatizeFilterTokenizer(BaseTokenizer): def __call__(self, text): return process_text( text, load_nlp_pipeline("jp"), return_lemma=True, filter_numbers=True, filter_punctuation=True, filter_short_tokens=True, ) class JapaneseLemmatizeRemoveStopwordsFilterTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("jp"), return_lemma=True, filter_stopwords=True) class ChineseTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("zh")) class ChineseFilterTokenizer(BaseTokenizer): def __call__(self, text): return process_text( text, load_nlp_pipeline("zh"), filter_numbers=True, filter_punctuation=True, filter_short_tokens=True ) class ChineseRemoveStopwordsTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("zh"), filter_stopwords=True) class ChineseLemmatizeTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("zh"), return_lemma=True) class ChineseLemmatizeFilterTokenizer(BaseTokenizer): def __call__(self, text): return process_text( text, load_nlp_pipeline("zh"), return_lemma=True, filter_numbers=True, filter_punctuation=True, filter_short_tokens=True, ) class ChineseLemmatizeRemoveStopwordsFilterTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("zh"), return_lemma=True, filter_stopwords=True) class MultiTokenizer(BaseTokenizer): def __call__(self, text): return process_text( text, load_nlp_pipeline("xx"), filter_numbers=True, filter_punctuation=True, filter_short_tokens=True ) class MultiFilterTokenizer(BaseTokenizer): def __call__(self, text): return process_text( text, load_nlp_pipeline("xx"), filter_numbers=True, filter_punctuation=True, filter_short_tokens=True ) class MultiRemoveStopwordsTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("xx"), filter_stopwords=True) class MultiLemmatizeTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("xx"), return_lemma=True) class MultiLemmatizeFilterTokenizer(BaseTokenizer): def __call__(self, text): return process_text( text, load_nlp_pipeline("xx"), return_lemma=True, filter_numbers=True, filter_punctuation=True, filter_short_tokens=True, ) class MultiLemmatizeRemoveStopwordsTokenizer(BaseTokenizer): def __call__(self, text): return process_text(text, load_nlp_pipeline("xx"), return_lemma=True, filter_stopwords=True) class HFTokenizer(BaseTokenizer): def __init__(self, pretrained_model_name_or_path, **kwargs): super().__init__() from transformers import AutoTokenizer self.tokenizer = AutoTokenizer.from_pretrained( pretrained_model_name_or_path, trust_remote_code=kwargs.get("trust_remote_code", False), ) # Some models (e.g. LLaMA) don't have a pad_token by default. # Set it to eos_token to avoid NoneType errors in preprocessing. if self.tokenizer.pad_token is None and self.tokenizer.eos_token is not None: self.tokenizer.pad_token = self.tokenizer.eos_token def __call__(self, text): return self.tokenizer.encode(text, truncation=True) def get_vocab(self): return self.tokenizer.get_vocab() def get_pad_token(self) -> str: return self.tokenizer.pad_token def get_unk_token(self) -> str: return self.tokenizer.unk_token def convert_token_to_id(self, token: str) -> int: if token is None: return 0 return self.tokenizer.convert_tokens_to_ids(token) tokenizer_registry = { # Torchscript-compatible tokenizers. "space": SpaceStringToListTokenizer, "space_punct": SpacePunctuationStringToListTokenizer, # Tokenizers not compatible with torchscript "characters": CharactersToListTokenizer, "underscore": UnderscoreStringToListTokenizer, "comma": CommaStringToListTokenizer, "untokenized": UntokenizedStringToListTokenizer, "stripped": StrippedStringToListTokenizer, "english_tokenize": EnglishTokenizer, "english_tokenize_filter": EnglishFilterTokenizer, "english_tokenize_remove_stopwords": EnglishRemoveStopwordsTokenizer, "english_lemmatize": EnglishLemmatizeTokenizer, "english_lemmatize_filter": EnglishLemmatizeFilterTokenizer, "english_lemmatize_remove_stopwords": EnglishLemmatizeRemoveStopwordsTokenizer, "italian_tokenize": ItalianTokenizer, "italian_tokenize_filter": ItalianFilterTokenizer, "italian_tokenize_remove_stopwords": ItalianRemoveStopwordsTokenizer, "italian_lemmatize": ItalianLemmatizeTokenizer, "italian_lemmatize_filter": ItalianLemmatizeFilterTokenizer, "italian_lemmatize_remove_stopwords": ItalianLemmatizeRemoveStopwordsTokenizer, "spanish_tokenize": SpanishTokenizer, "spanish_tokenize_filter": SpanishFilterTokenizer, "spanish_tokenize_remove_stopwords": SpanishRemoveStopwordsTokenizer, "spanish_lemmatize": SpanishLemmatizeTokenizer, "spanish_lemmatize_filter": SpanishLemmatizeFilterTokenizer, "spanish_lemmatize_remove_stopwords": SpanishLemmatizeRemoveStopwordsTokenizer, "german_tokenize": GermanTokenizer, "german_tokenize_filter": GermanFilterTokenizer, "german_tokenize_remove_stopwords": GermanRemoveStopwordsTokenizer, "german_lemmatize": GermanLemmatizeTokenizer, "german_lemmatize_filter": GermanLemmatizeFilterTokenizer, "german_lemmatize_remove_stopwords": GermanLemmatizeRemoveStopwordsTokenizer, "french_tokenize": FrenchTokenizer, "french_tokenize_filter": FrenchFilterTokenizer, "french_tokenize_remove_stopwords": FrenchRemoveStopwordsTokenizer, "french_lemmatize": FrenchLemmatizeTokenizer, "french_lemmatize_filter": FrenchLemmatizeFilterTokenizer, "french_lemmatize_remove_stopwords": FrenchLemmatizeRemoveStopwordsTokenizer, "portuguese_tokenize": PortugueseTokenizer, "portuguese_tokenize_filter": PortugueseFilterTokenizer, "portuguese_tokenize_remove_stopwords": PortugueseRemoveStopwordsTokenizer, "portuguese_lemmatize": PortugueseLemmatizeTokenizer, "portuguese_lemmatize_filter": PortugueseLemmatizeFilterTokenizer, "portuguese_lemmatize_remove_stopwords": PortugueseLemmatizeRemoveStopwordsTokenizer, "dutch_tokenize": DutchTokenizer, "dutch_tokenize_filter": DutchFilterTokenizer, "dutch_tokenize_remove_stopwords": DutchRemoveStopwordsTokenizer, "dutch_lemmatize": DutchLemmatizeTokenizer, "dutch_lemmatize_filter": DutchLemmatizeFilterTokenizer, "dutch_lemmatize_remove_stopwords": DutchLemmatizeRemoveStopwordsTokenizer, "greek_tokenize": GreekTokenizer, "greek_tokenize_filter": GreekFilterTokenizer, "greek_tokenize_remove_stopwords": GreekRemoveStopwordsTokenizer, "greek_lemmatize": GreekLemmatizeTokenizer, "greek_lemmatize_filter": GreekLemmatizeFilterTokenizer, "greek_lemmatize_remove_stopwords": GreekLemmatizeRemoveStopwordsFilterTokenizer, "norwegian_tokenize": NorwegianTokenizer, "norwegian_tokenize_filter": NorwegianFilterTokenizer, "norwegian_tokenize_remove_stopwords": NorwegianRemoveStopwordsTokenizer, "norwegian_lemmatize": NorwegianLemmatizeTokenizer, "norwegian_lemmatize_filter": NorwegianLemmatizeFilterTokenizer, "norwegian_lemmatize_remove_stopwords": NorwegianLemmatizeRemoveStopwordsFilterTokenizer, "lithuanian_tokenize": LithuanianTokenizer, "lithuanian_tokenize_filter": LithuanianFilterTokenizer, "lithuanian_tokenize_remove_stopwords": LithuanianRemoveStopwordsTokenizer, "lithuanian_lemmatize": LithuanianLemmatizeTokenizer, "lithuanian_lemmatize_filter": LithuanianLemmatizeFilterTokenizer, "lithuanian_lemmatize_remove_stopwords": LithuanianLemmatizeRemoveStopwordsFilterTokenizer, "danish_tokenize": DanishTokenizer, "danish_tokenize_filter": DanishFilterTokenizer, "danish_tokenize_remove_stopwords": DanishRemoveStopwordsTokenizer, "danish_lemmatize": DanishLemmatizeTokenizer, "danish_lemmatize_filter": DanishLemmatizeFilterTokenizer, "danish_lemmatize_remove_stopwords": DanishLemmatizeRemoveStopwordsFilterTokenizer, "polish_tokenize": PolishTokenizer, "polish_tokenize_filter": PolishFilterTokenizer, "polish_tokenize_remove_stopwords": PolishRemoveStopwordsTokenizer, "polish_lemmatize": PolishLemmatizeTokenizer, "polish_lemmatize_filter": PolishLemmatizeFilterTokenizer, "polish_lemmatize_remove_stopwords": PolishLemmatizeRemoveStopwordsFilterTokenizer, "romanian_tokenize": RomanianTokenizer, "romanian_tokenize_filter": RomanianFilterTokenizer, "romanian_tokenize_remove_stopwords": RomanianRemoveStopwordsTokenizer, "romanian_lemmatize": RomanianLemmatizeTokenizer, "romanian_lemmatize_filter": RomanianLemmatizeFilterTokenizer, "romanian_lemmatize_remove_stopwords": RomanianLemmatizeRemoveStopwordsFilterTokenizer, "japanese_tokenize": JapaneseTokenizer, "japanese_tokenize_filter": JapaneseFilterTokenizer, "japanese_tokenize_remove_stopwords": JapaneseRemoveStopwordsTokenizer, "japanese_lemmatize": JapaneseLemmatizeTokenizer, "japanese_lemmatize_filter": JapaneseLemmatizeFilterTokenizer, "japanese_lemmatize_remove_stopwords": JapaneseLemmatizeRemoveStopwordsFilterTokenizer, "chinese_tokenize": ChineseTokenizer, "chinese_tokenize_filter": ChineseFilterTokenizer, "chinese_tokenize_remove_stopwords": ChineseRemoveStopwordsTokenizer, "chinese_lemmatize": ChineseLemmatizeTokenizer, "chinese_lemmatize_filter": ChineseLemmatizeFilterTokenizer, "chinese_lemmatize_remove_stopwords": ChineseLemmatizeRemoveStopwordsFilterTokenizer, "multi_tokenize": MultiTokenizer, "multi_tokenize_filter": MultiFilterTokenizer, "multi_tokenize_remove_stopwords": MultiRemoveStopwordsTokenizer, "multi_lemmatize": MultiLemmatizeTokenizer, "multi_lemmatize_filter": MultiLemmatizeFilterTokenizer, "multi_lemmatize_remove_stopwords": MultiLemmatizeRemoveStopwordsTokenizer, } class SentencePieceTokenizer(torch.nn.Module): """SentencePiece tokenizer using HuggingFace transformers (XLMR-based).""" def __init__(self, pretrained_model_name_or_path: str | None = None, **kwargs): super().__init__() from transformers import AutoTokenizer if pretrained_model_name_or_path is None: pretrained_model_name_or_path = "xlm-roberta-base" self.tokenizer = AutoTokenizer.from_pretrained(pretrained_model_name_or_path) def forward(self, v: str | list[str] | torch.Tensor): if isinstance(v, torch.Tensor): raise ValueError(f"Unsupported input: {v}") if isinstance(v, str): return self.tokenizer.tokenize(v) return [self.tokenizer.tokenize(s) for s in v] class CLIPTokenizer(torch.nn.Module): """CLIP tokenizer using HuggingFace transformers.""" def __init__(self, pretrained_model_name_or_path: str | None = None, **kwargs): super().__init__() from transformers import CLIPTokenizer as HFCLIPTokenizer if pretrained_model_name_or_path is None: pretrained_model_name_or_path = "openai/clip-vit-base-patch32" self.tokenizer = HFCLIPTokenizer.from_pretrained(pretrained_model_name_or_path) def __call__(self, text): if isinstance(text, str): return self.tokenizer.tokenize(text) return [self.tokenizer.tokenize(t) for t in text] def get_vocab(self): return self.tokenizer.get_vocab() class GPT2BPETokenizer(torch.nn.Module): """GPT-2 BPE tokenizer using HuggingFace transformers.""" def __init__(self, pretrained_model_name_or_path: str | None = None, **kwargs): super().__init__() from transformers import GPT2Tokenizer if pretrained_model_name_or_path is None: pretrained_model_name_or_path = "gpt2" self.tokenizer = GPT2Tokenizer.from_pretrained(pretrained_model_name_or_path) def __call__(self, text): if isinstance(text, str): return self.tokenizer.tokenize(text) return [self.tokenizer.tokenize(t) for t in text] def get_vocab(self): return self.tokenizer.get_vocab() class BERTTokenizer(torch.nn.Module): """BERT tokenizer using HuggingFace transformers.""" def __init__( self, vocab_file: str | None = None, pretrained_model_name_or_path: str | None = None, is_hf_tokenizer: bool | None = False, do_lower_case: bool | None = None, **kwargs, ): super().__init__() from transformers import BertTokenizer if pretrained_model_name_or_path is None: pretrained_model_name_or_path = "bert-base-uncased" tokenizer_kwargs = {} if do_lower_case is not None: tokenizer_kwargs["do_lower_case"] = do_lower_case self.tokenizer = BertTokenizer.from_pretrained(pretrained_model_name_or_path, **tokenizer_kwargs) self.is_hf_tokenizer = is_hf_tokenizer self.pad_token = self.tokenizer.pad_token self.unk_token = self.tokenizer.unk_token self.cls_token_id = self.tokenizer.cls_token_id self.sep_token_id = self.tokenizer.sep_token_id def __call__(self, text): if isinstance(text, str): texts = [text] else: texts = text if self.is_hf_tokenizer: results = [self.tokenizer.encode(t) for t in texts] else: results = [self.tokenizer.tokenize(t) for t in texts] return results[0] if isinstance(text, str) else results def get_vocab(self): return self.tokenizer.get_vocab() def get_pad_token(self) -> str: return self.pad_token def get_unk_token(self) -> str: return self.unk_token def convert_token_to_id(self, token: str) -> int: if token is None: return 0 return self.tokenizer.convert_tokens_to_ids(token) tokenizer_registry.update( { "sentencepiece": SentencePieceTokenizer, "clip": CLIPTokenizer, "gpt2bpe": GPT2BPETokenizer, "bert": BERTTokenizer, } ) def get_hf_tokenizer(pretrained_model_name_or_path, **kwargs): """Gets a HuggingFace-based tokenizer that follows HF convention. Args: pretrained_model_name_or_path: Name of the model in the HF repo. Example: "bert-base-uncased". Returns: A HF tokenizer. """ model_name_lower = pretrained_model_name_or_path.lower() # Use BERTTokenizer only for actual BERT models, not for models like albert/roberta # that have "bert" in their name but use different tokenization (SentencePiece, BPE, etc.) if "bert" in model_name_lower and not any(x in model_name_lower for x in ("albert", "roberta", "distilbert")): logger.info(f"Loading BERT tokenizer for {pretrained_model_name_or_path}") return BERTTokenizer(pretrained_model_name_or_path=pretrained_model_name_or_path, is_hf_tokenizer=True) logger.info(f"Loading HuggingFace tokenizer for {pretrained_model_name_or_path}") return HFTokenizer(pretrained_model_name_or_path) tokenizer_registry.update( { "hf_tokenizer": get_hf_tokenizer, } ) def get_tokenizer_from_registry(tokenizer_name: str) -> torch.nn.Module: """Returns the appropriate tokenizer from the tokenizer registry.""" if tokenizer_name in tokenizer_registry: return tokenizer_registry[tokenizer_name] raise KeyError(f"Invalid tokenizer name: '{tokenizer_name}'. Available tokenizers: {tokenizer_registry.keys()}") ================================================ FILE: ludwig/utils/torch_utils.py ================================================ import math import os import warnings from abc import abstractmethod from functools import lru_cache import torch from torch import nn from torch.nn import Module, ModuleDict from ludwig.api_annotations import DeveloperAPI from ludwig.constants import ENCODER_OUTPUT from ludwig.utils.strings_utils import SpecialSymbol _TORCH_INIT_PARAMS: tuple | None = None @DeveloperAPI def get_torch_device(): if torch.cuda.is_available() and torch.cuda.device_count() > 0: # Use cublasLt for batched GEMM operations. The default cublas library has known # bugs with cublasSgemmStridedBatched on certain GPU/driver combinations. torch.backends.cuda.preferred_blas_library("cublaslt") return "cuda" if bool(os.environ.get("LUDWIG_ENABLE_MPS")): if torch.backends.mps.is_available() and torch.backends.mps.is_built(): if not bool(os.environ.get("PYTORCH_ENABLE_MPS_FALLBACK")): warnings.warn( "LUDWIG_ENABLE_MPS is set and MPS is available, but PYTORCH_ENABLE_MPS_FALLBACK has not been set. " "Depending on your model config, some operations may not be compatible. If errors occur, try " "setting `PYTORCH_ENABLE_MPS_FALLBACK=1` and resubmitting." ) return "mps" else: warnings.warn("LUDWIG_ENABLE_MPS is set but MPS is not available, falling back to CPU.") return "cpu" DEVICE = get_torch_device() @DeveloperAPI def place_on_device(x, device): """Recursively places the input on the specified device.""" if isinstance(x, list): return [place_on_device(xi, device) for xi in x] elif isinstance(x, dict): return {k: place_on_device(v, device) for k, v in x.items()} elif isinstance(x, set): return {place_on_device(xi, device) for xi in x} elif isinstance(x, tuple): return tuple(place_on_device(xi, device) for xi in x) elif isinstance(x, torch.Tensor): return x.to(device) else: return x @DeveloperAPI def sequence_length_2D(sequence: torch.Tensor) -> torch.Tensor: """Returns the number of non-padding elements per sequence in batch. :param sequence: (torch.Tensor) A 2D tensor of shape [batch size x max sequence length]. # Return :returns: (torch.Tensor) The count on non-zero elements per sequence. """ used = (sequence != SpecialSymbol.PADDING.value).type(torch.int32) length = torch.sum(used, 1) return length @DeveloperAPI def sequence_length_3D(sequence: torch.Tensor) -> torch.Tensor: """Returns the number of non-zero elements per sequence in batch. :param sequence: (torch.Tensor) A 3D tensor of shape [batch size x max sequence length x hidden size]. # Return :returns: (torch.Tensor) The count on non-zero elements per sequence. """ used = torch.sign(torch.amax(torch.abs(sequence), dim=2)) length = torch.sum(used, 1) length = length.int() return length @DeveloperAPI def sequence_mask(lengths: torch.Tensor, maxlen: int | None = None, dtype: torch.dtype = torch.bool): """Returns a mask of shape (batch_size x maxlen), where mask[i] is True for each element up to lengths[i], otherwise False i.e. if maxlen=5 and lengths[i] = 3, mask[i] = [True, True True, False False]. :param lengths: (torch.Tensor) A 1d integer tensor of shape [batch size]. :param maxlen: (Optional[int]) The maximum sequence length. If not specified, the max(lengths) is used. :param dtype: (type) The type to output. # Return :returns: (torch.Tensor) A sequence mask tensor of shape (batch_size x maxlen). """ if maxlen is None: maxlen = lengths.max() matrix = torch.unsqueeze(lengths, dim=-1) row_vector = torch.arange(0, maxlen, 1, device=lengths.device) mask = row_vector < matrix mask = mask.type(dtype) return mask @DeveloperAPI def periodic(inputs: torch.Tensor, period: int) -> torch.Tensor: """Returns periodic representation assuming 0 is start of period.""" return torch.cos(inputs * 2 * math.pi / period) initializer_registry = { "uniform": nn.init.uniform_, "normal": nn.init.normal_, "constant": nn.init.constant_, "ones": nn.init.ones_, "zeros": nn.init.zeros_, "eye": nn.init.eye_, "dirac": nn.init.dirac_, "xavier_uniform": nn.init.xavier_uniform_, "xavier_normal": nn.init.xavier_normal_, "kaiming_uniform": nn.init.kaiming_uniform_, "kaiming_normal": nn.init.kaiming_normal_, "orthogonal": nn.init.orthogonal_, "sparse": nn.init.sparse_, "identity": nn.init.eye_, } activations = { "elu": nn.ELU, "leakyRelu": nn.LeakyReLU, "logSigmoid": nn.LogSigmoid, "relu": nn.ReLU, "sigmoid": nn.Sigmoid, "tanh": nn.Tanh, "softmax": nn.Softmax, None: nn.Identity, } @DeveloperAPI def get_activation(activation): return activations[activation]() @DeveloperAPI def reg_loss(model: nn.Module, regularizer: str, l1: float = 0.01, l2: float = 0.01): """Computes the regularization loss for a given model. Parameters: model: torch.nn.Module object to compute regularization loss for. regularizer: regularizer to use (currently l1, l2 and l1_l2 supported). l1: L1 regularization coefficient. l2: L2 regularization coefficient. Returns: Regularization loss for the model (float). """ if regularizer == "l1": l1_reg = l1 * sum(torch.abs(p).sum() for p in model.parameters()) return l1_reg if regularizer == "l2": l2_reg = l2 * sum(torch.square(p).sum() for p in model.parameters()) return l2_reg if regularizer == "l1_l2": l1_reg = l1 * sum(torch.abs(p).sum() for p in model.parameters()) l2_reg = l2 * sum(torch.square(p).sum() for p in model.parameters()) return l1_reg + l2_reg @DeveloperAPI class LudwigModule(Module): def __init__(self): super().__init__() self._losses = {} self.register_buffer("device_tensor", torch.zeros(0), persistent=False) @property def device(self): return self.device_tensor.device def prepare_for_training(self): """This is called from within the Trainer object to do any final instantiation before model training.""" def losses(self): collected_losses = [] for loss in self._losses.values(): collected_losses.append(loss) for child in self.children(): if isinstance(child, LudwigModule): collected_losses.extend(child.losses()) elif isinstance(child, ModuleDict): for c in child.values(): if hasattr(c, "losses"): # Some modules, i.e. SequenceReducers, don't have losses. collected_losses.extend(c.losses()) elif isinstance(child, Module): pass else: raise ValueError return collected_losses def update_loss(self, key: str, loss: torch.Tensor): """This should be called in the forward pass to add a custom loss term to the combined loss.""" self._losses[key] = loss @property def input_dtype(self): return torch.float32 @property @abstractmethod def input_shape(self) -> torch.Size: """Returns size of the input tensor without the batch dimension.""" # raise NotImplementedError("Abstract class.") @property def output_shape(self) -> torch.Size: """Returns size of the output tensor without the batch dimension.""" return self._computed_output_shape() @lru_cache(maxsize=1) def _computed_output_shape(self) -> torch.Size: dummy_input = torch.rand(2, *self.input_shape, device=self.device) output_tensor = self.forward(dummy_input.type(self.input_dtype)) if isinstance(output_tensor, torch.Tensor): return output_tensor.size()[1:] elif isinstance(output_tensor, dict) and ENCODER_OUTPUT in output_tensor: return output_tensor[ENCODER_OUTPUT].size()[1:] else: raise ValueError("Unknown output tensor type.") def freeze_parameters(module: nn.Module): """Freezes the parameters of a torch module.""" for p in module.parameters(): p.requires_grad = False @DeveloperAPI class FreezeModule(nn.Module): def __init__(self, module: nn.Module, frozen: bool): super().__init__() if frozen: freeze_parameters(module) module.eval() else: module.train() self.module = module self.frozen = frozen def train(self, mode: bool = True): if self.frozen: # Ignores any attempt to set params trainable return self return super().train(mode) @DeveloperAPI class Dense(LudwigModule): def __init__( self, input_size, output_size, use_bias=True, weights_initializer="xavier_uniform", bias_initializer="zeros", ): super().__init__() self.dense = nn.Linear(in_features=input_size, out_features=output_size, bias=use_bias) weights_initializer = initializer_registry[weights_initializer] weights_initializer(self.dense.weight) if use_bias: bias_initializer = initializer_registry[bias_initializer] bias_initializer(self.dense.bias) @property def input_shape(self) -> torch.Size: return self.dense.input_shape def forward(self, input: torch.Tensor) -> torch.Tensor: output = torch.squeeze(self.dense(input), dim=-1) return output @DeveloperAPI def initialize_pytorch( gpus: int | str | list[int] | None = None, gpu_memory_limit: float | None = None, allow_parallel_threads: bool = True, ): param_tuple = (gpus, gpu_memory_limit, allow_parallel_threads) if _TORCH_INIT_PARAMS is not None: if _TORCH_INIT_PARAMS != param_tuple: warnings.warn( "PyTorch has already been initialized. Changes to `gpus`, " "`gpu_memory_limit`, and `allow_parallel_threads` will be ignored. " "Start a new Python process to modify these values." ) return # For reproducivility / determinism, set parallel threads to 1. # For performance, leave unset to allow PyTorch to select the best value automatically. if not allow_parallel_threads: torch.set_num_threads(1) torch.set_num_interop_threads(1) if torch.cuda.is_available() and torch.cuda.device_count() > 0: torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False if isinstance(gpus, int): gpus = [gpus] elif isinstance(gpus, str): gpus = gpus.strip() gpus = [int(g) for g in gpus.split(",")] if gpus and len(gpus) == 1 and gpus[0] == -1: # CUDA_VISIBLE_DEVICES syntax for disabling all GPUs os.environ["CUDA_VISIBLE_DEVICES"] = "" elif torch.cuda.is_available() and torch.cuda.device_count() > 0: # Set visible devices so GPU utilization is isolated # (no GPU contention between workers). if gpus is not None: if len(gpus) == 1: torch.cuda.set_device(gpus[0]) elif len(gpus) > 1: os.environ["CUDA_VISIBLE_DEVICES"] = ",".join(str(i) for i in gpus) # Limit the amount of memory that can be consumed per GPU if gpu_memory_limit is not None: for gpu in gpus or range(torch.cuda.device_count()): torch.cuda.memory.set_per_process_memory_fraction(gpu_memory_limit, gpu) _set_torch_init_params(param_tuple) def _set_torch_init_params(params: tuple | None): global _TORCH_INIT_PARAMS _TORCH_INIT_PARAMS = params def _get_torch_init_params() -> tuple | None: return _TORCH_INIT_PARAMS @DeveloperAPI def model_size(model: nn.Module): """Computes PyTorch model size in bytes.""" size = 0 size += sum(param.nelement() * param.element_size() for param in model.parameters()) size += sum(buffer.nelement() * buffer.element_size() for buffer in model.buffers()) return size ================================================ FILE: ludwig/utils/trainer_utils.py ================================================ import logging import re from collections import defaultdict from typing import TYPE_CHECKING try: from typing import Literal except ImportError: from typing import Literal from ludwig.api_annotations import DeveloperAPI from ludwig.constants import AUTO, COMBINED, LOSS from ludwig.models.base import BaseModel from ludwig.models.ecd import ECD from ludwig.models.llm import LLM from ludwig.modules.metric_modules import get_best_function from ludwig.schema.trainer import ECDTrainerConfig, FineTuneTrainerConfig from ludwig.utils.data_utils import save_json from ludwig.utils.metric_utils import TrainerMetric if TYPE_CHECKING: from ludwig.features.base_feature import OutputFeature from ludwig.schema.trainer import BaseTrainerConfig logger = logging.getLogger(__name__) @DeveloperAPI def initialize_trainer_metric_dict(output_features) -> dict[str, dict[str, list[TrainerMetric]]]: """Returns a dict of dict of metrics, output_feature_name -> metric_name -> List[TrainerMetric].""" metrics = defaultdict(lambda: defaultdict(list)) return metrics def get_latest_metrics_dict( progress_tracker_metrics: dict[str, dict[str, list[TrainerMetric]]], ) -> dict[str, dict[str, float]]: """Returns a dict of field name -> metric name -> latest metric value.""" latest_metrics_dict = defaultdict(dict) for feature_name, metrics_dict in progress_tracker_metrics.items(): for metric_name, metrics in metrics_dict.items(): if metrics: # Metrics may be missing if computing metrics was excepted, if the metrics are entirely empty # due to a missing subset, or if evaluate_training_set is False. latest_metrics_dict[feature_name][metric_name] = metrics[-1][-1] return latest_metrics_dict @DeveloperAPI def get_new_progress_tracker( batch_size: int, best_eval_metric_value: float, best_increase_batch_size_eval_metric: float, learning_rate: float, output_features: dict[str, "OutputFeature"], ): """Returns a new instance of a ProgressTracker with empty metrics.""" return ProgressTracker( epoch=0, batch_size=batch_size, steps=0, tune_checkpoint_num=0, checkpoint_number=0, best_eval_metric_steps=0, best_eval_metric_epoch=0, best_eval_metric_checkpoint_number=0, last_learning_rate_reduction_steps=0, last_increase_batch_size_steps=0, last_improvement_steps=0, best_eval_metric_value=best_eval_metric_value, best_increase_batch_size_eval_metric=best_increase_batch_size_eval_metric, last_increase_batch_size_eval_metric_improvement=0, learning_rate=learning_rate, num_reductions_learning_rate=0, num_increases_batch_size=0, train_metrics=initialize_trainer_metric_dict(output_features), validation_metrics=initialize_trainer_metric_dict(output_features), test_metrics=initialize_trainer_metric_dict(output_features), last_learning_rate_reduction=0, last_increase_batch_size=0, best_eval_train_metrics={}, best_eval_validation_metrics={}, best_eval_test_metrics={}, llm_eval_examples={}, checkpoint_to_step={}, checkpoint_to_epoch={}, incremental_step_token_usage={}, cumulative_step_token_usage={}, incremental_checkpoint_token_usage={}, cumulative_checkpoint_token_usage={}, total_tokens_used=0, ) @DeveloperAPI class ProgressTracker: def __init__( self, epoch: int, batch_size: int, steps: int, tune_checkpoint_num: int, checkpoint_number: int, best_eval_metric_steps: int, best_eval_metric_epoch: int, best_eval_metric_checkpoint_number: int, last_improvement_steps: int, last_learning_rate_reduction_steps: int, last_increase_batch_size_steps: int, best_eval_metric_value: float, best_increase_batch_size_eval_metric: float, last_increase_batch_size_eval_metric_improvement: int, learning_rate: float, num_reductions_learning_rate: int, num_increases_batch_size: int, train_metrics: dict[str, dict[str, list[TrainerMetric]]], validation_metrics: dict[str, dict[str, list[TrainerMetric]]], test_metrics: dict[str, dict[str, list[TrainerMetric]]], last_learning_rate_reduction: int, last_increase_batch_size: int, best_eval_train_metrics: dict[str, dict[str, float]], best_eval_validation_metrics: dict[str, dict[str, float]], best_eval_test_metrics: dict[str, dict[str, float]], llm_eval_examples: dict[str, list[str]] = None, checkpoint_to_step: dict[str, int] = None, checkpoint_to_epoch: dict[str, int] = None, incremental_step_token_usage: dict[str, int] = None, cumulative_step_token_usage: dict[str, int] = None, incremental_checkpoint_token_usage: dict[str, int] = None, cumulative_checkpoint_token_usage: dict[str, int] = None, total_tokens_used: int = 0, ): """JSON-serializable holder object that stores information related to training progress. [train/vali/test]_metrics is a nested dictionary of TrainerMetrics: feature_name -> metric_name -> List[TrainerMetrics], with one entry per training checkpoint. When the model is saved, all of the progress tracker's attributes are serialized to JSON as `training_progress.json` under the model output directory. JSON serialization automatically converts all dictionary top-level keys to strings, and the string typing is preserved when the progress tracker is deserialized from JSON when model resumes training from a checkpoint. For this reason, all of the dictionary attributes of the progress tracker are keyed by strings to ensure a consistent interface before or after deserialization. For example, the `tokens` dictionaries are keyed by steps, as strings. When the progress tracker is deserialized from JSON like when a model resumes training from a checkpoint, the TrainerMetrics namedtuples are automatically converted into regular (epoch, steps, value) tuples, which is why in trainer.py, we often use `[-1]` to index into the last element of the TrainerMetric namedtuple to get the actual metric value instead of the named field. Args: epoch: The current epoch number. steps: The current step of training. batch_size: The current batch size. tune_checkpoint_num: The hyperopt checkpoint number (Ray Tune). checkpoint_number: The current checkpoint number. best_eval_metric_steps: The step of training that has the best evaluation so far. best_eval_metric_epoch: The epoch of training that has the best evaluation so far. best_eval_metric_checkpoint_number: The checkpoint number that has the best evaluation so far. last_improvement_steps: The number of steps since the last improvement. last_learning_rate_reduction_steps: The training step of the last learning rate reduction. last_increase_batch_size_steps: The training_step of the the last batch size increase. best_eval_metric_value: The metric value of the best evaluation so far. best_increase_batch_size_eval_metric: The metric value of the best evaluation so far, for increasing the batch size. last_learning_rate_reduction: The number of steps since the last learning rate reduction. last_increase_batch_size: The number of steps since the last batch size increase. last_increase_batch_size_eval_metric_improvement: The number of checkpoints since the last batch size increase. num_reductions_learning_rate: The number of total reductions in learning rate. num_increases_batch_size: The number of total increases in batch size. train_metrics: Training metrics. -> -> History of metrics. validation_metrics: Validation metrics. -> -> History of metrics. test_metrics: Test metrics. -> -> History of metrics. best_eval_train_metrics: Best eval train metrics: -> -> . best_eval_validation_metrics: Best eval validation metrics: -> -> . best_eval_test_metrics: Best eval test metrics: -> -> . llm_eval_examples: Dictionary whose keys are "inputs", "targets", and "outputs" and whose values are dicts. The keys of each subdict are the names of the input/target/output features and the values are lists of example tensors. This is only set for LLM fine-tuning. checkpoint_to_step: Map of checkpoint number to step number. checkpoint_to_epoch: Map of checkpoint number to epoch number. incremental_step_token_usage: Map of step number to number of tokens used in that step. cumulative_step_token_usage: Map of step number to cumulative number of tokens used up to that step. incremental_checkpoint_token_usage: Map of checkpoint number to number of tokens used up to that checkpoint since the last checkpoint. cumulative_checkpoint_token_usage: Map of checkpoint number to cumulative number of tokens used up to that checkpoint. total_tokens_used: Total number of tokens used. """ self.batch_size = batch_size self.epoch = epoch self.steps = steps self.tune_checkpoint_num = tune_checkpoint_num self.checkpoint_number = checkpoint_number self.best_eval_metric_steps = best_eval_metric_steps self.best_eval_metric_epoch = best_eval_metric_epoch self.best_eval_metric_checkpoint_number = best_eval_metric_checkpoint_number self.last_improvement_steps = last_improvement_steps self.last_learning_rate_reduction_steps = last_learning_rate_reduction_steps self.last_learning_rate_reduction = last_learning_rate_reduction self.last_increase_batch_size_steps = last_increase_batch_size_steps self.last_increase_batch_size = last_increase_batch_size self.learning_rate = learning_rate self.best_eval_metric_value = best_eval_metric_value self.best_increase_batch_size_eval_metric = best_increase_batch_size_eval_metric self.last_increase_batch_size_eval_metric_improvement = last_increase_batch_size_eval_metric_improvement self.num_reductions_learning_rate = num_reductions_learning_rate self.num_increases_batch_size = num_increases_batch_size self.train_metrics = train_metrics self.validation_metrics = validation_metrics self.test_metrics = test_metrics # This should be an dictionary whose keys are "inputs", "targets", and "outputs" and whose values are dicts. # The keys of each subdict are the names of the input/target/output features and the values are lists of # example tensors. This is only set for LLM fine-tuning. self.llm_eval_examples = llm_eval_examples # Best metrics. self.best_eval_train_metrics = best_eval_train_metrics self.best_eval_validation_metrics = best_eval_validation_metrics self.best_eval_test_metrics = best_eval_test_metrics # Checkpoint tracking. self.checkpoint_to_step = checkpoint_to_step self.checkpoint_to_epoch = checkpoint_to_epoch # Token usage. self.incremental_step_token_usage = incremental_step_token_usage self.cumulative_step_token_usage = cumulative_step_token_usage self.incremental_checkpoint_token_usage = incremental_checkpoint_token_usage self.cumulative_checkpoint_token_usage = cumulative_checkpoint_token_usage self.total_tokens_used = total_tokens_used def save(self, filepath): # sort_keys=False to ensure that token usage dictionaries (keyed by integers) are encodable. # save_json(filepath, self.__dict__, sort_keys=False) save_json(filepath, self.__dict__) @staticmethod def load(progress_tracking_dict: dict): from ludwig.utils.backward_compatibility import upgrade_model_progress loaded = upgrade_model_progress(progress_tracking_dict) return ProgressTracker(**loaded) def log_metrics(self): log_metrics = { "batch_size": self.batch_size, "epoch": self.epoch, "steps": self.steps, "tune_checkpoint_num": self.tune_checkpoint_num, "checkpoint_number": self.checkpoint_number, "last_improvement_steps": self.last_improvement_steps, "best_eval_metric_steps": self.best_eval_metric_steps, "best_eval_metric_epoch": self.best_eval_metric_epoch, "best_eval_metric_checkpoint_number": self.best_eval_metric_checkpoint_number, "learning_rate": self.learning_rate, "best_valid_metric": self.best_eval_metric_value, "num_reductions_lr": self.num_reductions_learning_rate, "num_increases_bs": self.num_increases_batch_size, "total_tokens_used": self.total_tokens_used, } # This is a non-numerical metric that is only for LLM fine-tuning # This should be an dictionary whose keys are "inputs", "targets", and "outputs" and whose values are dicts. # The keys of each subdict are the names of the input/target/output features and the values are lists of # example tensors. if self.llm_eval_examples: log_metrics["llm_eval_examples"] = self.llm_eval_examples for metrics_dict_name in [ "train_metrics", "validation_metrics", "test_metrics", ]: metrics_dict = getattr(self, metrics_dict_name) for feature_name in metrics_dict: for metric_name, metrics_tuples in metrics_dict[feature_name].items(): if metrics_tuples: # For logging, get the latest metrics. The second "-1" indexes into the TrainerMetric # namedtuple. The last element of the TrainerMetric namedtuple is the actual metric value. # # TODO: when loading an existing model, this loses metric values for all but the last epoch. log_metrics[f"{metrics_dict_name}.{feature_name}.{metric_name}"] = metrics_tuples[-1][-1] # Add best metrics. for feature_name, metrics in self.best_eval_train_metrics.items(): for metric_name, metric_value in metrics.items(): log_metrics[f"best.train_metrics.{feature_name}.{metric_name}"] = metric_value for feature_name, metrics in self.best_eval_validation_metrics.items(): for metric_name, metric_value in metrics.items(): log_metrics[f"best.validation_metrics.{feature_name}.{metric_name}"] = metric_value for feature_name, metrics in self.best_eval_test_metrics.items(): for metric_name, metric_value in metrics.items(): log_metrics[f"best.test_metrics.{feature_name}.{metric_name}"] = metric_value return log_metrics def _add_checkpoint_entry_for_used_tokens(self, checkpoint_number: int): """Adds an entry to the token usage dictionaries for the given checkpoint number. Assumes that the token usage dictionaries for steps are filled. """ self.cumulative_checkpoint_token_usage[str(checkpoint_number)] = self.total_tokens_used if checkpoint_number <= 0: raise ValueError("Checkpoint number should be greater than 0.") if checkpoint_number == 1: # The incremental token usage for checkpoint 0 is the same as the total tokens used so far. self.incremental_checkpoint_token_usage[str(checkpoint_number)] = self.total_tokens_used else: # The incremental token usage for this checkpoint is the total tokens used minus the cumulative tokens used # up to the previous checkpoint. previous_checkpoint_number = checkpoint_number - 1 tokens_used_since_previous_checkpoint = ( self.total_tokens_used - self.cumulative_checkpoint_token_usage[str(previous_checkpoint_number)] ) self.incremental_checkpoint_token_usage[str(checkpoint_number)] = tokens_used_since_previous_checkpoint def increment_checkpoint(self): """Update the progress tracker for a new checkpoint.""" self.checkpoint_number += 1 # Set checkpoint -> step/epoch lookup maps. self.checkpoint_to_step[str(self.checkpoint_number)] = self.steps self.checkpoint_to_epoch[str(self.checkpoint_number)] = self.epoch # Set checkpoint -> used tokens lookup maps. self._add_checkpoint_entry_for_used_tokens(self.checkpoint_number) def set_token_usage_for_this_step(self, used_tokens: int): """Update the token usage for the current step.""" steps_str = str(self.steps) self.incremental_step_token_usage[steps_str] = used_tokens self.total_tokens_used += used_tokens self.cumulative_step_token_usage[steps_str] = self.total_tokens_used @DeveloperAPI def append_metrics( model: BaseModel, dataset_name: Literal["train", "validation", "test"], results: dict[str, dict[str, float]], metrics_log: dict[str, dict[str, list[TrainerMetric]]], progress_tracker: ProgressTracker, ) -> dict[str, dict[str, list[TrainerMetric]]]: epoch = progress_tracker.epoch steps = progress_tracker.steps for output_feature in model.output_features: scores = [dataset_name] # collect metric names based on output features metrics to # ensure consistent order of reporting metrics metric_names = sorted(results[output_feature].keys()) for metric in metric_names: if metric in results[output_feature]: # Some metrics may have been excepted and excluded from results. score = results[output_feature][metric] metrics_log[output_feature][metric].append(TrainerMetric(epoch=epoch, step=steps, value=score)) scores.append(score) metrics_log[COMBINED][LOSS].append(TrainerMetric(epoch=epoch, step=steps, value=results[COMBINED][LOSS])) return metrics_log @DeveloperAPI def get_total_steps(epochs: int, steps_per_epoch: int, train_steps: int): """Returns train_steps if provided, otherwise epochs * steps_per_epoch.""" if train_steps: return train_steps return epochs * steps_per_epoch @DeveloperAPI def get_final_steps_per_checkpoint( steps_per_epoch: int, steps_per_checkpoint: int = 0, checkpoints_per_epoch: float = 0, should_log: bool = False ): """Returns the steps per checkpoint to use for the training loop, given user+default inputs.""" if steps_per_checkpoint != 0 and checkpoints_per_epoch != 0: raise ValueError( "It is invalid to specify both checkpoints_per_epoch AND steps_per_checkpoint. Please specify one or the " "other, or specify neither to checkpoint/eval the model every epoch." ) # Set steps_per_checkpoint based on the checkpoints_per_epoch, if checkpoints_per_epoch was specified. if checkpoints_per_epoch != 0: steps_per_checkpoint = int(steps_per_epoch / checkpoints_per_epoch) # Cap steps_per_checkpoint at steps_per_epoch. if steps_per_checkpoint > steps_per_epoch: if should_log: logger.info( f"Note: steps_per_checkpoint (was {steps_per_checkpoint}) is now set to the number of " f"steps per epoch: {steps_per_epoch}.\n" ) return steps_per_epoch # steps_per_checkpoint wasn't specified. Use steps_per_epoch. if steps_per_checkpoint == 0: return steps_per_epoch return steps_per_checkpoint def get_total_expected_checkpoints(total_steps: int, final_steps_per_checkpoint: int, epochs: int) -> int: return total_steps // final_steps_per_checkpoint + epochs @DeveloperAPI def get_training_report( validation_field: str, validation_metric: str, include_test_set: bool, train_valiset_stats: dict[str, dict[str, list[float]]], train_testset_stats: dict[str, dict[str, list[float]]], ) -> list[tuple[str, str]]: """Returns a training report in the form of a list [(report item, value)].""" validation_field_result = train_valiset_stats[validation_field] best_function = get_best_function(validation_metric) training_report = [] best_vali_index, ( epoch_best_validation_metric, step_best_validation_metric, best_validation_metric, ) = best_function( enumerate(validation_field_result[validation_metric]), # -1 for the last element of the TrainerMetric namedtuple. key=lambda index_epoch_step_value: index_epoch_step_value[1][-1], ) training_report.append(["Validation feature", validation_field]) training_report.append(["Validation metric", validation_metric]) training_report.append(["Best model step", step_best_validation_metric]) training_report.append(["Best model epoch", epoch_best_validation_metric + 1]) training_report.append( [ f"Best model's validation {validation_metric}", best_validation_metric, ] ) if include_test_set: validation_selected_test_metric_score = train_testset_stats[validation_field][validation_metric][ best_vali_index ][ -1 ] # -1 for the last element of the TrainerMetric namedtuple. training_report.append( [ f"Best model's test {validation_metric}", validation_selected_test_metric_score, ] ) return training_report def get_rendered_batch_size_grad_accum(config: "BaseTrainerConfig", num_workers: int) -> tuple[int, int]: """Returns the batch size and gradient accumulation steps to use for training. For batch_size==AUTO: 1. effective_batch_size is not AUTO and gradient_accumulation_steps is not AUTO: batch size is set to the effective batch size divided by the gradient accumulation steps, divided by the number of workers. 2. effective_batch_size is AUTO or gradient_accumulation_steps is AUTO: batch size remains AUTO. For gradient_accumulation_steps==AUTO: 1. batch size is AUTO: gradient accumulation steps remains AUTO. 2. batch_size is not AUTO and effective batch size is not AUTO: gradient accumulation steps is set to the effective batch size divided by the batch size, divided by the number of workers. 3. batch size is not AUTO and effective batch size is AUTO: gradient accumulation steps is set to 1. """ effective_batch_size = config.effective_batch_size batch_size = config.batch_size gradient_accumulation_steps = config.gradient_accumulation_steps if config.batch_size == AUTO: if config.effective_batch_size != AUTO and config.gradient_accumulation_steps != AUTO: batch_size = max(int(effective_batch_size / gradient_accumulation_steps / num_workers), 1) if config.gradient_accumulation_steps == AUTO: if config.batch_size != AUTO: if config.effective_batch_size != AUTO: gradient_accumulation_steps = max(int(effective_batch_size / batch_size / num_workers), 1) else: gradient_accumulation_steps = 1 return batch_size, gradient_accumulation_steps def freeze_layers_regex(config: ECDTrainerConfig | FineTuneTrainerConfig, model: ECD | LLM) -> None: """Freezes layers in a model whose names match a specified regular expression pattern. This function iterates over all parameters of the model, checking each parameter's name against the regular expression defined in the configuration object. If a match is found, the parameter's `requires_grad` attribute is set to False, effectively freezing the layer for training purposes. If no matches are found, an error is logged indicating the issue with the regex or the model's layer names. Parameters: - config (Union[ECDTrainerConfig, FineTuneTrainerConfig]): - model (Union[ECD, LLM]): The model object containing layers and parameters. This could be an instance of either ECD or LLM classes, which should have a method `named_parameters()` that yields the name and parameter object of each layer. Raises: - re.error: If the regular expression pattern in `config.layers_to_freeze_regex` is invalid, an error is logged and the function exits. Returns: - None: This function does not return any value but modifies the model in-place by freezing certain layers. """ pattern = re.compile(config.layers_to_freeze_regex) matched_layers = set() for name, p in model.named_parameters(): if re.search(pattern, str(name)): p.requires_grad = False matched_layers.add(name) if matched_layers: logger.info(f"Layers where requires_grad was set to False: {matched_layers}") else: logger.error(f"No regex match for {config.layers_to_freeze_regex}! Check layer names and regex syntax.") count_parameters(model) def count_parameters(model) -> None: """Counts number of trainable parameters post freezing. Returns: - None: This function does not return any value. """ total_params = 0 for _, parameter in model.named_parameters(): if not parameter.requires_grad: continue params = parameter.numel() total_params += params logger.info(f"Total Trainable Parameters after freezing: {total_params}") ================================================ FILE: ludwig/utils/triton_utils.py ================================================ import importlib.util import os import re import shutil import tempfile from dataclasses import dataclass import pandas as pd import torch from ludwig.api import LudwigModel from ludwig.api_annotations import DeveloperAPI from ludwig.constants import ( AUDIO, BAG, BINARY, CATEGORY, DATE, IMAGE, INPUT_FEATURES, POSTPROCESSOR, PREDICTOR, PREPROCESSOR, SEQUENCE, SET, TEXT, TIMESERIES, TYPE, VECTOR, ) from ludwig.data.dataset_synthesizer import build_synthetic_dataset from ludwig.models.inference import ( _InferencePostprocessor, _InferencePredictor, _InferencePreprocessor, InferenceModule, ) from ludwig.types import ModelConfigDict from ludwig.utils.inference_utils import to_inference_module_input_from_dataframe from ludwig.utils.misc_utils import remove_empty_lines from ludwig.utils.torch_utils import model_size, place_on_device from ludwig.utils.types import TorchAudioTuple, TorchscriptPreprocessingInput FEATURES_TO_CAST_AS_STRINGS = {BINARY, CATEGORY, BAG, SET, TEXT, SEQUENCE, TIMESERIES, VECTOR} INFERENCE_STAGES = [PREPROCESSOR, PREDICTOR, POSTPROCESSOR] INPUT = "INPUT" OUTPUT = "OUTPUT" ENSEMBLE = "ensemble" INFERENCE_MODULE_TEMPLATE = """ from typing import Any, Dict, List, Union import torch from ludwig.utils.types import TorchscriptPreprocessingInput class GeneratedInferenceModule(torch.nn.Module): def __init__(self, inference_module): super().__init__() self.inference_module = inference_module def forward(self, {input_signature}): with torch.no_grad(): inputs: Dict[str, {input_type}] = {input_dict} results = self.inference_module(inputs) return {output_tuple} """ FEATURE_RESHAPE_SPEC = """reshape: {{ shape: [ {reshape_dims} ] }} """ TRITON_SPEC = """ {{ name: "{key}" data_type: {data_type} dims: [ {data_dims} ] {reshape_spec} }}""" INSTANCE_SPEC = """ {{ count: {count} kind: {kind} }}""" DYNAMIC_BATCHING_TEMPLATE = """dynamic_batching {{ max_queue_delay_microseconds: {delay} }}""" TRITON_CONFIG_TEMPLATE = """name: "{model_name}" platform: "pytorch_libtorch" max_batch_size: {max_batch_size} {dynamic_batching_spec} input [{input_spec} ] output [{output_spec} ] instance_group [{instance_spec} ] """ ENSEMBLE_SCHEDULING_INPUT_MAP = """ input_map {{ key: "{key}" value: "{value}" }}""" ENSEMBLE_SCHEDULING_OUTPUT_MAP = """ output_map {{ key: "{key}" value: "{value}" }}""" ENSEMBLE_SCHEDULING_STEP = """ {{ model_name: "{ensemble_model_name}" model_version: -1 {input_maps} {output_maps} }}""" TRITON_ENSEMBLE_CONFIG_TEMPLATE = """name: "{model_name}" platform: "ensemble" max_batch_size: 0 input [{input_spec} ] output [{output_spec} ] ensemble_scheduling {{ step [{ensemble_scheduling_steps} ] }} """ def _get_type_map(dtype: str) -> str: """Return the Triton API type mapped to numpy type.""" # see: https://github.com/triton-inference-server/server/blob/main/docs/model_configuration.md return { "bool": "TYPE_BOOL", "uint8": "TYPE_UINT8", "uint16": "TYPE_UINT16", "uint32": "TYPE_UINT32", "uint64": "TYPE_UINT64", "int8": "TYPE_INT8", "int16": "TYPE_INT16", "int32": "TYPE_INT32", "int64": "TYPE_INT64", "float16": "TYPE_FP16", "float32": "TYPE_FP32", "float64": "TYPE_FP64", "string": "TYPE_STRING", "torch.float32": "TYPE_FP32", "torch.float": "TYPE_FP32", "torch.float64": "TYPE_FP64", "torch.double": "TYPE_FP64", "torch.float16": "TYPE_FP16", "torch.half": "TYPE_FP16", "torch.uint8": "TYPE_UINT8", "torch.int8": "TYPE_INT8", "torch.int16": "TYPE_INT16", "torch.short": "TYPE_INT16", "torch.int32": "TYPE_INT32", "torch.int": "TYPE_INT32", "torch.int64": "TYPE_INT64", "torch.long": "TYPE_INT64", "torch.bool": "TYPE_BOOL", }[dtype] def to_triton_dimension(content: list[str] | list[torch.Tensor] | list[TorchAudioTuple] | torch.Tensor): # todo (Wael): tests for all types. if isinstance(content, list) and content: if isinstance(content[0], str): return [len(content)] elif isinstance(content, torch.Tensor): return list(content.size()) return [-1] def to_triton_type(content: list[str] | list[torch.Tensor] | list[TorchAudioTuple] | torch.Tensor): # todo (Wael): tests for all types. if isinstance(content, list) and content: if isinstance(content[0], str): return _get_type_map("string") elif isinstance(content, torch.Tensor): return _get_type_map(str(content.dtype)) @DeveloperAPI @dataclass class TritonArtifact: """Dataclass for exported Triton artifacts.""" # Name of the model. model_name: str # Model version. model_version: int | str # Triton backend (e.g. "pytorch_libtorch"). platform: str # Model path. path: str # Type of artifact (application/octet-stream, plain text, etc.) content_type: str # Size of the artifact in bytes. content_length: int @DeveloperAPI @dataclass class TritonConfigFeature: """Represents an input/output feature in a Triton config.""" # Name of the feature. name: str # Ludwig type of the feature, or "tensor" ludwig_type: str # The data contents of the feature. content: TorchscriptPreprocessingInput | torch.Tensor # One of PREPROCESSOR, PREDICTOR, POSTPROCESSOR. inference_stage: str # One of INPUT, OUTPUT. kind: str # Index of the feature in the Triton Config. index: int def __post_init__(self): # removing non-alphanumeric characters as this will go in the wrapper function header. self.wrapper_signature_name = re.sub(r"[\W]+", "_", self.name) # get Triton type self.type = to_triton_type(self.content) # get dimension self.dimension = to_triton_dimension(self.content) # get ensemble_scheduling output_map key (same as "name" in input/output) self.key = f"{self.kind}__{self.index}" self.value = self._get_feature_ensemble_value() def _get_feature_ensemble_value(self) -> str: # get ensemble_scheduling output_map value. if self.inference_stage == PREPROCESSOR and self.kind == INPUT: return self.name if self.inference_stage == PREDICTOR and self.kind == INPUT: # PREPROCESSOR outputs and PREDICTOR inputs must have the same "value" attribute. return f"{PREPROCESSOR}_{OUTPUT}_{self.index}" elif self.inference_stage == POSTPROCESSOR and self.kind == INPUT: # PREDICTOR outputs and POSTPROCESSOR inputs must have the same "value" attribute. return f"{PREDICTOR}_{OUTPUT}_{self.index}" elif self.inference_stage == POSTPROCESSOR and self.kind == OUTPUT: return self.name else: return f"{self.inference_stage}_{self.kind}_{self.index}" def _get_wrapper_signature_type(self) -> str: if self.ludwig_type in FEATURES_TO_CAST_AS_STRINGS: return "List[str]" elif self.ludwig_type in [IMAGE, AUDIO, DATE, "tensor"]: return { IMAGE: "List[torch.Tensor]", AUDIO: "TorchAudioTuple", DATE: "List[torch.Tensor]", "tensor": "torch.Tensor", }[self.ludwig_type] return "torch.Tensor" @DeveloperAPI @dataclass class TritonMaster: """Provides access to the Triton Config and the scripted module.""" # The inference module. module: _InferencePreprocessor | _InferencePredictor | _InferencePostprocessor # An input for the module that will help determine the input and output dimensions. input_data_example: dict[str, TorchscriptPreprocessingInput | torch.Tensor] # One of PREPROCESSOR, PREDICTOR, POSTPROCESSOR. inference_stage: str # Max batch size of the model. (Triton config param). max_batch_size: int # Max time a request to the Triton model spends in the queue. (Triton config param). max_queue_delay_microseconds: int # Name of the model as specified by the caller of the triton_export function. model_name: str # Base output directory. Corresponds to the Triton model registry. output_path: str # Triton model version. model_version: int | str # Ludwig config. ludwig_config: ModelConfigDict # One of "cpu", "cuda". device: str # Number of model instances on device. model_instance_count: int def __post_init__(self): """Extract input and output features and necessary information for a Triton config.""" if self.inference_stage not in INFERENCE_STAGES: raise ValueError(f"Invalid inference stage. Choose one of {INFERENCE_STAGES}") self.full_model_name = self.model_name + "_" + self.inference_stage self.base_path = os.path.join(self.output_path, self.full_model_name) os.makedirs(self.base_path, exist_ok=True) self.output_data_example: dict[str, TorchscriptPreprocessingInput | torch.Tensor] = self.module( self.input_data_example ) # generate input and output features. self.input_features: list[TritonConfigFeature] = [] for i, (feature_name, content) in enumerate(self.input_data_example.items()): ludwig_type = "tensor" if self.inference_stage == PREPROCESSOR: ludwig_type = self.ludwig_config[INPUT_FEATURES][i][TYPE] self.input_features.append( TritonConfigFeature(feature_name, ludwig_type, content, self.inference_stage, INPUT, i) ) self.output_features: list[TritonConfigFeature] = [] for i, (feature_name, content) in enumerate(self.output_data_example.items()): ludwig_type = "tensor" self.output_features.append( TritonConfigFeature(feature_name, ludwig_type, content, self.inference_stage, OUTPUT, i) ) def save_model(self) -> TritonArtifact: """Script the model and saves it. Return the appropriate artifact. """ if isinstance(self.model_version, str) and self.model_version.isdigit(): self.model_version = int(self.model_version) if not isinstance(self.model_version, int) or self.model_version < 1: raise ValueError("Model version has to be a non-zero positive integer") # wrapper.py is optional and is just for visualizing the inputs/outputs to the model exported to Triton. wrapper_definition = TritonModel( self.module, self.input_features, self.output_features, self.inference_stage ).generate_inference_module_wrapper() with open(os.path.join(self.base_path, "wrapper.py"), "w") as f: f.write(wrapper_definition) os.makedirs(os.path.join(self.base_path, str(self.model_version)), exist_ok=True) model_path = os.path.join(self.base_path, str(self.model_version), "model.pt") self.model_ts = TritonModel( self.module, self.input_features, self.output_features, self.inference_stage ).generate_scripted_module() self.model_ts.save(model_path) model_artifact = TritonArtifact( model_name=self.full_model_name, model_version=self.model_version, platform="pytorch_libtorch", path=model_path, content_type="application/octet-stream", content_length=model_size(self.model_ts), ) return model_artifact def save_config(self) -> TritonArtifact: """Save the Triton config. Return the appropriate artifact. """ device = self.device if self.inference_stage != PREDICTOR: device = "cpu" self.config = TritonConfig( self.full_model_name, self.input_features, self.output_features, self.max_batch_size, self.max_queue_delay_microseconds, device, self.model_instance_count, self.inference_stage, ) config_path = os.path.join(self.base_path, "config.pbtxt") with open(config_path, "w") as f: formatted_config = remove_empty_lines(self.config.get_model_config()) f.write(formatted_config) config_artifact = TritonArtifact( model_name=self.full_model_name, model_version=self.model_version, platform="pytorch_libtorch", path=config_path, content_type="text/x-protobuf", content_length=os.path.getsize(config_path), ) return config_artifact @DeveloperAPI @dataclass class TritonEnsembleConfig: """Dataclass for creating and saving the Triton ensemble config.""" # TritonMaster object for the preprocessor. triton_master_preprocessor: TritonMaster # TritonMaster object for the predictor. triton_master_predictor: TritonMaster # TritonMaster object for the postprocessor. triton_master_postprocessor: TritonMaster # Name of the model as specified by the caller of the triton_export function. model_name: str # Base output directory. Corresponds to the Triton model registry. output_path: str # Triton model version. model_version: int | str def __post_init__(self): self.ensemble_model_name = self.model_name self.base_path = os.path.join(self.output_path, self.ensemble_model_name) os.makedirs(self.base_path, exist_ok=True) def _get_ensemble_scheduling_input_maps(self, triton_features: list[TritonConfigFeature]) -> str: return "".join( ENSEMBLE_SCHEDULING_INPUT_MAP.format(key=feature.key, value=feature.value) for feature in triton_features ) def _get_ensemble_scheduling_output_maps(self, triton_features: list[TritonConfigFeature]) -> str: return "".join( ENSEMBLE_SCHEDULING_OUTPUT_MAP.format(key=feature.key, value=feature.value) for feature in triton_features ) def _get_ensemble_scheduling_step(self, triton_master: TritonMaster) -> str: return ENSEMBLE_SCHEDULING_STEP.format( ensemble_model_name=triton_master.config.full_model_name, input_maps=self._get_ensemble_scheduling_input_maps(triton_master.input_features), output_maps=self._get_ensemble_scheduling_output_maps(triton_master.output_features), ) def _get_ensemble_spec(self, triton_features: list[TritonConfigFeature]) -> str: spec = [] for feature in triton_features: spec.append( TRITON_SPEC.format( key=feature.value, data_type=feature.type, data_dims=", ".join(str(dim) for dim in feature.dimension), # check correctness reshape_spec="", ) ) return ",".join(spec) def get_config(self) -> str: triton_masters = [ self.triton_master_preprocessor, self.triton_master_predictor, self.triton_master_postprocessor, ] ensemble_scheduling_steps = ",".join( [self._get_ensemble_scheduling_step(triton_master) for triton_master in triton_masters] ) return TRITON_ENSEMBLE_CONFIG_TEMPLATE.format( model_name=self.ensemble_model_name, input_spec=self._get_ensemble_spec(self.triton_master_preprocessor.input_features), output_spec=self._get_ensemble_spec(self.triton_master_postprocessor.output_features), ensemble_scheduling_steps=ensemble_scheduling_steps, ) def save_ensemble_config(self) -> TritonArtifact: config_path = os.path.join(self.base_path, "config.pbtxt") with open(config_path, "w") as f: formatted_config = remove_empty_lines(self.get_config()) f.write(formatted_config) config_artifact = TritonArtifact( model_name=self.ensemble_model_name, model_version=self.model_version, platform="ensemble", path=config_path, content_type="text/x-protobuf", content_length=os.path.getsize(config_path), ) return config_artifact def save_ensemble_dummy_model(self) -> TritonArtifact: """Scripts the model and saves it.""" if isinstance(self.model_version, str) and self.model_version.isdigit(): self.model_version = int(self.model_version) if not isinstance(self.model_version, int) or self.model_version < 1: raise ValueError("Model version has to be a non-zero positive integer") os.makedirs(os.path.join(self.base_path, str(self.model_version)), exist_ok=True) model_path = os.path.join(self.base_path, str(self.model_version), "model.txt") with open(model_path, "w") as f: f.write("no model for the ensemble") model_artifact = TritonArtifact( model_name=self.ensemble_model_name, model_version=self.model_version, platform="ensemble", path=model_path, content_type="text/plain", content_length=os.path.getsize(model_path), ) return model_artifact @DeveloperAPI @dataclass class TritonConfig: """Enables the creation and export of a Triton config.""" # Name of the model. Must be the same as the directory where the config is saved. full_model_name: str # Input features of the model. input_features: list[TritonConfigFeature] # Output features of the model. output_features: list[TritonConfigFeature] # Max batch size of the model. (Triton config param). max_batch_size: int # Max time a request to the Triton model spends in the queue. (Triton config param). max_queue_delay_microseconds: int # One of "cpu", "cuda". device: str # Number of model instances on device. model_instance_count: int # One of PREPROCESSOR, PREDICTOR, POSTPROCESSOR. inference_stage: str def _get_triton_spec(self, triton_features: list[TritonConfigFeature]) -> str: spec = [] for feature in triton_features: spec.append( TRITON_SPEC.format( key=feature.key, data_type=feature.type, data_dims=", ".join(str(dim) for dim in feature.dimension), # check correctness reshape_spec=self._get_reshape_spec(feature), ) ) return ",".join(spec) def _get_reshape_spec(self, feature) -> str: if feature.kind == INPUT and self.inference_stage == PREDICTOR: return FEATURE_RESHAPE_SPEC.format(reshape_dims=", ".join(str(dim) for dim in feature.dimension[1:])) return "" def _get_instance_spec(self) -> str: if self.device == "cpu": kind = "KIND_CPU" else: kind = "KIND_GPU" spec = INSTANCE_SPEC.format(count=self.model_instance_count, kind=kind) return spec def _get_dynamic_batching_spec(self) -> str: if self.inference_stage == PREDICTOR: return DYNAMIC_BATCHING_TEMPLATE.format(delay=self.max_queue_delay_microseconds) return "" def get_model_config(self) -> str: """Generate a Triton config for a model from the input and output features.""" max_batch_size = self.max_batch_size if self.inference_stage != PREDICTOR: max_batch_size = 0 config = TRITON_CONFIG_TEMPLATE.format( model_name=self.full_model_name, max_batch_size=max_batch_size, dynamic_batching_spec=self._get_dynamic_batching_spec(), input_spec=self._get_triton_spec(self.input_features), output_spec=self._get_triton_spec(self.output_features), instance_spec=self._get_instance_spec(), ) return config @DeveloperAPI @dataclass class TritonModel: """Enables the scripting and export of a model.""" # The inference module. module: _InferencePreprocessor | _InferencePredictor | _InferencePostprocessor # Input features of the model. input_features: list[TritonConfigFeature] # Output features of the model. output_features: list[TritonConfigFeature] # One of PREPROCESSOR, PREDICTOR, POSTPROCESSOR. inference_stage: str def _get_dict_type_hint(self) -> str: return { PREPROCESSOR: "TorchscriptPreprocessingInput", PREDICTOR: "torch.Tensor", POSTPROCESSOR: "torch.Tensor", }[self.inference_stage] def _get_input_signature(self, triton_features: list[TritonConfigFeature]) -> str: elems = [ f"{feature.wrapper_signature_name}: {feature._get_wrapper_signature_type()}" for feature in triton_features ] return ", ".join(elems) def _get_input_dict(self, triton_features: list[TritonConfigFeature]) -> str: elems = [f'"{feature.name}": {feature.wrapper_signature_name}' for feature in triton_features] return "{" + ", ".join(elems) + "}" def _get_output_tuple(self, triton_features: list[TritonConfigFeature]) -> str: elems = [f'results["{feature.name}"]' for feature in triton_features] return "(" + ", ".join(elems) + ",)" def generate_inference_module_wrapper(self) -> str: """Generate the class wrapper around an inference module.""" return INFERENCE_MODULE_TEMPLATE.format( input_signature=self._get_input_signature(self.input_features), input_type=self._get_dict_type_hint(), input_dict=self._get_input_dict(self.input_features), output_tuple=self._get_output_tuple(self.output_features), ) def generate_scripted_module(self): """Generate the scripted module from the wrapper class.""" wrapper_definition = self.generate_inference_module_wrapper() with tempfile.TemporaryDirectory() as tmpdir: ts_path = os.path.join(tmpdir, "generated.py") with open(ts_path, "w") as f: f.write(wrapper_definition) spec = importlib.util.spec_from_file_location("generated.ts", ts_path) gen_ts = importlib.util.module_from_spec(spec) spec.loader.exec_module(gen_ts) gen_module = gen_ts.GeneratedInferenceModule(self.module) scripted_module = torch.jit.script(gen_module) return scripted_module def get_device_types_and_counts( preprocessor_num_instances: int, predictor_device_type: str, predictor_num_instances: int, postprocessor_num_instances: int, ) -> tuple[list[str], list[int]]: """Retrun device types and instance counts for each of the three inference modules.""" if predictor_device_type not in ["cuda", "cpu"]: raise ValueError('Invalid predictor device type. Choose one of ["cpu", "cuda"].') elif predictor_device_type == "cuda" and not torch.cuda.is_available(): raise ValueError("Specified num_gpus > 0, but CUDA isn't available.") preprocessor_device_type = "cpu" postprocessor_device_type = "cpu" device_types = [preprocessor_device_type, predictor_device_type, postprocessor_device_type] device_counts = [preprocessor_num_instances, predictor_num_instances, postprocessor_num_instances] return device_types, device_counts def get_inference_modules(model: LudwigModel, predictor_device_type: str) -> list[torch.jit.ScriptModule]: """Return the three inference modules.""" inference_module = InferenceModule.from_ludwig_model( model.model, model.config, model.training_set_metadata, device=predictor_device_type ) return [inference_module.preprocessor, inference_module.predictor, inference_module.postprocessor] def get_example_input( model: LudwigModel, device_types: list[str], data_example: None | pd.DataFrame ) -> dict[str, TorchscriptPreprocessingInput]: """Return an inference module-compatible input example. Generates a synthetic example if one is not provided. """ config = model.config if data_example is None: features = config["input_features"] + config["output_features"] df = build_synthetic_dataset(dataset_size=1, features=features) data = [row for row in df] data_example = pd.DataFrame(data[1:], columns=data[0]) return to_inference_module_input_from_dataframe( data_example.head(1), config, load_paths=True, device=device_types[0] ) def clean_up_synthetic_data(): """Clean up synthetic example generated data for audio and image features.""" shutil.rmtree("audio_files", ignore_errors=True) shutil.rmtree("image_files", ignore_errors=True) @DeveloperAPI def export_triton( model: LudwigModel, data_example: pd.DataFrame | None = None, output_path: str | None = "model_repository", model_name: str | None = "ludwig_model", model_version: int | str | None = 1, preprocessor_num_instances: int | None = 1, predictor_device_type: str | None = "cpu", predictor_num_instances: int | None = 1, postprocessor_num_instances: int | None = 1, predictor_max_batch_size: int | None = 64, max_queue_delay_microseconds: int | None = 100, ) -> list[TritonArtifact]: """Exports a torchscript model to a output path that serves as a repository for Triton Inference Server. # Inputs :param model: (LudwigModel) A ludwig model. :param data_example: (pd.DataFrame) an example from the dataset. Used to get dimensions throughout the pipeline. :param output_path: (str) The output path for the model repository. :param model_name: (str) The optional model name. :param model_version: (Union[int,str]) The optional model verison. :param preprocessor_num_instances: (int) number of instances for the preprocessor (on CPU). :param predictor_device_type: (str) device type for the predictor to be deployed on. One of "cpu" or "cuda" :param predictor_num_instances: (int) number of instances for the predictor. :param postprocessor_num_instances: (int) number of instances for the postprocessor (on CPU). :param predictor_max_batch_size: (int) max_batch_size parameter for the predictor Triton config. :param max_queue_delay_microseconds: (int) max_queue_delay_microseconds for all Triton configs. # Return :return: (List[TritonArtifact]) list of TritonArtifacts that contains information about exported artifacts. """ device_types, instance_counts = get_device_types_and_counts( preprocessor_num_instances, predictor_device_type, predictor_num_instances, postprocessor_num_instances ) split_modules = get_inference_modules(model, device_types[1]) example_input = get_example_input(model, device_types, data_example) triton_masters = [] triton_artifacts = [] for i, module in enumerate(split_modules): example_input = place_on_device(example_input, device_types[i]) triton_master = TritonMaster( module=module, input_data_example=example_input, inference_stage=INFERENCE_STAGES[i], max_batch_size=predictor_max_batch_size, max_queue_delay_microseconds=max_queue_delay_microseconds, model_name=model_name, output_path=output_path, model_version=model_version, ludwig_config=model.config, device=device_types[i], model_instance_count=instance_counts[i], ) example_input = triton_master.output_data_example config_artifact = triton_master.save_config() model_artifact = triton_master.save_model() triton_masters.append(triton_master) triton_artifacts.extend([config_artifact, model_artifact]) # saving ensemble config triton_master_preprocessor, triton_master_predictor, triton_master_postprocessor = triton_masters ensemble_config = TritonEnsembleConfig( triton_master_preprocessor, triton_master_predictor, triton_master_postprocessor, model_name, output_path, model_version, ) config_artifact = ensemble_config.save_ensemble_config() model_artifact = ensemble_config.save_ensemble_dummy_model() triton_artifacts.extend([config_artifact, model_artifact]) clean_up_synthetic_data() return triton_artifacts ================================================ FILE: ludwig/utils/types.py ================================================ from typing import Union import pandas as pd import torch try: import dask.dataframe as dd DataFrame = Union[pd.DataFrame, dd.DataFrame] Series = Union[pd.Series, dd.Series] except ImportError: DataFrame = pd.DataFrame Series = pd.Series # torchaudio.load returns the audio tensor and the sampling rate as a tuple. TorchAudioTuple = tuple[torch.Tensor, int] TorchscriptPreprocessingInput = Union[list[str], list[torch.Tensor], list[TorchAudioTuple], torch.Tensor] TorchDevice = Union[str, torch.device] ================================================ FILE: ludwig/utils/upload_utils.py ================================================ from __future__ import annotations import logging import os from abc import ABC, abstractmethod from huggingface_hub import HfApi, login from huggingface_hub.hf_api import CommitInfo from ludwig.globals import MODEL_FILE_NAME, MODEL_HYPERPARAMETERS_FILE_NAME, MODEL_WEIGHTS_FILE_NAME logger = logging.getLogger(__name__) class BaseModelUpload(ABC): """Abstract base class for uploading trained model artifacts to different repositories. This class defines the interface for uploading trained model artifacts to various repositories such as Huggingface Hub, without specifying the concrete implementation for each repository. Subclasses of this base class must implement the 'login' and 'upload' methods. """ @abstractmethod def login(self): """Abstract method to handle authentication with the target repository. Subclasses must implement this method to provide the necessary authentication mechanisms required by the repository where the model artifacts will be uploaded. Raises: NotImplementedError: If this method is not implemented in the subclass. """ raise NotImplementedError() @abstractmethod def upload( self, repo_id: str, model_path: str, repo_type: str | None = None, private: bool | None = False, commit_message: str | None = None, commit_description: str | None = None, dataset_file: str | None = None, dataset_name: str | None = None, ) -> bool: """Abstract method to upload trained model artifacts to the target repository. Subclasses must implement this method to define the process of pushing model artifacts to the respective repository. This may include creating a new model version, uploading model files, and any other specific steps required by the model repository service. Returns: bool: True if the model artifacts were successfully uploaded, False otherwise. Raises: NotImplementedError: If this method is not implemented in the subclass. """ raise NotImplementedError() @staticmethod def _validate_upload_parameters( repo_id: str, model_path: str, repo_type: str | None = None, private: bool | None = False, commit_message: str | None = None, commit_description: str | None = None, ): """Validate parameters before uploading trained model artifacts. This method checks if the input parameters meet the necessary requirements before uploading trained model artifacts to the target repository. Args: repo_id (str): The ID of the target repository. Each provider will verify their specific rules. model_path (str): The path to the directory containing the trained model artifacts. This is the parent-folder of the folder where the 'model_weights' folder and the 'model_hyperparameters.json' file are stored. repo_type (str, optional): The type of the repository. Not used in the base class, but subclasses may use it for specific repository implementations. Defaults to None. private (bool, optional): Whether the repository should be private or not. Not used in the base class, but subclasses may use it for specific repository implementations. Defaults to False. commit_message (str, optional): A message to attach to the commit when uploading to version control systems. Not used in the base class, but subclasses may use it for specific repository implementations. Defaults to None. commit_description (str, optional): A description of the commit when uploading to version control systems. Not used in the base class, but subclasses may use it for specific repository implementations. Defaults to None. Raises: FileNotFoundError: If the model_path does not exist. Exception: If the trained model artifacts are not found at the expected location within model_path, or if the artifacts are not in the required format (i.e., 'pytorch_model.bin'; or 'adapter_model.bin' or 'adapter_model.safetensors'). """ # Make sure the model's save path is actually a valid path if not os.path.exists(model_path): raise FileNotFoundError(f"The path '{model_path}' does not exist.") # Make sure the model is actually trained trained_model_artifacts_path = os.path.join(model_path, MODEL_FILE_NAME, MODEL_WEIGHTS_FILE_NAME) if not os.path.exists(trained_model_artifacts_path): raise Exception( f"Model artifacts not found at {trained_model_artifacts_path}. " f"It is possible that model at '{model_path}' hasn't been trained yet, or something went" "wrong during training where the model's weights were not saved." ) def hf_hub_login(): """Login to huggingface hub using the token stored in ~/.cache/huggingface/token and returns a HfApi client object that can be used to interact with HF Hub.""" cached_token_path = os.path.join(os.path.expanduser("~"), ".cache", "huggingface", "token") if not os.path.exists(cached_token_path): login(add_to_git_credential=True) with open(cached_token_path) as f: hf_token = f.read() hf_api = HfApi(token=hf_token) assert hf_api.token == hf_token return hf_api class HuggingFaceHub(BaseModelUpload): def __init__(self): self.api = None self.login() def login(self): """Login to huggingface hub using the token stored in ~/.cache/huggingface/token and return a HfApi client object that can be used to interact with HF Hub.""" self.api = hf_hub_login() @staticmethod def _validate_upload_parameters( repo_id: str, model_path: str, repo_type: str | None = None, private: bool | None = False, commit_message: str | None = None, commit_description: str | None = None, ): """Validate parameters before uploading trained model artifacts. This method checks if the input parameters meet the necessary requirements before uploading trained model artifacts to the target repository. Args: repo_id (str): The ID of the target repository. It must be a namespace (user or an organization) and a repository name separated by a '/'. For example, if your HF username is 'johndoe' and you want to create a repository called 'test', the repo_id should be 'johndoe/test'. model_path (str): The path to the directory containing the trained model artifacts. This is the parent-folder of the folder where the 'model_weights' folder and the 'model_hyperparameters.json' file are stored. repo_type (str, optional): The type of the repository. Not used in the base class, but subclasses may use it for specific repository implementations. Defaults to None. private (bool, optional): Whether the repository should be private or not. Not used in the base class, but subclasses may use it for specific repository implementations. Defaults to False. commit_message (str, optional): A message to attach to the commit when uploading to version control systems. Not used in the base class, but subclasses may use it for specific repository implementations. Defaults to None. commit_description (str, optional): A description of the commit when uploading to version control systems. Not used in the base class, but subclasses may use it for specific repository implementations. Defaults to None. Raises: ValueError: If the repo_id does not have both a namespace and a repo name separated by a '/'. """ # Validate repo_id has both a namespace and a repo name if "/" not in repo_id: raise ValueError( "`repo_id` must be a namespace (user or an organization) and a repo name separated by a `/`." " For example, if your HF username is `johndoe` and you want to create a repository called `test`, the" " repo_id should be johndoe/test" ) BaseModelUpload._validate_upload_parameters( repo_id, model_path, repo_type, private, commit_message, commit_description, ) trained_model_artifacts_path = os.path.join(model_path, MODEL_FILE_NAME, MODEL_WEIGHTS_FILE_NAME) """Make sure the model's saved artifacts either contain: 1. pytorch_model.bin -> regular model training, such as ECD or for LLMs 2. adapter_model.bin or adapter_model.safetensors -> LLM fine-tuning using PEFT As of PEFT version "0.7.0", "adapter_model" storage format was changed from ".bin" to ".safetensors". For backward compatibility, both formats will be supported, until depracating ".bin" format formally. """ files = set(os.listdir(trained_model_artifacts_path)) acceptable_model_artifact_file_names: set[str] = { "pytorch_model.bin", "adapter_model.bin", # Delete per formal deprecation policy TBD (per above comment). "adapter_model.safetensors", # New format as of PEFT version "0.7.0" (per above comment). } if not (files & acceptable_model_artifact_file_names): raise ValueError( f"Can't find model weights at {trained_model_artifacts_path}. Trained model weights should " "either be saved as `pytorch_model.bin` for regular model training, or have `adapter_model.bin`" "or `adapter_model.safetensors` if using parameter efficient fine-tuning methods like LoRA." ) model_hyperparameters_path: str = os.path.join(model_path, MODEL_FILE_NAME) if MODEL_HYPERPARAMETERS_FILE_NAME not in os.listdir(model_hyperparameters_path): raise ValueError(f"Can't find '{MODEL_HYPERPARAMETERS_FILE_NAME}' at {model_hyperparameters_path}.") def upload( self, repo_id: str, model_path: str, repo_type: str | None = None, private: bool | None = False, commit_message: str | None = None, commit_description: str | None = None, **kwargs, ) -> bool: """Create an empty repo on the HuggingFace Hub and upload trained model artifacts to that repo. Args: repo_id (`str`): A namespace (user or an organization) and a repo name separated by a `/`. model_path (`str`): The path of the saved model. This is the parent-folder of the folder where the 'model_weights' folder and the 'model_hyperparameters.json' file are stored. repo_type (`str`, *optional*): Set to `"dataset"` or `"space"` if uploading to a dataset or space, `None` or `"model"` if uploading to a model. Default is `None`. private (`bool`, *optional*, defaults to `False`): Whether the model repo should be private. commit_message (`str`, *optional*): The summary / title / first line of the generated commit. Defaults to: `f"Upload {path_in_repo} with huggingface_hub"` commit_description (`str` *optional*): The description of the generated commit """ # Validate upload parameters are in the right format HuggingFaceHub._validate_upload_parameters( repo_id, model_path, repo_type, private, commit_message, commit_description, ) # Create empty model repo using repo_id, but it is okay if it already exists. self.api.create_repo( repo_id=repo_id, private=private, repo_type=repo_type, exist_ok=True, ) # Upload all artifacts in model weights folder commit_message_weights: str | None = f"{commit_message} (weights)" if commit_message else commit_message commit_description_weights: str | None = ( f"{commit_description} (weights)" if commit_description else commit_description ) folder_path = os.path.join(model_path, MODEL_FILE_NAME, MODEL_WEIGHTS_FILE_NAME) upload_path_weights: CommitInfo = self.api.upload_folder( folder_path=folder_path, repo_id=repo_id, repo_type=repo_type, commit_message=commit_message_weights, commit_description=commit_description_weights, ) if upload_path_weights: logger.info(f"Model weights uploaded to `{upload_path_weights}` with repository name `{repo_id}`") # Upload the ludwig configuration file commit_message_config: str | None = f"{commit_message} (config)" if commit_message else commit_message commit_description_config: str | None = ( f"{commit_description} (config)" if commit_description else commit_description ) path_or_fileobj = os.path.join(model_path, MODEL_FILE_NAME, MODEL_HYPERPARAMETERS_FILE_NAME) upload_path_config: CommitInfo = self.api.upload_file( path_or_fileobj=path_or_fileobj, path_in_repo="ludwig_config.json", repo_id=repo_id, repo_type=repo_type, commit_message=commit_message_config, commit_description=commit_description_config, ) if upload_path_config: logger.info(f"Model config uploaded to `{upload_path_config}` with repository name `{repo_id}`") return True return False class Predibase(BaseModelUpload): def __init__(self): self.pc = None self.login() def login(self): """Login to Predibase using the token stored in the PREDIBASE_API_TOKEN environment variable and return a PredibaseClient object that can be used to interact with Predibase.""" from predibase import PredibaseClient token = os.environ.get("PREDIBASE_API_TOKEN") if token is None: raise ValueError( "Unable to find PREDIBASE_API_TOKEN environment variable. Please log into Predibase, " "generate a token and use `export PREDIBASE_API_TOKEN=` to use Predibase" ) try: pc = PredibaseClient() # TODO: Check if subscription has expired self.pc = pc except Exception as e: raise Exception(f"Failed to login to Predibase: {e}") return False return True @staticmethod def _validate_upload_parameters( repo_id: str, model_path: str, repo_type: str | None = None, private: bool | None = False, commit_message: str | None = None, commit_description: str | None = None, ): """Validate parameters before uploading trained model artifacts. This method checks if the input parameters meet the necessary requirements before uploading trained model artifacts to the target repository. Args: repo_id (str): The ID of the target repository. It must be a less than 256 characters. model_path (str): The path to the directory containing the trained model artifacts. It should contain the model's weights, usually saved under 'model/model_weights'. repo_type (str, optional): The type of the repository. Not used in the base class, but subclasses may use it for specific repository implementations. Defaults to None. private (bool, optional): Whether the repository should be private or not. Not used in the base class, but subclasses may use it for specific repository implementations. Defaults to False. commit_message (str, optional): A message to attach to the commit when uploading to version control systems. Not used in the base class, but subclasses may use it for specific repository implementations. Defaults to None. commit_description (str, optional): A description of the commit when uploading to version control systems. Not used in the base class, but subclasses may use it for specific repository implementations. Defaults to None. Raises: ValueError: If the repo_id is too long. """ if len(repo_id) > 255: raise ValueError("`repo_id` must be 255 characters or less.") BaseModelUpload._validate_upload_parameters( repo_id, model_path, repo_type, private, commit_message, commit_description, ) def upload( self, repo_id: str, model_path: str, commit_description: str | None = None, dataset_file: str | None = None, dataset_name: str | None = None, **kwargs, ) -> bool: """Create an empty repo in Predibase and upload trained model artifacts to that repo. Args: model_path (`str`): The path of the saved model. This is the top level directory where the models weights as well as other associated training artifacts are saved. repo_name (`str`): A repo name. repo_description (`str` *optional*): The description of the repo. dataset_file (`str` *optional*): The path to the dataset file. Required if `service` is set to `"predibase"` for new model repos. dataset_name (`str` *optional*): The name of the dataset. Used by the `service` `"predibase"`. Falls back to the filename. """ # Validate upload parameters are in the right format Predibase._validate_upload_parameters( repo_id, model_path, None, False, "", commit_description, ) # Upload the dataset to Predibase try: dataset = self.pc.upload_dataset(file_path=dataset_file, name=dataset_name) except Exception as e: raise RuntimeError("Failed to upload dataset to Predibase") from e # Create empty model repo using repo_name, but it is okay if it already exists. try: repo = self.pc.create_model_repo( name=repo_id, description=commit_description, exists_ok=True, ) except Exception as e: raise RuntimeError("Failed to create repo in Predibase") from e # Upload the zip file to Predibase try: self.pc.upload_model( repo=repo, model_path=model_path, dataset=dataset, ) except Exception as e: raise RuntimeError("Failed to upload model to Predibase") from e logger.info(f"Model uploaded to Predibase with repository name `{repo_id}`") return True ================================================ FILE: ludwig/utils/version_transformation.py ================================================ #! /usr/bin/env python # Copyright (c) 2022 Predibase, 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. # ============================================================================== import copy import logging from collections import defaultdict from collections.abc import Callable from functools import total_ordering from packaging import version as pkg_version logger = logging.getLogger(__name__) @total_ordering class VersionTransformation: """Wrapper class for transformations to config dicts.""" def __init__(self, transform: Callable[[dict], dict], version: str, prefixes: list[str] = None): """Constructor. Args: transform: A function or other callable from Dict -> Dict which returns a modified version of the config. The callable may update the config in-place and return it, or return a new dict. version: The Ludwig version, should be the first version which requires this transform. prefixes: A list of config prefixes this transform should apply to, i.e. ["hyperopt"]. If not specified, transform will be called with the entire config dictionary. """ self.transform = transform self.version = version self.pkg_version = pkg_version.parse(version) self.prefixes = prefixes if prefixes else [] def transform_config(self, config: dict): """Transforms the sepcified config, returns the transformed config.""" prefixes = self.prefixes if self.prefixes else [""] for prefix in prefixes: if prefix and (prefix not in config or not config[prefix]): # If the prefix is non-empty (transformation applies to a specific section), but the section is either # absent or empty, then skip. continue config = self.transform_config_with_prefix(config, prefix) return config def transform_config_with_prefix(self, config: dict, prefix: str | None = None) -> dict: """Applied this version transformation to a specified prefix of the config, returns the updated config. If prefix names a list, i.e. "input_features", applies the transformation to each list element (input feature). Args: config: A config dictionary. prefix: An optional keypath prefix i.e. "input_features". If no prefix specified, transformation is applied to config itself. Returns The updated config. """ if prefix: components = prefix.split(".", 1) key = components[0] rest_of_prefix = components[1] if len(components) > 1 else "" if key in config: subsection = config[key] if isinstance(subsection, list): config[key] = [ self.transform_config_with_prefix(v, prefix=rest_of_prefix) if isinstance(v, dict) else v for v in subsection ] elif isinstance(subsection, dict): config[key] = self.transform_config_with_prefix(subsection, prefix=rest_of_prefix) return config else: # Base case: no prefix specified, pass entire dictionary to transform function. transformed_config = self.transform(config) if transformed_config is None: logger.error("Error: version transformation returned None. Check for missing return statement.") return transformed_config @property def max_prefix_length(self): """Returns the length of the longest prefix.""" return max(len(prefix.split(".")) for prefix in self.prefixes) if self.prefixes else 0 @property def longest_prefix(self): """Returns the longest prefix, or empty string if no prefixes specified.""" prefixes = self.prefixes if not prefixes: return "" max_index = max(range(len(prefixes)), key=lambda i: prefixes[i]) return prefixes[max_index] def __lt__(self, other): """Defines sort order of version transformations. Sorted by: - version (ascending) - max_prefix_length (ascending) Process outer config transformations before inner. - longest_prefix (ascending) Order alphabetically by prefix if max_prefix_length equal. """ return (self.pkg_version, self.max_prefix_length, self.longest_prefix) < ( other.pkg_version, other.max_prefix_length, other.longest_prefix, ) def __repr__(self): return f'VersionTransformation(, version="{self.version}", prefixes={repr(self.prefixes)})' class VersionTransformationRegistry: """A registry of transformations which update versioned config files.""" def __init__(self): self._registry = defaultdict(list) # Maps version number to list of transformations. def register(self, transformation: VersionTransformation): """Registers a version transformation.""" self._registry[transformation.version].append(transformation) def get_transformations(self, from_version: str, to_version: str) -> list[VersionTransformation]: """Filters transformations to create an ordered list of the config transformations from one version to another. All transformations returned have version st. from_version < version <= to_version. Args: from_version: The ludwig version of the input config. to_version: The version to update the config to (usually the current LUDWIG_VERSION). Returns an ordered list of transformations to apply to the config to update it. """ from_version = pkg_version.parse(from_version) # Ignore pre-release, development versions. Otherwise transformations for upcoming releases will not be applied. to_version = pkg_version.parse(to_version) to_version = pkg_version.parse(f"{to_version.major}.{to_version.minor}") def in_range(v, to_version, from_version): v = pkg_version.parse(v) return from_version <= v <= to_version versions = [v for v in self._registry.keys() if in_range(v, to_version, from_version)] transforms = sorted(t for v in versions for t in self._registry[v]) return transforms def update_config(self, config: dict, from_version: str, to_version: str) -> dict: """Applies the transformations from an older version to a newer version. Args: config: The config, created by ludwig at from_version. from_version: The version of ludwig which wrote the older config. to_version: The version of ludwig to update to (usually the current LUDWIG_VERSION). Returns The updated config after applying update transformations and updating the "ludwig_version" key. """ transformations = self.get_transformations(from_version, to_version) updated_config = copy.deepcopy(config) for t in transformations: updated_config = t.transform_config(updated_config) updated_config["ludwig_version"] = to_version return updated_config ================================================ FILE: ludwig/utils/visualization_utils.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import copy import logging from collections import Counter from sys import platform import numpy as np import pandas as pd import ptitprince as pt from ludwig.constants import SPACE, TRAINING, VALIDATION logger = logging.getLogger(__name__) try: import matplotlib as mpl if platform == "darwin": # OS X try: mpl.use("TkAgg") except ModuleNotFoundError: logger.warning("Unable to set TkAgg backend for matplotlib. Your Python may not be configured for Tk") import matplotlib.patches as patches import matplotlib.path as path import matplotlib.patheffects as PathEffects import matplotlib.pyplot as plt import seaborn as sns from matplotlib import ticker from matplotlib.lines import Line2D from mpl_toolkits.mplot3d import Axes3D except ImportError as e: raise RuntimeError( "matplotlib or seaborn are not installed. " "In order to install all visualization dependencies run " "pip install ludwig[viz]" ) from e INT_QUANTILES = 10 FLOAT_QUANTILES = 10 # mapping from RayTune search space to Ludwig types (float, int, category) for hyperopt visualizations RAY_TUNE_FLOAT_SPACES = {"uniform", "quniform", "loguniform", "qloguniform", "randn", "qrandn"} RAY_TUNE_INT_SPACES = {"randint", "qrandint", "lograndint", "qlograndint"} RAY_TUNE_CATEGORY_SPACES = {"choice", "grid_search"} def visualize_callbacks(callbacks, fig): if callbacks is None: return for callback in callbacks: callback.on_visualize_figure(fig) def learning_curves_plot( train_values, vali_values, metric, x_label="epoch", x_step=1, algorithm_names=None, title=None, filename=None, callbacks=None, ): num_algorithms = len(train_values) max_len = max(len(tv) for tv in train_values) fig, ax = plt.subplots() sns.set_style("whitegrid") if title is not None: ax.set_title(title) if num_algorithms == 1: colors = plt.get_cmap("tab10").colors else: # num_algorithms > 1 colors = plt.get_cmap("tab20").colors ax.grid(which="both") ax.grid(which="minor", alpha=0.5) ax.grid(which="major", alpha=0.75) ax.set_xlabel(x_label) ax.set_ylabel(metric.replace("_", " ")) xs = np.arange(1, (max_len * x_step) + 1, x_step) for i in range(num_algorithms): name_prefix = algorithm_names[i] + " " if algorithm_names is not None and i < len(algorithm_names) else "" ax.plot( xs[: len(train_values[i])], train_values[i], label=name_prefix + TRAINING, color=colors[i * 2], linewidth=3 ) if i < len(vali_values) and vali_values[i] is not None and len(vali_values[i]) > 0: ax.plot( xs[: len(vali_values[i])], vali_values[i], label=name_prefix + VALIDATION, color=colors[i * 2 + 1], linewidth=3, ) ax.legend() plt.tight_layout() visualize_callbacks(callbacks, plt.gcf()) if filename: plt.savefig(filename) else: plt.show() def compare_classifiers_plot( scores, metrics, algoritm_names=None, adaptive=False, decimals=4, title=None, filename=None, callbacks=None, ): assert len(scores) == len(metrics) assert len(scores) > 0 num_metrics = len(metrics) sns.set_style("whitegrid") fig, ax = plt.subplots() ax.grid(which="both") ax.grid(which="minor", alpha=0.5) ax.grid(which="major", alpha=0.75) ax.set_xticklabels([], minor=True) if title is not None: ax.set_title(title) width = 0.8 / num_metrics if num_metrics > 1 else 0.4 ticks = np.arange(len(scores[0])) if num_metrics <= 10: colors = plt.get_cmap("tab10").colors else: colors = plt.get_cmap("tab20").colors if adaptive: maximum = max(max(score) for score in scores) else: ax.set_xlim([0, 1]) ax.set_xticks(np.linspace(0.0, 1.0, num=21), minor=True) ax.set_xticks(np.linspace(0.0, 1.0, num=11)) maximum = 1 half_total_width = 0.4 if num_metrics > 1 else 0.2 ax.set_yticks(ticks + half_total_width - width / 2) ax.set_yticklabels(algoritm_names if algoritm_names is not None else "") ax.invert_yaxis() # labels read top-to-bottom for i, metric in enumerate(metrics): ax.barh(ticks + (i * width), scores[i], width, label=metric, color=colors[i]) for j, v in enumerate(scores[i]): if v < maximum * (0.025 * decimals + 0.1): x = v + maximum * 0.01 horizontal_alignment = "left" else: x = v - maximum * 0.01 horizontal_alignment = "right" txt = ax.text( x, ticks[j] + (i * width), ("{:." + str(decimals) + "f}").format(v), color="white", fontweight="bold", verticalalignment="center", horizontalalignment=horizontal_alignment, ) txt.set_path_effects([PathEffects.withStroke(linewidth=3, foreground="black")]) plt.setp(ax.get_xminorticklabels(), visible=False) ax.legend(loc="center left", bbox_to_anchor=(1, 0.5)) plt.tight_layout() visualize_callbacks(callbacks, plt.gcf()) if filename: plt.savefig(filename) else: plt.show() def compare_classifiers_line_plot( xs, scores, metric, algorithm_names=None, title=None, filename=None, callbacks=None, ): assert len(scores) > 0 sns.set_style("whitegrid") if len(scores) <= 10: colors = plt.get_cmap("tab10").colors else: colors = plt.get_cmap("tab20").colors fig, ax = plt.subplots() ax.grid(which="both") ax.grid(which="minor", alpha=0.5) ax.grid(which="major", alpha=0.75) if title is not None: ax.set_title(title) ax.set_xticks(xs) ax.set_xticklabels(xs) ax.set_xlabel("k") ax.set_ylabel(metric) for i, score in enumerate(scores): ax.plot( xs, score, label=algorithm_names[i] if algorithm_names is not None and i < len(algorithm_names) else f"Algorithm {i}", color=colors[i], linewidth=3, marker="o", ) ax.legend(loc="center left", bbox_to_anchor=(1, 0.5)) plt.tight_layout() visualize_callbacks(callbacks, plt.gcf()) if filename: plt.savefig(filename) else: plt.show() def compare_classifiers_multiclass_multimetric_plot( scores, metrics, labels=None, title=None, filename=None, callbacks=None, ): assert len(scores) > 0 sns.set_style("whitegrid") fig, ax = plt.subplots() if title is not None: ax.set_title(title) width = 0.9 / len(scores) ticks = np.arange(len(scores[0])) if len(scores) <= 10: colors = plt.get_cmap("tab10").colors else: colors = plt.get_cmap("tab20").colors ax.set_xlabel("class") ax.set_xticks(ticks + width) if labels is not None: ax.set_xticklabels(labels, rotation=90) else: ax.set_xticklabels(ticks, rotation=90) for i, score in enumerate(scores): ax.bar(ticks + i * width, score, width, label=metrics[i], color=colors[i]) ax.legend(loc="center left", bbox_to_anchor=(1, 0.5)) plt.tight_layout() visualize_callbacks(callbacks, plt.gcf()) if filename: plt.savefig(filename) else: plt.show() def radar_chart( ground_truth, predictions, algorithms=None, log_scale=False, title=None, filename=None, callbacks=None, ): sns.set_style("whitegrid") if title is not None: plt.title(title) ground_truth = ground_truth[0:10] predictions = [pred[0:10] for pred in predictions] gt_argsort = np.argsort(-ground_truth) # sort deacreasing logger.info(gt_argsort) ground_truth = ground_truth[gt_argsort] predictions = [pred[gt_argsort] for pred in predictions] maximum = max(max(ground_truth), max(max(p) for p in predictions)) ax = plt.subplot(111, polar=True) ax.set_theta_zero_location("N") ax.set_theta_direction(-1) ax.set_rmax(maximum) ax.set_rlabel_position(305) ax.set_ylabel("Probability") # ax.set_rscale('log') ax.grid(True) colors = plt.get_cmap("tab10").colors num_classes = len(ground_truth) # Set ticks to the number of properties (in radians) t = np.arange(0, 2 * np.pi, 2 * np.pi / num_classes) ax.set_xticks(t) ax.set_xticklabels(np.arange(0, num_classes)) # Set yticks from 0 to 10 # ax.set_yticks(np.linspace(0, 10, 11)) # Set axes limits # ax.set_rlim(0, 1) # ax.set_rscale('log') def draw_polygon(values, label, color="grey"): points = [(x, y) for x, y in zip(t, values)] points.append(points[0]) points = np.array(points) codes = [path.Path.MOVETO] + [path.Path.LINETO] * (len(values) - 1) + [path.Path.CLOSEPOLY] _path = path.Path(points, codes) _patch = patches.PathPatch(_path, fill=True, color=color, linewidth=0, alpha=0.2) ax.add_patch(_patch) _patch = patches.PathPatch(_path, fill=False, color=color, linewidth=3) ax.add_patch(_patch) # Draw circles at value points # line = ax.scatter(points[:, 0], points[:, 1], linewidth=3, # s=50, color='white', edgecolor=color, zorder=10) ax.plot( points[:, 0], points[:, 1], linewidth=3, marker="o", fillstyle="full", markerfacecolor="white", markeredgecolor=color, markeredgewidth=2, color=color, zorder=10, label=label, ) draw_polygon(ground_truth, "Ground Truth") # Draw polygon representing values for i, alg_predictions in enumerate(predictions): draw_polygon(alg_predictions, algorithms[i], colors[i]) ax.legend(frameon=True, loc="upper left") plt.tight_layout() visualize_callbacks(callbacks, plt.gcf()) if filename: plt.savefig(filename) else: plt.show() def pie(ax, values, **kwargs): total = sum(values) def formatter(pct): if pct > 0: return f"{pct * total / 100:0.0f}\n({pct:0.1f}%)" else: return "" wedges, _, labels = ax.pie(values, autopct=formatter, **kwargs) return wedges def donut( inside_values, inside_labels, outside_values, outside_labels, outside_groups, title=None, tight_layout=None, filename=None, callbacks=None, ): fig, ax = plt.subplots(figsize=(7, 5)) if title is not None: ax.set_title(title) ax.axis("equal") width = 0.35 colors_tab20c = list(plt.get_cmap("tab20c").colors) colors_set2 = list(plt.get_cmap("Set2").colors) colors_set3 = list(plt.get_cmap("Set3").colors) colors_pastel1 = list(plt.get_cmap("Pastel1").colors) # swap green and red # for i in range(4): # tmp = colors[4 + i] # colors[4 + i] = colors[8 + i] # colors[8 + i] = tmp colors = [] colors.extend(colors_tab20c[8:12]) colors.append(colors_set2[5]) colors.append(colors_set3[11]) colors.append(colors_set3[1]) colors.append(colors_pastel1[5]) colors.extend(colors_tab20c[4:8]) inside_colors = [colors[x * 4] for x in range(len(inside_values))] group_count = Counter(outside_groups) outside_colors = [colors[(i * 4) + ((j % 3) + 1)] for i in list(set(outside_groups)) for j in range(group_count[i])] outside = pie( ax, outside_values, radius=1, pctdistance=1 - width / 2, colors=outside_colors, startangle=90, counterclock=False, textprops={ "color": "w", "weight": "bold", "path_effects": [PathEffects.withStroke(linewidth=3, foreground="black")], }, ) inside = pie( ax, inside_values, radius=1 - width, pctdistance=1 - (width / 2) / (1 - width), colors=inside_colors, startangle=90, counterclock=False, textprops={ "color": "w", "weight": "bold", "path_effects": [PathEffects.withStroke(linewidth=3, foreground="black")], }, ) plt.setp(inside + outside, width=width, edgecolor="white") wedges = [] labels = [] so_far = 0 for i in list(set(outside_groups)): wedges.append(inside[i]) labels.append(inside_labels[i]) for j in range(group_count[i]): wedges.append(outside[so_far]) labels.append(outside_labels[so_far]) so_far += 1 if tight_layout: ax.legend(wedges, labels, frameon=True, loc=1, bbox_to_anchor=(1.30, 1.00)) else: ax.legend(wedges, labels, frameon=True, loc=1, bbox_to_anchor=(1.50, 1.00)) visualize_callbacks(callbacks, plt.gcf()) if filename: plt.savefig(filename, bbox_inches="tight") else: plt.show() def confidence_filtering_plot( thresholds, accuracies, dataset_kepts, algorithm_names=None, title=None, filename=None, callbacks=None, ): assert len(accuracies) == len(dataset_kepts) num_algorithms = len(accuracies) sns.set_style("whitegrid") if num_algorithms == 1: colors = plt.get_cmap("tab10").colors else: # num_algorithms > 1 colors = plt.get_cmap("tab20").colors y_ticks_minor = np.linspace(0.0, 1.0, num=21) y_ticks_major = np.linspace(0.0, 1.0, num=11) y_ticks_major_labels = [f"{y * 100:3.0f}%" for y in y_ticks_major] fig, ax1 = plt.subplots() if title is not None: ax1.set_title(title) ax1.grid(which="both") ax1.grid(which="minor", alpha=0.5) ax1.grid(which="major", alpha=0.75) ax1.set_xticks([x for idx, x in enumerate(thresholds) if idx % 2 == 0]) ax1.set_xticks(thresholds, minor=True) ax1.set_xlim(-0.05, 1.05) ax1.set_xlabel("confidence threshold") ax1.set_ylim(0, 1.05) ax1.set_yticks(y_ticks_major) ax1.set_yticklabels(y_ticks_major_labels) ax1.set_yticks(y_ticks_minor, minor=True) ax2 = ax1.twinx() ax2.set_ylim(0, 1.05) ax2.set_yticks(y_ticks_major) ax2.set_yticklabels(y_ticks_major_labels) ax2.set_yticks(y_ticks_minor, minor=True) for i in range(len(accuracies)): algorithm_name = algorithm_names[i] + " " if algorithm_names is not None and i < len(algorithm_names) else "" ax1.plot(thresholds, accuracies[i], label=f"{algorithm_name} accuracy", color=colors[i * 2], linewidth=3) ax1.plot( thresholds, dataset_kepts[i], label=f"{algorithm_name} data coverage", color=colors[i * 2 + 1], linewidth=3 ) ax1.legend(frameon=True, loc=3) plt.tight_layout() visualize_callbacks(callbacks, plt.gcf()) if filename: plt.savefig(filename) else: plt.show() def confidence_filtering_data_vs_acc_plot( accuracies, dataset_kepts, model_names=None, dotted=False, decimal_digits=0, y_label="accuracy", title=None, filename=None, callbacks=None, ): assert len(accuracies) == len(dataset_kepts) sns.set_style("whitegrid") colors = plt.get_cmap("tab10").colors max_dataset_kept = max(max(dataset_kept) for dataset_kept in dataset_kepts) x_ticks_minor = np.linspace(0.0, max_dataset_kept, num=21) x_ticks_major = np.linspace(0.0, max_dataset_kept, num=11) x_ticks_major_labels = [ "{value:3.{decimal_digits}f}%".format(decimal_digits=decimal_digits, value=x * 100) for x in x_ticks_major ] y_ticks_minor = np.linspace(0.0, 1.0, num=21) y_ticks_major = np.linspace(0.0, 1.0, num=11) fig, ax = plt.subplots() if title is not None: ax.set_title(title) ax.grid(which="both") ax.grid(which="minor", alpha=0.5) ax.grid(which="major", alpha=0.75) ax.set_xticks(x_ticks_major) ax.set_xticks(x_ticks_minor, minor=True) ax.set_xticklabels(x_ticks_major_labels) ax.set_xlim(0, max_dataset_kept) ax.set_xlabel("data coverage") ax.set_ylim(0, 1) ax.set_yticks(y_ticks_major) ax.set_yticks(y_ticks_minor, minor=True) ax.set_ylabel(y_label) for i in range(len(accuracies)): curr_dotted = dotted[i] if isinstance(dotted, (list, tuple)) and i < len(dotted) else dotted algorithm_name = model_names[i] + " " if model_names is not None and i < len(model_names) else "" ax.plot( dataset_kepts[i], accuracies[i], label=algorithm_name, color=colors[i], linewidth=3, linestyle=":" if curr_dotted else "-", ) ax.legend(frameon=True, loc=3) plt.tight_layout() visualize_callbacks(callbacks, plt.gcf()) if filename: plt.savefig(filename) else: plt.show() def confidence_filtering_data_vs_acc_multiline_plot( accuracies, dataset_kepts, models_names, title=None, filename=None, callbacks=None, ): assert len(accuracies) == len(dataset_kepts) sns.set_style("whitegrid") colors = plt.get_cmap("tab20").colors max_dataset_kept = max(max(dataset_kept) for dataset_kept in dataset_kepts) x_ticks_minor = np.linspace(0.0, max_dataset_kept, num=21) x_ticks_major = np.linspace(0.0, max_dataset_kept, num=11) x_ticks_major_labels = [f"{x * 100:3.0f}%" for x in x_ticks_major] y_ticks_minor = np.linspace(0.0, 1.0, num=21) y_ticks_major = np.linspace(0.0, 1.0, num=11) fig, ax = plt.subplots() if title is not None: ax.set_title(title) ax.grid(which="both") ax.grid(which="minor", alpha=0.5) ax.grid(which="major", alpha=0.75) ax.set_xticks(x_ticks_major) ax.set_xticks(x_ticks_minor, minor=True) ax.set_xticklabels(x_ticks_major_labels) ax.set_xlim(0, max_dataset_kept) ax.set_xlabel("data coverage") ax.set_ylim(0, 1) ax.set_yticks(y_ticks_major) ax.set_yticks(y_ticks_minor, minor=True) ax.set_ylabel("accuracy") for i in range(len(accuracies)): ax.plot(dataset_kepts[i], accuracies[i], color=colors[0], linewidth=1.0, alpha=0.35) legend_elements = [Line2D([0], [0], linewidth=1.0, color=colors[0])] ax.legend(legend_elements, models_names) plt.tight_layout() visualize_callbacks(callbacks, plt.gcf()) if filename: plt.savefig(filename) else: plt.show() def confidence_filtering_3d_plot( thresholds_1, thresholds_2, accuracies, dataset_kepts, threshold_output_feature_names=None, title=None, filename=None, callbacks=None, ): assert len(accuracies) == len(dataset_kepts) assert len(thresholds_1) == len(thresholds_2) thresholds_1, thresholds_2 = np.meshgrid(thresholds_1, thresholds_2) colors = plt.get_cmap("tab10").colors sns.set_style("white") z_ticks_minor = np.linspace(0.0, 1.0, num=21) z_ticks_major = np.linspace(0.0, 1.0, num=11) z_ticks_major_labels = [f"{z * 100:3.0f}%" for z in z_ticks_major] fig = plt.figure() ax = Axes3D ax = fig.add_subplot(111, projection="3d") if title is not None: ax.set_title(title) ax.grid(which="both") ax.grid(which="minor", alpha=0.5) ax.grid(which="major", alpha=0.75) ax.set_xlabel(f"{threshold_output_feature_names[0]} probability") ax.set_ylabel(f"{threshold_output_feature_names[1]} probability") ax.set_xlim(np.min(thresholds_1), np.max(thresholds_1)) ax.set_ylim(np.min(thresholds_2), np.max(thresholds_2)) ax.set_zlim(0, 1) ax.set_zticks(z_ticks_major) ax.set_zticklabels(z_ticks_major_labels) ax.set_zticks(z_ticks_minor, minor=True) # HACK: Remove padding from 3D axes by adjusting coordinate info from mpl_toolkits.mplot3d.axis3d import Axis if not hasattr(Axis, "_get_coord_info_old"): def _get_coord_info_new(self): result = self._get_coord_info_old() mins, maxs = result[0], result[1] deltas = maxs - mins mins += deltas / 4 maxs -= deltas / 4 return (mins, maxs) + result[2:] Axis._get_coord_info_old = Axis._get_coord_info Axis._get_coord_info = _get_coord_info_new surf_1 = ax.plot_surface( thresholds_1, thresholds_2, accuracies, alpha=0.5, label="accuracy", cmap=plt.get_cmap("winter"), edgecolor="none", ) surf_2 = ax.plot_surface( thresholds_1, thresholds_2, dataset_kepts, alpha=0.5, label="data coverage", cmap=plt.get_cmap("autumn"), edgecolor="none", ) handle_1 = copy.copy(surf_1) handle_2 = copy.copy(surf_2) handle_1.set_color(colors[0]) handle_2.set_color(colors[1]) # ## the next block is needed because matplotlib 3.3.3 renamed # _edgecolors3d -> _edgecolor3d # _facecolors3d -> _facecolor3d # but we want to try to keep compatibility with older versions # #### BEGIN COMPATIBILITY BLOCK ##### if hasattr(handle_1, "_edgecolors3d"): edgecolor3d = handle_1._edgecolors3d else: edgecolor3d = handle_1._edgecolor3d handle_1._edgecolors2d = edgecolor3d handle_1._edgecolor2d = edgecolor3d if hasattr(handle_2, "_edgecolors3d"): edgecolor3d = handle_2._edgecolors3d else: edgecolor3d = handle_2._edgecolor3d handle_2._edgecolors2d = edgecolor3d handle_2._edgecolor2d = edgecolor3d if hasattr(handle_1, "_facecolors3d"): facecolor3d = handle_1._facecolors3d else: facecolor3d = handle_1._facecolor3d handle_1._facecolors2d = facecolor3d handle_1._facecolor2d = facecolor3d if hasattr(handle_2, "_facecolors3d"): facecolor3d = handle_2._facecolors3d else: facecolor3d = handle_2._facecolor3d handle_2._facecolors2d = facecolor3d handle_2._facecolor2d = facecolor3d # #### END COMPATIBILITY BLOCK ##### ax.legend(frameon=True, loc=3, handles=[handle_1, handle_2]) plt.tight_layout() visualize_callbacks(callbacks, plt.gcf()) if filename: plt.savefig(filename) else: plt.show() def threshold_vs_metric_plot( thresholds, scores, algorithm_names=None, title=None, filename=None, callbacks=None, ): sns.set_style("whitegrid") colors = plt.get_cmap("tab10").colors # y_ticks_minor = np.linspace(0.0, 1.0, num=21) # y_ticks_major = np.linspace(0.0, 1.0, num=11) # y_ticks_major_labels = ['{:3.0f}%'.format(y * 100) for y in y_ticks_major] fig, ax1 = plt.subplots() if title is not None: ax1.set_title(title) ax1.grid(which="both") ax1.grid(which="minor", alpha=0.5) ax1.grid(which="major", alpha=0.75) ax1.set_xticks([x for idx, x in enumerate(thresholds) if idx % 2 == 0]) ax1.set_xticks(thresholds, minor=True) # ax1.set_xlim(0, 1) ax1.set_xlabel("confidence threshold") # ax1.set_ylim(0, 1) # ax1.set_yticks(y_ticks_major) # ax1.set_yticklabels(y_ticks_major_labels) # ax1.set_yticks(y_ticks_minor, minor=True) for i in range(len(scores)): algorithm_name = algorithm_names[i] + " " if algorithm_names is not None and i < len(algorithm_names) else "" ax1.plot(thresholds, scores[i], label=algorithm_name, color=colors[i], linewidth=3, marker="o") ax1.legend(frameon=True) plt.tight_layout() visualize_callbacks(callbacks, plt.gcf()) if filename: plt.savefig(filename) else: plt.show() def roc_curves( fpr_tprs, algorithm_names=None, title=None, graded_color=False, filename=None, callbacks=None, ): sns.set_style("whitegrid") colors = plt.get_cmap("tab10").colors colormap = plt.get_cmap("RdYlGn") y_ticks_minor = np.linspace(0.0, 1.0, num=21) y_ticks_major = np.linspace(0.0, 1.0, num=11) fig, ax = plt.subplots() if title is not None: ax.set_title(title) ax.grid(which="both") ax.grid(which="minor", alpha=0.5) ax.grid(which="major", alpha=0.75) ax.set_xlim(0, 1) ax.set_xlabel("False positive rate") ax.set_ylim(0, 1) ax.set_yticks(y_ticks_major) ax.set_yticks(y_ticks_minor, minor=True) ax.set_ylabel("True positive rate") plt.plot([0, 1], [0, 1], color="black", linewidth=3, linestyle="--") for i in range(len(fpr_tprs)): algorithm_name = algorithm_names[i] + " " if algorithm_names is not None and i < len(algorithm_names) else "" color = colormap(i / len(fpr_tprs)) if graded_color else colors[i] ax.plot(fpr_tprs[i][0], fpr_tprs[i][1], label=algorithm_name, color=color, linewidth=3) ax.legend(frameon=True) plt.tight_layout() visualize_callbacks(callbacks, plt.gcf()) if filename: plt.savefig(filename) else: plt.show() def precision_recall_curves_plot( precision_recalls: dict[str, list[float]], model_names: list[str], title: str = None, filename: str = None, callbacks=None, ): """Generates a precision recall curve for each model in the model_names list. Args: precision_recalls: A list of dictionaries representing the precision and recall values for each model in model_names. Each dictionary has two keys: "precisions" and "recalls". """ sns.set_style("whitegrid") colors = plt.get_cmap("tab10").colors _, ax = plt.subplots() ax.set_xlim(0, 1) # Create ticks for every 0.1 increment ax.set_xticks(np.linspace(0, 1, 11)) ax.set_xlabel("Recall") ax.set_ylim(0, 1) # Create ticks for every 0.1 increment ax.set_yticks(np.linspace(0, 1, 11)) ax.set_ylabel("Precision") if title is not None: ax.set_title(title) for i in range(len(precision_recalls)): model_name = model_names[i] if model_names is not None and i < len(model_names) else "" ax.plot( precision_recalls[i]["recalls"], precision_recalls[i]["precisions"], label=model_name, color=colors[i], linewidth=3, ) ax.legend(frameon=True) plt.tight_layout() visualize_callbacks(callbacks, plt.gcf()) if filename: plt.savefig(filename) else: plt.show() def calibration_plot( fraction_positives, mean_predicted_values, algorithm_names=None, class_name=None, filename=None, callbacks=None, ): assert len(fraction_positives) == len(mean_predicted_values) sns.set_style("whitegrid") colors = plt.get_cmap("tab10").colors num_algorithms = len(fraction_positives) plt.figure(figsize=(9, 9)) plt.grid(which="both") plt.grid(which="minor", alpha=0.5) plt.grid(which="major", alpha=0.75) plt.plot([0, 1], [0, 1], "k:", label="Perfectly calibrated") for i in range(num_algorithms): # ax1.plot(mean_predicted_values[i], fraction_positives[i], # label=algorithms[i] if algorithm_names is not None and i < len(algorithms) else '') # sns.tsplot(mean_predicted_values[i], fraction_positives[i], ax=ax1, color=colors[i]) assert len(mean_predicted_values[i]) == len(fraction_positives[i]) order = min(3, len(mean_predicted_values[i]) - 1) sns.regplot( x=mean_predicted_values[i], y=fraction_positives[i], order=order, x_estimator=np.mean, color=colors[i], marker="o", scatter_kws={"s": 40}, label=algorithm_names[i] if algorithm_names is not None and i < len(algorithm_names) else f"Model {i}", ) ticks = np.linspace(0.0, 1.0, num=11) plt.xlim([-0.05, 1.05]) plt.xticks(ticks) plt.xlabel("Predicted probability") plt.ylabel("Observed probability") plt.ylim([-0.05, 1.05]) plt.yticks(ticks) plt.legend(loc="lower right") if class_name is not None: plt.title(f"{class_name}: Calibration (reliability curve)") else: plt.title("Calibration (reliability curve)") plt.tight_layout() visualize_callbacks(callbacks, plt.gcf()) if filename: plt.savefig(filename) else: plt.show() def brier_plot( brier_scores, algorithm_names=None, class_names=None, title=None, filename=None, callbacks=None, ): sns.set_style("whitegrid") # Dynamically set the size of the plot based on the number of labels # Use minimum size to prevent plot from being too small default_width, default_height = plt.rcParams.get("figure.figsize") width = max(default_width, len(class_names) / 2) height = max(default_height, len(class_names) / 2) fig, ax = plt.subplots(figsize=(width, height)) if title is not None: plt.title(title) colors = plt.get_cmap("tab10").colors n_algorithms = brier_scores.shape[1] n_classes = brier_scores.shape[0] x = np.arange(n_classes) max_width = 0.35 bar_width = min(0.5 / n_algorithms, max_width) bar_left = -bar_width * (n_algorithms // 2) + ((bar_width / 2) if (n_algorithms % 2) == 0 else 0) ax.grid(which="both") ax.grid(which="minor", alpha=0.5) ax.grid(which="major", alpha=0.75) ax.set_xlabel("class") ax.set_ylabel("brier score") if class_names is not None: ax.set_xticks( x, class_names, rotation=45, ha="center", ) else: ax.set_xticks( x, [str(i) for i in range(n_classes)], rotation=45, ha="center", ) for i in range(n_algorithms): # Plot bar for each class label = algorithm_names[i] if algorithm_names is not None and i < len(algorithm_names) else f"Model {i}" ax.bar(x + bar_left + (bar_width * i), brier_scores[:, i], bar_width, color=colors[i], label=label) ax.legend() fig.tight_layout() visualize_callbacks(callbacks, plt.gcf()) if filename: plt.savefig(filename) else: plt.show() def predictions_distribution_plot( probabilities, algorithm_names=None, filename=None, callbacks=None, ): sns.set_style("whitegrid") colors = plt.get_cmap("tab10").colors num_algorithms = len(probabilities) plt.figure(figsize=(9, 9)) plt.grid(which="both") plt.grid(which="minor", alpha=0.5) plt.grid(which="major", alpha=0.75) for i in range(num_algorithms): plt.hist( probabilities[i], range=(0, 1), bins=41, color=colors[i], label=algorithm_names[i] if algorithm_names is not None and i < len(algorithm_names) else "", histtype="stepfilled", alpha=0.5, lw=2, ) plt.xlabel("Mean predicted value") plt.xlim([0, 1]) plt.xticks(np.linspace(0.0, 1.0, num=21)) plt.ylabel("Count") plt.legend(loc="upper center", ncol=2) plt.tight_layout() visualize_callbacks(callbacks, plt.gcf()) if filename: plt.savefig(filename) else: plt.show() def confusion_matrix_plot( confusion_matrix, labels=None, output_feature_name=None, filename=None, callbacks=None, ): mpl.rcParams.update({"figure.autolayout": True}) # Dynamically set the size of the plot based on the number of labels # Use minimum size to prevent plot from being too small default_width, default_height = plt.rcParams.get("figure.figsize") width = max(default_width, len(labels)) height = max(default_height, len(labels)) fig, ax = plt.subplots(figsize=(width, height)) ax.invert_yaxis() ax.xaxis.tick_top() ax.xaxis.set_label_position("top") # Set alpha value to prevent blue hues from being too dark cax = ax.matshow(confusion_matrix, cmap="Blues", alpha=0.6) # Annotate confusion matrix plot for (i, j), z in np.ndenumerate(confusion_matrix): # Format differently based on whether the value is normalized or not if z.is_integer(): z_format = f"{z:.0f}" else: z_format = f"{z:.3f}" ax.text( j, i, z_format, ha="center", va="center", color="black", fontweight="medium", wrap=True, ) ax.xaxis.set_major_locator(ticker.MultipleLocator(1)) ax.yaxis.set_major_locator(ticker.MultipleLocator(1)) ax.set_xticklabels([""] + labels, rotation=45, ha="left") ax.set_yticklabels([""] + labels, rotation=45, ha="right") ax.grid(False) ax.tick_params(axis="both", which="both", length=0) # https://stackoverflow.com/a/26720422/10102370 works nicely for square plots fig.colorbar(cax, ax=ax, extend="max", fraction=0.046, pad=0.04) ax.set_xlabel(f"Predicted {output_feature_name}") ax.set_ylabel(f"Actual {output_feature_name}") plt.tight_layout() visualize_callbacks(callbacks, plt.gcf()) if filename: plt.savefig(filename, bbox_inches="tight") else: plt.show() def double_axis_line_plot( y1_sorted, y2, y1_name, y2_name, labels=None, title=None, filename=None, callbacks=None, ): sns.set_style("whitegrid") colors = plt.get_cmap("tab10").colors # Dynamically adjust figure size based on number of labels default_width, default_height = plt.rcParams.get("figure.figsize") width = max(default_width, len(labels) / 3) height = max(default_height, len(labels) / 3) fig, ax1 = plt.subplots(layout="constrained", figsize=(width, height)) if title is not None: ax1.set_title(title) ax1.set_xlabel(f"class (sorted by {y1_name})") ax1.set_xlim(0, len(y1_sorted) - 1) if labels is not None: ax1.set_xticklabels(labels, rotation=45, ha="right") ax1.set_xticks(np.arange(len(labels))) ax1.set_ylabel(y1_name, color=colors[1]) ax1.tick_params("y", colors=colors[1]) ax1.set_ylim(min(y1_sorted), max(y1_sorted)) ax2 = ax1.twinx() ax2.set_ylabel(y2_name, color=colors[0]) ax2.tick_params("y", colors=colors[0]) ax2.set_ylim(min(y2), max(y2)) ax1.plot(y1_sorted, label=y1_name, color=colors[1], linewidth=4) ax2.plot(y2, label=y2_name, color=colors[0], linewidth=3) fig.tight_layout() visualize_callbacks(callbacks, plt.gcf()) if filename: plt.savefig(filename) else: plt.show() def plot_matrix( matrix, cmap="hot", filename=None, callbacks=None, ): plt.figure() plt.matshow(matrix, cmap=cmap) visualize_callbacks(callbacks, plt.gcf()) if filename: plt.savefig(filename) else: plt.show() def plot_distributions( distributions, labels=None, title=None, filename=None, callbacks=None, ): sns.set_style("whitegrid") colors = plt.get_cmap("tab10").colors fig, ax1 = plt.subplots() if title is not None: ax1.set_title(title) ax1.grid(which="both") ax1.grid(which="minor", alpha=0.5) ax1.grid(which="major", alpha=0.75) ax1.set_xlabel("class") ax1.set_ylabel("p") ax1.tick_params("y") for i, distribution in enumerate(distributions): ax1.plot( distribution, color=colors[i], alpha=0.6, label=labels[i] if labels is not None and i < len(labels) else f"Distribution {i}", ) ax1.legend(frameon=True) fig.tight_layout() visualize_callbacks(callbacks, plt.gcf()) if filename: plt.savefig(filename) else: plt.show() def plot_distributions_difference( distribution, labels=None, title=None, filename=None, callbacks=None, ): sns.set_style("whitegrid") colors = plt.get_cmap("tab10").colors fig, ax1 = plt.subplots() if title is not None: ax1.set_title(title) ax1.grid(which="both") ax1.grid(which="minor", alpha=0.5) ax1.grid(which="major", alpha=0.75) ax1.set_xlabel("class") ax1.set_ylabel("p") ax1.tick_params("y") ax1.plot(distribution, color=colors[0]) fig.tight_layout() visualize_callbacks(callbacks, plt.gcf()) if filename: plt.savefig(filename) else: plt.show() def bar_plot( xs, ys, decimals=4, labels=None, title=None, filename=None, callbacks=None, ): assert len(xs) == len(ys) assert len(xs) > 0 sns.set_style("whitegrid") # Dynamically set the size of the plot based on the number of labels # Use minimum size to prevent plot from being too small default_width, default_height = plt.rcParams.get("figure.figsize") width = max(default_width, len(labels) / 2) _, ax = plt.subplots(figsize=(width, default_height)) ax.grid(which="both") ax.grid(which="minor", alpha=0.5) ax.grid(which="major", alpha=0.75) if title is not None: ax.set_title(title) colors = plt.get_cmap("tab10").colors ax.invert_yaxis() # labels read top-to-bottom maximum = ys.max() ticks = np.arange(len(xs)) ax.set_yticks(ticks) if labels is None: ax.set_yticklabels(xs) else: ax.set_yticklabels(labels) ax.barh(ticks, ys, color=colors[0], align="center") for i, v in enumerate(ys): if v < maximum * (0.025 * decimals + 0.1): x = v + maximum * 0.01 horizontal_alignment = "left" else: x = v - maximum * 0.01 horizontal_alignment = "right" txt = ax.text( x, ticks[i], ("{:." + str(decimals) + "f}").format(v), color="white", fontweight="bold", verticalalignment="center", horizontalalignment=horizontal_alignment, ) txt.set_path_effects([PathEffects.withStroke(linewidth=3, foreground="black")]) plt.tight_layout() visualize_callbacks(callbacks, plt.gcf()) if filename: plt.savefig(filename) else: plt.show() def hyperopt_report(hyperparameters, hyperopt_results_df, metric, filename_template, float_precision=3): title = "Hyperopt Report: {}" for hp_name, hp_params in hyperparameters.items(): if hp_params[SPACE] in RAY_TUNE_INT_SPACES: hyperopt_int_plot( hyperopt_results_df, hp_name, metric, title.format(hp_name), filename_template.format(hp_name) if filename_template else None, ) elif hp_params[SPACE] in RAY_TUNE_FLOAT_SPACES: hyperopt_float_plot( hyperopt_results_df, hp_name, metric, title.format(hp_name), filename_template.format(hp_name) if filename_template else None, log_scale_x=hp_params["scale"] == "log" if "scale" in hp_params else False, ) elif hp_params[SPACE] in RAY_TUNE_CATEGORY_SPACES: hyperopt_category_plot( hyperopt_results_df, hp_name, metric, title.format(hp_name), filename_template.format(hp_name) if filename_template else None, ) else: # TODO: more research needed on how to handle RayTune "sample_from" search space raise ValueError( f"{hp_params[SPACE]} search space not supported in Ludwig. " # noqa: E713 f"Supported values are {RAY_TUNE_FLOAT_SPACES | RAY_TUNE_INT_SPACES | RAY_TUNE_CATEGORY_SPACES}." ) # quantize float and int columns for hp_name, hp_params in hyperparameters.items(): if hp_params[SPACE] in RAY_TUNE_INT_SPACES: num_distinct_values = len(hyperopt_results_df[hp_name].unique()) if num_distinct_values > INT_QUANTILES: hyperopt_results_df[hp_name] = pd.qcut(hyperopt_results_df[hp_name], q=INT_QUANTILES, precision=0) elif hp_params[SPACE] in RAY_TUNE_FLOAT_SPACES: hyperopt_results_df[hp_name] = pd.qcut( hyperopt_results_df[hp_name], q=FLOAT_QUANTILES, precision=float_precision, duplicates="drop", ) hyperopt_pair_plot( hyperopt_results_df, metric, title.format("pair plot"), filename_template.format("pair_plot") if filename_template else None, ) def hyperopt_int_plot(hyperopt_results_df, hp_name, metric, title, filename, log_scale_x=False, log_scale_y=True): sns.set_style("whitegrid") plt.figure() seaborn_figure = sns.scatterplot(x=hp_name, y=metric, data=hyperopt_results_df) seaborn_figure.set_title(title) if log_scale_x: seaborn_figure.set(xscale="log") if log_scale_y: seaborn_figure.set(yscale="log") seaborn_figure.xaxis.set_major_locator(ticker.MaxNLocator(integer=True)) seaborn_figure.xaxis.set_major_formatter(ticker.ScalarFormatter()) seaborn_figure.xaxis.set_minor_formatter(ticker.NullFormatter()) seaborn_figure.figure.tight_layout() if filename: seaborn_figure.figure.savefig(filename) else: seaborn_figure.figure.show() def hyperopt_float_plot(hyperopt_results_df, hp_name, metric, title, filename, log_scale_x=False, log_scale_y=True): sns.set_style("whitegrid") plt.figure() seaborn_figure = sns.scatterplot(x=hp_name, y=metric, data=hyperopt_results_df) seaborn_figure.set_title(title) seaborn_figure.set(ylabel=metric) if log_scale_x: seaborn_figure.set(xscale="log") if log_scale_y: seaborn_figure.set(yscale="log") seaborn_figure.figure.tight_layout() if filename: seaborn_figure.figure.savefig(filename) else: seaborn_figure.figure.show() def hyperopt_category_plot(hyperopt_results_df, hp_name, metric, title, filename, log_scale=True): sns.set_style("whitegrid") plt.figure() # Ensure that all parameter values have at least 2 trials, otherwise the Raincloud Plot will create awkward # looking "flat clouds" in the cloud part of the plot (the "rain" part is ok with 1 trial). In this case, # just use stripplots since they are categorical scatter plots. parameter_to_trial_count = hyperopt_results_df[hp_name].value_counts() parameter_to_trial_count = parameter_to_trial_count[parameter_to_trial_count < 2] if len(parameter_to_trial_count) != 0: seaborn_figure = sns.stripplot(x=hp_name, y=metric, data=hyperopt_results_df, size=5) else: seaborn_figure = pt.RainCloud( x=hp_name, y=metric, data=hyperopt_results_df, palette="Set2", bw=0.2, width_viol=0.7, point_size=6, cut=1, ) seaborn_figure.set_title(title) seaborn_figure.set(ylabel=metric) sns.despine() if log_scale: seaborn_figure.set(yscale="log") plt.tight_layout() if filename: plt.savefig(filename) else: plt.show() def hyperopt_pair_plot(hyperopt_results_df, metric, title, filename): params = sorted(list(hyperopt_results_df.keys())) params.remove(metric) num_param = len(params) # Pair plot is empty if there's only 1 parameter, so skip creating a pair plot if num_param == 1: return sns.set_style("white") fig = plt.figure(figsize=(20, 20)) fig.suptitle(title) gs = fig.add_gridspec(num_param, num_param) for i, param1 in enumerate(params): for j, param2 in enumerate(params): if i != j: ax = fig.add_subplot(gs[i, j]) heatmap = hyperopt_results_df.pivot_table(index=param1, columns=param2, values=metric, aggfunc="mean") sns.heatmap( heatmap, linewidths=1, cmap="viridis", cbar_kws={"label": metric}, ax=ax, ) plt.tight_layout(pad=5) if filename: plt.savefig(filename) else: plt.show() def hyperopt_hiplot( hyperopt_df, filename, ): import hiplot as hip experiment = hip.Experiment.from_dataframe(hyperopt_df) experiment.to_html(filename) ================================================ FILE: ludwig/vector_index/__init__.py ================================================ import logging from ludwig.api_annotations import DeveloperAPI from ludwig.vector_index.base import VectorIndex logger = logging.getLogger(__name__) FAISS = "faiss" ALL_INDICES = [FAISS] def get_faiss_index_cls() -> type[VectorIndex]: from ludwig.vector_index.faiss import FaissIndex return FaissIndex # TODO(travis): add other indexing structures vector_index_registry = { FAISS: get_faiss_index_cls, } @DeveloperAPI def get_vector_index_cls(type: str) -> type[VectorIndex]: return vector_index_registry[type]() ================================================ FILE: ludwig/vector_index/base.py ================================================ from abc import ABC, abstractmethod import numpy as np class VectorIndex(ABC): @abstractmethod def search(self, query: np.ndarray, k: int) -> np.ndarray: pass @abstractmethod def save(self, path: str): pass @classmethod @abstractmethod def from_path(cls, path: str) -> "VectorIndex": pass @classmethod @abstractmethod def from_embeddings(cls, embeddings: np.ndarray) -> "VectorIndex": pass ================================================ FILE: ludwig/vector_index/faiss.py ================================================ import faiss import numpy as np from ludwig.vector_index.base import VectorIndex class FaissIndex(VectorIndex): def __init__(self, index: faiss.Index): self.index = index def search(self, query: np.ndarray, k: int) -> np.ndarray: top_k = self.index.search(query.reshape(1, -1), k) return top_k[1].tolist()[0] def save(self, path: str): faiss.write_index(self.index, path) @classmethod def from_path(cls, path: str) -> "VectorIndex": index = faiss.read_index(path) return cls(index) @classmethod def from_embeddings(cls, embeddings: np.ndarray) -> "VectorIndex": index = faiss.IndexFlatL2(embeddings.shape[1]) index.add(embeddings) return cls(index) ================================================ FILE: ludwig/visualize.py ================================================ #! /usr/bin/env python # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import argparse import itertools import logging import os import sys from collections.abc import Callable from functools import partial from typing import Any import numpy as np import pandas as pd import sklearn from scipy.stats import entropy from sklearn.calibration import calibration_curve from sklearn.metrics import brier_score_loss from yaml import warnings from ludwig.api import EvaluationFrequency, TrainingStats from ludwig.api_annotations import DeveloperAPI, PublicAPI from ludwig.backend import LOCAL_BACKEND from ludwig.callbacks import Callback from ludwig.constants import ACCURACY, EDIT_DISTANCE, HITS_AT_K, LOSS, PREDICTIONS, SPACE, SPLIT from ludwig.contrib import add_contrib_callback_args from ludwig.utils import visualization_utils from ludwig.utils.data_utils import ( CACHEABLE_FORMATS, data_reader_registry, figure_data_format_dataset, load_array, load_from_file, load_json, replace_file_extension, ) from ludwig.utils.dataframe_utils import to_numpy_dataset, unflatten_df from ludwig.utils.fs_utils import path_exists from ludwig.utils.misc_utils import get_from_registry from ludwig.utils.print_utils import get_logging_level_registry from ludwig.utils.types import DataFrame logger = logging.getLogger(__name__) _PREDICTIONS_SUFFIX = "_predictions" _PROBABILITIES_SUFFIX = "_probabilities" _CSV_SUFFIX = "csv" _PARQUET_SUFFIX = "parquet" def _convert_ground_truth(ground_truth, feature_metadata, ground_truth_apply_idx, positive_label): """Converts non-np.array representation to be np.array.""" if "str2idx" in feature_metadata: # categorical output feature as binary ground_truth = _vectorize_ground_truth(ground_truth, feature_metadata["str2idx"], ground_truth_apply_idx) # convert category index to binary representation ground_truth = ground_truth == positive_label else: # binary output feature if "str2bool" in feature_metadata: # non-standard boolean representation ground_truth = _vectorize_ground_truth(ground_truth, feature_metadata["str2bool"], ground_truth_apply_idx) else: # standard boolean representation ground_truth = ground_truth.values # ensure positive_label is 1 for binary feature positive_label = 1 # convert to 0/1 representation and return return ground_truth.astype(int), positive_label def _vectorize_ground_truth( ground_truth: pd.Series, str2idx: np.array, ground_truth_apply_idx: bool = True ) -> np.array: # raw hdf5 files generated during preprocessing don't need to be converted with str2idx if not ground_truth_apply_idx: return np.vectorize(lambda x, y: x)(ground_truth, str2idx) try: return np.vectorize(_encode_categorical_feature)(ground_truth, str2idx) except KeyError as e: logger.info(f"Unable to vectorize using str2idx with exception {e}. Falling back to ignoring str2idx") return np.vectorize(lambda x, y: x)(ground_truth, str2idx) @DeveloperAPI def validate_conf_thresholds_and_probabilities_2d_3d(probabilities, threshold_output_feature_names): """Ensure probabilities and threshold output_feature_names arrays have two members each. :param probabilities: List of probabilities per model :param threshhold_output_feature_names: List of threshhold output_feature_names per model :raise: RuntimeError """ validation_mapping = { "probabilities": probabilities, "threshold_output_feature_names": threshold_output_feature_names, } for item, value in validation_mapping.items(): item_len = len(value) if item_len != 2: exception_message = "Two {} should be provided - " "{} was given.".format(item, item_len) logger.error(exception_message) raise RuntimeError(exception_message) @DeveloperAPI def load_data_for_viz(load_type, model_file_statistics, dtype=int, ground_truth_split=2) -> dict[str, Any]: """Load JSON files (training stats, evaluation stats...) for a list of models. :param load_type: type of the data loader to be used. :param model_file_statistics: JSON file or list of json files containing any model experiment stats. :return List of training statistics loaded as json objects. """ supported_load_types = dict( load_json=load_json, load_from_file=partial(load_from_file, dtype=dtype, ground_truth_split=ground_truth_split), ) loader = supported_load_types[load_type] # Loads training stats from JSON file(s). try: stats_per_model = [loader(stats_f) for stats_f in model_file_statistics] except (TypeError, AttributeError): logger.exception(f"Unable to open model statistics file {model_file_statistics}!") raise return stats_per_model def _load_training_stats(data: dict) -> TrainingStats: """Construct a TrainingStats from a dict loaded from JSON.""" eval_freq = data.get("evaluation_frequency") if isinstance(eval_freq, dict): eval_freq = EvaluationFrequency(**eval_freq) elif eval_freq is None: eval_freq = EvaluationFrequency() return TrainingStats( training=data.get("training", {}), validation=data.get("validation", {}), test=data.get("test", {}), evaluation_frequency=eval_freq, ) @DeveloperAPI def load_training_stats_for_viz(load_type, model_file_statistics, dtype=int, ground_truth_split=2) -> TrainingStats: """Load model file data (specifically training stats) for a list of models. :param load_type: type of the data loader to be used. :param model_file_statistics: JSON file or list of json files containing any model experiment stats. :return List of model statistics loaded as TrainingStats objects. """ stats_per_model = load_data_for_viz( load_type, model_file_statistics, dtype=dtype, ground_truth_split=ground_truth_split ) try: stats_per_model = [_load_training_stats(j) for j in stats_per_model] except Exception: logger.exception(f"Failed to load model statistics {model_file_statistics}!") raise return stats_per_model @DeveloperAPI def convert_to_list(item): """If item is not list class instance or None put inside a list. :param item: object to be checked and converted :return: original item if it is a list instance or list containing the item. """ return item if item is None or isinstance(item, list) else [item] def _validate_output_feature_name_from_train_stats(output_feature_name, train_stats_per_model): """Validate prediction output_feature_name from model train stats and return it as list. :param output_feature_name: output_feature_name containing ground truth :param train_stats_per_model: list of per model train stats :return output_feature_names: list of output_feature_name(s) containing ground truth """ output_feature_names_set = set() for train_stats in train_stats_per_model: for key in itertools.chain(train_stats.training.keys(), train_stats.validation.keys(), train_stats.test.keys()): output_feature_names_set.add(key) try: if output_feature_name in output_feature_names_set: return [output_feature_name] else: return output_feature_names_set # raised if output_feature_name is empty iterable (e.g. [] in set()) except TypeError: return output_feature_names_set def _validate_output_feature_name_from_test_stats(output_feature_name, test_stats_per_model): """Validate prediction output_feature_name from model test stats and return it as list. :param output_feature_name: output_feature_name containing ground truth :param test_stats_per_model: list of per model test stats :return output_feature_names: list of output_feature_name(s) containing ground truth """ output_feature_names_set = set() for ls in test_stats_per_model: for key in ls: output_feature_names_set.add(key) try: if output_feature_name in output_feature_names_set: return [output_feature_name] else: return output_feature_names_set # raised if output_feature_name is empty iterable (e.g. [] in set()) except TypeError: return output_feature_names_set def _encode_categorical_feature(raw: np.array, str2idx: dict) -> np.array: """Encodes raw categorical string value to encoded numeric value. Args: :param raw: (np.array) string categorical representation :param str2idx: (dict) dictionary that maps string representation to encoded value. Returns: np.array """ return str2idx[raw] def _get_ground_truth_df(ground_truth: str) -> DataFrame: # determine ground truth data format and get appropriate reader data_format = figure_data_format_dataset(ground_truth) if data_format not in CACHEABLE_FORMATS: raise ValueError( "{} is not supported for ground truth file, " "valid types are {}".format(data_format, CACHEABLE_FORMATS) ) reader = get_from_registry(data_format, data_reader_registry) # retrieve ground truth from source data set if data_format in {"csv", "tsv"}: return reader(ground_truth, dtype=None, df_lib=pd) # allow type inference return reader(ground_truth, df_lib=pd) def _extract_ground_truth_values( ground_truth: str | DataFrame, output_feature_name: str, ground_truth_split: int, split_file: str | None = None, ) -> pd.Series: """Helper function to extract ground truth values. Args: :param ground_truth: (str, DataFrame) path to source data containing ground truth or ground truth dataframe :param output_feature_name: (str) output feature name for ground truth values. :param ground_truth_split: (int) dataset split to use for ground truth, defaults to 2. :param split_file: (Union[str, None]) optional file path to split values. # Return :return pd.Series: ground truth values from source data set """ ground_truth_df = _get_ground_truth_df(ground_truth) if isinstance(ground_truth, str) else ground_truth # extract ground truth for visualization if SPLIT in ground_truth_df: # get split value from source data set split = ground_truth_df[SPLIT] gt = ground_truth_df[output_feature_name][split == ground_truth_split] elif split_file is not None: # retrieve from split file if split_file.endswith(".csv"): # Legacy code path for previous split file format warnings.warn( "Using a CSV split file is deprecated and will be removed in a future version. " "Please retrain or convert to Parquet", DeprecationWarning, ) split = load_array(split_file) mask = split == ground_truth_split else: split = pd.read_parquet(split_file) # Realign index from the split file with the ground truth to account for # dropped rows during preprocessing. # https://stackoverflow.com/a/65731168 mask = split.iloc[:, 0] == ground_truth_split mask = mask.reindex(ground_truth_df.index, fill_value=False) gt = ground_truth_df[output_feature_name][mask] else: # use all the data in ground_truth gt = ground_truth_df[output_feature_name] return gt def _get_cols_from_predictions(predictions_paths, cols, metadata): results_per_model = [] for predictions_path in predictions_paths: pred_df = pd.read_parquet(predictions_path) shapes_fname = replace_file_extension(predictions_path, "shapes.json") if path_exists(shapes_fname): column_shapes = load_json(shapes_fname) pred_df = unflatten_df(pred_df, column_shapes, LOCAL_BACKEND.df_engine) for col in cols: # Convert categorical features back to indices if col.endswith(_PREDICTIONS_SUFFIX): feature_name = col[: -len(_PREDICTIONS_SUFFIX)] feature_metadata = metadata[feature_name] if "str2idx" in feature_metadata: pred_df[col] = pred_df[col].map(lambda x: feature_metadata["str2idx"][x]) pred_df = to_numpy_dataset(pred_df, LOCAL_BACKEND) results_per_model += [pred_df[col] for col in cols] return results_per_model @DeveloperAPI def generate_filename_template_path(output_dir, filename_template): """Ensure path to template file can be constructed given an output dir. Create output directory if yet does exist. :param output_dir: Directory that will contain the filename_template file :param filename_template: name of the file template to be appended to the filename template path :return: path to filename template inside the output dir or None if the output dir is None """ if output_dir: os.makedirs(output_dir, exist_ok=True) return os.path.join(output_dir, filename_template) return None @DeveloperAPI def compare_performance_cli(test_statistics: str | list[str], **kwargs: dict) -> None: """Load model data from files to be shown by compare_performance. # Inputs :param test_statistics: (Union[str, List[str]]) path to experiment test statistics file. :param kwargs: (dict) parameters for the requested visualizations. # Return :return None: """ test_stats_per_model = load_data_for_viz("load_json", test_statistics) compare_performance(test_stats_per_model, **kwargs) @DeveloperAPI def learning_curves_cli(training_statistics: str | list[str], **kwargs: dict) -> None: """Load model data from files to be shown by learning_curves. # Inputs :param training_statistics: (Union[str, List[str]]) path to experiment training statistics file :param kwargs: (dict) parameters for the requested visualizations. # Return :return None: """ train_stats_per_model = load_training_stats_for_viz("load_json", training_statistics) learning_curves(train_stats_per_model, **kwargs) @DeveloperAPI def compare_classifiers_performance_from_prob_cli( probabilities: str | list[str], ground_truth: str, ground_truth_split: int, split_file: str, ground_truth_metadata: str, output_feature_name: str, output_directory: str, **kwargs: dict, ) -> None: """Load model data from files to be shown by compare_classifiers_from_prob. # Inputs :param probabilities: (Union[str, List[str]]) list of prediction results file names to extract probabilities from. :param ground_truth: (str) path to ground truth file :param ground_truth_split: (str) type of ground truth split - `0` for training split, `1` for validation split or 2 for `'test'` split. :param split_file: (str, None) file path to csv file containing split values :param ground_truth_metadata: (str) file path to feature metadata json file created during training. :param output_feature_name: (str) name of the output feature to visualize. :param output_directory: (str) name of output directory containing training results. :param kwargs: (dict) parameters for the requested visualizations. # Return :return None: """ # retrieve feature metadata to convert raw predictions to encoded value metadata = load_json(ground_truth_metadata) # translate string to encoded numeric value # retrieve ground truth from source data set ground_truth = _extract_ground_truth_values( ground_truth, output_feature_name, ground_truth_split, split_file=split_file ) col = f"{output_feature_name}{_PROBABILITIES_SUFFIX}" probabilities_per_model = _get_cols_from_predictions(probabilities, [col], metadata) compare_classifiers_performance_from_prob( probabilities_per_model, ground_truth, metadata, output_feature_name, output_directory=output_directory, **kwargs, ) @DeveloperAPI def compare_classifiers_performance_from_pred_cli( predictions: list[str], ground_truth: str, ground_truth_metadata: str, ground_truth_split: int, split_file: str, output_feature_name: str, output_directory: str, **kwargs: dict, ) -> None: """Load model data from files to be shown by compare_classifiers_from_pred. # Inputs :param predictions: (List[str]) list of prediction results file names to extract predictions from. :param ground_truth: (str) path to ground truth file. :param ground_truth_metadata: (str) path to ground truth metadata file. :param ground_truth_split: (str) type of ground truth split - `0` for training split, `1` for validation split or 2 for `'test'` split. :param split_file: (str, None) file path to csv file containing split values :param ground_truth_metadata: (str) file path to feature metadata json file created during training. :param output_feature_name: (str) name of the output feature to visualize. :param output_directory: (str) name of output directory containing training results. :param kwargs: (dict) parameters for the requested visualizations. # Return :return None: """ # retrieve feature metadata to convert raw predictions to encoded value metadata = load_json(ground_truth_metadata) # retrieve ground truth from source data set ground_truth = _extract_ground_truth_values(ground_truth, output_feature_name, ground_truth_split, split_file) col = f"{output_feature_name}{_PREDICTIONS_SUFFIX}" predictions_per_model = _get_cols_from_predictions(predictions, [col], metadata) compare_classifiers_performance_from_pred( predictions_per_model, ground_truth, metadata, output_feature_name, output_directory=output_directory, **kwargs ) @DeveloperAPI def compare_classifiers_performance_subset_cli( probabilities: str | list[str], ground_truth: str, ground_truth_split: int, split_file: str, ground_truth_metadata: str, output_feature_name: str, output_directory: str, **kwargs: dict, ) -> None: """Load model data from files to be shown by compare_classifiers_subset. # Inputs :param probabilities: (Union[str, List[str]]) list of prediction results file names to extract probabilities from. :param ground_truth: (str) path to ground truth file :param ground_truth_split: (str) type of ground truth split - `0` for training split, `1` for validation split or 2 for `'test'` split. :param split_file: (str, None) file path to csv file containing split values :param ground_truth_metadata: (str) file path to feature metadata json file created during training. :param output_feature_name: (str) name of the output feature to visualize. :param output_directory: (str) name of output directory containing training results. :param kwargs: (dict) parameters for the requested visualizations. # Return :return None: """ # retrieve feature metadata to convert raw predictions to encoded value metadata = load_json(ground_truth_metadata) # retrieve ground truth from source data set ground_truth = _extract_ground_truth_values(ground_truth, output_feature_name, ground_truth_split, split_file) col = f"{output_feature_name}{_PROBABILITIES_SUFFIX}" probabilities_per_model = _get_cols_from_predictions(probabilities, [col], metadata) compare_classifiers_performance_subset( probabilities_per_model, ground_truth, metadata, output_feature_name, output_directory=output_directory, **kwargs, ) @DeveloperAPI def compare_classifiers_performance_changing_k_cli( probabilities: str | list[str], ground_truth: str, ground_truth_split: int, split_file: str, ground_truth_metadata: str, output_feature_name: str, output_directory: str, **kwargs: dict, ) -> None: """Load model data from files to be shown by compare_classifiers_changing_k. # Inputs :param probabilities: (Union[str, List[str]]) list of prediction results file names to extract probabilities from. :param ground_truth: (str) path to ground truth file :param ground_truth_split: (str) type of ground truth split - `0` for training split, `1` for validation split or 2 for `'test'` split. :param split_file: (str, None) file path to csv file containing split values :param split_file: (str, None) file path to csv file containing split values :param ground_truth_metadata: (str) file path to feature metadata json file created during training. :param output_feature_name: (str) name of the output feature to visualize. :param output_directory: (str) name of output directory containing training results. :param kwargs: (dict) parameters for the requested visualizations. # Return :return None: """ # retrieve feature metadata to convert raw predictions to encoded value metadata = load_json(ground_truth_metadata) # retrieve ground truth from source data set ground_truth = _extract_ground_truth_values(ground_truth, output_feature_name, ground_truth_split, split_file) col = f"{output_feature_name}{_PROBABILITIES_SUFFIX}" probabilities_per_model = _get_cols_from_predictions(probabilities, [col], metadata) compare_classifiers_performance_changing_k( probabilities_per_model, ground_truth, metadata, output_feature_name, output_directory=output_directory, **kwargs, ) @DeveloperAPI def compare_classifiers_multiclass_multimetric_cli( test_statistics: str | list[str], ground_truth_metadata: str, **kwargs: dict ) -> None: """Load model data from files to be shown by compare_classifiers_multiclass. # Inputs :param test_statistics: (Union[str, List[str]]) path to experiment test statistics file. :param ground_truth_metadata: (str) path to ground truth metadata file. :param kwargs: (dict) parameters for the requested visualizations. # Return :return None: """ test_stats_per_model = load_data_for_viz("load_json", test_statistics) metadata = load_json(ground_truth_metadata) compare_classifiers_multiclass_multimetric(test_stats_per_model, metadata=metadata, **kwargs) @DeveloperAPI def compare_classifiers_predictions_cli( predictions: list[str], ground_truth: str, ground_truth_split: int, split_file: str, ground_truth_metadata: str, output_feature_name: str, output_directory: str, **kwargs: dict, ) -> None: """Load model data from files to be shown by compare_classifiers_predictions. # Inputs :param predictions: (List[str]) list of prediction results file names to extract predictions from. :param ground_truth: (str) path to ground truth file. :param ground_truth_split: (str) type of ground truth split - `0` for training split, `1` for validation split or 2 for `'test'` split. :param split_file: (str, None) file path to csv file containing split values :param ground_truth_metadata: (str) file path to feature metadata json file created during training. :param output_feature_name: (str) name of the output feature to visualize. :param output_directory: (str) name of output directory containing training results. :param kwargs: (dict) parameters for the requested visualizations. # Return :return None: """ # retrieve feature metadata to convert raw predictions to encoded value metadata = load_json(ground_truth_metadata) # retrieve ground truth from source data set ground_truth = _extract_ground_truth_values(ground_truth, output_feature_name, ground_truth_split, split_file) col = f"{output_feature_name}{_PREDICTIONS_SUFFIX}" predictions_per_model = _get_cols_from_predictions(predictions, [col], metadata) compare_classifiers_predictions( predictions_per_model, ground_truth, metadata, output_feature_name, output_directory=output_directory, **kwargs ) @DeveloperAPI def compare_classifiers_predictions_distribution_cli( predictions: list[str], ground_truth: str, ground_truth_split: int, split_file: str, ground_truth_metadata: str, output_feature_name: str, output_directory: str, **kwargs: dict, ) -> None: """Load model data from files to be shown by compare_predictions_distribution. # Inputs :param predictions: (List[str]) list of prediction results file names to extract predictions from. :param ground_truth: (str) path to ground truth file. :param ground_truth_split: (str) type of ground truth split - `0` for training split, `1` for validation split or 2 for `'test'` split. :param split_file: (str, None) file path to csv file containing split values :param ground_truth_metadata: (str) file path to feature metadata json file created during training. :param output_feature_name: (str) name of the output feature to visualize. :param output_directory: (str) name of output directory containing training results. :param kwargs: (dict) parameters for the requested visualizations. # Return :return None: """ # retrieve feature metadata to convert raw predictions to encoded value metadata = load_json(ground_truth_metadata) # retrieve ground truth from source data set ground_truth = _extract_ground_truth_values(ground_truth, output_feature_name, ground_truth_split, split_file) col = f"{output_feature_name}{_PREDICTIONS_SUFFIX}" predictions_per_model = _get_cols_from_predictions(predictions, [col], metadata) compare_classifiers_predictions_distribution( predictions_per_model, ground_truth, metadata, output_feature_name, output_directory=output_directory, **kwargs ) @DeveloperAPI def confidence_thresholding_cli( probabilities: str | list[str], ground_truth: str, ground_truth_split: int, split_file: str, ground_truth_metadata: str, output_feature_name: str, output_directory: str, **kwargs: dict, ) -> None: """Load model data from files to be shown by confidence_thresholding. # Inputs :param probabilities: (Union[str, List[str]]) list of prediction results file names to extract probabilities from. :param ground_truth: (str) path to ground truth file. :param ground_truth_split: (str) type of ground truth split - `0` for training split, `1` for validation split or 2 for `'test'` split. :param split_file: (str, None) file path to csv file containing split values :param ground_truth_metadata: (str) file path to feature metadata json file created during training. :param output_feature_name: (str) name of the output feature to visualize. :param output_directory: (str) name of output directory containing training results. :param kwargs: (dict) parameters for the requested visualizations. # Return :return None: """ # retrieve feature metadata to convert raw predictions to encoded value metadata = load_json(ground_truth_metadata) # retrieve ground truth from source data set ground_truth = _extract_ground_truth_values(ground_truth, output_feature_name, ground_truth_split, split_file) col = f"{output_feature_name}{_PROBABILITIES_SUFFIX}" probabilities_per_model = _get_cols_from_predictions(probabilities, [col], metadata) confidence_thresholding( probabilities_per_model, ground_truth, metadata, output_feature_name, output_directory=output_directory, **kwargs, ) @DeveloperAPI def confidence_thresholding_data_vs_acc_cli( probabilities: str | list[str], ground_truth: str, ground_truth_split: int, split_file: str, ground_truth_metadata: str, output_feature_name: str, output_directory: str, **kwargs: dict, ) -> None: """Load model data from files to be shown by confidence_thresholding_data_vs_acc_cli. # Inputs :param probabilities: (Union[str, List[str]]) list of prediction results file names to extract probabilities from. :param ground_truth: (str) path to ground truth file. :param ground_truth_split: (str) type of ground truth split - `0` for training split, `1` for validation split or 2 for `'test'` split. :param split_file: (str, None) file path to csv file containing split values :param ground_truth_metadata: (str) file path to feature metadata json file created during training. :param output_feature_name: (str) name of the output feature to visualize. :param output_directory: (str) name of output directory containing training results. :param kwargs: (dict) parameters for the requested visualizations. # Return :return None: """ # retrieve feature metadata to convert raw predictions to encoded value metadata = load_json(ground_truth_metadata) # retrieve ground truth from source data set ground_truth = _extract_ground_truth_values(ground_truth, output_feature_name, ground_truth_split, split_file) col = f"{output_feature_name}{_PROBABILITIES_SUFFIX}" probabilities_per_model = _get_cols_from_predictions(probabilities, [col], metadata) confidence_thresholding_data_vs_acc( probabilities_per_model, ground_truth, metadata, output_feature_name, output_directory=output_directory, **kwargs, ) @DeveloperAPI def confidence_thresholding_data_vs_acc_subset_cli( probabilities: str | list[str], ground_truth: str, ground_truth_split: int, split_file: str, ground_truth_metadata: str, output_feature_name: str, output_directory: str, **kwargs: dict, ) -> None: """Load model data from files to be shown by confidence_thresholding_data_vs_acc_subset. # Inputs :param probabilities: (Union[str, List[str]]) list of prediction results file names to extract probabilities from. :param ground_truth: (str) path to ground truth file. :param ground_truth_split: (str) type of ground truth split - `0` for training split, `1` for validation split or 2 for `'test'` split. :param split_file: (str, None) file path to csv file containing split values :param ground_truth_metadata: (str) file path to feature metadata json file created during training. :param output_feature_name: (str) name of the output feature to visualize. :param output_directory: (str) name of output directory containing training results. :param kwargs: (dict) parameters for the requested visualizations. # Return :return None: """ # retrieve feature metadata to convert raw predictions to encoded value metadata = load_json(ground_truth_metadata) # retrieve ground truth from source data set ground_truth = _extract_ground_truth_values(ground_truth, output_feature_name, ground_truth_split, split_file) col = f"{output_feature_name}{_PROBABILITIES_SUFFIX}" probabilities_per_model = _get_cols_from_predictions(probabilities, [col], metadata) confidence_thresholding_data_vs_acc_subset( probabilities_per_model, ground_truth, metadata, output_feature_name, output_directory=output_directory, **kwargs, ) @DeveloperAPI def confidence_thresholding_data_vs_acc_subset_per_class_cli( probabilities: str | list[str], ground_truth: str, ground_truth_metadata: str, ground_truth_split: int, split_file: str, output_feature_name: str, output_directory: str, **kwargs: dict, ) -> None: """Load model data from files to be shown by compare_classifiers_multiclass. # Inputs :param probabilities: (Union[str, List[str]]) list of prediction results file names to extract probabilities from. :param ground_truth: (str) path to ground truth file. :param ground_truth_metadata: (str) path to ground truth metadata file. :param ground_truth_split: (str) type of ground truth split - `0` for training split, `1` for validation split or 2 for `'test'` split. :param split_file: (str, None) file path to csv file containing split values :param output_feature_name: (str) name of the output feature to visualize. :param output_directory: (str) name of output directory containing training results. :param kwargs: (dict) parameters for the requested visualizations. # Return :return None: """ # retrieve feature metadata to convert raw predictions to encoded value metadata = load_json(ground_truth_metadata) # retrieve ground truth from source data set ground_truth = _extract_ground_truth_values(ground_truth, output_feature_name, ground_truth_split, split_file) col = f"{output_feature_name}{_PROBABILITIES_SUFFIX}" probabilities_per_model = _get_cols_from_predictions(probabilities, [col], metadata) confidence_thresholding_data_vs_acc_subset_per_class( probabilities_per_model, ground_truth, metadata, output_feature_name, output_directory=output_directory, **kwargs, ) @DeveloperAPI def confidence_thresholding_2thresholds_2d_cli( probabilities: str | list[str], ground_truth: str, ground_truth_split: int, split_file: str, ground_truth_metadata: str, threshold_output_feature_names: list[str], output_directory: str, **kwargs: dict, ) -> None: """Load model data from files to be shown by confidence_thresholding_2thresholds_2d_cli. # Inputs :param probabilities: (Union[str, List[str]]) list of prediction results file names to extract probabilities from. :param ground_truth: (str) path to ground truth file. :param ground_truth_split: (str) type of ground truth split - `0` for training split, `1` for validation split or 2 for `'test'` split. :param split_file: (str, None) file path to csv file containing split values :param ground_truth_metadata: (str) file path to feature metadata json file created during training. :param threshold_output_feature_names: (List[str]) name of the output feature to visualizes. :param output_directory: (str) name of output directory containing training results. :param kwargs: (dict) parameters for the requested visualizations. # Return :return None: """ # retrieve feature metadata to convert raw predictions to encoded value metadata = load_json(ground_truth_metadata) # retrieve ground truth from source data set ground_truth0 = _extract_ground_truth_values( ground_truth, threshold_output_feature_names[0], ground_truth_split, split_file ) ground_truth1 = _extract_ground_truth_values( ground_truth, threshold_output_feature_names[1], ground_truth_split, split_file ) cols = [f"{feature_name}{_PROBABILITIES_SUFFIX}" for feature_name in threshold_output_feature_names] probabilities_per_model = _get_cols_from_predictions(probabilities, cols, metadata) confidence_thresholding_2thresholds_2d( probabilities_per_model, [ground_truth0, ground_truth1], metadata, threshold_output_feature_names, output_directory=output_directory, **kwargs, ) @DeveloperAPI def confidence_thresholding_2thresholds_3d_cli( probabilities: str | list[str], ground_truth: str, ground_truth_split: int, split_file: str, ground_truth_metadata: str, threshold_output_feature_names: list[str], output_directory: str, **kwargs: dict, ) -> None: """Load model data from files to be shown by confidence_thresholding_2thresholds_3d_cli. # Inputs :param probabilities: (Union[str, List[str]]) list of prediction results file names to extract probabilities from. :param ground_truth: (str) path to ground truth file. :param ground_truth_split: (str) type of ground truth split - `0` for training split, `1` for validation split or 2 for `'test'` split. :param split_file: (str, None) file path to csv file containing split values :param ground_truth_metadata: (str) file path to feature metadata json file created during training. :param threshold_output_feature_names: (List[str]) name of the output feature to visualizes. :param output_directory: (str) name of output directory containing training results. :param kwargs: (dict) parameters for the requested visualizations. # Return :return None: """ # retrieve feature metadata to convert raw predictions to encoded value metadata = load_json(ground_truth_metadata) # retrieve ground truth from source data set ground_truth0 = _extract_ground_truth_values( ground_truth, threshold_output_feature_names[0], ground_truth_split, split_file ) ground_truth1 = _extract_ground_truth_values( ground_truth, threshold_output_feature_names[1], ground_truth_split, split_file ) cols = [f"{feature_name}{_PROBABILITIES_SUFFIX}" for feature_name in threshold_output_feature_names] probabilities_per_model = _get_cols_from_predictions(probabilities, cols, metadata) confidence_thresholding_2thresholds_3d( probabilities_per_model, [ground_truth0, ground_truth1], metadata, threshold_output_feature_names, output_directory=output_directory, **kwargs, ) @DeveloperAPI def binary_threshold_vs_metric_cli( probabilities: str | list[str], ground_truth: str, ground_truth_split: int, split_file: str, ground_truth_metadata: str, output_feature_name: str, output_directory: str, **kwargs: dict, ) -> None: """Load model data from files to be shown by binary_threshold_vs_metric_cli. # Inputs :param probabilities: (Union[str, List[str]]) list of prediction results file names to extract probabilities from. :param ground_truth: (str) path to ground truth file. :param ground_truth_split: (str) type of ground truth split - `0` for training split, `1` for validation split or 2 for `'test'` split. :param split_file: (str, None) file path to csv file containing split values :param ground_truth_metadata: (str) file path to feature metadata json file created during training. :param output_feature_name: (str) name of the output feature to visualize. :param output_directory: (str) name of output directory containing training results. :param kwargs: (dict) parameters for the requested visualizations. # Return :return None: """ # retrieve feature metadata to convert raw predictions to encoded value metadata = load_json(ground_truth_metadata) # retrieve ground truth from source data set ground_truth = _extract_ground_truth_values(ground_truth, output_feature_name, ground_truth_split, split_file) col = f"{output_feature_name}{_PROBABILITIES_SUFFIX}" probabilities_per_model = _get_cols_from_predictions(probabilities, [col], metadata) binary_threshold_vs_metric( probabilities_per_model, ground_truth, metadata, output_feature_name, output_directory=output_directory, **kwargs, ) @DeveloperAPI def precision_recall_curves_cli( probabilities: str | list[str], ground_truth: str, ground_truth_split: int, split_file: str, ground_truth_metadata: str, output_feature_name: str, output_directory: str, **kwargs: dict, ) -> None: """Load model data from files to be shown by precision_recall_curves_cli. Args :param probabilities: (Union[str, List[str]]) list of prediction results file names to extract probabilities from. :param ground_truth: (str) path to ground truth file. :param ground_truth_split: (str) type of ground truth split - `0` for training split, `1` for validation split or 2 for `'test'` split. :param split_file: (str, None) file path to csv file containing split values :param ground_truth_metadata: (str) file path to feature metadata json file created during training. :param output_feature_name: (str) name of the output feature to visualize. :param output_directory: (str) name of output directory containing training results. :param kwargs: (dict) parameters for the requested visualizations. Return :return None: """ # retrieve feature metadata to convert raw predictions to encoded value metadata = load_json(ground_truth_metadata) # retrieve ground truth from source data set ground_truth = _extract_ground_truth_values(ground_truth, output_feature_name, ground_truth_split, split_file) col = f"{output_feature_name}{_PROBABILITIES_SUFFIX}" probabilities_per_model = _get_cols_from_predictions(probabilities, [col], metadata) precision_recall_curves( probabilities_per_model, ground_truth, metadata, output_feature_name, output_directory=output_directory, **kwargs, ) @DeveloperAPI def roc_curves_cli( probabilities: str | list[str], ground_truth: str, ground_truth_split: int, split_file: str, ground_truth_metadata: str, output_feature_name: str, output_directory: str, **kwargs: dict, ) -> None: """Load model data from files to be shown by roc_curves_cli. # Inputs :param probabilities: (Union[str, List[str]]) list of prediction results file names to extract probabilities from. :param ground_truth: (str) path to ground truth file. :param ground_truth_split: (str) type of ground truth split - `0` for training split, `1` for validation split or 2 for `'test'` split. :param split_file: (str, None) file path to csv file containing split values :param ground_truth_metadata: (str) file path to feature metadata json file created during training. :param output_feature_name: (str) name of the output feature to visualize. :param output_directory: (str) name of output directory containing training results. :param kwargs: (dict) parameters for the requested visualizations. # Return :return None: """ # retrieve feature metadata to convert raw predictions to encoded value metadata = load_json(ground_truth_metadata) # retrieve ground truth from source data set ground_truth = _extract_ground_truth_values(ground_truth, output_feature_name, ground_truth_split, split_file) col = f"{output_feature_name}{_PROBABILITIES_SUFFIX}" probabilities_per_model = _get_cols_from_predictions(probabilities, [col], metadata) roc_curves( probabilities_per_model, ground_truth, metadata, output_feature_name, output_directory=output_directory, **kwargs, ) @DeveloperAPI def roc_curves_from_test_statistics_cli(test_statistics: str | list[str], **kwargs: dict) -> None: """Load model data from files to be shown by roc_curves_from_test_statistics_cli. # Inputs :param test_statistics: (Union[str, List[str]]) path to experiment test statistics file. :param kwargs: (dict) parameters for the requested visualizations. # Return :return None: """ test_stats_per_model = load_data_for_viz("load_json", test_statistics) roc_curves_from_test_statistics(test_stats_per_model, **kwargs) @DeveloperAPI def precision_recall_curves_from_test_statistics_cli(test_statistics: str | list[str], **kwargs: dict) -> None: """Load model data from files to be shown by precision_recall_curves_from_test_statistics_cli. Args: :param test_statistics: (Union[str, List[str]]) path to experiment test statistics file. :param kwargs: (dict) parameters for the requested visualizations. Return: :return None: """ test_stats_per_model = load_data_for_viz("load_json", test_statistics) precision_recall_curves_from_test_statistics(test_stats_per_model, **kwargs) @DeveloperAPI def calibration_1_vs_all_cli( probabilities: str | list[str], ground_truth: str, ground_truth_split: int, split_file: str, ground_truth_metadata: str, output_feature_name: str, output_directory: str, output_feature_proc_name: str | None = None, ground_truth_apply_idx: bool = True, **kwargs: dict, ) -> None: """Load model data from files to be shown by calibration_1_vs_all_cli. # Inputs :param probabilities: (Union[str, List[str]]) list of prediction results file names to extract probabilities from. :param ground_truth: (str) path to ground truth file :param ground_truth_split: (str) type of ground truth split - `0` for training split, `1` for validation split or 2 for `'test'` split. :param split_file: (str, None) file path to csv file containing split values :param ground_truth_metadata: (str) file path to feature metadata json file created during training. :param output_feature_name: (str) name of the output feature to visualize. :param output_directory: (str) name of output directory containing training results. :param output_feature_proc_name: (str) name of the output feature column in ground_truth. If ground_truth is a preprocessed parquet or hdf5 file, the column name will be _ :param ground_truth_apply_idx: (bool, default: `True`) whether to use metadata['str2idx'] in np.vectorize :param kwargs: (dict) parameters for the requested visualizations. # Return :return None: """ # retrieve feature metadata to convert raw predictions to encoded value metadata = load_json(ground_truth_metadata) # retrieve ground truth from source data set ground_truth = _extract_ground_truth_values( ground_truth, output_feature_proc_name or output_feature_name, ground_truth_split, split_file ) feature_metadata = metadata[output_feature_name] ground_truth = _vectorize_ground_truth(ground_truth, feature_metadata["str2idx"], ground_truth_apply_idx) col = f"{output_feature_name}{_PROBABILITIES_SUFFIX}" probabilities_per_model = _get_cols_from_predictions(probabilities, [col], metadata) calibration_1_vs_all( probabilities_per_model, ground_truth, metadata, output_feature_name, output_directory=output_directory, **kwargs, ) @DeveloperAPI def calibration_multiclass_cli( probabilities: str | list[str], ground_truth: str, ground_truth_split: int, split_file: str, ground_truth_metadata: str, output_feature_name: str, output_directory: str, **kwargs: dict, ) -> None: """Load model data from files to be shown by calibration_multiclass_cli. # Inputs :param probabilities: (Union[str, List[str]]) list of prediction results file names to extract probabilities from. :param ground_truth: (str) path to ground truth file :param ground_truth_split: (str) type of ground truth split - `0` for training split, `1` for validation split or 2 for `'test'` split. :param split_file: (str, None) file path to csv file containing split values :param ground_truth_metadata: (str) file path to feature metadata json file created during training. :param output_feature_name: (str) name of the output feature to visualize. :param output_directory: (str) name of output directory containing training results. :param kwargs: (dict) parameters for the requested visualizations. # Return :return None: """ # retrieve feature metadata to convert raw predictions to encoded value metadata = load_json(ground_truth_metadata) # retrieve ground truth from source data set ground_truth = _extract_ground_truth_values(ground_truth, output_feature_name, ground_truth_split, split_file) col = f"{output_feature_name}{_PROBABILITIES_SUFFIX}" probabilities_per_model = _get_cols_from_predictions(probabilities, [col], metadata) calibration_multiclass( probabilities_per_model, ground_truth, metadata, output_feature_name, output_directory=output_directory, **kwargs, ) @DeveloperAPI def confusion_matrix_cli(test_statistics: str | list[str], ground_truth_metadata: str, **kwargs: dict) -> None: """Load model data from files to be shown by confusion_matrix. # Inputs :param test_statistics: (Union[str, List[str]]) path to experiment test statistics file. :param ground_truth_metadata: (str) path to ground truth metadata file. :param kwargs: (dict) parameters for the requested visualizations. # Return :return None: """ test_stats_per_model = load_data_for_viz("load_json", test_statistics) metadata = load_json(ground_truth_metadata) confusion_matrix(test_stats_per_model, metadata, **kwargs) @DeveloperAPI def frequency_vs_f1_cli(test_statistics: str | list[str], ground_truth_metadata: str, **kwargs: dict) -> None: """Load model data from files to be shown by frequency_vs_f1. # Inputs :param test_statistics: (Union[str, List[str]]) path to experiment test statistics file. :param ground_truth_metadata: (str) path to ground truth metadata file. :param kwargs: (dict) parameters for the requested visualizations. # Return :return None: """ test_stats_per_model = load_data_for_viz("load_json", test_statistics) metadata = load_json(ground_truth_metadata) frequency_vs_f1(test_stats_per_model, metadata, **kwargs) @DeveloperAPI def learning_curves( train_stats_per_model: list[dict], output_feature_name: str | None = None, model_names: str | list[str] = None, output_directory: str = None, file_format: str = "pdf", callbacks: list[Callback] = None, **kwargs, ) -> None: """Show how model metrics change over training and validation data epochs. For each model and for each output feature and metric of the model, it produces a line plot showing how that metric changed over the course of the epochs of training on the training and validation sets. # Inputs :param train_stats_per_model: (List[dict]) list containing dictionary of training statistics per model. :param output_feature_name: (Union[str, `None`], default: `None`) name of the output feature to use for the visualization. If `None`, use all output features. :param model_names: (Union[str, List[str]], default: `None`) model name or list of the model names to use as labels. :param output_directory: (str, default: `None`) directory where to save plots. If not specified, plots will be displayed in a window :param file_format: (str, default: `'pdf'`) file format of output plots - `'pdf'` or `'png'`. :param callbacks: (list, default: `None`) a list of `ludwig.callbacks.Callback` objects that provide hooks into the Ludwig pipeline. # Return :return: (None) """ filename_template = "learning_curves_{}_{}." + file_format filename_template_path = generate_filename_template_path(output_directory, filename_template) train_stats_per_model_list = convert_to_list(train_stats_per_model) model_names_list = convert_to_list(model_names) output_feature_names = _validate_output_feature_name_from_train_stats( output_feature_name, train_stats_per_model_list ) metrics = [LOSS, ACCURACY, HITS_AT_K, EDIT_DISTANCE] for output_feature_name in output_feature_names: for metric in metrics: if metric in train_stats_per_model_list[0].training[output_feature_name]: filename = None if filename_template_path: filename = filename_template_path.format(output_feature_name, metric) training_stats = [ learning_stats.training[output_feature_name][metric] for learning_stats in train_stats_per_model_list ] validation_stats = [] for learning_stats in train_stats_per_model_list: if learning_stats.validation and output_feature_name in learning_stats.validation: validation_stats.append(learning_stats.validation[output_feature_name][metric]) else: validation_stats.append(None) evaluation_frequency = train_stats_per_model_list[0].evaluation_frequency visualization_utils.learning_curves_plot( training_stats, validation_stats, metric, x_label=evaluation_frequency.period, x_step=evaluation_frequency.frequency, algorithm_names=model_names_list, title=f"Learning Curves {output_feature_name}", filename=filename, callbacks=callbacks, ) @DeveloperAPI def compare_performance( test_stats_per_model: list[dict], output_feature_name: str | None = None, model_names: str | list[str] = None, output_directory: str = None, file_format: str = "pdf", **kwargs, ) -> None: """Produces model comparison barplot visualization for each overall metric. For each model (in the aligned lists of test_statistics and model_names) it produces bars in a bar plot, one for each overall metric available in the test_statistics file for the specified output_feature_name. # Inputs :param test_stats_per_model: (List[dict]) dictionary containing evaluation performance statistics. :param output_feature_name: (Union[str, `None`], default: `None`) name of the output feature to use for the visualization. If `None`, use all output features. :param model_names: (Union[str, List[str]], default: `None`) model name or list of the model names to use as labels. :param output_directory: (str, default: `None`) directory where to save plots. If not specified, plots will be displayed in a window :param file_format: (str, default: `'pdf'`) file format of output plots - `'pdf'` or `'png'`. # Return :return: (None) # Example usage: ```python model_a = LudwigModel(config) model_a.train(dataset) a_evaluation_stats, _, _ = model_a.evaluate(eval_set) model_b = LudwigModel.load("path/to/model/") b_evaluation_stats, _, _ = model_b.evaluate(eval_set) compare_performance([a_evaluation_stats, b_evaluation_stats], model_names=["A", "B"]) ``` """ ignore_names = { "overall_stats", "confusion_matrix", "per_class_stats", "predictions", "probabilities", "roc_curve", "precision_recall_curve", LOSS, } filename_template = "compare_performance_{}." + file_format filename_template_path = generate_filename_template_path(output_directory, filename_template) test_stats_per_model_list = convert_to_list(test_stats_per_model) model_names_list = convert_to_list(model_names) output_feature_names = _validate_output_feature_name_from_test_stats(output_feature_name, test_stats_per_model_list) for output_feature_name in output_feature_names: metric_names_sets = list(set(tspr[output_feature_name].keys()) for tspr in test_stats_per_model_list) metric_names = metric_names_sets[0] for metric_names_set in metric_names_sets: metric_names = metric_names.intersection(metric_names_set) metric_names = metric_names - ignore_names metrics_dict = {name: [] for name in metric_names} for test_stats_per_model in test_stats_per_model_list: for metric_name in metric_names: metrics_dict[metric_name].append(test_stats_per_model[output_feature_name][metric_name]) # are there any metrics to compare? if metrics_dict: metrics = [] metrics_names = [] min_val = float("inf") max_val = float("-inf") for metric_name, metric_vals in metrics_dict.items(): if len(metric_vals) > 0: metrics.append(metric_vals) metrics_names.append(metric_name) curr_min = min(metric_vals) if curr_min < min_val: min_val = curr_min curr_max = max(metric_vals) if curr_max > max_val: max_val = curr_max filename = None if filename_template_path: filename = filename_template_path.format(output_feature_name) os.makedirs(output_directory, exist_ok=True) visualization_utils.compare_classifiers_plot( metrics, metrics_names, model_names_list, adaptive=min_val < 0 or max_val > 1, title=f"Performance comparison on {output_feature_name}", filename=filename, ) @DeveloperAPI def compare_classifiers_performance_from_prob( probabilities_per_model: list[np.ndarray], ground_truth: pd.Series | np.ndarray, metadata: dict, output_feature_name: str, labels_limit: int = 0, top_n_classes: list[int] | int = 3, model_names: str | list[str] = None, output_directory: str = None, file_format: str = "pdf", ground_truth_apply_idx: bool = True, **kwargs, ) -> None: """Produces model comparison barplot visualization from probabilities. For each model it produces bars in a bar plot, one for each overall metric computed on the fly from the probabilities of predictions for the specified `model_names`. # Inputs :param probabilities_per_model: (List[np.ndarray]) path to experiment probabilities file :param ground_truth: (pd.Series) ground truth values :param metadata: (dict) feature metadata dictionary :param output_feature_name: (str) output feature name :param top_n_classes: (List[int]) list containing the number of classes to plot. :param labels_limit: (int) upper limit on the numeric encoded label value. Encoded numeric label values in dataset that are higher than `labels_limit` are considered to be "rare" labels. :param model_names: (Union[str, List[str]], default: `None`) model name or list of the model names to use as labels. :param output_directory: (str, default: `None`) directory where to save plots. If not specified, plots will be displayed in a window :param file_format: (str, default: `'pdf'`) file format of output plots - `'pdf'` or `'png'`. :param ground_truth_apply_idx: (bool, default: `True`) whether to use metadata['str2idx'] in np.vectorize # Return :return: (None) """ if not isinstance(ground_truth, np.ndarray): # not np array, assume we need to translate raw value to encoded value feature_metadata = metadata[output_feature_name] ground_truth = _vectorize_ground_truth(ground_truth, feature_metadata["str2idx"], ground_truth_apply_idx) top_n_classes_list = convert_to_list(top_n_classes) k = top_n_classes_list[0] model_names_list = convert_to_list(model_names) if labels_limit > 0: ground_truth[ground_truth > labels_limit] = labels_limit probs = probabilities_per_model accuracies = [] hits_at_ks = [] mrrs = [] for i, prob in enumerate(probs): if labels_limit > 0 and prob.shape[1] > labels_limit + 1: prob_limit = prob[:, : labels_limit + 1] prob_limit[:, labels_limit] = prob[:, labels_limit:].sum(1) prob = prob_limit prob = np.argsort(prob, axis=1) top1 = prob[:, -1] topk = prob[:, -k:] accuracies.append((ground_truth == top1).sum() / len(ground_truth)) hits_at_k = 0 for j in range(len(ground_truth)): hits_at_k += np.in1d(ground_truth[j], topk[j]) hits_at_ks.append(hits_at_k.item() / len(ground_truth)) mrr = 0 for j in range(len(ground_truth)): ground_truth_pos_in_probs = prob[j] == ground_truth[j] if np.any(ground_truth_pos_in_probs): mrr += 1 / -(np.argwhere(ground_truth_pos_in_probs).item() - prob.shape[1]) mrrs.append(mrr / len(ground_truth)) filename = None if output_directory: os.makedirs(output_directory, exist_ok=True) filename = os.path.join(output_directory, "compare_classifiers_performance_from_prob." + file_format) visualization_utils.compare_classifiers_plot( [accuracies, hits_at_ks, mrrs], [ACCURACY, HITS_AT_K, "mrr"], model_names_list, filename=filename ) @DeveloperAPI def compare_classifiers_performance_from_pred( predictions_per_model: list[np.ndarray], ground_truth: pd.Series | np.ndarray, metadata: dict, output_feature_name: str, labels_limit: int, model_names: str | list[str] = None, output_directory: str = None, file_format: str = "pdf", ground_truth_apply_idx: bool = True, **kwargs, ) -> None: """Produces model comparison barplot visualization from predictions. For each model it produces bars in a bar plot, one for each overall metric computed on the fly from the predictions for the specified `model_names`. # Inputs :param predictions_per_model: (List[str]) path to experiment predictions file. :param ground_truth: (pd.Series) ground truth values :param metadata: (dict) feature metadata dictionary. :param output_feature_name: (str) name of the output feature to visualize. :param labels_limit: (int) upper limit on the numeric encoded label value. Encoded numeric label values in dataset that are higher than `labels_limit` are considered to be "rare" labels. :param model_names: (Union[str, List[str]], default: `None`) model name or list of the model names to use as labels. :param output_directory: (str, default: `None`) directory where to save plots. If not specified, plots will be displayed in a window :param file_format: (str, default: `'pdf'`) file format of output plots - `'pdf'` or `'png'`. :param ground_truth_apply_idx: (bool, default: `True`) whether to use metadata['str2idx'] in np.vectorize # Return :return: (None) """ if not isinstance(ground_truth, np.ndarray): # not np array, assume we need to translate raw value to encoded value feature_metadata = metadata[output_feature_name] ground_truth = _vectorize_ground_truth(ground_truth, feature_metadata["str2idx"], ground_truth_apply_idx) predictions_per_model = [np.ndarray.flatten(np.array(pred)) for pred in predictions_per_model] if labels_limit > 0: ground_truth[ground_truth > labels_limit] = labels_limit preds = predictions_per_model model_names_list = convert_to_list(model_names) mapped_preds = [] try: for pred in preds: mapped_preds.append([metadata[output_feature_name]["str2idx"][val] for val in pred]) preds = mapped_preds # If predictions are coming from npy file there is no need to convert to # numeric labels using metadata except (TypeError, KeyError): pass accuracies = [] precisions = [] recalls = [] f1s = [] for i, pred in enumerate(preds): accuracies.append(sklearn.metrics.accuracy_score(ground_truth, pred)) precisions.append(sklearn.metrics.precision_score(ground_truth, pred, average="macro")) recalls.append(sklearn.metrics.recall_score(ground_truth, pred, average="macro")) f1s.append(sklearn.metrics.f1_score(ground_truth, pred, average="macro")) filename = None if output_directory: os.makedirs(output_directory, exist_ok=True) filename = os.path.join(output_directory, "compare_classifiers_performance_from_pred." + file_format) visualization_utils.compare_classifiers_plot( [accuracies, precisions, recalls, f1s], [ACCURACY, "precision", "recall", "f1"], model_names_list, filename=filename, ) @DeveloperAPI def compare_classifiers_performance_subset( probabilities_per_model: list[np.array], ground_truth: pd.Series | np.ndarray, metadata: dict, output_feature_name: str, top_n_classes: list[int], labels_limit: int, subset: str, model_names: str | list[str] = None, output_directory: str = None, file_format: str = "pdf", ground_truth_apply_idx: bool = True, **kwargs, ) -> None: """Produces model comparison barplot visualization from train subset. For each model it produces bars in a bar plot, one for each overall metric computed on the fly from the probabilities predictions for the specified `model_names`, considering only a subset of the full training set. The way the subset is obtained is using the `top_n_classes` and `subset` parameters. # Inputs :param probabilities_per_model: (List[numpy.array]) list of model probabilities. :param ground_truth: (Union[pd.Series, np.ndarray]) ground truth values :param metadata: (dict) feature metadata dictionary :param output_feature_name: (str) output feature name :param top_n_classes: (List[int]) list containing the number of classes to plot. :param labels_limit: (int) upper limit on the numeric encoded label value. Encoded numeric label values in dataset that are higher than `labels_limit` are considered to be "rare" labels. :param subset: (str) string specifying type of subset filtering. Valid values are `ground_truth` or `predictions`. :param model_names: (Union[str, List[str]], default: `None`) model name or list of the model names to use as labels. :param output_directory: (str, default: `None`) directory where to save plots. If not specified, plots will be displayed in a window :param file_format: (str, default: `'pdf'`) file format of output plots - `'pdf'` or `'png'`. :param ground_truth_apply_idx: (bool, default: `True`) whether to use metadata['str2idx'] in np.vectorize # Return :return: (None) """ if not isinstance(ground_truth, np.ndarray): # not np array, assume we need to translate raw value to encoded value feature_metadata = metadata[output_feature_name] ground_truth = _vectorize_ground_truth(ground_truth, feature_metadata["str2idx"], ground_truth_apply_idx) top_n_classes_list = convert_to_list(top_n_classes) k = top_n_classes_list[0] model_names_list = convert_to_list(model_names) if labels_limit > 0: ground_truth[ground_truth > labels_limit] = labels_limit subset_indices = ground_truth > 0 gt_subset = ground_truth if subset == "ground_truth": subset_indices = ground_truth < k gt_subset = ground_truth[subset_indices] logger.info(f"Subset is {len(gt_subset) / len(ground_truth) * 100:.2f}% of the data") probs = probabilities_per_model accuracies = [] hits_at_ks = [] for i, prob in enumerate(probs): if labels_limit > 0 and prob.shape[1] > labels_limit + 1: prob_limit = prob[:, : labels_limit + 1] prob_limit[:, labels_limit] = prob[:, labels_limit:].sum(1) prob = prob_limit if subset == PREDICTIONS: subset_indices = np.argmax(prob, axis=1) < k gt_subset = ground_truth[subset_indices] logger.info( "Subset for model_name {} is {:.2f}% of the data".format( model_names[i] if model_names and i < len(model_names) else i, len(gt_subset) / len(ground_truth) * 100, ) ) model_names[i] = "{} ({:.2f}%)".format( model_names[i] if model_names and i < len(model_names) else i, len(gt_subset) / len(ground_truth) * 100 ) prob_subset = prob[subset_indices] prob_subset = np.argsort(prob_subset, axis=1) top1_subset = prob_subset[:, -1] top3_subset = prob_subset[:, -3:] accuracies.append(np.sum(gt_subset == top1_subset) / len(gt_subset)) hits_at_k = 0 for j in range(len(gt_subset)): hits_at_k += np.in1d(gt_subset[j], top3_subset[i, :]) hits_at_ks.append(hits_at_k.item() / len(gt_subset)) title = None if subset == "ground_truth": title = "Classifier performance on first {} class{} ({:.2f}%)".format( k, "es" if k > 1 else "", len(gt_subset) / len(ground_truth) * 100 ) elif subset == PREDICTIONS: title = "Classifier performance on first {} class{}".format(k, "es" if k > 1 else "") filename = None if output_directory: os.makedirs(output_directory, exist_ok=True) filename = os.path.join(output_directory, "compare_classifiers_performance_subset." + file_format) visualization_utils.compare_classifiers_plot( [accuracies, hits_at_ks], [ACCURACY, HITS_AT_K], model_names_list, title=title, filename=filename ) @DeveloperAPI def compare_classifiers_performance_changing_k( probabilities_per_model: list[np.array], ground_truth: pd.Series | np.ndarray, metadata: dict, output_feature_name: str, top_k: int, labels_limit: int, model_names: str | list[str] = None, output_directory: str = None, file_format: str = "pdf", ground_truth_apply_idx: bool = True, **kwargs, ) -> None: """Produce lineplot that show Hits@K metric while k goes from 1 to `top_k`. For each model it produces a line plot that shows the Hits@K metric (that counts a prediction as correct if the model produces it among the first k) while changing k from 1 to top_k for the specified `output_feature_name`. # Inputs :param probabilities_per_model: (List[numpy.array]) list of model probabilities. :param ground_truth: (Union[pd.Series, np.ndarray]) ground truth values :param metadata: (dict) feature metadata dictionary :param output_feature_name: (str) output feature name :param top_k: (int) number of elements in the ranklist to consider. :param labels_limit: (int) upper limit on the numeric encoded label value. Encoded numeric label values in dataset that are higher than `labels_limit` are considered to be "rare" labels. :param model_names: (Union[str, List[str]], default: `None`) model name or list of the model names to use as labels. :param output_directory: (str, default: `None`) directory where to save plots. If not specified, plots will be displayed in a window :param file_format: (str, default: `'pdf'`) file format of output plots - `'pdf'` or `'png'`. :param ground_truth_apply_idx: (bool, default: `True`) whether to use metadata['str2idx'] in np.vectorize # Return :return: (None) """ if not isinstance(ground_truth, np.ndarray): # not np array, assume we need to translate raw value to encoded value feature_metadata = metadata[output_feature_name] ground_truth = _vectorize_ground_truth(ground_truth, feature_metadata["str2idx"], ground_truth_apply_idx) k = top_k if labels_limit > 0: ground_truth[ground_truth > labels_limit] = labels_limit probs = probabilities_per_model hits_at_ks = [] model_names_list = convert_to_list(model_names) for i, prob in enumerate(probs): if labels_limit > 0 and prob.shape[1] > labels_limit + 1: prob_limit = prob[:, : labels_limit + 1] prob_limit[:, labels_limit] = prob[:, labels_limit:].sum(1) prob = prob_limit prob = np.argsort(prob, axis=1) hits_at_k = [0.0] * k for g in range(len(ground_truth)): for j in range(k): hits_at_k[j] += np.in1d(ground_truth[g], prob[g, -j - 1 :]) hits_at_ks.append(np.array(hits_at_k) / len(ground_truth)) filename = None if output_directory: os.makedirs(output_directory, exist_ok=True) filename = os.path.join(output_directory, "compare_classifiers_performance_changing_k." + file_format) visualization_utils.compare_classifiers_line_plot( np.arange(1, k + 1), hits_at_ks, "hits@k", model_names_list, title="Classifier comparison (hits@k)", filename=filename, ) @DeveloperAPI def compare_classifiers_multiclass_multimetric( test_stats_per_model: list[dict], metadata: dict, output_feature_name: str, top_n_classes: list[int], model_names: str | list[str] = None, output_directory: str = None, file_format: str = "pdf", **kwargs, ) -> None: """Show the precision, recall and F1 of the model for the specified output_feature_name. For each model it produces four plots that show the precision, recall and F1 of the model on several classes for the specified output_feature_name. # Inputs :param test_stats_per_model: (List[dict]) list containing dictionary of evaluation performance statistics :param metadata: (dict) intermediate preprocess structure created during training containing the mappings of the input dataset. :param output_feature_name: (Union[str, `None`]) name of the output feature to use for the visualization. If `None`, use all output features. :param top_n_classes: (List[int]) list containing the number of classes to plot. :param model_names: (Union[str, List[str]], default: `None`) model name or list of the model names to use as labels. :param output_directory: (str, default: `None`) directory where to save plots. If not specified, plots will be displayed in a window :param file_format: (str, default: `'pdf'`) file format of output plots - `'pdf'` or `'png'`. # Return :return: (None) """ filename_template = "compare_classifiers_multiclass_multimetric_{}_{}_{}." + file_format filename_template_path = generate_filename_template_path(output_directory, filename_template) test_stats_per_model_list = convert_to_list(test_stats_per_model) model_names_list = convert_to_list(model_names) output_feature_names = _validate_output_feature_name_from_test_stats(output_feature_name, test_stats_per_model_list) for i, test_statistics in enumerate(test_stats_per_model_list): for output_feature_name in output_feature_names: model_name_name = model_names_list[i] if model_names_list is not None and i < len(model_names_list) else "" if "per_class_stats" not in test_statistics[output_feature_name]: logger.warning( f"The output_feature_name {output_feature_name} in test statistics does not contain " + "per_class_stats, skipping it." ) break per_class_stats = test_statistics[output_feature_name]["per_class_stats"] precisions = [] recalls = [] f1_scores = [] labels = [] for _, class_name in sorted( ((metadata[output_feature_name]["str2idx"][key], key) for key in per_class_stats.keys()), key=lambda tup: tup[0], ): class_stats = per_class_stats[class_name] precisions.append(class_stats["precision"]) recalls.append(class_stats["recall"]) f1_scores.append(class_stats["f1_score"]) labels.append(class_name) for k in top_n_classes: k = min(k, len(precisions)) if k > 0 else len(precisions) ps = precisions[0:k] rs = recalls[0:k] fs = f1_scores[0:k] ls = labels[0:k] filename = None if filename_template_path: os.makedirs(output_directory, exist_ok=True) filename = filename_template_path.format(model_name_name, output_feature_name, f"top{k}") visualization_utils.compare_classifiers_multiclass_multimetric_plot( [ps, rs, fs], ["precision", "recall", "f1 score"], labels=ls, title="{} Multiclass Precision / Recall / " "F1 Score top {} {}".format(model_name_name, k, output_feature_name), filename=filename, ) p_np = np.nan_to_num(np.array(precisions, dtype=np.float32)) r_np = np.nan_to_num(np.array(recalls, dtype=np.float32)) f1_np = np.nan_to_num(np.array(f1_scores, dtype=np.float32)) labels_np = np.nan_to_num(np.array(labels)) sorted_indices = f1_np.argsort() higher_f1s = sorted_indices[-k:][::-1] filename = None if filename_template_path: os.makedirs(output_directory, exist_ok=True) filename = filename_template_path.format(model_name_name, output_feature_name, f"best{k}") visualization_utils.compare_classifiers_multiclass_multimetric_plot( [p_np[higher_f1s], r_np[higher_f1s], f1_np[higher_f1s]], ["precision", "recall", "f1 score"], labels=labels_np[higher_f1s].tolist(), title="{} Multiclass Precision / Recall / " "F1 Score best {} classes {}".format(model_name_name, k, output_feature_name), filename=filename, ) lower_f1s = sorted_indices[:k] filename = None if filename_template_path: filename = filename_template_path.format(model_name_name, output_feature_name, f"worst{k}") visualization_utils.compare_classifiers_multiclass_multimetric_plot( [p_np[lower_f1s], r_np[lower_f1s], f1_np[lower_f1s]], ["precision", "recall", "f1 score"], labels=labels_np[lower_f1s].tolist(), title=( f"{model_name_name} Multiclass Precision / Recall / F1 Score worst " + f"{k} classes {output_feature_name}" ), filename=filename, ) filename = None if filename_template_path: filename = filename_template_path.format(model_name_name, output_feature_name, "sorted") visualization_utils.compare_classifiers_multiclass_multimetric_plot( [p_np[sorted_indices[::-1]], r_np[sorted_indices[::-1]], f1_np[sorted_indices[::-1]]], ["precision", "recall", "f1 score"], labels=labels_np[sorted_indices[::-1]].tolist(), title=f"{model_name_name} Multiclass Precision / Recall / F1 Score {output_feature_name} sorted", filename=filename, ) logger.info("\n") logger.info(model_name_name) tmp_str = f"{output_feature_name} best 5 classes: " tmp_str += "{}" logger.info(tmp_str.format(higher_f1s)) logger.info(f1_np[higher_f1s]) tmp_str = f"{output_feature_name} worst 5 classes: " tmp_str += "{}" logger.info(tmp_str.format(lower_f1s)) logger.info(f1_np[lower_f1s]) tmp_str = f"{output_feature_name} number of classes with f1 score > 0: " tmp_str += "{}" logger.info(tmp_str.format(np.sum(f1_np > 0))) tmp_str = f"{output_feature_name} number of classes with f1 score = 0: " tmp_str += "{}" logger.info(tmp_str.format(np.sum(f1_np == 0))) @DeveloperAPI def compare_classifiers_predictions( predictions_per_model: list[list], ground_truth: pd.Series | np.ndarray, metadata: dict, output_feature_name: str, labels_limit: int, model_names: str | list[str] = None, output_directory: str = None, file_format: str = "pdf", ground_truth_apply_idx: bool = True, **kwargs, ) -> None: """Show two models comparison of their output_feature_name predictions. # Inputs :param predictions_per_model: (List[list]) list containing the model predictions for the specified output_feature_name. :param ground_truth: (Union[pd.Series, np.ndarray]) ground truth values :param metadata: (dict) feature metadata dictionary :param output_feature_name: (str) output feature name :param labels_limit: (int) upper limit on the numeric encoded label value. Encoded numeric label values in dataset that are higher than `labels_limit` are considered to be "rare" labels. :param model_names: (Union[str, List[str]], default: `None`) model name or list of the model names to use as labels. :param output_directory: (str, default: `None`) directory where to save plots. If not specified, plots will be displayed in a window :param file_format: (str, default: `'pdf'`) file format of output plots - `'pdf'` or `'png'`. :param ground_truth_apply_idx: (bool, default: `True`) whether to use metadata['str2idx'] in np.vectorize # Return :return: (None) """ if not isinstance(ground_truth, np.ndarray): # not np array, assume we need to translate raw value to encoded value feature_metadata = metadata[output_feature_name] ground_truth = _vectorize_ground_truth(ground_truth, feature_metadata["str2idx"], ground_truth_apply_idx) model_names_list = convert_to_list(model_names) name_c1 = model_names_list[0] if model_names is not None and len(model_names) > 0 else "c1" name_c2 = model_names_list[1] if model_names is not None and len(model_names) > 1 else "c2" pred_c1 = predictions_per_model[0] pred_c2 = predictions_per_model[1] if labels_limit > 0: ground_truth[ground_truth > labels_limit] = labels_limit pred_c1[pred_c1 > labels_limit] = labels_limit pred_c2[pred_c2 > labels_limit] = labels_limit # TODO all shadows built in name - come up with a more descriptive name all = len(ground_truth) if all == 0: logger.error("No labels in the ground truth") return both_right = 0 both_wrong_same = 0 both_wrong_different = 0 c1_right_c2_wrong = 0 c1_wrong_c2_right = 0 for i in range(all): if ground_truth[i] == pred_c1[i] and ground_truth[i] == pred_c2[i]: both_right += 1 elif ground_truth[i] != pred_c1[i] and ground_truth[i] != pred_c2[i]: if pred_c1[i] == pred_c2[i]: both_wrong_same += 1 else: both_wrong_different += 1 elif ground_truth[i] == pred_c1[i] and ground_truth[i] != pred_c2[i]: c1_right_c2_wrong += 1 elif ground_truth[i] != pred_c1[i] and ground_truth[i] == pred_c2[i]: c1_wrong_c2_right += 1 one_right = c1_right_c2_wrong + c1_wrong_c2_right both_wrong = both_wrong_same + both_wrong_different logger.info(f"Test datapoints: {all}") logger.info(f"Both right: {both_right} {100 * both_right / all:.2f}%") logger.info(f"One right: {one_right} {100 * one_right / all:.2f}%") logger.info( " {} right / {} wrong: {} {:.2f}% {:.2f}%".format( name_c1, name_c2, c1_right_c2_wrong, 100 * c1_right_c2_wrong / all, 100 * c1_right_c2_wrong / one_right if one_right > 0 else 0, ) ) logger.info( " {} wrong / {} right: {} {:.2f}% {:.2f}%".format( name_c1, name_c2, c1_wrong_c2_right, 100 * c1_wrong_c2_right / all, 100 * c1_wrong_c2_right / one_right if one_right > 0 else 0, ) ) logger.info(f"Both wrong: {both_wrong} {100 * both_wrong / all:.2f}%") logger.info( " same prediction: {} {:.2f}% {:.2f}%".format( both_wrong_same, 100 * both_wrong_same / all, 100 * both_wrong_same / both_wrong if both_wrong > 0 else 0 ) ) logger.info( " different prediction: {} {:.2f}% {:.2f}%".format( both_wrong_different, 100 * both_wrong_different / all, 100 * both_wrong_different / both_wrong if both_wrong > 0 else 0, ) ) filename = None if output_directory: os.makedirs(output_directory, exist_ok=True) filename = os.path.join(output_directory, f"compare_classifiers_predictions_{name_c1}_{name_c2}.{file_format}") visualization_utils.donut( [both_right, one_right, both_wrong], ["both right", "one right", "both wrong"], [both_right, c1_right_c2_wrong, c1_wrong_c2_right, both_wrong_same, both_wrong_different], [ "both right", f"{name_c1} right / {name_c2} wrong", f"{name_c1} wrong / {name_c2} right", "same prediction", "different prediction", ], [0, 1, 1, 2, 2], title=f"{name_c1} vs {name_c2}", tight_layout=kwargs.pop("tight_layout", True), filename=filename, ) @DeveloperAPI def compare_classifiers_predictions_distribution( predictions_per_model: list[list], ground_truth: pd.Series | np.ndarray, metadata: dict, output_feature_name: str, labels_limit: int, model_names: str | list[str] = None, output_directory: str = None, file_format: str = "pdf", ground_truth_apply_idx: bool = True, **kwargs, ) -> None: """Show comparison of models predictions distribution for 10 output_feature_name classes. This visualization produces a radar plot comparing the distributions of predictions of the models for the first 10 classes of the specified output_feature_name. # Inputs :param predictions_per_model: (List[list]) list containing the model predictions for the specified output_feature_name. :param ground_truth: (Union[pd.Series, np.ndarray]) ground truth values :param metadata: (dict) feature metadata dictionary :param output_feature_name: (str) output feature name :param labels_limit: (int) upper limit on the numeric encoded label value. Encoded numeric label values in dataset that are higher than `labels_limit` are considered to be "rare" labels. :param model_names: (Union[str, List[str]], default: `None`) model name or list of the model names to use as labels. :param output_directory: (str, default: `None`) directory where to save plots. If not specified, plots will be displayed in a window :param file_format: (str, default: `'pdf'`) file format of output plots - `'pdf'` or `'png'`. :param ground_truth_apply_idx: (bool, default: `True`) whether to use metadata['str2idx'] in np.vectorize # Return :return: (None) """ if not isinstance(ground_truth, np.ndarray): # not np array, assume we need to translate raw value to encoded value feature_metadata = metadata[output_feature_name] ground_truth = _vectorize_ground_truth(ground_truth, feature_metadata["str2idx"], ground_truth_apply_idx) model_names_list = convert_to_list(model_names) if labels_limit > 0: ground_truth[ground_truth > labels_limit] = labels_limit for i in range(len(predictions_per_model)): predictions_per_model[i][predictions_per_model[i] > labels_limit] = labels_limit max_gt = max(ground_truth) max_pred = max(max(alg_predictions) for alg_predictions in predictions_per_model) max_val = max(max_gt, max_pred) + 1 counts_gt = np.bincount(ground_truth, minlength=max_val) prob_gt = counts_gt / counts_gt.sum() counts_predictions = [np.bincount(alg_predictions, minlength=max_val) for alg_predictions in predictions_per_model] prob_predictions = [ alg_count_prediction / alg_count_prediction.sum() for alg_count_prediction in counts_predictions ] filename = None if output_directory: os.makedirs(output_directory, exist_ok=True) filename = os.path.join(output_directory, "compare_classifiers_predictions_distribution." + file_format) visualization_utils.radar_chart(prob_gt, prob_predictions, model_names_list, filename=filename) @DeveloperAPI def confidence_thresholding( probabilities_per_model: list[np.array], ground_truth: pd.Series | np.ndarray, metadata: dict, output_feature_name: str, labels_limit: int, model_names: str | list[str] = None, output_directory: str = None, file_format: str = "pdf", ground_truth_apply_idx: bool = True, **kwargs, ) -> None: """Show models accuracy and data coverage while increasing treshold. For each model it produces a pair of lines indicating the accuracy of the model and the data coverage while increasing a threshold (x axis) on the probabilities of predictions for the specified output_feature_name. # Inputs :param probabilities_per_model: (List[numpy.array]) list of model probabilities. :param ground_truth: (Union[pd.Series, np.ndarray]) ground truth values :param metadata: (dict) feature metadata dictionary :param output_feature_name: (str) output feature name :param labels_limit: (int) upper limit on the numeric encoded label value. Encoded numeric label values in dataset that are higher than `labels_limit` are considered to be "rare" labels. :param model_names: (Union[str, List[str]], default: `None`) model name or list of the model names to use as labels. :param output_directory: (str, default: `None`) directory where to save plots. If not specified, plots will be displayed in a window :param file_format: (str, default: `'pdf'`) file format of output plots - `'pdf'` or `'png'`. :param ground_truth_apply_idx: (bool, default: `True`) whether to use metadata['str2idx'] in np.vectorize # Return :return: (None) """ if not isinstance(ground_truth, np.ndarray): # not np array, assume we need to translate raw value to encoded value feature_metadata = metadata[output_feature_name] ground_truth = _vectorize_ground_truth(ground_truth, feature_metadata["str2idx"], ground_truth_apply_idx) if labels_limit > 0: ground_truth[ground_truth > labels_limit] = labels_limit probs = probabilities_per_model model_names_list = convert_to_list(model_names) thresholds = [t / 100 for t in range(0, 101, 5)] accuracies = [] dataset_kept = [] for i, prob in enumerate(probs): if labels_limit > 0 and prob.shape[1] > labels_limit + 1: prob_limit = prob[:, : labels_limit + 1] prob_limit[:, labels_limit] = prob[:, labels_limit:].sum(1) prob = prob_limit max_prob = np.max(prob, axis=1) predictions = np.argmax(prob, axis=1) accuracies_alg = [] dataset_kept_alg = [] for threshold in thresholds: threshold = threshold if threshold < 1 else 0.999 filtered_indices = max_prob >= threshold filtered_gt = ground_truth[filtered_indices] filtered_predictions = predictions[filtered_indices] accuracy = (filtered_gt == filtered_predictions).sum() / len(filtered_gt) accuracies_alg.append(accuracy) dataset_kept_alg.append(len(filtered_gt) / len(ground_truth)) accuracies.append(accuracies_alg) dataset_kept.append(dataset_kept_alg) filename = None if output_directory: os.makedirs(output_directory, exist_ok=True) filename = os.path.join(output_directory, "confidence_thresholding." + file_format) visualization_utils.confidence_filtering_plot( thresholds, accuracies, dataset_kept, model_names_list, title="Confidence_Thresholding", filename=filename ) @DeveloperAPI def confidence_thresholding_data_vs_acc( probabilities_per_model: list[np.array], ground_truth: pd.Series | np.ndarray, metadata: dict, output_feature_name: str, labels_limit: int, model_names: str | list[str] = None, output_directory: str = None, file_format: str = "pdf", ground_truth_apply_idx: bool = True, **kwargs, ) -> None: """Show models comparison of confidence threshold data vs accuracy. For each model it produces a line indicating the accuracy of the model and the data coverage while increasing a threshold on the probabilities of predictions for the specified output_feature_name. The difference with confidence_thresholding is that it uses two axes instead of three, not visualizing the threshold and having coverage as x axis instead of the threshold. # Inputs :param probabilities_per_model: (List[numpy.array]) list of model probabilities. :param ground_truth: (Union[pd.Series, np.ndarray]) ground truth values :param metadata: (dict) feature metadata dictionary :param output_feature_name: (str) output feature name :param labels_limit: (int) upper limit on the numeric encoded label value. Encoded numeric label values in dataset that are higher than `labels_limit` are considered to be "rare" labels. :param model_names: (Union[str, List[str]], default: `None`) model name or list of the model names to use as labels. :param output_directory: (str, default: `None`) directory where to save plots. If not specified, plots will be displayed in a window :param file_format: (str, default: `'pdf'`) file format of output plots - `'pdf'` or `'png'`. :param ground_truth_apply_idx: (bool, default: `True`) whether to use metadata['str2idx'] in np.vectorize # Return :return: (None) """ if not isinstance(ground_truth, np.ndarray): # not np array, assume we need to translate raw value to encoded value feature_metadata = metadata[output_feature_name] ground_truth = _vectorize_ground_truth(ground_truth, feature_metadata["str2idx"], ground_truth_apply_idx) if labels_limit > 0: ground_truth[ground_truth > labels_limit] = labels_limit probs = probabilities_per_model model_names_list = convert_to_list(model_names) thresholds = [t / 100 for t in range(0, 101, 5)] accuracies = [] dataset_kept = [] for i, prob in enumerate(probs): if labels_limit > 0 and prob.shape[1] > labels_limit + 1: prob_limit = prob[:, : labels_limit + 1] prob_limit[:, labels_limit] = prob[:, labels_limit:].sum(1) prob = prob_limit max_prob = np.max(prob, axis=1) predictions = np.argmax(prob, axis=1) accuracies_alg = [] dataset_kept_alg = [] for threshold in thresholds: threshold = threshold if threshold < 1 else 0.999 filtered_indices = max_prob >= threshold filtered_gt = ground_truth[filtered_indices] filtered_predictions = predictions[filtered_indices] accuracy = (filtered_gt == filtered_predictions).sum() / len(filtered_gt) accuracies_alg.append(accuracy) dataset_kept_alg.append(len(filtered_gt) / len(ground_truth)) accuracies.append(accuracies_alg) dataset_kept.append(dataset_kept_alg) filename = None if output_directory: os.makedirs(output_directory, exist_ok=True) filename = os.path.join(output_directory, "confidence_thresholding_data_vs_acc." + file_format) visualization_utils.confidence_filtering_data_vs_acc_plot( accuracies, dataset_kept, model_names_list, title="Confidence_Thresholding (Data vs Accuracy)", filename=filename, ) @DeveloperAPI def confidence_thresholding_data_vs_acc_subset( probabilities_per_model: list[np.array], ground_truth: pd.Series | np.ndarray, metadata: dict, output_feature_name: str, top_n_classes: list[int], labels_limit: int, subset: str, model_names: str | list[str] = None, output_directory: str = None, file_format: str = "pdf", ground_truth_apply_idx: bool = True, **kwargs, ) -> None: """Show models comparison of confidence threshold data vs accuracy on a subset of data. For each model it produces a line indicating the accuracy of the model and the data coverage while increasing a threshold on the probabilities of predictions for the specified output_feature_name, considering only a subset of the full training set. The way the subset is obtained is using the `top_n_classes` and subset parameters. The difference with confidence_thresholding is that it uses two axes instead of three, not visualizing the threshold and having coverage as x axis instead of the threshold. If the values of subset is `ground_truth`, then only datapoints where the ground truth class is within the top n most frequent ones will be considered as test set, and the percentage of datapoints that have been kept from the original set will be displayed. If the values of subset is `predictions`, then only datapoints where the the model predicts a class that is within the top n most frequent ones will be considered as test set, and the percentage of datapoints that have been kept from the original set will be displayed for each model. # Inputs :param probabilities_per_model: (List[numpy.array]) list of model probabilities. :param ground_truth: (Union[pd.Series, np.ndarray]) ground truth values :param metadata: (dict) feature metadata dictionary :param output_feature_name: (str) output feature name :param top_n_classes: (List[int]) list containing the number of classes to plot. :param labels_limit: (int) upper limit on the numeric encoded label value. Encoded numeric label values in dataset that are higher than `labels_limit` are considered to be "rare" labels. :param subset: (str) string specifying type of subset filtering. Valid values are `ground_truth` or `predictions`. :param model_names: (Union[str, List[str]], default: `None`) model name or list of the model names to use as labels. :param output_directory: (str, default: `None`) directory where to save plots. If not specified, plots will be displayed in a window :param file_format: (str, default: `'pdf'`) file format of output plots - `'pdf'` or `'png'`. :param ground_truth_apply_idx: (bool, default: `True`) whether to use metadata['str2idx'] in np.vectorize # Return :return: (None) """ if not isinstance(ground_truth, np.ndarray): # not np array, assume we need to translate raw value to encoded value feature_metadata = metadata[output_feature_name] ground_truth = _vectorize_ground_truth(ground_truth, feature_metadata["str2idx"], ground_truth_apply_idx) top_n_classes_list = convert_to_list(top_n_classes) k = top_n_classes_list[0] if labels_limit > 0: ground_truth[ground_truth > labels_limit] = labels_limit probs = probabilities_per_model model_names_list = convert_to_list(model_names) thresholds = [t / 100 for t in range(0, 101, 5)] accuracies = [] dataset_kept = [] subset_indices = ground_truth > 0 gt_subset = ground_truth if subset == "ground_truth": subset_indices = ground_truth < k gt_subset = ground_truth[subset_indices] logger.info(f"Subset is {len(gt_subset) / len(ground_truth) * 100:.2f}% of the data") for i, prob in enumerate(probs): if labels_limit > 0 and prob.shape[1] > labels_limit + 1: prob_limit = prob[:, : labels_limit + 1] prob_limit[:, labels_limit] = prob[:, labels_limit:].sum(1) prob = prob_limit if subset == PREDICTIONS: subset_indices = np.argmax(prob, axis=1) < k gt_subset = ground_truth[subset_indices] logger.info( "Subset for model_name {} is {:.2f}% of the data".format( model_names[i] if model_names and i < len(model_names) else i, len(gt_subset) / len(ground_truth) * 100, ) ) prob_subset = prob[subset_indices] max_prob = np.max(prob_subset, axis=1) predictions = np.argmax(prob_subset, axis=1) accuracies_alg = [] dataset_kept_alg = [] for threshold in thresholds: threshold = threshold if threshold < 1 else 0.999 filtered_indices = max_prob >= threshold filtered_gt = gt_subset[filtered_indices] filtered_predictions = predictions[filtered_indices] accuracy = (filtered_gt == filtered_predictions).sum() / len(filtered_gt) accuracies_alg.append(accuracy) dataset_kept_alg.append(len(filtered_gt) / len(ground_truth)) accuracies.append(accuracies_alg) dataset_kept.append(dataset_kept_alg) filename = None if output_directory: os.makedirs(output_directory, exist_ok=True) filename = os.path.join(output_directory, "confidence_thresholding_data_vs_acc_subset." + file_format) visualization_utils.confidence_filtering_data_vs_acc_plot( accuracies, dataset_kept, model_names_list, title="Confidence_Thresholding (Data vs Accuracy)", filename=filename, ) @DeveloperAPI def confidence_thresholding_data_vs_acc_subset_per_class( probabilities_per_model: list[np.array], ground_truth: pd.Series | np.ndarray, metadata: dict, output_feature_name: str, top_n_classes: int | list[int], labels_limit: int, subset: str, model_names: str | list[str] = None, output_directory: str = None, file_format: str = "pdf", ground_truth_apply_idx: bool = True, **kwargs, ) -> None: """Show models comparison of confidence threshold data vs accuracy on a subset of data per class in top n classes. For each model (in the aligned lists of probabilities and model_names) it produces a line indicating the accuracy of the model and the data coverage while increasing a threshold on the probabilities of predictions for the specified output_feature_name, considering only a subset of the full training set. The way the subset is obtained is using the `top_n_classes` and `subset` parameters. The difference with confidence_thresholding is that it uses two axes instead of three, not visualizing the threshold and having coverage as x axis instead of the threshold. If the values of subset is `ground_truth`, then only datapoints where the ground truth class is within the top n most frequent ones will be considered as test set, and the percentage of datapoints that have been kept from the original set will be displayed. If the values of subset is `predictions`, then only datapoints where the the model predicts a class that is within the top n most frequent ones will be considered as test set, and the percentage of datapoints that have been kept from the original set will be displayed for each model. The difference with confidence_thresholding_data_vs_acc_subset is that it produces one plot per class within the top_n_classes. # Inputs :param probabilities_per_model: (List[numpy.array]) list of model probabilities. :param ground_truth: (Union[pd.Series, np.ndarray]) ground truth values :param metadata: (dict) intermediate preprocess structure created during training containing the mappings of the input dataset. :param output_feature_name: (str) name of the output feature to use for the visualization. :param top_n_classes: (Union[int, List[int]]) number of top classes or list containing the number of top classes to plot. :param labels_limit: (int) upper limit on the numeric encoded label value. Encoded numeric label values in dataset that are higher than `labels_limit` are considered to be "rare" labels. :param subset: (str) string specifying type of subset filtering. Valid values are `ground_truth` or `predictions`. :param model_names: (Union[str, List[str]], default: `None`) model name or list of the model names to use as labels. :param output_directory: (str, default: `None`) directory where to save plots. If not specified, plots will be displayed in a window :param file_format: (str, default: `'pdf'`) file format of output plots - `'pdf'` or `'png'`. :param ground_truth_apply_idx: (bool, default: `True`) whether to use metadata['str2idx'] in np.vectorize # Return :return: (None) """ if not isinstance(ground_truth, np.ndarray): # not np array, assume we need to translate raw value to encoded value feature_metadata = metadata[output_feature_name] ground_truth = _vectorize_ground_truth(ground_truth, feature_metadata["str2idx"], ground_truth_apply_idx) filename_template = "confidence_thresholding_data_vs_acc_subset_per_class_{}." + file_format filename_template_path = generate_filename_template_path(output_directory, filename_template) top_n_classes_list = convert_to_list(top_n_classes) k = top_n_classes_list[0] # If top_n_classes is greater than the maximum number of tokens, truncate to use max token size if k > len(metadata[output_feature_name]["idx2str"]): k = len(metadata[output_feature_name]["idx2str"]) if labels_limit > 0: ground_truth[ground_truth > labels_limit] = labels_limit probs = probabilities_per_model model_names_list = convert_to_list(model_names) thresholds = [t / 100 for t in range(0, 101, 5)] for curr_k in range(k): accuracies = [] dataset_kept = [] subset_indices = ground_truth > 0 gt_subset = ground_truth if subset == "ground_truth": subset_indices = ground_truth == curr_k gt_subset = ground_truth[subset_indices] logger.info(f"Subset is {len(gt_subset) / len(ground_truth) * 100:.2f}% of the data") for i, prob in enumerate(probs): if labels_limit > 0 and prob.shape[1] > labels_limit + 1: prob_limit = prob[:, : labels_limit + 1] prob_limit[:, labels_limit] = prob[:, labels_limit:].sum(1) prob = prob_limit if subset == PREDICTIONS: subset_indices = np.argmax(prob, axis=1) == curr_k gt_subset = ground_truth[subset_indices] logger.info( "Subset for model_name {} is {:.2f}% of the data".format( model_names_list[i] if model_names_list and i < len(model_names_list) else i, len(gt_subset) / len(ground_truth) * 100, ) ) prob_subset = prob[subset_indices] max_prob = np.max(prob_subset, axis=1) predictions = np.argmax(prob_subset, axis=1) accuracies_alg = [] dataset_kept_alg = [] for threshold in thresholds: threshold = threshold if threshold < 1 else 0.999 filtered_indices = max_prob >= threshold filtered_gt = gt_subset[filtered_indices] filtered_predictions = predictions[filtered_indices] accuracy = (filtered_gt == filtered_predictions).sum() / len(filtered_gt) if len(filtered_gt) > 0 else 0 accuracies_alg.append(accuracy) dataset_kept_alg.append(len(filtered_gt) / len(ground_truth)) accuracies.append(accuracies_alg) dataset_kept.append(dataset_kept_alg) output_feature_name_name = metadata[output_feature_name]["idx2str"][curr_k] filename = None if filename_template_path: os.makedirs(output_directory, exist_ok=True) filename = filename_template_path.format(output_feature_name_name) visualization_utils.confidence_filtering_data_vs_acc_plot( accuracies, dataset_kept, model_names_list, decimal_digits=2, title="Confidence_Thresholding (Data vs Accuracy) " "for class {}".format(output_feature_name_name), filename=filename, ) @DeveloperAPI def confidence_thresholding_2thresholds_2d( probabilities_per_model: list[np.array], ground_truths: list[np.array] | list[pd.Series], metadata, threshold_output_feature_names: list[str], labels_limit: int, model_names: str | list[str] = None, output_directory: str = None, file_format: str = "pdf", **kwargs, ) -> None: """Show confidence threshold data vs accuracy for two output feature names. The first plot shows several semi transparent lines. They summarize the 3d surfaces displayed by confidence_thresholding_2thresholds_3d that have thresholds on the confidence of the predictions of the two `threshold_output_feature_names` as x and y axes and either the data coverage percentage or the accuracy as z axis. Each line represents a slice of the data coverage surface projected onto the accuracy surface. # Inputs :param probabilities_per_model: (List[numpy.array]) list of model probabilities. :param ground_truth: (Union[List[np.array], List[pd.Series]]) containing ground truth data :param metadata: (dict) feature metadata dictionary :param threshold_output_feature_names: (List[str]) List containing two output feature names for visualization. :param labels_limit: (int) upper limit on the numeric encoded label value. Encoded numeric label values in dataset that are higher than `labels_limit` are considered to be "rare" labels. :param model_names: (Union[str, List[str]], default: `None`) model name or list of the model names to use as labels. :param output_directory: (str, default: `None`) directory where to save plots. If not specified, plots will be displayed in a window :param file_format: (str, default: `'pdf'`) file format of output plots - `'pdf'` or `'png'`. # Return :return: (None) """ try: validate_conf_thresholds_and_probabilities_2d_3d(probabilities_per_model, threshold_output_feature_names) except RuntimeError: return probs = probabilities_per_model model_names_list = convert_to_list(model_names) filename_template = "confidence_thresholding_2thresholds_2d_{}." + file_format filename_template_path = generate_filename_template_path(output_directory, filename_template) if not isinstance(ground_truths[0], np.ndarray): # not np array, assume we need to translate raw value to encoded value feature_metadata = metadata[threshold_output_feature_names[0]] vfunc = np.vectorize(_encode_categorical_feature) gt_1 = vfunc(ground_truths[0], feature_metadata["str2idx"]) feature_metadata = metadata[threshold_output_feature_names[1]] gt_2 = vfunc(ground_truths[1], feature_metadata["str2idx"]) else: gt_1 = ground_truths[0] gt_2 = ground_truths[1] if labels_limit > 0: gt_1[gt_1 > labels_limit] = labels_limit gt_2[gt_2 > labels_limit] = labels_limit thresholds = [t / 100 for t in range(0, 101, 5)] fixed_step_coverage = thresholds name_t1 = f"{threshold_output_feature_names[0]} threshold" name_t2 = f"{threshold_output_feature_names[1]} threshold" accuracies = [] dataset_kept = [] interps = [] table = [[name_t1, name_t2, "coverage", ACCURACY]] if labels_limit > 0 and probs[0].shape[1] > labels_limit + 1: prob_limit = probs[0][:, : labels_limit + 1] prob_limit[:, labels_limit] = probs[0][:, labels_limit:].sum(1) probs[0] = prob_limit if labels_limit > 0 and probs[1].shape[1] > labels_limit + 1: prob_limit = probs[1][:, : labels_limit + 1] prob_limit[:, labels_limit] = probs[1][:, labels_limit:].sum(1) probs[1] = prob_limit max_prob_1 = np.max(probs[0], axis=1) predictions_1 = np.argmax(probs[0], axis=1) max_prob_2 = np.max(probs[1], axis=1) predictions_2 = np.argmax(probs[1], axis=1) for threshold_1 in thresholds: threshold_1 = threshold_1 if threshold_1 < 1 else 0.999 curr_accuracies = [] curr_dataset_kept = [] for threshold_2 in thresholds: threshold_2 = threshold_2 if threshold_2 < 1 else 0.999 filtered_indices = np.logical_and(max_prob_1 >= threshold_1, max_prob_2 >= threshold_2) filtered_gt_1 = gt_1[filtered_indices] filtered_predictions_1 = predictions_1[filtered_indices] filtered_gt_2 = gt_2[filtered_indices] filtered_predictions_2 = predictions_2[filtered_indices] coverage = len(filtered_gt_1) / len(gt_1) accuracy = ( np.logical_and(filtered_gt_1 == filtered_predictions_1, filtered_gt_2 == filtered_predictions_2) ).sum() / len(filtered_gt_1) curr_accuracies.append(accuracy) curr_dataset_kept.append(coverage) table.append([threshold_1, threshold_2, coverage, accuracy]) accuracies.append(curr_accuracies) dataset_kept.append(curr_dataset_kept) interps.append( np.interp( fixed_step_coverage, list(reversed(curr_dataset_kept)), list(reversed(curr_accuracies)), left=1, right=0 ) ) logger.info("CSV table") for row in table: logger.info(",".join([str(e) for e in row])) # ===========# # Multiline # # ===========# filename = None if filename_template_path: os.makedirs(output_directory, exist_ok=True) filename = filename_template_path.format("multiline") visualization_utils.confidence_filtering_data_vs_acc_multiline_plot( accuracies, dataset_kept, model_names_list, title="Coverage vs Accuracy, two thresholds", filename=filename ) # ==========# # Max line # # ==========# filename = None if filename_template_path: filename = filename_template_path.format("maxline") max_accuracies = np.amax(np.array(interps), 0) visualization_utils.confidence_filtering_data_vs_acc_plot( [max_accuracies], [thresholds], model_names_list, title="Coverage vs Accuracy, two thresholds", filename=filename, ) # ==========================# # Max line with thresholds # # ==========================# acc_matrix = np.array(accuracies) cov_matrix = np.array(dataset_kept) t1_maxes = [1] t2_maxes = [1] for i in range(len(fixed_step_coverage) - 1): lower = fixed_step_coverage[i] upper = fixed_step_coverage[i + 1] indices = np.logical_and(cov_matrix >= lower, cov_matrix < upper) selected_acc = acc_matrix.copy() selected_acc[np.logical_not(indices)] = -1 threshold_indices = np.unravel_index(np.argmax(selected_acc, axis=None), selected_acc.shape) t1_maxes.append(thresholds[threshold_indices[0]]) t2_maxes.append(thresholds[threshold_indices[1]]) model_name = model_names_list[0] if model_names_list is not None and len(model_names_list) > 0 else "" filename = None if filename_template_path: os.makedirs(output_directory, exist_ok=True) filename = filename_template_path.format("maxline_with_thresholds") visualization_utils.confidence_filtering_data_vs_acc_plot( [max_accuracies, t1_maxes, t2_maxes], [fixed_step_coverage, fixed_step_coverage, fixed_step_coverage], model_names=[model_name + " accuracy", name_t1, name_t2], dotted=[False, True, True], y_label="", title="Coverage vs Accuracy & Threshold", filename=filename, ) @DeveloperAPI def confidence_thresholding_2thresholds_3d( probabilities_per_model: list[np.array], ground_truths: list[np.array] | list[pd.Series], metadata, threshold_output_feature_names: list[str], labels_limit: int, output_directory: str = None, file_format: str = "pdf", **kwargs, ) -> None: """Show 3d confidence threshold data vs accuracy for two output feature names. The plot shows the 3d surfaces displayed by confidence_thresholding_2thresholds_3d that have thresholds on the confidence of the predictions of the two `threshold_output_feature_names` as x and y axes and either the data coverage percentage or the accuracy as z axis. # Inputs :param probabilities_per_model: (List[numpy.array]) list of model probabilities. :param ground_truth: (Union[List[np.array], List[pd.Series]]) containing ground truth data :param metadata: (dict) feature metadata dictionary :param threshold_output_feature_names: (List[str]) List containing two output feature names for visualization. :param labels_limit: (int) upper limit on the numeric encoded label value. Encoded numeric label values in dataset that are higher than `labels_limit` are considered to be "rare" labels. :param output_directory: (str, default: `None`) directory where to save plots. If not specified, plots will be displayed in a window :param file_format: (str, default: `'pdf'`) file format of output plots - `'pdf'` or `'png'`. # Return :return: (None) """ try: validate_conf_thresholds_and_probabilities_2d_3d(probabilities_per_model, threshold_output_feature_names) except RuntimeError: return probs = probabilities_per_model if not isinstance(ground_truths[0], np.ndarray): # not np array, assume we need to translate raw value to encoded value feature_metadata = metadata[threshold_output_feature_names[0]] vfunc = np.vectorize(_encode_categorical_feature) gt_1 = vfunc(ground_truths[0], feature_metadata["str2idx"]) feature_metadata = metadata[threshold_output_feature_names[1]] gt_2 = vfunc(ground_truths[1], feature_metadata["str2idx"]) else: gt_1 = ground_truths[0] gt_2 = ground_truths[1] if labels_limit > 0: gt_1[gt_1 > labels_limit] = labels_limit gt_2[gt_2 > labels_limit] = labels_limit thresholds = [t / 100 for t in range(0, 101, 5)] accuracies = [] dataset_kept = [] if labels_limit > 0 and probs[0].shape[1] > labels_limit + 1: prob_limit = probs[0][:, : labels_limit + 1] prob_limit[:, labels_limit] = probs[0][:, labels_limit:].sum(1) probs[0] = prob_limit if labels_limit > 0 and probs[1].shape[1] > labels_limit + 1: prob_limit = probs[1][:, : labels_limit + 1] prob_limit[:, labels_limit] = probs[1][:, labels_limit:].sum(1) probs[1] = prob_limit max_prob_1 = np.max(probs[0], axis=1) predictions_1 = np.argmax(probs[0], axis=1) max_prob_2 = np.max(probs[1], axis=1) predictions_2 = np.argmax(probs[1], axis=1) for threshold_1 in thresholds: threshold_1 = threshold_1 if threshold_1 < 1 else 0.999 curr_accuracies = [] curr_dataset_kept = [] for threshold_2 in thresholds: threshold_2 = threshold_2 if threshold_2 < 1 else 0.999 filtered_indices = np.logical_and(max_prob_1 >= threshold_1, max_prob_2 >= threshold_2) filtered_gt_1 = gt_1[filtered_indices] filtered_predictions_1 = predictions_1[filtered_indices] filtered_gt_2 = gt_2[filtered_indices] filtered_predictions_2 = predictions_2[filtered_indices] accuracy = ( np.logical_and(filtered_gt_1 == filtered_predictions_1, filtered_gt_2 == filtered_predictions_2) ).sum() / len(filtered_gt_1) curr_accuracies.append(accuracy) curr_dataset_kept.append(len(filtered_gt_1) / len(gt_1)) accuracies.append(curr_accuracies) dataset_kept.append(curr_dataset_kept) filename = None if output_directory: os.makedirs(output_directory, exist_ok=True) filename = os.path.join(output_directory, "confidence_thresholding_2thresholds_3d." + file_format) visualization_utils.confidence_filtering_3d_plot( np.array(thresholds), np.array(thresholds), np.array(accuracies), np.array(dataset_kept), threshold_output_feature_names, title="Confidence_Thresholding, two thresholds", filename=filename, ) @DeveloperAPI def binary_threshold_vs_metric( probabilities_per_model: list[np.array], ground_truth: pd.Series | np.ndarray, metadata: dict, output_feature_name: str, metrics: list[str], positive_label: int = 1, model_names: list[str] = None, output_directory: str = None, file_format: str = "pdf", ground_truth_apply_idx: bool = True, **kwargs, ) -> None: """Show confidence of the model against metric for the specified output_feature_name. For each metric specified in metrics (options are `f1`, `precision`, `recall`, `accuracy`), this visualization produces a line chart plotting a threshold on the confidence of the model against the metric for the specified output_feature_name. If output_feature_name is a category feature, positive_label, which is specified as the numeric encoded value, indicates the class to be considered positive class and all others will be considered negative. To figure out the association between classes and numeric encoded values check the ground_truth_metadata JSON file. # Inputs :param probabilities_per_model: (List[numpy.array]) list of model probabilities. :param ground_truth: (Union[pd.Series, np.ndarray]) ground truth values :param metadata: (dict) feature metadata dictionary :param output_feature_name: (str) output feature name :param metrics: (List[str]) metrics to display (`'f1'`, `'precision'`, `'recall'`, `'accuracy'`). :param positive_label: (int, default: `1`) numeric encoded value for the positive class. :param model_names: (List[str], default: `None`) list of the names of the models to use as labels. :param output_directory: (str, default: `None`) directory where to save plots. If not specified, plots will be displayed in a window :param file_format: (str, default: `'pdf'`) file format of output plots - `'pdf'` or `'png'`. :param ground_truth_apply_idx: (bool, default: `True`) whether to use metadata['str2idx'] in np.vectorize # Return :return: (`None`) """ if not isinstance(ground_truth, np.ndarray): # not np array, assume we need to translate raw value to encoded value feature_metadata = metadata[output_feature_name] ground_truth, positive_label = _convert_ground_truth( ground_truth, feature_metadata, ground_truth_apply_idx, positive_label ) probs = probabilities_per_model model_names_list = convert_to_list(model_names) metrics_list = convert_to_list(metrics) filename_template = "binary_threshold_vs_metric_{}." + file_format filename_template_path = generate_filename_template_path(output_directory, filename_template) thresholds = [t / 100 for t in range(0, 101, 5)] supported_metrics = {"f1", "precision", "recall", "accuracy"} for metric in metrics_list: if metric not in supported_metrics: logger.error(f"Metric {metric} not supported") continue scores = [] for i, prob in enumerate(probs): scores_alg = [] if len(prob.shape) == 2: if prob.shape[1] > positive_label: prob = prob[:, positive_label] else: raise Exception( "the specified positive label {} is not " "present in the probabilities".format(positive_label) ) for threshold in thresholds: threshold = threshold if threshold < 1 else 0.99 predictions = prob >= threshold if metric == "f1": metric_score = sklearn.metrics.f1_score(ground_truth, predictions) elif metric == "precision": metric_score = sklearn.metrics.precision_score(ground_truth, predictions) elif metric == "recall": metric_score = sklearn.metrics.recall_score(ground_truth, predictions) elif metric == ACCURACY: metric_score = sklearn.metrics.accuracy_score(ground_truth, predictions) scores_alg.append(metric_score) scores.append(scores_alg) filename = None if output_directory: os.makedirs(output_directory, exist_ok=True) filename = filename_template_path.format(metric) visualization_utils.threshold_vs_metric_plot( thresholds, scores, model_names_list, title=f"Binary threshold vs {metric}", filename=filename ) @DeveloperAPI def precision_recall_curves( probabilities_per_model: list[np.array], ground_truth: pd.Series | np.ndarray, metadata: dict, output_feature_name: str, positive_label: int = 1, model_names: str | list[str] = None, output_directory: str = None, file_format: str = "pdf", ground_truth_apply_idx: bool = True, **kwargs, ) -> None: """Show the precision recall curves for output features in the specified models. This visualization produces a line chart plotting a precision recall curve for the specified output feature name. If output feature name is a category feature, `positive_label` indicates which is the class to be considered positive class and all the others will be considered negative. `positive_label` is the encoded numeric value for category classes. The numeric value can be determined by association between classes and integers captured in the training metadata JSON file. # Inputs :param probabilities_per_model: (List[numpy.array]) list of model probabilities. :param ground_truth: (Union[pd.Series, np.ndarray]) ground truth values :param metadata: (dict) feature metadata dictionary :param output_feature_name: (str) output feature name :param positive_label: (int, default: `1`) numeric encoded value for the positive class. :param model_names: (Union[str, List[str]], default: `None`) model name or list of the model names to use as labels. :param output_directory: (str, default: `None`) directory where to save plots. If not specified, plots will be displayed in a window :param file_format: (str, default: `'pdf'`) file format of output plots - `'pdf'` or `'png'`. :param ground_truth_apply_idx: (bool, default: `True`) whether to use metadata['str2idx'] in np.vectorize # Return :return: (None) """ if not isinstance(ground_truth, np.ndarray): # not np array, assume we need to translate raw value to encoded value feature_metadata = metadata[output_feature_name] ground_truth, positive_label = _convert_ground_truth( ground_truth, feature_metadata, ground_truth_apply_idx, positive_label ) probs = probabilities_per_model model_names_list = convert_to_list(model_names) precision_recalls = [] for _, prob in enumerate(probs): if len(prob.shape) > 1: prob = prob[:, positive_label] precision, recall, _ = sklearn.metrics.precision_recall_curve(ground_truth, prob, pos_label=positive_label) precision_recalls.append({"precisions": precision, "recalls": recall}) filename = None if output_directory: os.makedirs(output_directory, exist_ok=True) filename = os.path.join(output_directory, "precision_recall_curve." + file_format) visualization_utils.precision_recall_curves_plot( precision_recalls, model_names_list, title="Precision Recall Curves", filename=filename ) @DeveloperAPI def precision_recall_curves_from_test_statistics( test_stats_per_model: list[dict], output_feature_name: str, model_names: str | list[str] = None, output_directory: str = None, file_format: str = "pdf", **kwargs, ) -> None: """Show the PR curves for the specified models output binary `output_feature_name`. This visualization uses `output_feature_name`, `test_stats_per_model` and `model_names` parameters. `output_feature_name` needs to be binary feature. This visualization produces a line chart plotting the PR curves for the specified `output_feature_name`. Args: :param test_stats_per_model: (List[dict]) dictionary containing evaluation performance statistics. :param output_feature_name: (str) name of the output feature to use for the visualization. :param model_names: (Union[str, List[str]], default: `None`) model name or list of the model names to use as labels. :param output_directory: (str, default: `None`) directory where to save plots. If not specified, plots will be displayed in a window :param file_format: (str, default: `'pdf'`) file format of output plots - `'pdf'` or `'png'`. Return :return: (None) """ model_names_list = convert_to_list(model_names) filename_template = "precision_recall_curves_from_prediction_statistics." + file_format filename_template_path = generate_filename_template_path(output_directory, filename_template) precision_recalls = [] for curr_test_statistics in test_stats_per_model: precisions = curr_test_statistics[output_feature_name]["precision_recall_curve"]["precisions"] recalls = curr_test_statistics[output_feature_name]["precision_recall_curve"]["recalls"] precision_recalls.append({"precisions": precisions, "recalls": recalls}) visualization_utils.precision_recall_curves_plot( precision_recalls, model_names_list, title="Precision Recall Curves", filename=filename_template_path ) @DeveloperAPI def roc_curves( probabilities_per_model: list[np.array], ground_truth: pd.Series | np.ndarray, metadata: dict, output_feature_name: str, positive_label: int = 1, model_names: str | list[str] = None, output_directory: str = None, file_format: str = "pdf", ground_truth_apply_idx: bool = True, **kwargs, ) -> None: """Show the roc curves for output features in the specified models. This visualization produces a line chart plotting the roc curves for the specified output feature name. If output feature name is a category feature, `positive_label` indicates which is the class to be considered positive class and all the others will be considered negative. `positive_label` is the encoded numeric value for category classes. The numeric value can be determined by association between classes and integers captured in the training metadata JSON file. # Inputs :param probabilities_per_model: (List[numpy.array]) list of model probabilities. :param ground_truth: (Union[pd.Series, np.ndarray]) ground truth values :param metadata: (dict) feature metadata dictionary :param output_feature_name: (str) output feature name :param positive_label: (int, default: `1`) numeric encoded value for the positive class. :param model_names: (Union[str, List[str]], default: `None`) model name or list of the model names to use as labels. :param output_directory: (str, default: `None`) directory where to save plots. If not specified, plots will be displayed in a window :param file_format: (str, default: `'pdf'`) file format of output plots - `'pdf'` or `'png'`. :param ground_truth_apply_idx: (bool, default: `True`) whether to use metadata['str2idx'] in np.vectorize # Return :return: (None) """ if not isinstance(ground_truth, np.ndarray): # not np array, assume we need to translate raw value to encoded value feature_metadata = metadata[output_feature_name] ground_truth, positive_label = _convert_ground_truth( ground_truth, feature_metadata, ground_truth_apply_idx, positive_label ) probs = probabilities_per_model model_names_list = convert_to_list(model_names) fpr_tprs = [] for i, prob in enumerate(probs): if len(prob.shape) > 1: prob = prob[:, positive_label] fpr, tpr, _ = sklearn.metrics.roc_curve(ground_truth, prob, pos_label=positive_label) fpr_tprs.append((fpr, tpr)) filename = None if output_directory: os.makedirs(output_directory, exist_ok=True) filename = os.path.join(output_directory, "roc_curves." + file_format) visualization_utils.roc_curves(fpr_tprs, model_names_list, title="ROC curves", filename=filename) @DeveloperAPI def roc_curves_from_test_statistics( test_stats_per_model: list[dict], output_feature_name: str, model_names: str | list[str] = None, output_directory: str = None, file_format: str = "pdf", **kwargs, ) -> None: """Show the roc curves for the specified models output binary `output_feature_name`. This visualization uses `output_feature_name`, `test_stats_per_model` and `model_names` parameters. `output_feature_name` needs to be binary feature. This visualization produces a line chart plotting the roc curves for the specified `output_feature_name`. # Inputs :param test_stats_per_model: (List[dict]) dictionary containing evaluation performance statistics. :param output_feature_name: (str) name of the output feature to use for the visualization. :param model_names: (Union[str, List[str]], default: `None`) model name or list of the model names to use as labels. :param output_directory: (str, default: `None`) directory where to save plots. If not specified, plots will be displayed in a window :param file_format: (str, default: `'pdf'`) file format of output plots - `'pdf'` or `'png'`. # Return :return: (None) """ model_names_list = convert_to_list(model_names) filename_template = "roc_curves_from_prediction_statistics." + file_format filename_template_path = generate_filename_template_path(output_directory, filename_template) fpr_tprs = [] for curr_test_statistics in test_stats_per_model: fpr = curr_test_statistics[output_feature_name]["roc_curve"]["false_positive_rate"] tpr = curr_test_statistics[output_feature_name]["roc_curve"]["true_positive_rate"] fpr_tprs.append((fpr, tpr)) visualization_utils.roc_curves(fpr_tprs, model_names_list, title="ROC curves", filename=filename_template_path) @DeveloperAPI def calibration_1_vs_all( probabilities_per_model: list[np.array], ground_truth: pd.Series | np.ndarray, metadata: dict, output_feature_name: str, top_n_classes: list[int], labels_limit: int, model_names: list[str] = None, output_directory: str = None, file_format: str = "pdf", ground_truth_apply_idx: bool = True, **kwargs, ) -> None: """Show models probability of predictions for the specified output_feature_name. For each class or each of the k most frequent classes if top_k is specified, it produces two plots computed on the fly from the probabilities of predictions for the specified output_feature_name. The first plot is a calibration curve that shows the calibration of the predictions considering the current class to be the true one and all others to be a false one, drawing one line for each model (in the aligned lists of probabilities and model_names). The second plot shows the distributions of the predictions considering the current class to be the true one and all others to be a false one, drawing the distribution for each model (in the aligned lists of probabilities and model_names). # Inputs :param probabilities_per_model: (List[numpy.array]) list of model probabilities. :param ground_truth: (Union[pd.Series, np.ndarray]) ground truth values :param metadata: (dict) feature metadata dictionary :param output_feature_name: (str) output feature name :param top_n_classes: (list) List containing the number of classes to plot. :param labels_limit: (int) upper limit on the numeric encoded label value. Encoded numeric label values in dataset that are higher than `labels_limit` are considered to be "rare" labels. :param model_names: (List[str], default: `None`) list of the names of the models to use as labels. :param output_directory: (str, default: `None`) directory where to save plots. If not specified, plots will be displayed in a window :param file_format: (str, default: `'pdf'`) file format of output plots - `'pdf'` or `'png'`. :param ground_truth_apply_idx: (bool, default: `True`) whether to use metadata['str2idx'] in np.vectorize # String :return: (None) """ feature_metadata = metadata[output_feature_name] if not isinstance(ground_truth, np.ndarray): # not np array, assume we need to translate raw value to encoded value ground_truth = _vectorize_ground_truth(ground_truth, feature_metadata["str2idx"], ground_truth_apply_idx) probs = probabilities_per_model model_names_list = convert_to_list(model_names) filename_template = "calibration_1_vs_all_{}." + file_format filename_template_path = generate_filename_template_path(output_directory, filename_template) if labels_limit > 0: ground_truth[ground_truth > labels_limit] = labels_limit for i, prob in enumerate(probs): if labels_limit > 0 and prob.shape[1] > labels_limit + 1: prob_limit = prob[:, : labels_limit + 1] prob_limit[:, labels_limit] = prob[:, labels_limit:].sum(1) probs[i] = prob_limit num_classes = len(metadata[output_feature_name]["str2idx"]) brier_scores = [] classes = min(num_classes, top_n_classes[0]) if top_n_classes[0] > 0 else num_classes class_names = [feature_metadata["idx2str"][i] for i in range(classes)] for class_idx in range(classes): fraction_positives_class = [] mean_predicted_vals_class = [] probs_class = [] brier_scores_class = [] for prob in probs: # ground_truth is a vector of integers, each integer is a class # index to have a [0,1] vector we have to check if the value equals # the input class index and convert the resulting boolean vector # into an integer vector probabilities is a n x c matrix, n is the # number of datapoints and c number of classes; its values are the # probabilities of the ith datapoint to be classified as belonging # to the jth class according to the learned model. For this reason # we need to take only the column of predictions that is about the # class we are interested in, the input class index gt_class = (ground_truth == class_idx).astype(int) prob_class = prob[:, class_idx] curr_fraction_positives, curr_mean_predicted_vals = calibration_curve(gt_class, prob_class, n_bins=21) if len(curr_fraction_positives) < 2: curr_fraction_positives = np.concatenate((np.array([0.0]), curr_fraction_positives)) if len(curr_mean_predicted_vals) < 2: curr_mean_predicted_vals = np.concatenate((np.array([0.0]), curr_mean_predicted_vals)) fraction_positives_class.append(curr_fraction_positives) mean_predicted_vals_class.append(curr_mean_predicted_vals) probs_class.append(prob[:, class_idx]) brier_scores_class.append(brier_score_loss(gt_class, prob_class, pos_label=1)) brier_scores.append(brier_scores_class) filename = None if output_directory: os.makedirs(output_directory, exist_ok=True) filename = filename_template_path.format(class_idx) visualization_utils.calibration_plot( fraction_positives_class, mean_predicted_vals_class, model_names_list, class_name=class_names[class_idx], filename=filename, ) filename = None if output_directory: os.makedirs(output_directory, exist_ok=True) filename = filename_template_path.format("prediction_distribution_" + str(class_idx)) visualization_utils.predictions_distribution_plot(probs_class, model_names_list, filename=filename) filename = None if output_directory: os.makedirs(output_directory, exist_ok=True) filename = filename_template_path.format("brier") visualization_utils.brier_plot( np.array(brier_scores), algorithm_names=model_names_list, class_names=class_names, title="Brier scores for each class", filename=filename, ) @DeveloperAPI def calibration_multiclass( probabilities_per_model: list[np.array], ground_truth: pd.Series | np.ndarray, metadata: dict, output_feature_name: str, labels_limit: int, model_names: str | list[str] = None, output_directory: str = None, file_format: str = "pdf", ground_truth_apply_idx: bool = True, **kwargs, ) -> None: """Show models probability of predictions for each class of the specified output_feature_name. # Inputs :param probabilities_per_model: (List[numpy.array]) list of model probabilities. :param ground_truth: (Union[pd.Series, np.ndarray]) ground truth values :param metadata: (dict) feature metadata dictionary :param output_feature_name: (str) output feature name :param labels_limit: (int) upper limit on the numeric encoded label value. Encoded numeric label values in dataset that are higher than `labels_limit` are considered to be "rare" labels. :param model_names: (List[str], default: `None`) list of the names of the models to use as labels. :param output_directory: (str, default: `None`) directory where to save plots. If not specified, plots will be displayed in a window :param file_format: (str, default: `'pdf'`) file format of output plots - `'pdf'` or `'png'`. :param ground_truth_apply_idx: (bool, default: `True`) whether to use metadata['str2idx'] in np.vectorize # Return :return: (None) """ if not isinstance(ground_truth, np.ndarray): # not np array, assume we need to translate raw value to encoded value feature_metadata = metadata[output_feature_name] ground_truth = _vectorize_ground_truth(ground_truth, feature_metadata["str2idx"], ground_truth_apply_idx) probs = probabilities_per_model model_names_list = convert_to_list(model_names) filename_template = "calibration_multiclass{}." + file_format filename_template_path = generate_filename_template_path(output_directory, filename_template) if labels_limit > 0: ground_truth[ground_truth > labels_limit] = labels_limit prob_classes = 0 for i, prob in enumerate(probs): if labels_limit > 0 and prob.shape[1] > labels_limit + 1: prob_limit = prob[:, : labels_limit + 1] prob_limit[:, labels_limit] = prob[:, labels_limit:].sum(1) probs[i] = prob_limit if probs[i].shape[1] > prob_classes: prob_classes = probs[i].shape[1] gt_one_hot_dim_2 = max(prob_classes, max(ground_truth) + 1) gt_one_hot = np.zeros((len(ground_truth), gt_one_hot_dim_2)) gt_one_hot[np.arange(len(ground_truth)), ground_truth] = 1 gt_one_hot_flat = gt_one_hot.flatten() fraction_positives = [] mean_predicted_vals = [] brier_scores = [] for prob in probs: # flatten probabilities to be compared to flatten ground truth prob_flat = prob.flatten() curr_fraction_positives, curr_mean_predicted_vals = calibration_curve(gt_one_hot_flat, prob_flat, n_bins=21) fraction_positives.append(curr_fraction_positives) mean_predicted_vals.append(curr_mean_predicted_vals) brier_scores.append(brier_score_loss(gt_one_hot_flat, prob_flat, pos_label=1)) filename = None if output_directory: os.makedirs(output_directory, exist_ok=True) filename = filename_template_path.format("") visualization_utils.calibration_plot(fraction_positives, mean_predicted_vals, model_names_list, filename=filename) filename = None if output_directory: filename = filename_template_path.format("_brier") visualization_utils.compare_classifiers_plot( [brier_scores], ["brier"], model_names, adaptive=True, decimals=8, filename=filename ) for i, brier_score in enumerate(brier_scores): if i < len(model_names): tokenizer_name = f"{model_names[i]}: " tokenizer_name += "{}" else: tokenizer_name = "{}" logger.info(tokenizer_name.format(brier_score)) @DeveloperAPI def confusion_matrix( test_stats_per_model: list[dict], metadata: dict, output_feature_name: str | None, top_n_classes: list[int], normalize: bool, model_names: str | list[str] = None, output_directory: str = None, file_format: str = "pdf", **kwargs, ) -> None: """Show confusion matrix in the models predictions for each `output_feature_name`. For each model (in the aligned lists of test_statistics and model_names) it produces a heatmap of the confusion matrix in the predictions for each output_feature_name that has a confusion matrix in test_statistics. The value of `top_n_classes` limits the heatmap to the n most frequent classes. # Inputs :param test_stats_per_model: (List[dict]) dictionary containing evaluation performance statistics. :param metadata: (dict) intermediate preprocess structure created during training containing the mappings of the input dataset. :param output_feature_name: (Union[str, `None`]) name of the output feature to use for the visualization. If `None`, use all output features. :param top_n_classes: (List[int]) number of top classes or list containing the number of top classes to plot. :param normalize: (bool) flag to normalize rows in confusion matrix. :param model_names: (Union[str, List[str]], default: `None`) model name or list of the model names to use as labels. :param output_directory: (str, default: `None`) directory where to save plots. If not specified, plots will be displayed in a window :param file_format: (str, default: `'pdf'`) file format of output plots - `'pdf'` or `'png'`. # Return :return: (None) """ test_stats_per_model_list = test_stats_per_model model_names_list = convert_to_list(model_names) filename_template = "confusion_matrix_{}_{}_{}." + file_format filename_template_path = generate_filename_template_path(output_directory, filename_template) output_feature_names = _validate_output_feature_name_from_test_stats(output_feature_name, test_stats_per_model_list) confusion_matrix_found = False for i, test_statistics in enumerate(test_stats_per_model_list): for output_feature_name in output_feature_names: if "confusion_matrix" in test_statistics[output_feature_name]: confusion_matrix_found = True _confusion_matrix = np.array(test_statistics[output_feature_name]["confusion_matrix"]) model_name_name = ( model_names_list[i] if (model_names_list is not None and i < len(model_names_list)) else "" ) if ( metadata is not None and output_feature_name in metadata and ("idx2str" in metadata[output_feature_name] or "bool2str" in metadata[output_feature_name]) ): if "bool2str" in metadata[output_feature_name]: # Handles the binary output case labels = metadata[output_feature_name]["bool2str"] else: labels = metadata[output_feature_name]["idx2str"] else: labels = list(range(len(_confusion_matrix))) for k in top_n_classes: k = min(k, _confusion_matrix.shape[0]) if k > 0 else _confusion_matrix.shape[0] cm = _confusion_matrix[:k, :k] if normalize: with np.errstate(divide="ignore", invalid="ignore"): cm_norm = np.true_divide(cm, cm.sum(1)[:, np.newaxis]) cm_norm[cm_norm == np.inf] = 0 cm_norm = np.nan_to_num(cm_norm) cm = cm_norm filename = None if output_directory: os.makedirs(output_directory, exist_ok=True) filename = filename_template_path.format(model_name_name, output_feature_name, "top" + str(k)) visualization_utils.confusion_matrix_plot( cm, labels[:k], output_feature_name=output_feature_name, filename=filename ) entropies = [] for row in cm: if np.count_nonzero(row) > 0: entropies.append(entropy(row)) else: entropies.append(0) class_entropy = np.array(entropies) class_desc_entropy = np.argsort(class_entropy)[::-1] desc_entropy = class_entropy[class_desc_entropy] filename = None if output_directory: filename = filename_template_path.format( "entropy_" + model_name_name, output_feature_name, "top" + str(k) ) visualization_utils.bar_plot( class_desc_entropy, desc_entropy, labels=[labels[i] for i in class_desc_entropy], title="Classes ranked by entropy of " "Confusion Matrix row", filename=filename, ) if not confusion_matrix_found: logger.error("Cannot find confusion_matrix in evaluation data") raise FileNotFoundError("Cannot find confusion_matrix in evaluation " "data") @DeveloperAPI def frequency_vs_f1( test_stats_per_model: list[dict], metadata: dict, output_feature_name: str | None, top_n_classes: list[int], model_names: str | list[str] = None, output_directory: str = None, file_format: str = "pdf", **kwargs, ): """Show prediction statistics for the specified `output_feature_name` for each model. For each model (in the aligned lists of `test_stats_per_model` and `model_names`), produces two plots statistics of predictions for the specified `output_feature_name`. The first plot is a line plot with one x axis representing the different classes and two vertical axes colored in orange and blue respectively. The orange one is the frequency of the class and an orange line is plotted to show the trend. The blue one is the F1 score for that class and a blue line is plotted to show the trend. The classes on the x axis are sorted by f1 score. The second plot has the same structure of the first one, but the axes are flipped and the classes on the x axis are sorted by frequency. # Inputs :param test_stats_per_model: (List[dict]) dictionary containing evaluation performance statistics. :param metadata: (dict) intermediate preprocess structure created during training containing the mappings of the input dataset. :param output_feature_name: (Union[str, `None`]) name of the output feature to use for the visualization. If `None`, use all output features. :param top_n_classes: (List[int]) number of top classes or list containing the number of top classes to plot. :param model_names: (Union[str, List[str]], default: `None`) model name or list of the model names to use as labels. :param output_directory: (str, default: `None`) directory where to save plots. If not specified, plots will be displayed in a window :param file_format: (str, default: `'pdf'`) file format of output plots - `'pdf'` or `'png'`. # Return :return: (None) """ test_stats_per_model_list = test_stats_per_model model_names_list = convert_to_list(model_names) filename_template = "frequency_vs_f1_{}_{}." + file_format filename_template_path = generate_filename_template_path(output_directory, filename_template) output_feature_names = _validate_output_feature_name_from_test_stats(output_feature_name, test_stats_per_model_list) k = top_n_classes[0] for i, test_stats in enumerate(test_stats_per_model_list): for of_name in output_feature_names: # Figure out model name model_name = model_names_list[i] if model_names_list is not None and i < len(model_names_list) else "" # setup directory and filename filename = None if output_directory: os.makedirs(output_directory, exist_ok=True) filename = filename_template_path.format(model_name, of_name) # setup local variables per_class_stats = test_stats[of_name]["per_class_stats"] class_names = metadata[of_name]["idx2str"] # get np arrays of frequencies, f1s and labels idx2freq = {metadata[of_name]["str2idx"][key]: val for key, val in metadata[of_name]["str2freq"].items()} freq_np = np.array([idx2freq[class_id] for class_id in sorted(idx2freq)], dtype=np.int32) if k > 0: class_names = class_names[:k] freq_np = freq_np[:k] f1_scores = [] labels = [] for class_name in class_names: class_stats = per_class_stats[class_name] f1_scores.append(class_stats["f1_score"]) labels.append(class_name) f1_np = np.nan_to_num(np.array(f1_scores, dtype=np.float32)) labels_np = np.array(labels) # sort by f1 f1_sort_idcs = f1_np.argsort()[::-1] len_f1_sort_idcs = len(f1_sort_idcs) freq_sorted_by_f1 = freq_np[f1_sort_idcs] freq_sorted_by_f1 = freq_sorted_by_f1[:len_f1_sort_idcs] f1_sorted_by_f1 = f1_np[f1_sort_idcs] f1_sorted_by_f1 = f1_sorted_by_f1[:len_f1_sort_idcs] labels_sorted_by_f1 = labels_np[f1_sort_idcs] labels_sorted_by_f1 = labels_sorted_by_f1[:len_f1_sort_idcs] # create viz sorted by f1 visualization_utils.double_axis_line_plot( f1_sorted_by_f1, freq_sorted_by_f1, "F1 score", "frequency", labels=labels_sorted_by_f1, title=f"{model_name} F1 Score vs Frequency {of_name}", filename=filename, ) # sort by freq freq_sort_idcs = freq_np.argsort()[::-1] len_freq_sort_idcs = len(freq_sort_idcs) freq_sorted_by_freq = freq_np[freq_sort_idcs] freq_sorted_by_freq = freq_sorted_by_freq[:len_freq_sort_idcs] f1_sorted_by_freq = f1_np[freq_sort_idcs] f1_sorted_by_freq = f1_sorted_by_freq[:len_freq_sort_idcs] labels_sorted_by_freq = labels_np[freq_sort_idcs] labels_sorted_by_freq = labels_sorted_by_freq[:len_freq_sort_idcs] # create viz sorted by freq visualization_utils.double_axis_line_plot( freq_sorted_by_freq, f1_sorted_by_freq, "frequency", "F1 score", labels=labels_sorted_by_freq, title=f"{model_name} F1 Score vs Frequency {of_name}", filename=filename, ) @DeveloperAPI def hyperopt_report_cli(hyperopt_stats_path, output_directory=None, file_format="pdf", **kwargs) -> None: """Produces a report about hyperparameter optimization creating one graph per hyperparameter to show the distribution of results and one additional graph of pairwise hyperparameters interactions. :param hyperopt_stats_path: path to the hyperopt results JSON file :param output_directory: path where to save the output plots :param file_format: format of the output plot, pdf or png :return: """ hyperopt_report(hyperopt_stats_path, output_directory=output_directory, file_format=file_format) @DeveloperAPI def hyperopt_report(hyperopt_stats_path: str, output_directory: str = None, file_format: str = "pdf", **kwargs) -> None: """Produces a report about hyperparameter optimization creating one graph per hyperparameter to show the distribution of results and one additional graph of pairwise hyperparameters interactions. # Inputs :param hyperopt_stats_path: (str) path to the hyperopt results JSON file. :param output_directory: (str, default: `None`) directory where to save plots. If not specified, plots will be displayed in a window. :param file_format: (str, default: `'pdf'`) file format of output plots - `'pdf'` or `'png'`. # Return :return: (None) """ filename_template = "hyperopt_{}." + file_format filename_template_path = generate_filename_template_path(output_directory, filename_template) hyperopt_stats = load_json(hyperopt_stats_path) visualization_utils.hyperopt_report( hyperopt_stats["hyperopt_config"]["parameters"], hyperopt_results_to_dataframe( hyperopt_stats["hyperopt_results"], hyperopt_stats["hyperopt_config"]["parameters"], hyperopt_stats["hyperopt_config"]["metric"], ), metric=hyperopt_stats["hyperopt_config"]["metric"], filename_template=filename_template_path, ) @DeveloperAPI def hyperopt_hiplot_cli(hyperopt_stats_path, output_directory=None, **kwargs): """Produces a parallel coordinate plot about hyperparameter optimization creating one HTML file and optionally a CSV file to be read by hiplot. :param hyperopt_stats_path: path to the hyperopt results JSON file :param output_directory: path where to save the output plots :return: """ hyperopt_hiplot(hyperopt_stats_path, output_directory=output_directory) @DeveloperAPI def hyperopt_hiplot(hyperopt_stats_path, output_directory=None, **kwargs): """Produces a parallel coordinate plot about hyperparameter optimization creating one HTML file and optionally a CSV file to be read by hiplot. # Inputs :param hyperopt_stats_path: (str) path to the hyperopt results JSON file. :param output_directory: (str, default: `None`) directory where to save plots. If not specified, plots will be displayed in a window. # Return :return: (None) """ filename = "hyperopt_hiplot.html" filename_path = generate_filename_template_path(output_directory, filename) hyperopt_stats = load_json(hyperopt_stats_path) hyperopt_df = hyperopt_results_to_dataframe( hyperopt_stats["hyperopt_results"], hyperopt_stats["hyperopt_config"]["parameters"], hyperopt_stats["hyperopt_config"]["metric"], ) visualization_utils.hyperopt_hiplot( hyperopt_df, filename=filename_path, ) def _convert_space_to_dtype(space: str) -> str: if space in visualization_utils.RAY_TUNE_FLOAT_SPACES: return "float" elif space in visualization_utils.RAY_TUNE_INT_SPACES: return "int" else: return "object" @DeveloperAPI def hyperopt_results_to_dataframe(hyperopt_results, hyperopt_parameters, metric): df = pd.DataFrame([{metric: res["metric_score"], **res["parameters"]} for res in hyperopt_results]) df = df.astype( {hp_name: _convert_space_to_dtype(hp_params[SPACE]) for hp_name, hp_params in hyperopt_parameters.items()} ) return df @DeveloperAPI def get_visualizations_registry() -> dict[str, Callable]: return { "compare_performance": compare_performance_cli, "compare_classifiers_performance_from_prob": compare_classifiers_performance_from_prob_cli, "compare_classifiers_performance_from_pred": compare_classifiers_performance_from_pred_cli, "compare_classifiers_performance_subset": compare_classifiers_performance_subset_cli, "compare_classifiers_performance_changing_k": compare_classifiers_performance_changing_k_cli, "compare_classifiers_multiclass_multimetric": compare_classifiers_multiclass_multimetric_cli, "compare_classifiers_predictions": compare_classifiers_predictions_cli, "compare_classifiers_predictions_distribution": compare_classifiers_predictions_distribution_cli, "confidence_thresholding": confidence_thresholding_cli, "confidence_thresholding_data_vs_acc": confidence_thresholding_data_vs_acc_cli, "confidence_thresholding_data_vs_acc_subset": confidence_thresholding_data_vs_acc_subset_cli, "confidence_thresholding_data_vs_acc_subset_per_class": confidence_thresholding_data_vs_acc_subset_per_class_cli, # noqa: E501 "confidence_thresholding_2thresholds_2d": confidence_thresholding_2thresholds_2d_cli, "confidence_thresholding_2thresholds_3d": confidence_thresholding_2thresholds_3d_cli, "binary_threshold_vs_metric": binary_threshold_vs_metric_cli, "roc_curves": roc_curves_cli, "roc_curves_from_test_statistics": roc_curves_from_test_statistics_cli, "precision_recall_curves": precision_recall_curves_cli, "precision_recall_curves_from_test_statistics": precision_recall_curves_from_test_statistics_cli, "calibration_1_vs_all": calibration_1_vs_all_cli, "calibration_multiclass": calibration_multiclass_cli, "confusion_matrix": confusion_matrix_cli, "frequency_vs_f1": frequency_vs_f1_cli, "learning_curves": learning_curves_cli, "hyperopt_report": hyperopt_report_cli, "hyperopt_hiplot": hyperopt_hiplot_cli, } @PublicAPI def cli(sys_argv): parser = argparse.ArgumentParser( description="This script analyzes results and shows some nice plots.", prog="ludwig visualize", usage="%(prog)s [options]", ) parser.add_argument("-g", "--ground_truth", help="ground truth file") parser.add_argument("-gm", "--ground_truth_metadata", help="input metadata JSON file") parser.add_argument( "-sf", "--split_file", default=None, help="file containing split values used in conjunction with " "ground truth file.", ) parser.add_argument( "-od", "--output_directory", help="directory where to save plots." "If not specified, plots will be displayed in a window", ) parser.add_argument( "-ff", "--file_format", help="file format of output plots", default="pdf", choices=["pdf", "png"] ) parser.add_argument( "-v", "--visualization", choices=sorted(list(get_visualizations_registry().keys())), help="type of visualization to generate", required=True, ) parser.add_argument("-ofn", "--output_feature_name", default=[], help="name of the output feature to visualize") parser.add_argument( "-gts", "--ground_truth_split", default=2, help="ground truth split - 0:train, 1:validation, 2:test split" ) parser.add_argument( "-tf", "--threshold_output_feature_names", default=[], nargs="+", help="names of output features for 2d threshold", ) parser.add_argument("-pred", "--predictions", default=[], nargs="+", type=str, help="predictions files") parser.add_argument("-prob", "--probabilities", default=[], nargs="+", type=str, help="probabilities files") parser.add_argument("-trs", "--training_statistics", default=[], nargs="+", type=str, help="training stats files") parser.add_argument("-tes", "--test_statistics", default=[], nargs="+", type=str, help="test stats files") parser.add_argument("-hs", "--hyperopt_stats_path", default=None, type=str, help="hyperopt stats file") parser.add_argument( "-mn", "--model_names", default=[], nargs="+", type=str, help="names of the models to use as labels" ) parser.add_argument("-tn", "--top_n_classes", default=[0], nargs="+", type=int, help="number of classes to plot") parser.add_argument("-k", "--top_k", default=3, type=int, help="number of elements in the ranklist to consider") parser.add_argument( "-ll", "--labels_limit", default=0, type=int, help="maximum numbers of labels. Encoded numeric label values in dataset that are higher than " 'labels_limit are considered to be "rare" labels', ) parser.add_argument( "-ss", "--subset", default="ground_truth", choices=["ground_truth", PREDICTIONS], help="type of subset filtering", ) parser.add_argument( "-n", "--normalize", action="store_true", default=False, help="normalize rows in confusion matrix" ) parser.add_argument( "-m", "--metrics", default=["f1"], nargs="+", type=str, help="metrics to display in threshold_vs_metric" ) parser.add_argument( "-pl", "--positive_label", type=int, default=1, help="label of the positive class for the roc curve" ) parser.add_argument( "-l", "--logging_level", default="info", help="the level of logging to use", choices=["critical", "error", "warning", "info", "debug", "notset"], ) add_contrib_callback_args(parser) args = parser.parse_args(sys_argv) args.callbacks = args.callbacks or [] for callback in args.callbacks: callback.on_cmdline("visualize", *sys_argv) args.logging_level = get_logging_level_registry()[args.logging_level] logging.getLogger("ludwig").setLevel(args.logging_level) global logger logger = logging.getLogger("ludwig.visualize") try: vis_func = get_visualizations_registry()[args.visualization] except KeyError: logger.info("Visualization argument not recognized") raise vis_func(**vars(args)) if __name__ == "__main__": cli(sys.argv[1:]) ================================================ FILE: pyproject.toml ================================================ [tool.isort] profile = "black" line_length = 120 force_sort_within_sections = "False" order_by_type = "False" [tool.black] line-length = 120 exclude = "./python/" ================================================ FILE: pytest.ini ================================================ [pytest] markers = benchmark: mark a test as a benchmarking test. distributed: mark a test as a distributed test. filesystem: mark to test operating system systems. slow: mark test as slow. combinatorial: mark a test as combinatorial. llm: mark a test as an LLM test. integration_tests_a: mark a test to be run as part of integration tests, group A. integration_tests_b: mark a test to be run as part of integration tests, group B. integration_tests_c: mark a test to be run as part of integration tests, group C. integration_tests_d: mark a test to be run as part of integration tests, group D. integration_tests_e: mark a test to be run as part of integration tests, group E. integration_tests_f: mark a test to be run as part of integration tests, group F. filterwarnings = ignore::marshmallow.warnings.RemovedInMarshmallow4Warning ignore::DeprecationWarning:importlib._bootstrap ignore:builtin type Swig:DeprecationWarning ignore:.*torch.jit.script.*is deprecated:DeprecationWarning ================================================ FILE: requirements.txt ================================================ h5py>=3.8 numpy>=1.24 pandas>=2.0 scipy>=1.10 tabulate>=0.9 scikit-learn>=1.3 tqdm>=4.60 torch>=2.0 torchaudio>=2.0 torchvision>=0.15 transformers>=4.36 sentencepiece>=0.2 spacy>=2.3 PyYAML>=6.0 absl-py kaggle requests>=2.28 tables fsspec[http] dataclasses-json jsonschema>=4.17 tensorboard torchmetrics>=1.0 torchinfo filelock psutil protobuf>=4.0 gpustat rich~=12.4.4 packaging retry # required for TransfoXLTokenizer when using transformer_xl sacremoses sentencepiece # requirement for various paged and 8-bit optimizers bitsandbytes<0.41.0 # new data format support xlwt # excel xlrd # excel openpyxl # excel pyarrow>=14.0 # parquet lxml # html # requirement for loading hugging face datasets datasets ================================================ FILE: requirements_benchmarking.txt ================================================ s3fs ================================================ FILE: requirements_distributed.txt ================================================ # requirements for dask dask[dataframe] pyarrow>=14.0 # requirements for ray ray[default,data,serve,tune]>=2.9 GPUtil tblib awscli ================================================ FILE: requirements_explain.txt ================================================ captum ================================================ FILE: requirements_extra.txt ================================================ # alternative to Dask modin[ray] # Allows users to upload predibase>=2023.10.2 ================================================ FILE: requirements_hyperopt.txt ================================================ ray[default,tune]>=2.9 # required for Ray Tune Search Algorithm support for AutoML #search_alg: hyperopt hyperopt future<1.0 # hyperopt 0.2.7 requires future's Python 2 compat shims ================================================ FILE: requirements_llm.txt ================================================ sentence-transformers faiss-cpu accelerate loralib peft>=0.10.0 ================================================ FILE: requirements_serve.txt ================================================ uvicorn httpx fastapi python-multipart ================================================ FILE: requirements_test.txt ================================================ pytest pytest-timeout pytest-rerunfailures tifffile wget aim wandb comet_ml mlflow # For testing optional Ray Tune Search Algorithms # search_alg: bohb hpbandster ConfigSpace>=1.0 # search_alg: ax ax-platform sqlalchemy # search_alg: bayesopt bayesian-optimization # search_alg: cfo and blendsearch flaml[blendsearch] # search_alg: hebo HEBO # search_alg: nevergrad nevergrad # search_alg: optuna optuna # search_alg: skopt scikit-optimize # search_alg: zoopt zoopt # search_alg: hyperopt (TPE) hyperopt s3fs>=2022.8.2 ================================================ FILE: requirements_viz.txt ================================================ matplotlib>=3.4 seaborn hiplot ptitprince ================================================ FILE: schemastore/README.md ================================================ # SchemaStore Submission Materials This directory contains materials for submitting Ludwig's JSON Schema to the [JSON Schema Store](https://www.schemastore.org/json/). ## Catalog Entry The file `catalog-entry.json` contains the entry to add to SchemaStore's `src/api/json/catalog.json`. ## Test Configs The `test/` directory contains example Ludwig config files that validate against the schema. These are used as positive test cases in the SchemaStore PR. ## How to Submit 1. Fork [SchemaStore/schemastore](https://github.com/SchemaStore/schemastore) 1. Add the catalog entry from `catalog-entry.json` to `src/api/json/catalog.json` 1. Copy test configs from `test/` to `src/test/ludwig/` 1. Submit a PR referencing Ludwig issue #1343 ================================================ FILE: schemastore/catalog-entry.json ================================================ { "name": "Ludwig", "description": "Ludwig declarative deep learning framework configuration", "fileMatch": [ "ludwig.yaml", "ludwig.yml", "ludwig.json", "ludwig_config.yaml", "ludwig_config.yml", "ludwig_config.json", "**/ludwig/**/config.yaml", "**/ludwig/**/config.yml" ], "url": "https://ludwig-ai.github.io/schema/ludwig-config.json" } ================================================ FILE: schemastore/test/ludwig.yaml ================================================ # Rotten Tomatoes review classification - multimodal Ludwig config input_features: - name: genres type: set preprocessing: tokenizer: comma - name: content_rating type: category - name: top_critic type: binary - name: runtime type: number - name: review_content type: text encoder: type: embed output_features: - name: recommended type: binary trainer: epochs: 3 ================================================ FILE: schemastore/test/ludwig_config.yaml ================================================ # Titanic survival prediction - basic Ludwig config input_features: - name: Pclass type: category - name: Sex type: category - name: Age type: number preprocessing: missing_value_strategy: fill_with_mean - name: SibSp type: number - name: Parch type: number - name: Fare type: number preprocessing: missing_value_strategy: fill_with_mean - name: Embarked type: category output_features: - name: Survived type: binary ================================================ FILE: setup.cfg ================================================ [flake8] max-line-length = 120 exclude = .tox, *.egg, *_pb2.py, build, temp select = E,W,F doctests = True verbose = 2 format = pylint # E731: Do not assign a lambda expression, use a def # W503: Line break occurred before a binary operator # E203: whitespace before ':' ignore = E731, W503, E203 ================================================ FILE: setup.py ================================================ """Ludwig: Data-centric declarative deep learning framework.""" from codecs import open from os import path from setuptools import find_packages, setup here = path.abspath(path.dirname(__file__)) # Get the long description from the README.md file with open(path.join(here, "README.md"), encoding="utf-8") as f: long_description = f.read() with open(path.join(here, "requirements.txt"), encoding="utf-8") as f: requirements = [line.strip() for line in f if line] extra_requirements = {} with open(path.join(here, "requirements_serve.txt"), encoding="utf-8") as f: extra_requirements["serve"] = [line.strip() for line in f if line] with open(path.join(here, "requirements_viz.txt"), encoding="utf-8") as f: extra_requirements["viz"] = [line.strip() for line in f if line] with open(path.join(here, "requirements_distributed.txt"), encoding="utf-8") as f: extra_requirements["distributed"] = [line.strip() for line in f if line] with open(path.join(here, "requirements_hyperopt.txt"), encoding="utf-8") as f: extra_requirements["hyperopt"] = [line.strip() for line in f if line] with open(path.join(here, "requirements_llm.txt"), encoding="utf-8") as f: extra_requirements["llm"] = [line.strip() for line in f if line] with open(path.join(here, "requirements_explain.txt"), encoding="utf-8") as f: extra_requirements["explain"] = [line.strip() for line in f if line] with open(path.join(here, "requirements_benchmarking.txt"), encoding="utf-8") as f: extra_requirements["benchmarking"] = [line.strip() for line in f if line] extra_requirements["full"] = [item for sublist in extra_requirements.values() for item in sublist] with open(path.join(here, "requirements_test.txt"), encoding="utf-8") as f: extra_requirements["test"] = extra_requirements["full"] + [line.strip() for line in f if line] with open(path.join(here, "requirements_extra.txt"), encoding="utf-8") as f: extra_requirements["extra"] = [line.strip() for line in f if line] setup( name="ludwig", version="0.11.2", description="Declarative machine learning: End-to-end machine learning pipelines using data-driven configurations.", long_description=long_description, long_description_content_type="text/markdown", url="https://github.com/ludwig-ai/ludwig", download_url="https://pypi.org/project/ludwig/", author="Piero Molino", author_email="piero.molino@gmail.com", license="Apache 2.0", keywords="ludwig deep learning deep_learning machine machine_learning natural language processing computer vision", packages=find_packages(exclude=["contrib", "docs", "tests"]), python_requires=">=3.12", include_package_data=True, package_data={"ludwig": ["etc/*", "examples/*.py"]}, install_requires=requirements, extras_require=extra_requirements, entry_points={"console_scripts": ["ludwig=ludwig.cli:main"]}, ) ================================================ FILE: tests/README.md ================================================ # Test Guide Assuming your CWD is the Ludwig repo root. ## Basic ```bash pytest -vs tests ``` ## Private Tests These tests connect to services like remote filesystems (Minio / S3), which can be run locally using Docker. ```bash # prepare test services docker-compose -f tests/docker-compose.yml up # run all tests RUN_PRIVATE=1 pytest -vs tests ``` ## Slow Tests These tests are very slow, and should typically be run on GPU machines. ```bash RUN_SLOW=1 pytest -vs tests ``` ## Running GitHub Actions Locally It is possible to run the CI test suite locally by executing the `pytest` action using [act](https://github.com/nektos/act). First start up the local minio container, if it is not already running. Then call `act -j pytest` to run the test suite. ``` # Start minio container in background docker-compose -f tests/docker-compose.yml up -d # Run local test suite RUN_PRIVATE=1 act -j pytest ``` ## Tests that use ray clusters Use the distributed pytest decorator to make sure that the test runs on CI jobs with the right ray dependencies installed. ```python @pytest.mark.distributed def test_something(ray_cluster_2_cpu): pass ``` Use module-level pytest fixtures to share ray cluster startup and teardown overhead at the module level. List of fixtures are found in `conftest.py`, for example: ```python @pytest.fixture(scope="module") def ray_cluster_2cpu(request): with _ray_start(request, num_cpus=2): yield ``` ## Grouped Integration Tests To leverage more runners to cut Ludwig CI time down, we partition `tests/integration_tests` into 3 groups (A, B, default). Each group should take on a roughly equal share of testing time, which at the time of writing is ~45 minutes each. To define a new group and use it in tests: 1. Define a new pytest marker in `pytest.ini`. ```ini integration_tests_a: mark a test to be run as part of integration tests, group A. integration_tests_b: mark a test to be run as part of integration tests, group B. # (new) integration_tests_c: mark a test to be run as part of integration tests, group C. ``` 2. Use the marker in a test file under `tests/integration_tests/`. ```python import pytest pytestmark = pytest.mark.integration_tests_c ``` If there's already a `pytestmark` declaration, turn it into a list. ```python import pytest pytestmark = [pytest.mark.distributed, pytest.mark.integration_tests_c] ``` If there's a specific test to include in the group, decorate the test function. ```python @pytest.mark.integration_tests_c def test_something(): pass ``` 3. Create a new GHA to run pytest with that marker. You can use [this change](https://github.com/ludwig-ai/ludwig/pull/3391/files#diff-2500680f4bc6c1b75c3d4b36372bf4d64c5f603b90bfd7a5186f66a20329d16aR189-R245) as a reference. NOTE: Be sure to update other Integration Test GHA pytest jobs to exclude tests under the new marker. To check which tests would be run under the `pytest` command without actually running them, use `--collect-only`. ```sh pytest -m "not distributed and not slow and not combinatorial and not llm and integration_tests_c" --junitxml pytest.xml tests/integration_tests --collect-only ``` ================================================ FILE: tests/__init__.py ================================================ # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== ================================================ FILE: tests/conftest.py ================================================ # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import contextlib import os import tempfile import time import uuid from unittest import mock import pytest from ludwig.constants import ( BATCH_SIZE, COMBINER, EPOCHS, HYPEROPT, INPUT_FEATURES, NAME, OUTPUT_FEATURES, TRAINER, TYPE, ) from ludwig.hyperopt.run import hyperopt from tests.integration_tests.utils import category_feature, generate_data, text_feature TEST_SUITE_TIMEOUT_S = int(os.environ.get("LUDWIG_TEST_SUITE_TIMEOUT_S", 3600)) explicit_int_markers = { "integration_tests_a", "integration_tests_b", "integration_tests_c", "integration_tests_d", "integration_tests_e", } def pytest_sessionstart(session): session.start_time = time.time() def pytest_collection_modifyitems(config, items): for item in items: if all(False for x in item.iter_markers() if x.name in explicit_int_markers): item.add_marker("integration_tests_f") @pytest.fixture(autouse=True) def check_session_time(request): elapsed = time.time() - request.session.start_time if elapsed > TEST_SUITE_TIMEOUT_S: request.session.shouldstop = "time limit reached: %0.2f seconds" % elapsed @pytest.fixture(autouse=True) def setup_tests(request): if "distributed" not in request.keywords: # Only run this patch if we're running distributed tests, otherwise Ray will not be installed # and this will fail. # See: https://stackoverflow.com/a/38763328 yield return with mock.patch("ludwig.backend.ray.init_ray_local") as mock_init_ray_local: mock_init_ray_local.side_effect = RuntimeError("Ray must be initialized explicitly when running tests") yield mock_init_ray_local @pytest.fixture() def csv_filename(): """Yields a csv filename for holding temporary data.""" with tempfile.TemporaryDirectory() as tmpdir: csv_filename = os.path.join(tmpdir, uuid.uuid4().hex[:10].upper() + ".csv") yield csv_filename @pytest.fixture() def yaml_filename(): """Yields a yaml filename for holding a temporary config.""" with tempfile.TemporaryDirectory() as tmpdir: yaml_filename = os.path.join(tmpdir, "model_def_" + uuid.uuid4().hex[:10].upper() + ".yaml") yield yaml_filename @pytest.fixture(scope="module") def hyperopt_results_single_parameter(ray_cluster_4cpu): """This fixture is used by hyperopt visualization tests in test_visualization_api.py.""" config, rel_path = _get_sample_config() config[HYPEROPT] = { "parameters": { "trainer.learning_rate": { "space": "loguniform", "lower": 0.0001, "upper": 0.01, } }, "goal": "minimize", "output_feature": config[OUTPUT_FEATURES][0][NAME], "validation_metrics": "loss", "executor": { "type": "ray", "num_samples": 2, }, "search_alg": { "type": "variant_generator", }, } # Prevent resume from failure since this results in failures in other tests hyperopt(config, dataset=rel_path, output_directory="results", experiment_name="hyperopt_test", resume=False) return os.path.join(os.path.abspath("results"), "hyperopt_test") @pytest.fixture(scope="module") def hyperopt_results_multiple_parameters(ray_cluster_4cpu): """This fixture is used by hyperopt visualization tests in test_visualization_api.py.""" config, rel_path = _get_sample_config() output_feature_name = config[OUTPUT_FEATURES][0][NAME] config[HYPEROPT] = { "parameters": { "trainer.learning_rate": { "space": "loguniform", "lower": 0.0001, "upper": 0.01, }, output_feature_name + ".decoder.fc_output_size": {"space": "choice", "categories": [32, 64, 128, 256]}, output_feature_name + ".decoder.num_fc_layers": {"space": "randint", "lower": 1, "upper": 6}, }, "goal": "minimize", "output_feature": output_feature_name, "validation_metrics": "loss", "executor": { "type": "ray", "num_samples": 2, }, "search_alg": { "type": "variant_generator", }, } # Prevent resume from failure since this results in failures in other tests hyperopt(config, dataset=rel_path, output_directory="results", experiment_name="hyperopt_test", resume=False) return os.path.join(os.path.abspath("results"), "hyperopt_test") @pytest.fixture(scope="module") def ray_cluster_2cpu(request): with _ray_start(request, num_cpus=2): yield @pytest.fixture(scope="module") def ray_cluster_4cpu(request): with _ray_start(request, num_cpus=4): yield @pytest.fixture(scope="module") def ray_cluster_5cpu(request): with _ray_start(request, num_cpus=5): yield @pytest.fixture(scope="module") def ray_cluster_7cpu(request): with _ray_start(request, num_cpus=7): yield @contextlib.contextmanager def _ray_start(request, **kwargs): try: import ray except ImportError: if "distributed" in request.keywords: raise # Allow this fixture to run in environments where Ray is not installed # for parameterized tests that mix Ray with non-Ray backends yield None return init_kwargs = _get_default_ray_kwargs() init_kwargs.update(kwargs) # HACK(geoffrey): `hyperopt_resources` is a required resource for hyperopt to prevent deadlocks in Ludwig tests. # For context, if there are 4 hyperopt trials scheduled and 7 CPUs available, then the trial driver will require # some resource to run *in addition* to the resources required by the trainer downstream. If we use 1 CPU # (default trial driver request), then the trial will be scheduled on 1 CPU and the trainer will later request # an additional 1 CPU. Across all 4 trials, this will possibly consume >7 CPUs, causing a deadlock since # Ray Datasets will not be able to grab resources for data preprocessing. # # By adding a `hyperopt_resources` resource, we can ensure that the trial driver will be scheduled without # consuming any CPU resources. This allows each trial's trainer to request 1 CPU without starving Ray Datasets. # TODO(geoffrey): remove for Ray 2.2 res = ray.init(**init_kwargs, resources={"hyperopt_resources": 1000}) try: yield res finally: ray.shutdown() # Delete the cluster address just in case. if hasattr(ray._private.utils, "reset_ray_address"): ray._private.utils.reset_ray_address() def _get_default_ray_kwargs(): ray_kwargs = { "num_cpus": 1, "object_store_memory": 150 * 1024 * 1024, "dashboard_port": None, "include_dashboard": False, "namespace": "default_test_namespace", "ignore_reinit_error": True, } return ray_kwargs def _get_sample_config(): """Returns a sample config.""" input_features = [ text_feature(name="utterance", encoder={"cell_type": "lstm", "reduce_output": "sum"}), category_feature(encoder={"vocab_size": 2}, reduce_input="sum"), ] output_features = [category_feature(decoder={"vocab_size": 2}, reduce_input="sum")] csv_filename = uuid.uuid4().hex[:10].upper() + ".csv" rel_path = generate_data(input_features, output_features, csv_filename) config = { INPUT_FEATURES: input_features, OUTPUT_FEATURES: output_features, COMBINER: {TYPE: "concat", "num_fc_layers": 2}, TRAINER: {EPOCHS: 2, "learning_rate": 0.001, BATCH_SIZE: 128}, } return config, rel_path ================================================ FILE: tests/docker-compose.yml ================================================ version: '3' services: minio: image: 'minio/minio:latest' volumes: - minio_storage:/data ports: - 9000:9000 - 9001:9001 environment: - MINIO_ACCESS_KEY=minio - MINIO_SECRET_KEY=minio123 command: server --console-address ":9001" /data volumes: minio_storage: ================================================ FILE: tests/integration_tests/__init__.py ================================================ ================================================ FILE: tests/integration_tests/parameter_update_utils.py ================================================ import logging from collections.abc import Callable import torch from ludwig.constants import ENCODER_OUTPUT from ludwig.utils.torch_utils import LudwigModule logger = logging.getLogger(__name__) class ParameterUpdateError(Exception): pass def check_module_parameters_updated( module: LudwigModule, module_input_args: tuple, module_target: torch.Tensor, loss_function: Callable | None = None, max_steps: int = 1, learning_rate: float = 0.001, ) -> tuple: """ Reports on the number of parameters in a Ludwig component and their update status. Args: module: (LudwigModel) model to be tested. module_input_args: (tuple) input for model module_target: (Tensor) target values for computing loss and parameter updates loss_function: (None or Callable) Optional for module specific loss calculation max_steps: (int, default=1) maximum number of steps allowed to test for parameter updates. learning_rate: (float, default=0.001) learning rate for the optimizer Returns: Tuple(frozen_parameters, trainable_parameters, parameters_updated, not_updated) frozen_parameters: count of frozen parameters trainable_parameters: count of trainable parameters parameters_updated: count of trainable parameters that were updated not_updated: list of parameters that were not updated """ # setup if loss_function is None: loss_function = torch.nn.MSELoss() # Ensure module and all inputs are on the same device from ludwig.utils.torch_utils import get_torch_device device = get_torch_device() module = module.to(device) def _move_to_device(arg): if isinstance(arg, torch.Tensor): return arg.to(device) if isinstance(arg, dict): return {k: _move_to_device(v) for k, v in arg.items()} if isinstance(arg, (list, tuple)): return type(arg)(_move_to_device(v) for v in arg) return arg module_input_args = tuple(_move_to_device(arg) for arg in module_input_args) module_target = module_target.to(device) optimizer = torch.optim.SGD(module.parameters(), lr=learning_rate) module.train(True) trainable_parameter_list = [] frozen_parameter_list = [] parameter_updated = [] parameters_not_updated = [] for step in range(max_steps): # make pass through model module_output = module(*module_input_args) # check for any frozen parameters frozen_parameter_list = [] trainable_parameter_list = [] for p in module.named_parameters(): if p[1].requires_grad: trainable_parameter_list.append(p) else: frozen_parameter_list.append(p) # check parameter updates only if there are some unfrozen parameters if len(trainable_parameter_list) > 0: # do update of model parameters optimizer.zero_grad() if isinstance(module_output, torch.Tensor): module_target = module_target.to(device=module_output.device) loss = loss_function(module_output, module_target) elif isinstance(module_output, dict): if "logits" in module_output: module_target = module_target.to(device=module_output["logits"].device) loss = loss_function(module_output["logits"], module_target) elif ENCODER_OUTPUT in module_output: module_target = module_target.to(device=module_output[ENCODER_OUTPUT].device) loss = loss_function(module_output[ENCODER_OUTPUT], module_target) elif "combiner_output" in module_output: module_target = module_target.to(device=module_output["combiner_output"].device) loss = loss_function(module_output["combiner_output"], module_target) elif isinstance(module_output, (list, tuple)): module_target = module_target.to(device=module_output[0].device) loss = loss_function(module_output[0], module_target) else: raise ValueError(f"Unexpected output type. Module type found is {type(module_output)}") loss.backward() optimizer.step() # check for parameter updates parameter_updated = [] # create tuple for each parameter: (parameter name, update indicator True/False) # parameter is deemed updated if the gradient is not None and the gradient has non-zero value for p in module.named_parameters(): parameter_updated.append((p[0], (p[1].grad is not None) and (not torch.all(p[1].grad == 0)))) else: parameter_updated = [] parameters_not_updated = [] for p in parameter_updated: # if not updated, record parameter name if not p[1]: parameters_not_updated.append(p[0]) trainable_parameters = len(trainable_parameter_list) parameters_updated = sum(p[1] for p in parameter_updated) frozen_parameters = len(frozen_parameter_list) return frozen_parameters, trainable_parameters, parameters_updated, parameters_not_updated ================================================ FILE: tests/integration_tests/scripts/run_train_aim.py ================================================ import argparse import os import sys import tempfile from unittest.mock import Mock # Comet must be imported before the libraries it wraps import aim # noqa from ludwig.contribs.aim import AimCallback from tests.integration_tests.utils import category_feature, generate_data, image_feature, run_experiment PATH_HERE = os.path.abspath(os.path.dirname(__file__)) PATH_ROOT = os.path.join(PATH_HERE, "..", "..", "..") sys.path.insert(0, os.path.abspath(PATH_ROOT)) def run(csv_filename): callback = AimCallback() # Wrap these methods so we can check that they were called callback.on_train_init = Mock(side_effect=callback.on_train_init) callback.on_train_start = Mock(side_effect=callback.on_train_start) # Image Inputs with tempfile.TemporaryDirectory() as tmpdir: image_dest_folder = os.path.join(tmpdir, "generated_images") # Inputs & Outputs input_features = [image_feature(folder=image_dest_folder)] output_features = [category_feature(output_feature=True)] rel_path = generate_data(input_features, output_features, csv_filename) # Run experiment run_experiment(input_features, output_features, dataset=rel_path, callbacks=[callback]) # Check that these methods were called at least once callback.on_train_init.assert_called() callback.on_train_start.assert_called() if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("--csv-filename", required=True) args = parser.parse_args() run(args.csv_filename) ================================================ FILE: tests/integration_tests/scripts/run_train_comet.py ================================================ # Tests the following end-to-end: # # 1. Comet is imported # 2. Conflicting modules (i.e., TensorFlow) are not imported # 3. Overridden methods are called (train_init, train_model, etc.) and run without error # # This test runs in an isolated environment to ensure TensorFlow imports are not leaked # from previous tests. import argparse import os import sys import tempfile from unittest.mock import Mock, patch # Comet must be imported before the libraries it wraps import comet_ml # noqa from ludwig.api import LudwigModel from ludwig.constants import BATCH_SIZE, TRAINER from ludwig.contribs.comet import CometCallback # Bad key will ensure Comet is initialized, but nothing is uploaded externally. os.environ["COMET_API_KEY"] = "key" # Add tests dir to the import path PATH_HERE = os.path.abspath(os.path.dirname(__file__)) PATH_ROOT = os.path.join(PATH_HERE, "..", "..", "..") sys.path.insert(0, os.path.abspath(PATH_ROOT)) from tests.integration_tests.utils import category_feature, generate_data, image_feature # noqa parser = argparse.ArgumentParser() parser.add_argument("--csv-filename", required=True) def run(csv_filename): with tempfile.TemporaryDirectory() as tmpdir: # Image Inputs image_dest_folder = os.path.join(tmpdir, "generated_images") # Inputs & Outputs input_features = [image_feature(folder=image_dest_folder)] output_features = [category_feature(output_feature=True)] data_csv = generate_data(input_features, output_features, csv_filename) config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat", "output_size": 14}, TRAINER: {"epochs": 2, BATCH_SIZE: 128}, } callback = CometCallback() model = LudwigModel(config, callbacks=[callback]) # Wrap these methods so we can check that they were called callback.on_train_init = Mock(side_effect=callback.on_train_init) callback.on_train_start = Mock(side_effect=callback.on_train_start) with patch("comet_ml.Experiment.log_asset_data") as mock_log_asset_data: # Training with csv _, _, _ = model.train(dataset=data_csv, output_directory=os.path.join(tmpdir, "output")) model.predict(dataset=data_csv) # Verify that the experiment was created successfully assert callback.cometml_experiment is not None # Check that these methods were called at least once callback.on_train_init.assert_called() callback.on_train_start.assert_called() # Check that we ran `train_model`, which calls into `log_assert_data`, successfully mock_log_asset_data.assert_called() if __name__ == "__main__": args = parser.parse_args() run(args.csv_filename) ================================================ FILE: tests/integration_tests/scripts/run_train_wandb.py ================================================ # Tests the following end-to-end: # # 1. W&B is imported # 2. Overridden methods are called (train_init, train_model, etc.) and run without error # # This test runs in an isolated environment because W&B make breaking changes to the # global interpreter state that will otherwise cause subsequent tests to fail. import argparse import os import sys import tempfile from unittest.mock import Mock from ludwig.contribs.wandb import WandbCallback PATH_HERE = os.path.abspath(os.path.dirname(__file__)) PATH_ROOT = os.path.join(PATH_HERE, "..", "..", "..") sys.path.insert(0, os.path.abspath(PATH_ROOT)) from tests.integration_tests.utils import category_feature, generate_data, image_feature, run_experiment # noqa parser = argparse.ArgumentParser() parser.add_argument("--csv-filename", required=True) def run(csv_filename): callback = WandbCallback() # Wrap these methods so we can check that they were called callback.on_train_init = Mock(side_effect=callback.on_train_init) callback.on_train_start = Mock(side_effect=callback.on_train_start) # disable sync to cloud os.environ["WANDB_MODE"] = "dryrun" with tempfile.TemporaryDirectory() as tmpdir: # Image Inputs image_dest_folder = os.path.join(tmpdir, "generated_images") # Inputs & Outputs input_features = [image_feature(folder=image_dest_folder)] output_features = [category_feature(output_feature=True)] rel_path = generate_data(input_features, output_features, csv_filename) # Run experiment run_experiment(input_features, output_features, dataset=rel_path, callbacks=[callback]) # Check that these methods were called at least once callback.on_train_init.assert_called() callback.on_train_start.assert_called() if __name__ == "__main__": args = parser.parse_args() run(args.csv_filename) ================================================ FILE: tests/integration_tests/synthetic_test_data.py ================================================ """Utilities for producing synthetic test data that is convergence-friendly.""" from collections import namedtuple import numpy as np import pandas as pd from sklearn.model_selection import train_test_split RANDOM_SEED = 42 NUMBER_OBSERVATIONS = 200 GeneratedData = namedtuple("GeneratedData", "train_df validation_df test_df") def get_feature_configs(): input_features = [ {"name": "x", "type": "number"}, ] output_features = [ { "name": "y", "type": "number", "loss": {"type": "mean_squared_error"}, "decoder": { "num_fc_layers": 2, "fc_output_size": 64, }, } ] return input_features, output_features def get_generated_data(): # function generates simple training data that guarantee convergence # within 30 epochs for suitable config # generate data np.random.seed(RANDOM_SEED) x = np.array(range(NUMBER_OBSERVATIONS)).reshape(-1, 1) y = 2 * x + 1 + np.random.normal(size=x.shape[0]).reshape(-1, 1) raw_df = pd.DataFrame(np.concatenate((x, y), axis=1), columns=["x", "y"]) # create training data train, valid_test = train_test_split(raw_df, train_size=0.7) # create validation and test data validation, test = train_test_split(valid_test, train_size=0.5) return GeneratedData(train, validation, test) def get_generated_data_for_optimizer(): # function generates simple training data that guarantee convergence # within 30 epochs for suitable config # generate data np.random.seed(RANDOM_SEED) x = np.array(range(NUMBER_OBSERVATIONS)).reshape(-1, 1) y = 2 * x + 1 + np.random.normal(size=x.shape[0]).reshape(-1, 1) raw_df = pd.DataFrame(np.concatenate((x, y), axis=1), columns=["x", "y"]) raw_df["x"] = (raw_df["x"] - raw_df["x"].min()) / (raw_df["x"].max() - raw_df["x"].min()) raw_df["y"] = (raw_df["y"] - raw_df["y"].min()) / (raw_df["y"].max() - raw_df["y"].min()) # create training data train, valid_test = train_test_split(raw_df, train_size=0.7) # create validation and test data validation, test = train_test_split(valid_test, train_size=0.5) return GeneratedData(train, validation, test) ================================================ FILE: tests/integration_tests/test_api.py ================================================ # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import json import os import shutil from unittest import mock import pandas as pd import pytest import torch import yaml from ludwig.api import LudwigModel from ludwig.callbacks import Callback from ludwig.constants import BATCH_SIZE, ENCODER, TRAINER, TYPE from ludwig.globals import MODEL_FILE_NAME, MODEL_HYPERPARAMETERS_FILE_NAME from ludwig.models.inference import InferenceModule from ludwig.utils.data_utils import read_csv from tests.integration_tests.utils import ( category_feature, generate_data, get_weights, image_feature, run_api_experiment, sequence_feature, text_feature, ) def run_api_experiment_separated_datasets(input_features, output_features, data_csv): """Helper method to avoid code repetition in running an experiment. :param input_features: input schema :param output_features: output schema :param data_csv: path to data :return: None """ config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat", "output_size": 14}, TRAINER: {"epochs": 2, BATCH_SIZE: 128}, } model = LudwigModel(config) # Training with dataframe data_df = read_csv(data_csv) train_df = data_df.sample(frac=0.8) test_df = data_df.drop(train_df.index).sample(frac=0.5) validation_df = data_df.drop(train_df.index).drop(test_df.index) basename, ext = os.path.splitext(data_csv) train_fname = basename + ".train" + ext val_fname = basename + ".validation" + ext test_fname = basename + ".test" + ext output_dirs = [] try: train_df.to_csv(train_fname) validation_df.to_csv(val_fname) test_df.to_csv(test_fname) # Training with csv _, _, output_dir = model.train( training_set=train_fname, skip_save_processed_input=True, skip_save_progress=True, skip_save_unprocessed_output=True, ) output_dirs.append(output_dir) _, _, output_dir = model.train( training_set=train_fname, validation_set=val_fname, skip_save_processed_input=True, skip_save_progress=True, skip_save_unprocessed_output=True, ) output_dirs.append(output_dir) _, _, output_dir = model.train( training_set=train_fname, validation_set=val_fname, test_set=test_fname, skip_save_processed_input=True, skip_save_progress=True, skip_save_unprocessed_output=True, ) output_dirs.append(output_dir) _, output_dir = model.predict(dataset=test_fname) output_dirs.append(output_dir) finally: # Remove results/intermediate data saved to disk os.remove(train_fname) os.remove(val_fname) os.remove(test_fname) for output_dir in output_dirs: shutil.rmtree(output_dir, ignore_errors=True) output_dirs = [] try: _, _, output_dir = model.train( training_set=train_df, skip_save_processed_input=True, skip_save_progress=True, skip_save_unprocessed_output=True, ) output_dirs.append(output_dir) _, _, output_dir = model.train( training_set=train_df, validation_set=validation_df, skip_save_processed_input=True, skip_save_progress=True, skip_save_unprocessed_output=True, ) output_dirs.append(output_dir) _, _, output_dir = model.train( training_set=train_df, validation_set=validation_df, test_set=test_df, skip_save_processed_input=True, skip_save_progress=True, skip_save_unprocessed_output=True, ) output_dirs.append(output_dir) _, output_dir = model.predict(dataset=data_df) output_dirs.append(output_dir) finally: for output_dir in output_dirs: shutil.rmtree(output_dir, ignore_errors=True) def test_api_intent_classification(csv_filename): # Single sequence input, single category output input_features = [sequence_feature(encoder={"reduce_output": "sum"})] output_features = [category_feature(decoder={"vocab_size": 5}, reduce_input="sum")] # Generate test data rel_path = generate_data(input_features, output_features, csv_filename) # Test representative encoders (embed=simple, rnn=recurrent, transformer=attention) for encoder in ["embed", "rnn", "transformer"]: input_features[0][ENCODER][TYPE] = encoder run_api_experiment(input_features, output_features, data_csv=rel_path) def test_api_intent_classification_separated(csv_filename): # Single sequence input, single category output input_features = [sequence_feature(encoder={"reduce_output": "sum"})] output_features = [category_feature(decoder={"vocab_size": 5}, reduce_input="sum")] # Generate test data rel_path = generate_data(input_features, output_features, csv_filename) # Test representative encoders (embed=simple, rnn=recurrent, transformer=attention) for encoder in ["embed", "rnn", "transformer"]: input_features[0][ENCODER][TYPE] = encoder run_api_experiment_separated_datasets(input_features, output_features, data_csv=rel_path) def test_api_train_online(csv_filename): input_features = [sequence_feature(encoder={"reduce_output": "sum"})] output_features = [category_feature(decoder={"vocab_size": 5}, reduce_input="sum")] data_csv = generate_data(input_features, output_features, csv_filename) config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat", "output_size": 14}, } model = LudwigModel(config) for _ in range(2): model.train_online(dataset=data_csv) model.predict(dataset=data_csv) def test_api_training_set(tmpdir): input_features = [sequence_feature(encoder={"reduce_output": "sum"})] output_features = [category_feature(decoder={"vocab_size": 5}, reduce_input="sum")] data_csv = generate_data(input_features, output_features, os.path.join(tmpdir, "dataset.csv")) val_csv = shutil.copyfile(data_csv, os.path.join(tmpdir, "validation.csv")) test_csv = shutil.copyfile(data_csv, os.path.join(tmpdir, "test.csv")) config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat", "output_size": 14}, } model = LudwigModel(config) model.train(training_set=data_csv, validation_set=val_csv, test_set=test_csv) model.predict(dataset=test_csv) # Train again, this time the HDF5 cache will be used model.train(training_set=data_csv, validation_set=val_csv, test_set=test_csv) def test_api_training_determinism(tmpdir): input_features = [sequence_feature(encoder={"reduce_output": "sum"})] output_features = [category_feature(decoder={"vocab_size": 5}, reduce_input="sum")] data_csv = generate_data(input_features, output_features, os.path.join(tmpdir, "dataset.csv")) config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat", "output_size": 14}, "trainer": {BATCH_SIZE: 128}, # batch size must be fixed for determinism } # Train the model 3 times: # # 1. seed x # 2. seed y # 3. seed x # # Check that models (1) and (3) produce the same weights, # but (1) and (2) do not rand_x = 42 rand_y = 24 model_1 = LudwigModel(config) model_1.train(dataset=data_csv, output_directory=tmpdir, random_seed=rand_x) model_2 = LudwigModel(config) model_2.train(dataset=data_csv, output_directory=tmpdir, random_seed=rand_y) model_3 = LudwigModel(config) model_3.train(dataset=data_csv, output_directory=tmpdir, random_seed=rand_x) model_weights_1 = get_weights(model_1.model) model_weights_2 = get_weights(model_2.model) model_weights_3 = get_weights(model_3.model) divergence = False for weight_1, weight_2 in zip(model_weights_1, model_weights_2): if not torch.allclose(weight_1, weight_2): divergence = True break assert divergence, "model_1 and model_2 have identical weights with different seeds!" for weight_1, weight_3 in zip(model_weights_1, model_weights_3): assert torch.allclose(weight_1, weight_3) def run_api_commands( input_features, output_features, data_csv, output_dir, skip_save_training_description=False, skip_save_training_statistics=False, skip_save_model=False, skip_save_progress=False, skip_save_log=False, skip_save_processed_input=False, skip_save_unprocessed_output=False, skip_save_predictions=False, skip_save_eval_stats=False, skip_collect_predictions=False, skip_collect_overall_stats=False, ): """Helper method to avoid code repetition in running an experiment. :param input_features: input schema :param output_features: output schema :param data_csv: path to data :return: None """ config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat", "output_size": 14}, TRAINER: {"epochs": 2, BATCH_SIZE: 128}, } model = LudwigModel(config) # Training with csv model.train( dataset=data_csv, skip_save_training_description=skip_save_training_description, skip_save_training_statistics=skip_save_training_statistics, skip_save_model=skip_save_model, skip_save_progress=skip_save_progress, skip_save_log=skip_save_log, skip_save_processed_input=skip_save_processed_input, output_directory=output_dir, ) model.predict( dataset=data_csv, skip_save_unprocessed_output=skip_save_unprocessed_output, skip_save_predictions=skip_save_predictions, output_directory=output_dir, ) model.evaluate( dataset=data_csv, skip_save_unprocessed_output=skip_save_unprocessed_output, skip_save_predictions=skip_save_predictions, skip_save_eval_stats=skip_save_eval_stats, collect_predictions=not skip_collect_predictions, collect_overall_stats=not skip_collect_overall_stats, output_directory=output_dir, ) model.experiment( dataset=data_csv, skip_save_training_description=skip_save_training_description, skip_save_training_statistics=skip_save_training_statistics, skip_save_model=skip_save_model, skip_save_progress=skip_save_progress, skip_save_log=skip_save_log, skip_save_processed_input=skip_save_processed_input, skip_save_unprocessed_output=skip_save_unprocessed_output, skip_save_predictions=skip_save_predictions, skip_save_eval_stats=skip_save_eval_stats, skip_collect_predictions=skip_collect_predictions, skip_collect_overall_stats=skip_collect_overall_stats, output_directory=output_dir, ) @pytest.mark.parametrize( "skip_save_training_description,skip_save_training_statistics,skip_save_model," "skip_save_progress,skip_save_log,skip_save_processed_input", [ (False, False, False, False, False, False), # all saving enabled (True, True, True, True, True, True), # all saving disabled (True, False, True, False, True, False), # alternating pattern ], ids=["all_save", "all_skip", "mixed"], ) def test_api_skip_parameters_train( tmpdir, csv_filename, skip_save_training_description, skip_save_training_statistics, skip_save_model, skip_save_progress, skip_save_log, skip_save_processed_input, ): # Single sequence input, single category output input_features = [category_feature(encoder={"vocab_size": 5})] output_features = [category_feature(decoder={"vocab_size": 5})] # Generate test data rel_path = generate_data(input_features, output_features, os.path.join(tmpdir, csv_filename)) run_api_commands( input_features, output_features, data_csv=rel_path, output_dir=tmpdir, skip_save_training_description=skip_save_training_description, skip_save_training_statistics=skip_save_training_statistics, skip_save_model=skip_save_model, skip_save_progress=skip_save_progress, skip_save_log=skip_save_log, skip_save_processed_input=skip_save_processed_input, ) @pytest.mark.parametrize("skip_save_unprocessed_output", [False, True]) @pytest.mark.parametrize("skip_save_predictions", [False, True]) def test_api_skip_parameters_predict( tmpdir, csv_filename, skip_save_unprocessed_output, skip_save_predictions, ): # Single sequence input, single category output input_features = [category_feature(encoder={"vocab_size": 5})] output_features = [category_feature(decoder={"vocab_size": 5})] # Generate test data rel_path = generate_data(input_features, output_features, os.path.join(tmpdir, csv_filename)) run_api_commands( input_features, output_features, data_csv=rel_path, output_dir=tmpdir, skip_save_unprocessed_output=skip_save_unprocessed_output, skip_save_predictions=skip_save_predictions, ) @pytest.mark.parametrize( "skip_save_unprocessed_output,skip_save_predictions,skip_save_eval_stats," "skip_collect_predictions,skip_collect_overall_stats", [ (False, False, False, False, False), # all saving enabled (True, True, True, True, True), # all saving disabled (True, False, True, False, True), # alternating pattern ], ids=["all_save", "all_skip", "mixed"], ) def test_api_skip_parameters_evaluate( tmpdir, csv_filename, skip_save_unprocessed_output, skip_save_predictions, skip_save_eval_stats, skip_collect_predictions, skip_collect_overall_stats, ): # Single sequence input, single category output input_features = [category_feature(encoder={"vocab_size": 5})] output_features = [category_feature(decoder={"vocab_size": 5})] # Generate test data rel_path = generate_data(input_features, output_features, os.path.join(tmpdir, csv_filename)) run_api_commands( input_features, output_features, data_csv=rel_path, output_dir=tmpdir, skip_save_unprocessed_output=skip_save_unprocessed_output, skip_save_predictions=skip_save_predictions, skip_save_eval_stats=skip_save_eval_stats, skip_collect_predictions=skip_collect_predictions, skip_collect_overall_stats=skip_collect_overall_stats, ) @pytest.mark.parametrize( "epochs,batch_size,num_examples,steps_per_checkpoint", [ (1, 8, 16, 1), (2, 4, 32, 2), (2, 8, 16, 2), ], ids=["small", "large", "mixed"], ) def test_api_callbacks(tmpdir, csv_filename, epochs, batch_size, num_examples, steps_per_checkpoint): mock_callback = mock.Mock(wraps=Callback()) steps_per_epoch = num_examples / batch_size total_checkpoints = (steps_per_epoch / steps_per_checkpoint) * epochs total_batches = epochs * (num_examples / batch_size) input_features = [sequence_feature(encoder={"reduce_output": "sum"})] output_features = [category_feature(decoder={"vocab_size": 5}, reduce_input="sum")] config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat", "output_size": 14}, TRAINER: { "epochs": epochs, "batch_size": batch_size, "steps_per_checkpoint": steps_per_checkpoint, "early_stop": 0, # Disable early stopping. }, } model = LudwigModel(config, callbacks=[mock_callback]) data_csv = generate_data( input_features, output_features, os.path.join(tmpdir, csv_filename), num_examples=num_examples ) val_csv = shutil.copyfile(data_csv, os.path.join(tmpdir, "validation.csv")) test_csv = shutil.copyfile(data_csv, os.path.join(tmpdir, "test.csv")) model.train(training_set=data_csv, validation_set=val_csv, test_set=test_csv) assert mock_callback.on_epoch_start.call_count == epochs assert mock_callback.on_epoch_end.call_count == epochs assert mock_callback.should_early_stop.call_count == total_checkpoints assert mock_callback.on_validation_start.call_count == total_checkpoints assert mock_callback.on_validation_end.call_count == total_checkpoints assert mock_callback.on_test_start.call_count == total_checkpoints assert mock_callback.on_test_end.call_count == total_checkpoints assert mock_callback.on_batch_start.call_count == total_batches assert mock_callback.on_batch_end.call_count == total_batches assert mock_callback.on_eval_end.call_count == total_checkpoints assert mock_callback.on_eval_start.call_count == total_checkpoints @pytest.mark.parametrize( "epochs,batch_size,num_examples,checkpoints_per_epoch", [ (1, 8, 32, 1), (2, 4, 64, 2), (2, 8, 32, 4), ], ids=["single_checkpoint", "multi_checkpoint", "frequent_checkpoint"], ) def test_api_callbacks_checkpoints_per_epoch( tmpdir, csv_filename, epochs, batch_size, num_examples, checkpoints_per_epoch ): mock_callback = mock.Mock(wraps=Callback()) total_checkpoints = epochs * checkpoints_per_epoch total_batches = epochs * (num_examples / batch_size) input_features = [sequence_feature(encoder={"reduce_output": "sum"})] output_features = [category_feature(decoder={"vocab_size": 5}, reduce_input="sum")] config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat", "output_size": 14}, TRAINER: { "epochs": epochs, "batch_size": batch_size, "checkpoints_per_epoch": checkpoints_per_epoch, "early_stop": 0, # Disable early stopping. }, } model = LudwigModel(config, callbacks=[mock_callback]) data_csv = generate_data( input_features, output_features, os.path.join(tmpdir, csv_filename), num_examples=num_examples ) val_csv = shutil.copyfile(data_csv, os.path.join(tmpdir, "validation.csv")) test_csv = shutil.copyfile(data_csv, os.path.join(tmpdir, "test.csv")) model.train(training_set=data_csv, validation_set=val_csv, test_set=test_csv) assert mock_callback.on_epoch_start.call_count == epochs assert mock_callback.on_epoch_end.call_count == epochs assert mock_callback.should_early_stop.call_count == total_checkpoints assert mock_callback.on_validation_start.call_count == total_checkpoints assert mock_callback.on_validation_end.call_count == total_checkpoints assert mock_callback.on_test_start.call_count == total_checkpoints assert mock_callback.on_test_end.call_count == total_checkpoints assert mock_callback.on_batch_start.call_count == total_batches assert mock_callback.on_batch_end.call_count == total_batches assert mock_callback.on_eval_end.call_count == total_checkpoints assert mock_callback.on_eval_start.call_count == total_checkpoints def test_api_callbacks_default_train_steps(tmpdir, csv_filename): # Default for train_steps is -1: use epochs. train_steps = None epochs = 3 batch_size = 8 num_examples = 20 mock_callback = mock.Mock(wraps=Callback()) input_features = [sequence_feature(encoder={"reduce_output": "sum"})] output_features = [category_feature(decoder={"vocab_size": 5}, reduce_input="sum")] config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat", "output_size": 14}, TRAINER: {"epochs": epochs, "train_steps": train_steps, "batch_size": batch_size}, } model = LudwigModel(config, callbacks=[mock_callback]) model.train( training_set=generate_data( input_features, output_features, os.path.join(tmpdir, csv_filename), num_examples=num_examples ) ) assert mock_callback.on_epoch_start.call_count == epochs def test_api_callbacks_fixed_train_steps(tmpdir, csv_filename): train_steps = 4 batch_size = 8 num_examples = 20 mock_callback = mock.Mock(wraps=Callback()) input_features = [sequence_feature(encoder={"reduce_output": "sum"})] output_features = [category_feature(decoder={"vocab_size": 5}, reduce_input="sum")] config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat", "output_size": 14}, TRAINER: {"train_steps": train_steps, "batch_size": batch_size}, } model = LudwigModel(config, callbacks=[mock_callback]) model.train( training_set=generate_data( input_features, output_features, os.path.join(tmpdir, csv_filename), num_examples=num_examples ) ) # With 20 examples (14 train at 70% split), batch_size=8, steps_per_epoch=2. # So 4 train steps => 2 epochs. assert mock_callback.on_epoch_start.call_count == 2 def test_api_callbacks_fixed_train_steps_partial_epochs(tmpdir, csv_filename): # If train_steps is set manually, epochs is ignored. train_steps = 3 epochs = 2 batch_size = 8 num_examples = 20 mock_callback = mock.Mock(wraps=Callback()) input_features = [sequence_feature(encoder={"reduce_output": "sum"})] output_features = [category_feature(decoder={"vocab_size": 5}, reduce_input="sum")] config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat", "output_size": 14}, TRAINER: {"epochs": epochs, "train_steps": train_steps, "batch_size": batch_size}, } model = LudwigModel(config, callbacks=[mock_callback]) model.train( training_set=generate_data( input_features, output_features, os.path.join(tmpdir, csv_filename), num_examples=num_examples ) ) # With 20 examples, batch_size=8, steps_per_epoch=2. 3 train steps => 1 full epoch. assert mock_callback.on_epoch_end.call_count == 1 def test_api_callbacks_batch_size_1(tmpdir, csv_filename): epochs = 1 batch_size = 1 num_examples = 16 mock_callback = mock.Mock(wraps=Callback()) input_features = [sequence_feature(encoder={"reduce_output": "sum"})] output_features = [category_feature(decoder={"vocab_size": 5}, reduce_input="sum")] config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat", "output_size": 14}, TRAINER: {"epochs": epochs, "batch_size": batch_size}, } model = LudwigModel(config, callbacks=[mock_callback]) model.train( training_set=generate_data( input_features, output_features, os.path.join(tmpdir, csv_filename), num_examples=num_examples ) ) # There are exactly 1 epoch start, even with batch_size = 1. assert mock_callback.on_epoch_start.call_count == 1 assert mock_callback.on_epoch_end.call_count == 1 assert mock_callback.on_batch_start.call_count == 16 assert mock_callback.on_batch_end.call_count == 16 def test_api_callbacks_fixed_train_steps_less_than_one_epoch(tmpdir, csv_filename): # If train_steps is set manually, epochs is ignored. # With 80 examples at 70% split = 56 train examples, batch_size=8 => 7 steps per epoch. # train_steps=6 < 7, so less than one full epoch. train_steps = total_batches = 6 steps_per_checkpoint = 2 batch_size = 8 num_examples = 80 mock_callback = mock.Mock(wraps=Callback()) input_features = [sequence_feature(encoder={"reduce_output": "sum"})] output_features = [category_feature(decoder={"vocab_size": 5}, reduce_input="sum")] config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat", "output_size": 14}, TRAINER: { "train_steps": train_steps, "steps_per_checkpoint": steps_per_checkpoint, "batch_size": batch_size, }, } model = LudwigModel(config, callbacks=[mock_callback]) model.train( training_set=generate_data( input_features, output_features, os.path.join(tmpdir, csv_filename), num_examples=num_examples ) ) assert mock_callback.on_epoch_start.call_count == 1 assert mock_callback.on_epoch_end.call_count == 0 # The total number of batches is the number of train_steps assert mock_callback.on_batch_end.call_count == total_batches # The total number of evals is the number of times checkpoints are made assert mock_callback.on_eval_end.call_count == train_steps // steps_per_checkpoint def test_api_save_torchscript(tmpdir): """Tests successful saving and loading of model in TorchScript format.""" input_features = [category_feature(encoder={"vocab_size": 5})] output_features = [ category_feature(name="class", decoder={"vocab_size": 5}, reduce_input="sum", output_feature=True) ] data_csv = generate_data(input_features, output_features, os.path.join(tmpdir, "dataset.csv")) val_csv = shutil.copyfile(data_csv, os.path.join(tmpdir, "validation.csv")) test_csv = shutil.copyfile(data_csv, os.path.join(tmpdir, "test.csv")) config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat", "output_size": 14}, } model = LudwigModel(config) model.train(training_set=data_csv, validation_set=val_csv, test_set=test_csv, output_directory=tmpdir) test_df = pd.read_csv(test_csv) output_df_expected, _ = model.predict(test_df, return_type=pd.DataFrame) save_path = os.path.join(tmpdir, "torchscript") os.makedirs(save_path, exist_ok=True) model.save_torchscript(save_path) inference_module = InferenceModule.from_directory(save_path) output_df, _ = inference_module.predict(test_df, return_type=pd.DataFrame) for col in output_df.columns: assert output_df[col].equals(output_df_expected[col]) def test_saved_weights_in_checkpoint(tmpdir): image_dest_folder = os.path.join(tmpdir, "generated_images") input_features = [ text_feature(), image_feature(image_dest_folder), ] output_features = [category_feature(name="class", output_feature=True)] data_csv = generate_data(input_features, output_features, os.path.join(tmpdir, "dataset.csv")) val_csv = shutil.copyfile(data_csv, os.path.join(tmpdir, "validation.csv")) test_csv = shutil.copyfile(data_csv, os.path.join(tmpdir, "test.csv")) config = { "input_features": input_features, "output_features": output_features, TRAINER: {BATCH_SIZE: 128}, } model = LudwigModel(config) _, _, output_dir = model.train( training_set=data_csv, validation_set=val_csv, test_set=test_csv, output_directory=tmpdir ) config_save_path = os.path.join(output_dir, MODEL_FILE_NAME, MODEL_HYPERPARAMETERS_FILE_NAME) with open(config_save_path) as f: saved_config = json.load(f) saved_input_features = saved_config["input_features"] for saved_input_feature in saved_input_features: assert "encoder" in saved_input_feature input_feature_encoder = saved_input_feature["encoder"] assert "saved_weights_in_checkpoint" in input_feature_encoder assert input_feature_encoder["saved_weights_in_checkpoint"] def test_constant_metadata(tmpdir): input_features = [category_feature(encoder={"vocab_size": 5})] output_features = [category_feature(name="class", decoder={"vocab_size": 5}, output_feature=True)] data_csv1 = generate_data(input_features, output_features, os.path.join(tmpdir, "dataset1.csv")) val_csv1 = shutil.copyfile(data_csv1, os.path.join(tmpdir, "validation1.csv")) test_csv1 = shutil.copyfile(data_csv1, os.path.join(tmpdir, "test1.csv")) config = { "input_features": input_features, "output_features": output_features, } model = LudwigModel(config) model.train(training_set=data_csv1, validation_set=val_csv1, test_set=test_csv1, output_directory=tmpdir) metadata1 = model.training_set_metadata data_csv2 = generate_data(input_features, output_features, os.path.join(tmpdir, "dataset2.csv"), num_examples=10) val_csv2 = shutil.copyfile(data_csv2, os.path.join(tmpdir, "validation2.csv")) test_csv2 = shutil.copyfile(data_csv2, os.path.join(tmpdir, "test2.csv")) model.train(training_set=data_csv2, validation_set=val_csv2, test_set=test_csv2, output_directory=tmpdir) metadata2 = model.training_set_metadata assert metadata1 == metadata2 @pytest.mark.integration_tests_e @pytest.mark.parametrize( "input_max_sequence_length, global_max_sequence_length, expect_raise", [ (5, "null", True), ("null", 5, True), (5, 5, True), (100, 100, False), (100, "null", False), ("null", "null", False), ], ) def test_llm_template_too_long(tmpdir, input_max_sequence_length, global_max_sequence_length, expect_raise): zero_shot_config = yaml.safe_load(f""" model_type: llm base_model: hf-internal-testing/tiny-random-GPTJForCausalLM input_features: - name: instruction type: text preprocessing: max_sequence_length: {input_max_sequence_length} output_features: - name: output type: text preprocessing: global_max_sequence_length: {global_max_sequence_length} """) zero_shot_config["prompt"] = {} zero_shot_config["prompt"][ "template" ] = "This is a very long template that is longer than the max sequence length {instruction}" input_features = [text_feature(name="instruction")] output_features = [text_feature(name="output", output_feature=True)] data_csv1 = generate_data(input_features, output_features, os.path.join(tmpdir, "dataset1.csv")) model = LudwigModel(zero_shot_config) if expect_raise: with pytest.raises(RuntimeError): model.preprocess(dataset=data_csv1, output_directory=tmpdir) else: model.preprocess(dataset=data_csv1, output_directory=tmpdir) ================================================ FILE: tests/integration_tests/test_audio_feature.py ================================================ import pytest import torch from ludwig.constants import ENCODER_OUTPUT from ludwig.features.audio_feature import AudioInputFeature from ludwig.schema.features.audio_feature import AudioInputFeatureConfig from ludwig.schema.utils import load_config_with_kwargs from tests.integration_tests.utils import audio_feature BATCH_SIZE = 2 SEQ_SIZE = 20 AUDIO_W_SIZE = 16 DEFAULT_OUTPUT_SIZE = 256 @pytest.mark.parametrize("enc_encoder", ["stacked_cnn", "rnn"]) def test_audio_feature(enc_encoder): # synthetic audio tensor audio_tensor = torch.randn([BATCH_SIZE, SEQ_SIZE, AUDIO_W_SIZE], dtype=torch.float32) # generate audio feature config audio_feature_config = audio_feature( folder=".", encoder={"type": enc_encoder, "max_sequence_length": SEQ_SIZE, "embedding_size": AUDIO_W_SIZE} ) # instantiate audio input feature object audio_feature_config, _ = load_config_with_kwargs(AudioInputFeatureConfig, audio_feature_config) audio_input_feature = AudioInputFeature(audio_feature_config) # pass synthetic audio tensor through the audio input feature encoder_output = audio_input_feature(audio_tensor) # confirm correctness of the the audio encoder output assert isinstance(encoder_output, dict) assert ENCODER_OUTPUT in encoder_output assert isinstance(encoder_output[ENCODER_OUTPUT], torch.Tensor) if enc_encoder == "passthrough": assert encoder_output[ENCODER_OUTPUT].shape == (BATCH_SIZE, SEQ_SIZE, AUDIO_W_SIZE) else: assert encoder_output[ENCODER_OUTPUT].shape == (BATCH_SIZE, DEFAULT_OUTPUT_SIZE) ================================================ FILE: tests/integration_tests/test_automl.py ================================================ import os import tempfile from unittest import mock import numpy as np import pandas as pd import pytest from ludwig.api import LudwigModel from ludwig.constants import COLUMN, ENCODER, INPUT_FEATURES, NAME, OUTPUT_FEATURES, PREPROCESSING, SPLIT, TYPE from ludwig.schema.model_types.base import ModelConfig from ludwig.types import FeatureConfigDict, ModelConfigDict from ludwig.utils.misc_utils import merge_dict from tests.integration_tests.utils import ( binary_feature, category_feature, generate_data, image_feature, minio_test_creds, number_feature, private_param, remote_tmpdir, text_feature, ) ray = pytest.importorskip("ray") import dask.dataframe as dd # noqa E402 from ray.tune.experiment.trial import Trial # noqa E402 from ludwig.automl import auto_train, create_auto_config, train_with_config # noqa E402 from ludwig.automl.automl import OUTPUT_DIR # noqa E402 from ludwig.hyperopt.execution import RayTuneExecutor # noqa E402 pytestmark = [pytest.mark.distributed, pytest.mark.integration_tests_c] def to_name_set(features: list[FeatureConfigDict]) -> set[str]: """Returns the list of feature names.""" return {feature[NAME] for feature in features} def merge_lists(a_features: list, b_features: list): for idx in range(max(len(a_features), len(b_features))): if idx >= len(a_features): a_features.append(b_features[idx]) elif idx < len(b_features): a_features[idx] = merge_dict(a_features[idx], b_features[idx]) def merge_dict_with_features(a: ModelConfigDict, b: ModelConfigDict) -> ModelConfigDict: merge_lists(a[INPUT_FEATURES], b.get(INPUT_FEATURES, [])) merge_lists(a[OUTPUT_FEATURES], b.get(OUTPUT_FEATURES, [])) b = b.copy() if INPUT_FEATURES in b: del b[INPUT_FEATURES] if OUTPUT_FEATURES in b: del b[OUTPUT_FEATURES] return merge_dict(a, b) def check_types( config: ModelConfigDict, input_features: list[FeatureConfigDict], output_features: list[FeatureConfigDict] ): actual_features = config.get(INPUT_FEATURES, []) + config.get(OUTPUT_FEATURES, []) expected_features = {f[NAME]: f for f in input_features + output_features} assert len(actual_features) == len(expected_features) for actual_feature in actual_features: expected_feature = expected_features[actual_feature[NAME]] assert ( actual_feature[TYPE] == expected_feature[TYPE] ), f"{actual_feature[NAME]}: actual type {actual_feature[TYPE]} != {expected_feature[TYPE]}" @pytest.fixture(scope="module") def test_data_tabular_large(): with tempfile.TemporaryDirectory() as tmpdir: input_features = [ number_feature(), number_feature(), category_feature(encoder={"vocab_size": 3}), category_feature(encoder={"vocab_size": 3}), ] output_features = [category_feature(decoder={"vocab_size": 3})] dataset_csv = generate_data( input_features, output_features, os.path.join(tmpdir, "dataset.csv"), num_examples=50 ) yield input_features, output_features, dataset_csv @pytest.fixture(scope="module") def test_data_tabular_small(): with tempfile.TemporaryDirectory() as tmpdir: input_features = [ number_feature(), category_feature(encoder={"vocab_size": 3}), ] output_features = [category_feature(decoder={"vocab_size": 3})] dataset_csv = generate_data( input_features, output_features, os.path.join(tmpdir, "dataset.csv"), num_examples=50 ) yield input_features, output_features, dataset_csv @pytest.fixture(scope="module") def test_data_image(): with tempfile.TemporaryDirectory() as tmpdir: image_dest_folder = os.path.join(tmpdir, "generated_images") input_features = [ image_feature(folder=image_dest_folder), ] output_features = [binary_feature()] dataset_csv = generate_data( input_features, output_features, os.path.join(tmpdir, "dataset.csv"), num_examples=20 ) yield input_features, output_features, dataset_csv @pytest.fixture(scope="module") def test_data_text(): with tempfile.TemporaryDirectory() as tmpdir: input_features = [ text_feature(preprocessing={"tokenizer": "space"}), ] output_features = [binary_feature()] dataset_csv = generate_data( input_features, output_features, os.path.join(tmpdir, "dataset.csv"), num_examples=20 ) yield input_features, output_features, dataset_csv @pytest.fixture(scope="module") def test_data_multimodal(): with tempfile.TemporaryDirectory() as tmpdir: image_dest_folder = os.path.join(tmpdir, "generated_images") input_features = [ image_feature(folder=image_dest_folder), text_feature(preprocessing={"tokenizer": "space"}), number_feature(), category_feature(encoder={"vocab_size": 3}), category_feature(encoder={"vocab_size": 5}), ] output_features = [binary_feature()] dataset_csv = generate_data( input_features, output_features, os.path.join(tmpdir, "dataset.csv"), num_examples=20 ) yield input_features, output_features, dataset_csv @pytest.mark.distributed @pytest.mark.parametrize( "test_data,expectations", [ ("test_data_tabular_large", {"combiner": {"type": "tabnet"}}), ("test_data_tabular_small", {"combiner": {"type": "concat"}}), ("test_data_image", {"combiner": {"type": "concat"}}), ( "test_data_text", { "input_features": [{"type": "text", "encoder": {"type": "bert"}}], "combiner": {"type": "concat"}, "trainer": { "batch_size": "auto", "learning_rate": 1e-05, "epochs": 10, "optimizer": {"type": "adamw"}, "learning_rate_scheduler": {"warmup_fraction": 0.1}, "use_mixed_precision": True, }, "defaults": { "text": { "encoder": { "type": "bert", "trainable": True, } } }, }, ), ( "test_data_multimodal", { "input_features": [{"type": "image"}, {"type": "text", "encoder": {"type": "embed"}}], "combiner": {"type": "concat"}, }, ), ], ids=["tabular_large", "tabular_small", "image", "text", "multimodal"], ) def test_create_auto_config(test_data, expectations, ray_cluster_2cpu, request): test_data = request.getfixturevalue(test_data) input_features, output_features, dataset_csv = test_data targets = [feature[NAME] for feature in output_features] df = dd.read_csv(dataset_csv) config = create_auto_config(df, targets, time_limit_s=600, backend="ray") # Ensure our configs are using the latest Ludwig schema ModelConfig.from_dict(config) assert to_name_set(config[INPUT_FEATURES]) == to_name_set(input_features) assert to_name_set(config[OUTPUT_FEATURES]) == to_name_set(output_features) check_types(config, input_features, output_features) expected = merge_dict_with_features(config, expectations) assert config == expected def _get_sample_df(class_probs): nrows = 1000 thresholds = np.cumsum((class_probs * nrows).astype(int)) df = pd.DataFrame(np.random.randint(0, 100, size=(nrows, 3)), columns=["A", "B", "C"]) def get_category(v): if v < thresholds[0]: return 0 if thresholds[0] <= v < thresholds[1]: return 1 return 2 df["category"] = df.index.map(get_category).astype(np.int8) return df @pytest.mark.distributed def test_autoconfig_preprocessing_balanced(): df = _get_sample_df(np.array([0.33, 0.33, 0.34])) config = create_auto_config(dataset=df, target="category", time_limit_s=1) # Ensure our configs are using the latest Ludwig schema ModelConfig.from_dict(config) assert PREPROCESSING not in config @pytest.mark.distributed def test_autoconfig_preprocessing_imbalanced(): df = _get_sample_df(np.array([0.6, 0.2, 0.2])) config = create_auto_config(dataset=df, target="category", time_limit_s=1) # Ensure our configs are using the latest Ludwig schema ModelConfig.from_dict(config) assert PREPROCESSING in config assert SPLIT in config[PREPROCESSING] assert config[PREPROCESSING][SPLIT] == {TYPE: "stratify", COLUMN: "category"} @pytest.mark.distributed def test_autoconfig_preprocessing_text_image(tmpdir): image_dest_folder = os.path.join(tmpdir, "generated_images") input_features = [text_feature(preprocessing={"tokenizer": "space"}), image_feature(folder=image_dest_folder)] output_features = [category_feature(output_feature=True)] # Generate Dataset rel_path = generate_data(input_features, output_features, os.path.join(tmpdir, "dataset.csv")) df = pd.read_csv(rel_path) target = df.columns[-1] config = create_auto_config(dataset=df, target=target, time_limit_s=1) # Ensure our configs are using the latest Ludwig schema ModelConfig.from_dict(config) # Check no features shuffled around assert len(input_features) == 2 assert len(output_features) == 1 # Check encoders are properly nested assert isinstance(config[INPUT_FEATURES][0][ENCODER], dict) assert isinstance(config[INPUT_FEATURES][1][ENCODER], dict) # Check automl default encoders are properly set assert config[INPUT_FEATURES][0][ENCODER][TYPE] == "bert" assert config[INPUT_FEATURES][1][ENCODER][TYPE] == "stacked_cnn" @pytest.mark.slow @pytest.mark.distributed @pytest.mark.parametrize("time_budget", [120, 1], ids=["high", "low"]) def test_train_with_config(time_budget, test_data_tabular_large, ray_cluster_2cpu, tmpdir): _run_train_with_config(time_budget, test_data_tabular_large, tmpdir) @pytest.mark.distributed def test_auto_train(test_data_tabular_large, ray_cluster_2cpu, tmpdir): _, ofeatures, dataset_csv = test_data_tabular_large local_output_directory_path: str = f"{str(tmpdir)}/{OUTPUT_DIR}" results = auto_train( dataset=dataset_csv, target=ofeatures[0][NAME], time_limit_s=120, output_directory=local_output_directory_path, user_config={"hyperopt": {"executor": {"num_samples": 2}}}, ) analysis = results.experiment_analysis for trial in analysis.trials: assert trial.status != Trial.ERROR, f"Error in trial {trial}" @pytest.mark.slow @pytest.mark.parametrize("fs_protocol,bucket", [private_param(("s3", "ludwig-tests"))], ids=["s3"]) def test_train_with_config_remote(fs_protocol, bucket, test_data_tabular_large, ray_cluster_2cpu): backend = { "type": "local", "credentials": { "artifacts": minio_test_creds(), }, } with remote_tmpdir(fs_protocol, bucket) as tmpdir: _run_train_with_config(200, test_data_tabular_large, tmpdir, backend=backend) def _run_train_with_config(time_budget, test_data, tmpdir, **kwargs): input_features, output_features, dataset_csv = test_data config = { "input_features": input_features, "output_features": output_features, "trainer": {"epochs": 2}, "hyperopt": { "search_alg": { "type": "variant_generator", "random_state": 42, }, "executor": { "type": "ray", "time_budget_s": time_budget, "cpu_resources_per_trial": 1, "num_samples": 2, "scheduler": { "type": "async_hyperband", "max_t": time_budget, "time_attr": "time_total_s", "grace_period": min(72, time_budget), "reduction_factor": 5, }, }, "parameters": { "trainer.batch_size": { "space": "choice", "categories": [64, 128, 256], }, "trainer.learning_rate": { "space": "loguniform", "lower": 0.001, "upper": 0.1, }, }, }, } fn = RayTuneExecutor._evaluate_best_model with mock.patch("ludwig.hyperopt.execution.RayTuneExecutor._evaluate_best_model") as mock_fn: # We need to check that _evaluate_best_model is called when the time_budget is low # as this code path should be triggered when the trial was early stopped mock_fn.side_effect = fn outdir = os.path.join(tmpdir, "output") results = train_with_config(dataset_csv, config, output_directory=outdir, **kwargs) try: best_model = results.best_model except ValueError: # ValueError is raised when best_model can't be found. This typically # happens when the time_budget is low and the trial is stopped early, # resulting in no evaluations happening (and no scores being reported back to RayTune). # So RayTune has no way of determining what the best model is. best_model = None if time_budget > 1: assert isinstance(best_model, LudwigModel) assert best_model.config_obj.trainer.early_stop == -1 # assert mock_fn.call_count == 1 else: assert best_model is None assert mock_fn.call_count == 0 ================================================ FILE: tests/integration_tests/test_cache_manager.py ================================================ import os from pathlib import Path import pandas as pd import pytest from ludwig.constants import CHECKSUM, META, TEST, TRAINING, VALIDATION from ludwig.data.cache.manager import alphanum, CacheManager from ludwig.data.cache.types import CacheableDataframe, wrap from ludwig.data.dataset.pandas import PandasDatasetManager from ludwig.globals import TRAINING_PREPROC_FILE_NAME from tests.integration_tests.utils import category_feature, LocalTestBackend, sequence_feature @pytest.fixture def change_test_dir(tmpdir, monkeypatch): monkeypatch.chdir(tmpdir) @pytest.mark.parametrize("use_df", [True, False], ids=["df", "filename"]) @pytest.mark.parametrize("use_split", [True, False], ids=["split", "no_split"]) @pytest.mark.parametrize("use_cache_dir", [True, False], ids=["cache_dir", "no_cache_dir"]) def test_cache_dataset(use_cache_dir, use_split, use_df, tmpdir, change_test_dir): dataset_manager = PandasDatasetManager(backend=LocalTestBackend()) cache_dir = os.path.join(tmpdir, "cache") if use_cache_dir else None manager = CacheManager(dataset_manager, cache_dir=cache_dir) config = { "input_features": [sequence_feature(encoder={"reduce_output": "sum"})], "output_features": [category_feature(decoder={"vocab_size": 2}, reduce_input="sum")], "combiner": {"type": "concat", "output_size": 14}, "preprocessing": {}, } def touch(basename): path = os.path.join(tmpdir, f"{basename}.csv") Path(path).touch() return path def create_dataset(name): if use_df: return CacheableDataframe(df=pd.DataFrame(), name=name, checksum=name) else: return wrap(touch(name)) dataset = training_set = test_set = validation_set = None if not use_split: dataset = create_dataset("dataset") cache_key = manager.get_cache_key(dataset, config) else: training_set = create_dataset("train") test_set = create_dataset("test") validation_set = create_dataset("validation") cache_key = manager.get_cache_key(training_set, config) training_set_metadata = { CHECKSUM: cache_key, } cache = manager.get_dataset_cache(config, dataset, training_set, test_set, validation_set) cache_map = cache.cache_map assert len(cache_map) == 4 train_path = os.path.join(cache_dir, alphanum(cache_key)) if use_cache_dir else os.path.join(tmpdir, "dataset") test_path = val_path = train_path if use_split and not use_cache_dir: train_path = os.path.join(tmpdir, "train") test_path = os.path.join(tmpdir, "test") val_path = os.path.join(tmpdir, "validation") assert cache_map[META] == f"{train_path}.meta.json" assert cache_map[TRAINING] == f"{train_path}.{TRAINING_PREPROC_FILE_NAME}" assert cache_map[TEST] == f"{test_path}.test.hdf5" assert cache_map[VALIDATION] == f"{val_path}.validation.hdf5" for cache_path in cache_map.values(): assert not os.path.exists(cache_path) training_set = pd.DataFrame() test_set = pd.DataFrame() validation_set = pd.DataFrame() if use_cache_dir: os.makedirs(cache_dir) cache.put(training_set, test_set, validation_set, training_set_metadata) for cache_path in cache_map.values(): assert os.path.exists(cache_path) cache.delete() for cache_path in cache_map.values(): assert not os.path.exists(cache_path) ================================================ FILE: tests/integration_tests/test_cached_preprocessing.py ================================================ import os import numpy as np import pytest from ludwig.api import LudwigModel from ludwig.constants import MODEL_ECD, PREPROCESSING, PROC_COLUMN, TRAINER from tests.integration_tests.utils import ( binary_feature, category_feature, generate_data, number_feature, run_test_suite, text_feature, ) def _onehot_encoding_config(tmpdir): input_features = [ number_feature(), category_feature(encoder={"type": "onehot"}), ] output_features = [binary_feature()] data_csv_path = os.path.join(tmpdir, "dataset.csv") dataset = generate_data(input_features, output_features, data_csv_path) config = {"input_features": input_features, "output_features": output_features, TRAINER: {"train_steps": 1}} return config, dataset def test_onehot_encoding(tmpdir): config, dataset = _onehot_encoding_config(tmpdir) run_test_suite(config, dataset, "local") @pytest.mark.slow @pytest.mark.distributed def test_onehot_encoding_ray(tmpdir, ray_cluster_2cpu): config, dataset = _onehot_encoding_config(tmpdir) run_test_suite(config, dataset, "ray") def _hf_text_embedding_config(tmpdir): input_features = [ number_feature(), text_feature( encoder={ "type": "auto_transformer", "pretrained_model_name_or_path": "hf-internal-testing/tiny-bert-for-token-classification", }, preprocessing={"cache_encoder_embeddings": True}, ), ] output_features = [binary_feature()] data_csv_path = os.path.join(tmpdir, "dataset.csv") dataset = generate_data(input_features, output_features, data_csv_path) config = {"input_features": input_features, "output_features": output_features, TRAINER: {"train_steps": 1}} return config, dataset def test_hf_text_embedding(tmpdir): config, dataset = _hf_text_embedding_config(tmpdir) run_test_suite(config, dataset, "local") @pytest.mark.slow @pytest.mark.distributed def test_hf_text_embedding_ray(tmpdir, ray_cluster_2cpu): config, dataset = _hf_text_embedding_config(tmpdir) run_test_suite(config, dataset, "ray") @pytest.mark.parametrize("cache_encoder_embeddings", [True, False, None]) def test_onehot_encoding_preprocessing(cache_encoder_embeddings, tmpdir): vocab_size = 5 input_features = [ category_feature(encoder={"type": "onehot", "vocab_size": vocab_size}), number_feature(), ] output_features = [binary_feature()] if cache_encoder_embeddings is not None: if PREPROCESSING not in input_features[0]: input_features[0][PREPROCESSING] = {} input_features[0][PREPROCESSING]["cache_encoder_embeddings"] = cache_encoder_embeddings # Need sufficiently high number of examples to ensure at least one of each category type appears data_csv_path = os.path.join(tmpdir, "dataset.csv") num_examples = 100 dataset_fp = generate_data(input_features, output_features, data_csv_path, num_examples) config = { "model_type": MODEL_ECD, "input_features": input_features, "output_features": output_features, } # Run preprocessing ludwig_model = LudwigModel(config, backend="local") proc_dataset = ludwig_model.preprocess(training_set=dataset_fp) # Check preprocessed output proc_df = ludwig_model.backend.df_engine.compute(proc_dataset.training_set.to_df()) proc_col = input_features[0][PROC_COLUMN] proc_series = proc_df[proc_col] # ECD will not cache embeddings by default, but will if set to `cache_encoder_embeddings=true` expected_cache_encoder_embeddings = cache_encoder_embeddings or False if expected_cache_encoder_embeddings: assert proc_series.values.dtype == "object" data = np.stack(proc_series.values) assert data.shape == (num_examples, vocab_size) # Only one element in each row should be 1 assert all(x == 1 for x in data.sum(axis=1)) else: assert proc_series.values.dtype == "int8" data = proc_series.to_numpy() assert data.shape == (num_examples,) def test_hf_text_embedding_tied(tmpdir): input_features = [ text_feature( encoder={ "type": "auto_transformer", "pretrained_model_name_or_path": "hf-internal-testing/tiny-bert-for-token-classification", }, preprocessing={"cache_encoder_embeddings": True}, ), text_feature( encoder={ "type": "auto_transformer", "pretrained_model_name_or_path": "hf-internal-testing/tiny-bert-for-token-classification", }, preprocessing={"cache_encoder_embeddings": True}, ), ] input_features[1]["tied"] = input_features[0]["name"] output_features = [binary_feature()] data_csv_path = os.path.join(tmpdir, "dataset.csv") dataset = generate_data(input_features, output_features, data_csv_path) config = {"input_features": input_features, "output_features": output_features, TRAINER: {"epochs": 1}} run_test_suite(config, dataset, "local") ================================================ FILE: tests/integration_tests/test_carton.py ================================================ # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import asyncio import os import platform import numpy as np import pandas as pd import pytest import torch from ludwig.api import LudwigModel from ludwig.constants import BATCH_SIZE, NAME, PREDICTIONS, TRAINER from ludwig.utils.carton_utils import export_carton from tests.integration_tests.utils import ( binary_feature, category_feature, generate_data, LocalTestBackend, number_feature, ) @pytest.mark.skipif(platform.system() == "Windows", reason="Carton is not supported on Windows") def test_carton_torchscript(csv_filename, tmpdir): pytest.importorskip("cartonml", reason="cartonml-nightly not installed") data_csv_path = os.path.join(tmpdir, csv_filename) # Configure features to be tested: bin_str_feature = binary_feature() input_features = [ bin_str_feature, # binary_feature(), number_feature(), category_feature(encoder={"vocab_size": 3}), # TODO: future support # sequence_feature(vocab_size=3), # text_feature(vocab_size=3), # vector_feature(), # image_feature(image_dest_folder), # audio_feature(audio_dest_folder), # timeseries_feature(), # date_feature(), # h3_feature(), # set_feature(vocab_size=3), # bag_feature(vocab_size=3), ] output_features = [ bin_str_feature, # binary_feature(), number_feature(), category_feature(decoder={"vocab_size": 3}, output_feature=True), # TODO: future support # sequence_feature(vocab_size=3), # text_feature(vocab_size=3), # set_feature(vocab_size=3), # vector_feature() ] backend = LocalTestBackend() config = { "input_features": input_features, "output_features": output_features, TRAINER: {"epochs": 2, BATCH_SIZE: 128}, } # Generate training data training_data_csv_path = generate_data(input_features, output_features, data_csv_path) # Convert bool values to strings, e.g., {'Yes', 'No'} df = pd.read_csv(training_data_csv_path) false_value, true_value = "No", "Yes" df[bin_str_feature[NAME]] = df[bin_str_feature[NAME]].map(lambda x: true_value if x else false_value) df.to_csv(training_data_csv_path) # Train Ludwig (Pythonic) model: ludwig_model = LudwigModel(config, backend=backend) ludwig_model.train( dataset=training_data_csv_path, skip_save_training_description=True, skip_save_training_statistics=True, skip_save_model=True, skip_save_progress=True, skip_save_log=True, skip_save_processed_input=True, ) # Obtain predictions from Python model preds_dict, _ = ludwig_model.predict(dataset=training_data_csv_path, return_type=dict) # Create graph inference model (Torchscript) from trained Ludwig model. carton_path = os.path.join(tmpdir, "carton") export_carton(ludwig_model, carton_path) import cartonml as carton # Load the carton model # See https://pyo3.rs/v0.20.0/ecosystem/async-await#a-note-about-asynciorun for why we wrap it # in another function async def load(): return await carton.load(carton_path) carton_model = asyncio.run(load()) def to_input(s: pd.Series) -> list[str] | torch.Tensor: if s.dtype == "object": return np.array(s.to_list()) return s.to_numpy().astype(np.float32) df = pd.read_csv(training_data_csv_path) inputs = {name: to_input(df[feature.column]) for name, feature in ludwig_model.model.input_features.items()} # See https://pyo3.rs/v0.20.0/ecosystem/async-await#a-note-about-asynciorun for why we wrap it # in another function async def infer(inputs): return await carton_model.infer(inputs) outputs = asyncio.run(infer(inputs)) # Compare results from Python trained model against Carton assert len(preds_dict) == len(outputs) for feature_name, feature_outputs_expected in preds_dict.items(): assert feature_name in outputs output_values_expected = feature_outputs_expected[PREDICTIONS] output_values = outputs[feature_name] if output_values.dtype.type in {np.string_, np.str_}: # Strings should match exactly assert np.all(output_values == output_values_expected), f"feature: {feature_name}, output: predictions" else: assert np.allclose(output_values, output_values_expected), f"feature: {feature_name}, output: predictions" ================================================ FILE: tests/integration_tests/test_class_imbalance_feature.py ================================================ import contextlib import os import shutil import numpy as np import pandas as pd import pytest from ludwig.api import LudwigModel from ludwig.backend import LocalBackend from tests.integration_tests.utils import create_data_set_to_use, RAY_BACKEND_CONFIG, spawn try: import ray from ludwig.backend.ray import RayBackend except ImportError: ray = None rs = np.random.RandomState(42) @contextlib.contextmanager def ray_start(num_cpus=2, num_gpus=None): res = ray.init( num_cpus=num_cpus, num_gpus=num_gpus, include_dashboard=False, object_store_memory=150 * 1024 * 1024, ) try: yield res finally: ray.shutdown() # Delete the cluster address just in case. if hasattr(ray._private.utils, "reset_ray_address"): ray._private.utils.reset_ray_address() @spawn def run_test_imbalance_ray( tmpdir, input_df, config, balance, num_cpus=2, num_gpus=None, ): with ray_start(num_cpus=num_cpus, num_gpus=num_gpus): csv_filename = os.path.join(tmpdir, "dataset.csv") input_df.to_csv(csv_filename) dataset_parquet = create_data_set_to_use("parquet", csv_filename) model = LudwigModel(config, backend=RAY_BACKEND_CONFIG, callbacks=None) output_dir = None try: _, output_dataset, output_dir = model.train( dataset=dataset_parquet, training_set=None, validation_set=None, test_set=None, skip_save_processed_input=True, skip_save_progress=True, skip_save_unprocessed_output=True, skip_save_log=True, ) finally: # Remove results/intermediate data saved to disk shutil.rmtree(output_dir, ignore_errors=True) input_train_set = input_df.sample(frac=0.7, replace=False) processed_len = output_dataset[0].ds.count() processed_target_pos = output_dataset[0].ds.sum(on="Label_mZFLky") processed_target_neg = output_dataset[0].ds.count() - output_dataset[0].ds.sum(on="Label_mZFLky") assert len(input_train_set) == 140 assert 0.05 <= len(input_train_set[input_train_set["Label"] == 1]) / len(input_train_set) <= 0.15 assert round(processed_target_pos / processed_target_neg, 1) == 0.5 assert model.backend.df_engine.parallelism == RAY_BACKEND_CONFIG["processor"]["parallelism"] assert isinstance(model.backend, RayBackend) if balance == "oversample_minority": assert len(input_train_set) < processed_len if balance == "undersample_majority": assert len(input_train_set) > processed_len def run_test_imbalance_local( input_df, config, balance, ): model = LudwigModel(config) _, output_dataset, output_dir = model.train( input_df, skip_save_model=True, skip_save_log=True, skip_save_progress=True, skip_save_processed_input=True, skip_save_training_description=True, skip_save_training_statistics=True, ) input_train_set = input_df.sample(frac=0.7, replace=False) processed_len = output_dataset[0].size processed_target_pos = sum(output_dataset[0].dataset["Label_2Xl8CP"]) processed_target_neg = len(output_dataset[0].dataset["Label_2Xl8CP"]) - processed_target_pos assert len(input_train_set) == 140 assert 0.05 <= len(input_train_set[input_train_set["Label"] == 1]) / len(input_train_set) <= 0.15 assert round(processed_target_pos / processed_target_neg, 1) == 0.5 assert isinstance(model.backend, LocalBackend) if balance == "oversample_minority": assert len(input_train_set) < processed_len assert 55 <= processed_target_pos <= 75 assert 110 <= processed_target_neg <= 150 if balance == "undersample_majority": assert len(input_train_set) > processed_len assert 7 <= processed_target_pos <= 20 assert 14 <= processed_target_neg <= 40 @pytest.mark.parametrize( "balance", ["oversample_minority", "undersample_majority"], ) @pytest.mark.distributed @pytest.mark.skip(reason="Flaky") def test_imbalance_ray(balance): config = { "input_features": [ {"name": "Index", "column": "Index", "type": "numerical"}, {"name": "random_1", "column": "random_1", "type": "numerical"}, {"name": "random_2", "column": "random_2", "type": "numerical"}, ], "output_features": [{"name": "Label", "column": "Label", "type": "binary"}], "trainer": {"epochs": 2, "batch_size": 8}, "preprocessing": {}, } split_col = np.concatenate((np.zeros(140), np.ones(20), np.full(40, 2))) rs.shuffle(split_col) df = pd.DataFrame( { "Index": np.arange(0, 200, 1), "random_1": np.random.randint(0, 50, 200), "random_2": np.random.choice(["Type A", "Type B", "Type C", "Type D"], 200), "Label": np.concatenate((np.zeros(180), np.ones(20))), "split": split_col, } ) config["preprocessing"][balance] = 0.5 run_test_imbalance_ray(df, config, balance) @pytest.mark.parametrize( "balance", ["oversample_minority", "undersample_majority"], ) def test_imbalance_local(balance): config = { "input_features": [ {"name": "Index", "column": "Index", "type": "number"}, {"name": "random_1", "column": "random_1", "type": "number"}, {"name": "random_2", "column": "random_2", "type": "category"}, ], "output_features": [{"name": "Label", "column": "Label", "type": "binary"}], "trainer": {"epochs": 2, "batch_size": 8}, "preprocessing": {}, } df = pd.DataFrame( { "Index": np.arange(0, 200, 1), "random_1": np.random.randint(0, 50, 200), "random_2": np.random.choice(["Type A", "Type B", "Type C", "Type D"], 200), "Label": np.concatenate((np.zeros(180), np.ones(20))), } ) config["preprocessing"][balance] = 0.5 run_test_imbalance_local(df, config, balance) ================================================ FILE: tests/integration_tests/test_cli.py ================================================ # Copyright (c) 2023 Predibase, Inc., 2020 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. # ============================================================================== import json import os import os.path import pathlib import shutil import subprocess import sys import pytest import yaml from ludwig.constants import ( BATCH_SIZE, COMBINER, EVAL_BATCH_SIZE, INPUT_FEATURES, NAME, OUTPUT_FEATURES, PREPROCESSING, TRAINER, ) from ludwig.globals import MODEL_FILE_NAME from ludwig.types import FeatureConfigDict from ludwig.utils.data_utils import load_yaml from tests.integration_tests.utils import category_feature, generate_data, number_feature, sequence_feature pytestmark = pytest.mark.integration_tests_b def _run_commands(commands, **ludwig_kwargs): for arg_name, value in ludwig_kwargs.items(): commands += ["--" + arg_name, value] cmdline = " ".join(commands) print(cmdline) completed_process = subprocess.run(cmdline, shell=True, stdout=subprocess.PIPE, env=os.environ.copy()) assert completed_process.returncode == 0 return completed_process def _run_ludwig(command, **ludwig_kwargs): ludwig_bin = os.path.join(os.path.dirname(sys.executable), "ludwig") commands = [ludwig_bin, command] return _run_commands(commands, **ludwig_kwargs) def _prepare_data(csv_filename, config_filename): # Single sequence input, single category output input_features = [sequence_feature(encoder={"reduce_output": "sum"})] output_features = [category_feature(decoder={"vocab_size": 3}, reduce_input="sum")] # Generate test data dataset_filename = generate_data(input_features, output_features, csv_filename) # generate config file config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat", "output_size": 14}, TRAINER: {"epochs": 2, BATCH_SIZE: 128, EVAL_BATCH_SIZE: 128}, } with open(config_filename, "w") as f: yaml.dump(config, f) return dataset_filename def _prepare_hyperopt_data(csv_filename, config_filename): # Single sequence input, single category output input_features = [sequence_feature(encoder={"reduce_output": "sum"})] output_features = [category_feature(decoder={"vocab_size": 2}, reduce_input="sum")] # Generate test data dataset_filename = generate_data(input_features, output_features, csv_filename) # generate config file config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat", "output_size": 4}, TRAINER: {"epochs": 2, BATCH_SIZE: 128}, "hyperopt": { "parameters": { "trainer.learning_rate": { "space": "loguniform", "lower": 0.0001, "upper": 0.01, } }, "goal": "minimize", "output_feature": output_features[0]["name"], "validation_metrics": "loss", "executor": { "type": "ray", "num_samples": 2, }, "search_alg": { "type": "variant_generator", }, }, } with open(config_filename, "w") as f: yaml.dump(config, f) return dataset_filename def test_train_cli_dataset(tmpdir, csv_filename): """Test training using `ludwig train --dataset`.""" config_filename = os.path.join(tmpdir, "config.yaml") dataset_filename = _prepare_data(csv_filename, config_filename) _run_ludwig("train", dataset=dataset_filename, config=config_filename, output_directory=str(tmpdir)) def test_train_cli_gpu_memory_limit(tmpdir, csv_filename): """Test training using `ludwig train --dataset --gpu_memory_limit`.""" config_filename = os.path.join(tmpdir, "config.yaml") dataset_filename = _prepare_data(csv_filename, config_filename) _run_ludwig( "train", dataset=dataset_filename, config=config_filename, output_directory=str(tmpdir), gpu_memory_limit="0.5" ) def test_train_cli_training_set(tmpdir, csv_filename): """Test training using `ludwig train --training_set`.""" config_filename = os.path.join(tmpdir, "config.yaml") dataset_filename = _prepare_data(csv_filename, config_filename) validation_filename = shutil.copyfile(dataset_filename, os.path.join(tmpdir, "validation.csv")) test_filename = shutil.copyfile(dataset_filename, os.path.join(tmpdir, "test.csv")) _run_ludwig( "train", training_set=dataset_filename, validation_set=validation_filename, test_set=test_filename, config=config_filename, output_directory=str(tmpdir), ) def test_export_torchscript_cli(tmpdir, csv_filename): """Test exporting Ludwig model to torchscript format.""" config_filename = os.path.join(tmpdir, "config.yaml") dataset_filename = _prepare_data(csv_filename, config_filename) _run_ludwig("train", dataset=dataset_filename, config=config_filename, output_directory=str(tmpdir)) _run_ludwig( "export_torchscript", model_path=os.path.join(tmpdir, "experiment_run", MODEL_FILE_NAME), output_path=os.path.join(tmpdir, "torchscript"), ) def test_export_mlflow_cli(tmpdir, csv_filename): """Test export_mlflow cli.""" config_filename = os.path.join(tmpdir, "config.yaml") dataset_filename = _prepare_data(csv_filename, config_filename) _run_ludwig("train", dataset=dataset_filename, config=config_filename, output_directory=str(tmpdir)) _run_ludwig( "export_mlflow", model_path=os.path.join(tmpdir, "experiment_run", MODEL_FILE_NAME), output_path=os.path.join(tmpdir, "data/results/mlflow"), ) def test_experiment_cli(tmpdir, csv_filename): """Test experiment cli.""" config_filename = os.path.join(tmpdir, "config.yaml") dataset_filename = _prepare_data(csv_filename, config_filename) _run_ludwig("experiment", dataset=dataset_filename, config=config_filename, output_directory=str(tmpdir)) def test_predict_cli(tmpdir, csv_filename): """Test predict cli.""" config_filename = os.path.join(tmpdir, "config.yaml") dataset_filename = _prepare_data(csv_filename, config_filename) _run_ludwig("train", dataset=dataset_filename, config=config_filename, output_directory=str(tmpdir)) _run_ludwig( "predict", dataset=dataset_filename, model=os.path.join(tmpdir, "experiment_run", MODEL_FILE_NAME), output_directory=os.path.join(tmpdir, "predictions"), ) def test_evaluate_cli(tmpdir, csv_filename): """Test evaluate cli.""" config_filename = os.path.join(tmpdir, "config.yaml") dataset_filename = _prepare_data(csv_filename, config_filename) _run_ludwig("train", dataset=dataset_filename, config=config_filename, output_directory=str(tmpdir)) _run_ludwig( "evaluate", dataset=dataset_filename, model=os.path.join(tmpdir, "experiment_run", MODEL_FILE_NAME), output_directory=os.path.join(tmpdir, "predictions"), ) @pytest.mark.distributed def test_hyperopt_cli(tmpdir, csv_filename): """Test hyperopt cli.""" config_filename = os.path.join(tmpdir, "config.yaml") dataset_filename = _prepare_hyperopt_data(csv_filename, config_filename) _run_ludwig("hyperopt", dataset=dataset_filename, config=config_filename, output_directory=str(tmpdir)) def test_visualize_cli(tmpdir, csv_filename): """Test Ludwig 'visualize' cli.""" config_filename = os.path.join(tmpdir, "config.yaml") dataset_filename = _prepare_data(csv_filename, config_filename) _run_ludwig("train", dataset=dataset_filename, config=config_filename, output_directory=str(tmpdir)) _run_ludwig( "visualize", visualization="learning_curves", model_names="run", training_statistics=os.path.join(tmpdir, "experiment_run", "training_statistics.json"), output_directory=os.path.join(tmpdir, "visualizations"), ) def test_collect_summary_activations_weights_cli(tmpdir, csv_filename): """Test collect_summary cli.""" config_filename = os.path.join(tmpdir, "config.yaml") dataset_filename = _prepare_data(csv_filename, config_filename) _run_ludwig("train", dataset=dataset_filename, config=config_filename, output_directory=str(tmpdir)) assert _run_ludwig("collect_summary", model=os.path.join(tmpdir, "experiment_run", MODEL_FILE_NAME)) @pytest.mark.parametrize( "model_name", [ "alexnet", "convnext_base", "convnext_large", "convnext_small", "convnext_tiny", "densenet121", "densenet161", "densenet169", "openai-community/gpt2", "facebook/opt-125m", ], ) def test_collect_summary_pretrained_model_cli(model_name): """Test collect_summary pretrained model cli.""" assert _run_ludwig("collect_summary", pretrained_model=model_name) def test_synthesize_dataset_cli(tmpdir, csv_filename): """Test synthesize_data cli.""" # test depends on default setting of --dataset_size # if this parameter is specified, _run_ludwig fails when # attempting to build the cli parameter structure _run_ludwig( "synthesize_dataset", output_path=os.path.join(tmpdir, csv_filename), features="'[ \ {name: text, type: text}, \ {name: category, type: category}, \ {name: number, type: number}, \ {name: binary, type: binary}, \ {name: set, type: set}, \ {name: bag, type: bag}, \ {name: sequence, type: sequence}, \ {name: timeseries, type: timeseries}, \ {name: date, type: date}, \ {name: h3, type: h3}, \ {name: vector, type: vector}, \ {name: audio, type: audio}, \ {name: image, type: image} \ ]'", ) def test_preprocess_cli(tmpdir, csv_filename): """Test preprocess `ludwig preprocess.""" config_filename = os.path.join(tmpdir, "config.yaml") dataset_filename = _prepare_data(csv_filename, config_filename) _run_ludwig("preprocess", dataset=dataset_filename, preprocessing_config=config_filename) @pytest.mark.parametrize( "second_seed_offset,random_seed,type_of_run", [ (0, 42, "train"), # same seed train: should be reproducible (1, 42, "train"), # different seed train: should diverge (0, 42, "experiment"), # same seed experiment: should be reproducible ], ids=["same_seed_train", "diff_seed_train", "same_seed_experiment"], ) def test_reproducible_cli_runs( type_of_run: str, random_seed: int, second_seed_offset: int, csv_filename: str, tmpdir: pathlib.Path ) -> None: """ Test for reproducible training using `ludwig experiment|train --dataset`. Args: type_of_run(str): type of run, either train or experiment csv_filename(str): file path of dataset to use random_seed(int): random seed integer to use for test second_seed_offset(int): zero to use same random seed for second test, non-zero to use a different seed for the second run. tmpdir (pathlib.Path): temporary directory path Returns: None """ config_filename = os.path.join(tmpdir, "config.yaml") dataset_filename = _prepare_data(csv_filename, config_filename) # run first model _run_ludwig( type_of_run, dataset=dataset_filename, config=config_filename, output_directory=str(tmpdir), skip_save_processed_input="", # skip saving preprocessed inputs for reproducibility experiment_name="reproducible", model_name="run1", random_seed=str(random_seed), ) # run second model with same seed _run_ludwig( type_of_run, dataset=dataset_filename, config=config_filename, output_directory=str(tmpdir), skip_save_processed_input="", # skip saving preprocessed inputs for reproducibility experiment_name="reproducible", model_name="run2", random_seed=str(random_seed + second_seed_offset), ) # retrieve training statistics and compare with open(os.path.join(tmpdir, "reproducible_run1", "training_statistics.json")) as f: training1 = json.load(f) with open(os.path.join(tmpdir, "reproducible_run2", "training_statistics.json")) as f: training2 = json.load(f) if second_seed_offset == 0: # same seeds should result in same output assert training1 == training2 else: # non-zero second_seed_offset uses different seeds and should result in different output assert training1 != training2 # if type_of_run is experiment check test statistics and compare if type_of_run == "experiment": with open(os.path.join(tmpdir, "reproducible_run1", "test_statistics.json")) as f: test1 = json.load(f) with open(os.path.join(tmpdir, "reproducible_run2", "test_statistics.json")) as f: test2 = json.load(f) if second_seed_offset == 0: # same seeds should result in same output assert test1 == test2 else: # non-zero second_seed_offset uses different seeds and should result in different output assert test1 != test2 @pytest.mark.distributed def test_init_config(tmpdir): """Test initializing a config from a dataset and a target.""" input_features = [ number_feature(), number_feature(), category_feature(encoder={"vocab_size": 3}), category_feature(encoder={"vocab_size": 3}), ] output_features = [category_feature(decoder={"vocab_size": 3})] dataset_csv = generate_data(input_features, output_features, os.path.join(tmpdir, "dataset.csv"), num_examples=20) output_config_path = os.path.join(tmpdir, "config.yaml") _run_ludwig("init_config", dataset=dataset_csv, target=output_features[0][NAME], output=output_config_path) config = load_yaml(output_config_path) def to_name_set(features: list[FeatureConfigDict]) -> set[str]: return {feature[NAME] for feature in features} assert to_name_set(config[INPUT_FEATURES]) == to_name_set(input_features) assert to_name_set(config[OUTPUT_FEATURES]) == to_name_set(output_features) @pytest.mark.skip(reason="https://github.com/ludwig-ai/ludwig/issues/3377") def test_render_config(tmpdir): """Test rendering a full config from a partial user config.""" user_config_path = os.path.join(tmpdir, "config.yaml") input_features = [ number_feature(), number_feature(), category_feature(encoder={"vocab_size": 3}), category_feature(encoder={"vocab_size": 3}), ] output_features = [category_feature(decoder={"vocab_size": 3})] user_config = { INPUT_FEATURES: input_features, OUTPUT_FEATURES: output_features, } with open(user_config_path, "w") as f: yaml.dump(user_config, f) output_config_path = os.path.join(tmpdir, "rendered.yaml") _run_ludwig("render_config", config=user_config_path, output=output_config_path) rendered_config = load_yaml(output_config_path) assert len(rendered_config[INPUT_FEATURES]) == len(user_config[INPUT_FEATURES]) assert len(rendered_config[OUTPUT_FEATURES]) == len(user_config[OUTPUT_FEATURES]) assert TRAINER in rendered_config assert COMBINER in rendered_config assert PREPROCESSING in rendered_config ================================================ FILE: tests/integration_tests/test_collect.py ================================================ # Copyright (c) 2023 Predibase, Inc., 2020 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. # ============================================================================== import os import shutil import numpy as np import torch from ludwig.api import LudwigModel from ludwig.collect import collect_activations, collect_weights, print_model_summary from ludwig.constants import BATCH_SIZE, ENCODER, TRAINER, TYPE from ludwig.globals import MODEL_FILE_NAME from ludwig.utils.torch_utils import get_torch_device from tests.integration_tests.utils import category_feature, ENCODERS, generate_data, sequence_feature DEVICE = get_torch_device() def _prepare_data(csv_filename): # Single sequence input, single category output input_features = [sequence_feature(encoder={"reduce_output": "sum"})] output_features = [category_feature(decoder={"vocab_size": 2}, reduce_input="sum")] input_features[0][ENCODER][TYPE] = ENCODERS[0] # Generate test data data_csv = generate_data(input_features, output_features, csv_filename) return input_features, output_features, data_csv def _train(input_features, output_features, data_csv, **kwargs): config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat", "output_size": 14}, TRAINER: {"epochs": 2, BATCH_SIZE: 128}, } model = LudwigModel(config) _, _, output_dir = model.train(dataset=data_csv, **kwargs) return model, output_dir def _get_layers(model_path): model = LudwigModel.load(model_path) return [name for name, _ in model.model.named_children()] def _collect_activations(model_path, layers, csv_filename, output_directory): return collect_activations(model_path, layers, dataset=csv_filename, output_directory=output_directory) def test_collect_weights(tmpdir, csv_filename): output_dir = None try: model, output_dir = _train(*_prepare_data(csv_filename)) model_path = os.path.join(output_dir, MODEL_FILE_NAME) # 1 for the encoder (embeddings). # 2 for the decoder classifier (w and b). weights = [w for _, w in model.model.collect_weights()] assert len(weights) == 3 # Load model from disk to ensure correct weight names model_loaded = LudwigModel.load(model_path) tensor_names = [name for name, w in model_loaded.collect_weights()] assert len(tensor_names) == 3 filenames = collect_weights(model_path, tensor_names, tmpdir) assert len(filenames) == 3 for weight, filename in zip(weights, filenames): saved_weight = np.load(filename) assert torch.allclose(weight, torch.from_numpy(saved_weight).to(DEVICE), rtol=1.0e-4), filename finally: if output_dir: shutil.rmtree(output_dir, ignore_errors=True) def test_collect_activations(tmpdir, csv_filename): output_dir = None try: model, output_dir = _train(*_prepare_data(csv_filename)) model_path = os.path.join(output_dir, MODEL_FILE_NAME) # [last_hidden, logits, projection_input] filenames = _collect_activations( model_path, [name for name, _ in model.model.named_children()], csv_filename, tmpdir ) assert len(filenames) == 3 finally: if output_dir: shutil.rmtree(output_dir, ignore_errors=True) def test_print_model_summary(csv_filename): output_dir = None model, output_dir = _train(*_prepare_data(csv_filename)) model_path = os.path.join(output_dir, MODEL_FILE_NAME) print_model_summary(model_path) ================================================ FILE: tests/integration_tests/test_config_global_defaults.py ================================================ import logging from ludwig.constants import ( BATCH_SIZE, CATEGORY, COMBINER, DECODER, DEFAULTS, ENCODER, EPOCHS, FILL_WITH_CONST, INPUT_FEATURES, LOSS, OUTPUT_FEATURES, PREPROCESSING, TEXT, TRAINER, TYPE, ) from ludwig.schema.model_config import ModelConfig from tests.integration_tests.utils import category_feature, generate_data, run_experiment, text_feature logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) logging.getLogger("ludwig").setLevel(logging.INFO) def _prepare_data(csv_filename: str) -> tuple[dict, str]: input_features = [ text_feature(name="title", reduce_output="sum"), text_feature(name="summary"), category_feature(vocab_size=3), category_feature(vocab_size=3), ] output_features = [text_feature(name="article", embedding_size=3, output_feature=True)] dataset = generate_data(input_features, output_features, csv_filename) config = { INPUT_FEATURES: input_features, OUTPUT_FEATURES: output_features, COMBINER: {TYPE: "concat", "num_fc_layers": 2}, TRAINER: {EPOCHS: 1, "learning_rate": 0.001, BATCH_SIZE: 128}, DEFAULTS: { CATEGORY: { PREPROCESSING: {"missing_value_strategy": FILL_WITH_CONST, "fill_value": ""}, ENCODER: {TYPE: "sparse"}, DECODER: {"norm_params": None, "dropout": 0.1, "use_bias": True}, }, TEXT: { PREPROCESSING: {"most_common": 10, "padding_symbol": ""}, ENCODER: {TYPE: "rnn"}, DECODER: {TYPE: "generator", "num_fc_layers": 2, "dropout": 0.1}, LOSS: {"confidence_penalty": 0.1}, }, }, } return config, dataset def test_run_experiment_with_global_default_parameters(csv_filename): config, dataset = _prepare_data(csv_filename) run_experiment(config=config, dataset=dataset) def test_global_defaults_with_encoder_dependencies(): input_features = [text_feature(name="title", reduce_output="sum")] output_features = [category_feature(name="article", embedding_size=3, output_feature=True)] del input_features[0][ENCODER] config = { INPUT_FEATURES: input_features, OUTPUT_FEATURES: output_features, DEFAULTS: { TEXT: { ENCODER: {TYPE: "bert"}, } }, } # Config should populate with the additional required fields for bert updated_config = ModelConfig.from_dict(config).to_dict() assert updated_config[INPUT_FEATURES][0][ENCODER][TYPE] == "bert" assert updated_config[INPUT_FEATURES][0][ENCODER]["pretrained_model_name_or_path"] == "bert-base-uncased" ================================================ FILE: tests/integration_tests/test_contrib_aim.py ================================================ import logging import os import subprocess import sys import pytest logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) logging.getLogger("ludwig").setLevel(logging.INFO) TEST_SCRIPT = os.path.join(os.path.dirname(__file__), "scripts", "run_train_aim.py") @pytest.mark.skip(reason="Aim integration not compatible with Aim 4.0.") @pytest.mark.distributed def test_contrib_experiment(csv_filename, tmpdir): aim_test_path = os.path.join(tmpdir, "results") os.makedirs(aim_test_path, exist_ok=True) os.environ["AIM_TEST_PATH"] = aim_test_path subprocess.call(["chmod", "-R", "a+w", os.environ["AIM_TEST_PATH"]]) cmdline = [sys.executable, TEST_SCRIPT, "--csv-filename", csv_filename] print(cmdline) exit_code = subprocess.call(" ".join(cmdline), shell=True, env=os.environ.copy()) assert exit_code == 0 ================================================ FILE: tests/integration_tests/test_contrib_comet.py ================================================ import importlib.util import logging import os import subprocess import sys import pytest logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) logging.getLogger("ludwig").setLevel(logging.INFO) TEST_SCRIPT = os.path.join(os.path.dirname(__file__), "scripts", "run_train_comet.py") @pytest.mark.skipif( not importlib.util.find_spec("pkg_resources"), reason="comet_ml requires pkg_resources (removed in setuptools 82+)", ) def test_contrib_experiment(csv_filename): cmdline = [sys.executable, TEST_SCRIPT, "--csv-filename", csv_filename] exit_code = subprocess.call(" ".join(cmdline), shell=True, env=os.environ.copy()) assert exit_code == 0 if __name__ == "__main__": """To run tests individually, run: ```python -m pytest tests/integration_tests/test_contrib_comet.py::test_name``` """ ================================================ FILE: tests/integration_tests/test_contrib_wandb.py ================================================ import logging import os import subprocess import sys logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) logging.getLogger("ludwig").setLevel(logging.INFO) TEST_SCRIPT = os.path.join(os.path.dirname(__file__), "scripts", "run_train_wandb.py") def test_contrib_experiment(csv_filename, tmpdir): wandb_dir = os.path.join(tmpdir, "results") os.makedirs(wandb_dir, exist_ok=True) os.environ["WANDB_DIR"] = wandb_dir subprocess.call(["chmod", "-R", "a+w", os.environ["WANDB_DIR"]]) cmdline = [sys.executable, TEST_SCRIPT, "--csv-filename", csv_filename] exit_code = subprocess.call(" ".join(cmdline), shell=True, env=os.environ.copy()) assert exit_code == 0 if __name__ == "__main__": """To run tests individually, run: ```python -m pytest tests/integration_tests/test_contrib_wandb.py::test_name``` """ ================================================ FILE: tests/integration_tests/test_custom_components.py ================================================ import os import tempfile import torch from torch import nn, Tensor from ludwig.api import LudwigModel from ludwig.combiners.combiners import Combiner, register_combiner from ludwig.constants import BATCH_SIZE, ENCODER_OUTPUT, LOGITS, MINIMIZE, NUMBER, TRAINER from ludwig.decoders.base import Decoder from ludwig.decoders.registry import register_decoder from ludwig.encoders.base import Encoder from ludwig.encoders.registry import register_encoder from ludwig.modules.loss_modules import LogitsInputsMixin, register_loss from ludwig.modules.metric_modules import LossMetric, register_metric from ludwig.schema import utils as schema_utils from ludwig.schema.combiners.base import BaseCombinerConfig from ludwig.schema.combiners.utils import register_combiner_config from ludwig.schema.decoders.base import BaseDecoderConfig from ludwig.schema.decoders.utils import register_decoder_config from ludwig.schema.encoders.base import BaseEncoderConfig from ludwig.schema.encoders.utils import register_encoder_config from ludwig.schema.features.loss.loss import BaseLossConfig from ludwig.schema.features.loss.loss import register_loss as register_loss_schema from ludwig.schema.utils import ludwig_dataclass as dataclass from tests.integration_tests.utils import ( category_feature, generate_data, LocalTestBackend, number_feature, sequence_feature, ) @register_encoder_config("custom_number_encoder", NUMBER) @dataclass class CustomNumberEncoderConfig(BaseEncoderConfig): type: str = "custom_number_encoder" input_size: int = schema_utils.PositiveInteger(default=1, description="") @register_decoder_config("custom_number_decoder", NUMBER) @dataclass class CustomNumberDecoderConfig(BaseDecoderConfig): type: str = "custom_number_decoder" input_size: int = schema_utils.PositiveInteger(default=1, description="") @register_loss_schema([NUMBER]) @dataclass class CustomLossConfig(BaseLossConfig): type: str = "custom_loss" @register_combiner_config("custom_combiner") @dataclass class CustomTestCombinerConfig(BaseCombinerConfig): type: str = "custom_combiner" foo: bool = schema_utils.Boolean(default=False, description="") @register_combiner(CustomTestCombinerConfig) class CustomTestCombiner(Combiner): def __init__(self, input_features: dict = None, config: CustomTestCombinerConfig = None, **kwargs): super().__init__(input_features) self.foo = config.foo def forward(self, inputs: dict) -> dict: # encoder outputs if not self.foo: raise ValueError("expected foo to be True") # minimal transformation from inputs to outputs encoder_outputs = [inputs[k][ENCODER_OUTPUT] for k in inputs] hidden = torch.cat(encoder_outputs, 1) return_data = {"combiner_output": hidden} return return_data @register_encoder("custom_number_encoder", NUMBER) class CustomNumberEncoder(Encoder): def __init__(self, input_size, **kwargs): super().__init__() self.input_size = input_size def forward(self, inputs, **kwargs): return {ENCODER_OUTPUT: inputs} @property def input_shape(self) -> torch.Size: return torch.Size([self.input_size]) @property def output_shape(self) -> torch.Size: return self.input_shape @staticmethod def get_schema_cls(): return CustomNumberEncoderConfig @register_decoder("custom_number_decoder", NUMBER) class CustomNumberDecoder(Decoder): def __init__(self, input_size, **kwargs): super().__init__() self.input_size = input_size @property def input_shape(self): return torch.Size([self.input_size]) def forward(self, inputs, **kwargs): return torch.mean(inputs, 1) @staticmethod def get_schema_cls(): return CustomNumberDecoderConfig @register_loss(CustomLossConfig) class CustomLoss(nn.Module, LogitsInputsMixin): def __init__(self, config: CustomLossConfig): super().__init__() def forward(self, preds: Tensor, target: Tensor) -> Tensor: return torch.mean(torch.square(preds - target)) @staticmethod def get_schema_cls(): return CustomLossConfig @register_metric("custom_loss", [NUMBER], MINIMIZE, LOGITS) class CustomLossMetric(LossMetric): def __init__(self, config: CustomLossConfig, **kwargs): super().__init__() self.loss_fn = CustomLoss(config) def get_current_value(self, preds: Tensor, target: Tensor): return self.loss_fn(preds, target) def test_custom_combiner(): _run_test(combiner={"type": "custom_combiner", "foo": True}) def test_custom_encoder_decoder(): input_features = [ sequence_feature(encoder={"reduce_output": "sum"}), number_feature(encoder={"type": "custom_number_encoder"}), ] output_features = [ number_feature(decoder={"type": "custom_number_decoder"}), ] _run_test(input_features=input_features, output_features=output_features) def test_custom_loss_metric(): output_features = [ number_feature(loss={"type": "custom_loss"}), ] _run_test(output_features=output_features) def _run_test(input_features=None, output_features=None, combiner=None): with tempfile.TemporaryDirectory() as tmpdir: input_features = input_features or [ sequence_feature(encoder={"reduce_output": "sum"}), number_feature(), ] output_features = output_features or [category_feature(decoder={"vocab_size": 2}, reduce_input="sum")] combiner = combiner or {"type": "concat"} csv_filename = os.path.join(tmpdir, "training.csv") data_csv = generate_data(input_features, output_features, csv_filename) config = { "input_features": input_features, "output_features": output_features, "combiner": combiner, TRAINER: {"epochs": 2, BATCH_SIZE: 128}, } model = LudwigModel(config, backend=LocalTestBackend()) _, _, output_directory = model.train( dataset=data_csv, output_directory=tmpdir, ) model.predict(dataset=data_csv, output_directory=output_directory) ================================================ FILE: tests/integration_tests/test_date_feature.py ================================================ import datetime import time import pandas as pd import pytest from dateutil.parser import parse from ludwig.api import LudwigModel from ludwig.constants import ( BACKEND, BINARY, DATE, EPOCHS, FILL_WITH_CONST, INPUT_FEATURES, MISSING_VALUE_STRATEGY, NAME, OUTPUT_FEATURES, PREPROCESSING, RAY, TRAINER, TYPE, ) from ludwig.utils.date_utils import create_vector_from_datetime_obj ray = pytest.importorskip("ray") pytestmark = [ pytest.mark.distributed, ] @pytest.fixture(scope="module") def string_date_df() -> "pd.DataFrame": df = pd.DataFrame.from_dict( { "date_feature": [str(datetime.datetime.now()) for i in range(100)], "binary_feature": [i % 2 for i in range(100)], } ) return df @pytest.fixture(scope="module") def int_date_df() -> "pd.DataFrame": df = pd.DataFrame.from_dict( { "date_feature": [time.time_ns() for i in range(100)], "binary_feature": [i % 2 for i in range(100)], } ) return df @pytest.fixture(scope="module") def float_date_df() -> "pd.DataFrame": df = pd.DataFrame.from_dict( { "date_feature": [time.time() for i in range(100)], "binary_feature": [i % 2 for i in range(100)], } ) return df @pytest.mark.parametrize( "date_df", [ pytest.param("string_date_df", id="string_date"), pytest.param("int_date_df", id="int_date"), pytest.param("float_date_df", id="float_date"), ], ) def test_date_feature_formats(date_df, request, ray_cluster_2cpu): df = request.getfixturevalue(date_df) config = { INPUT_FEATURES: [ { NAME: "date_feature", TYPE: DATE, PREPROCESSING: {MISSING_VALUE_STRATEGY: FILL_WITH_CONST, "fill_value": "1970-01-01 00:00:00"}, } ], OUTPUT_FEATURES: [{NAME: "binary_feature", TYPE: BINARY}], TRAINER: {EPOCHS: 2}, BACKEND: {TYPE: RAY, "processor": {TYPE: "dask"}}, } fill_value = create_vector_from_datetime_obj(parse("1970-01-01 00:00:00")) model = LudwigModel(config) preprocessed = model.preprocess(df) # Because parsing errors are suppressed, we want to ensure that the data was preprocessed correctly. Sample data is # drawn from the current time, so the recorded years should not match the fill value's year. for date in preprocessed.training_set.to_df().compute().iloc[:, 0].values: assert date[0] != fill_value[0] for date in preprocessed.validation_set.to_df().compute().iloc[:, 0].values: assert date[0] != fill_value[0] for date in preprocessed.test_set.to_df().compute().iloc[:, 0].values: assert date[0] != fill_value[0] ================================================ FILE: tests/integration_tests/test_dependencies.py ================================================ import logging import pytest import torch from ludwig.combiners.combiners import ConcatCombiner from ludwig.constants import CATEGORY, DECODER, NUMBER, SEQUENCE, TYPE from ludwig.models.base import BaseModel from ludwig.modules.reduction_modules import SequenceReducer from ludwig.schema.model_config import ModelConfig from ludwig.utils import output_feature_utils from tests.integration_tests.utils import generate_output_features_with_dependencies, number_feature logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) logging.getLogger("ludwig").setLevel(logging.INFO) BATCH_SIZE = 16 SEQ_SIZE = 12 HIDDEN_SIZE = 128 OTHER_HIDDEN_SIZE = 32 OTHER_HIDDEN_SIZE2 = 64 # unit test for dependency concatenation # tests both single and multiple dependencies @pytest.mark.parametrize( "dependent_hidden_shape2", [ None, [BATCH_SIZE, OTHER_HIDDEN_SIZE2], [BATCH_SIZE, SEQ_SIZE, OTHER_HIDDEN_SIZE2], [BATCH_SIZE, SEQ_SIZE, OTHER_HIDDEN_SIZE], ], ) @pytest.mark.parametrize( "dependent_hidden_shape", [[BATCH_SIZE, OTHER_HIDDEN_SIZE], [BATCH_SIZE, SEQ_SIZE, OTHER_HIDDEN_SIZE]] ) @pytest.mark.parametrize("hidden_shape", [[BATCH_SIZE, HIDDEN_SIZE], [BATCH_SIZE, SEQ_SIZE, HIDDEN_SIZE]]) @pytest.mark.parametrize( # todo: re-add 'attention' after further research in implication of torch # migration "reduce_dependencies", ["sum", "mean", "avg", "max", "concat", "last"], ) def test_multiple_dependencies(reduce_dependencies, hidden_shape, dependent_hidden_shape, dependent_hidden_shape2): # setup at least for a single dependency hidden_layer = torch.randn(hidden_shape, dtype=torch.float32) other_hidden_layer = torch.randn(dependent_hidden_shape, dtype=torch.float32) other_dependencies = { "feature_name": other_hidden_layer, } # setup dummy output feature to be root of dependency list num_feature_defn = number_feature() num_feature_defn["loss"] = {"type": "mean_squared_error"} num_feature_defn["dependencies"] = ["feature_name"] if len(dependent_hidden_shape) > 2: num_feature_defn["reduce_dependencies"] = reduce_dependencies # Based on specification calculate expected resulting hidden size for # with one dependencies if reduce_dependencies == "concat" and len(hidden_shape) == 2 and len(dependent_hidden_shape) == 3: expected_hidden_size = HIDDEN_SIZE + OTHER_HIDDEN_SIZE * SEQ_SIZE else: expected_hidden_size = HIDDEN_SIZE + OTHER_HIDDEN_SIZE # set up if multiple dependencies specified, setup second dependent feature if dependent_hidden_shape2: other_hidden_layer2 = torch.randn(dependent_hidden_shape2, dtype=torch.float32) other_dependencies["feature_name2"] = other_hidden_layer2 num_feature_defn["dependencies"].append("feature_name2") if len(dependent_hidden_shape2) > 2: num_feature_defn["reduce_dependencies"] = reduce_dependencies # Based on specification calculate marginal increase in resulting # hidden size with two dependencies if reduce_dependencies == "concat" and len(hidden_shape) == 2 and len(dependent_hidden_shape2) == 3: expected_hidden_size += dependent_hidden_shape2[-1] * SEQ_SIZE else: expected_hidden_size += dependent_hidden_shape2[-1] # Set up dependency reducers. dependency_reducers = torch.nn.ModuleDict() for feature_name in other_dependencies.keys(): dependency_reducers[feature_name] = SequenceReducer(reduce_mode=reduce_dependencies) # test dependency concatenation num_feature_defn["input_size"] = expected_hidden_size results = output_feature_utils.concat_dependencies( "num_feature", num_feature_defn["dependencies"], dependency_reducers, hidden_layer, other_dependencies ) # confirm size of resulting concat_dependencies() call if len(hidden_shape) > 2: assert results.shape == (BATCH_SIZE, SEQ_SIZE, expected_hidden_size) else: assert results.shape == (BATCH_SIZE, expected_hidden_size) @pytest.mark.parametrize( "output_feature_defs", [ generate_output_features_with_dependencies("number_feature", ["category_feature"]), generate_output_features_with_dependencies("number_feature", ["category_feature", "sequence_feature"]), generate_output_features_with_dependencies("sequence_feature", ["category_feature", "number_feature"]), ], ) def test_construct_output_features_with_dependencies(output_feature_defs): # Add keys to output_feature_defs which would have been derived from data. def add_data_derived_keys(output_feature_def): if DECODER not in output_feature_def: output_feature_def[DECODER] = {} if output_feature_def[TYPE] == CATEGORY: output_feature_def["num_classes"] = 2 elif output_feature_def[TYPE] == NUMBER: output_feature_def[DECODER][TYPE] = "regressor" elif output_feature_def[TYPE] == SEQUENCE: output_feature_def[DECODER]["max_sequence_length"] = 5 return output_feature_def output_feature_defs = [add_data_derived_keys(of) for of in output_feature_defs] # Gets name of output feature which has dependencies. dep_feature_name = [of for of in output_feature_defs if len(of.get("dependencies", [])) > 0][0]["name"] # Creates a dummy input feature and combiner. config = { "input_features": [number_feature()], "output_features": output_feature_defs, "combiner": {"type": "concat", "output_size": 1}, } config_obj = ModelConfig.from_dict(config) input_features = BaseModel.build_inputs(config_obj.input_features) combiner = ConcatCombiner(input_features=input_features, config=config_obj.combiner) output_features = BaseModel.build_outputs(config_obj.output_features, combiner) # Gets the output feature object which has dependencies. feature_with_deps = output_features[dep_feature_name] n_dependencies = len(feature_with_deps.dependencies) assert n_dependencies > 0 # Each synthetic output feature has output size 1, so total size is 1 + n_dependencies. assert feature_with_deps.fc_stack.input_shape == torch.Size([1 + n_dependencies]) ================================================ FILE: tests/integration_tests/test_experiment.py ================================================ # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import contextlib import logging import os import shutil import uuid from collections import namedtuple import pandas as pd import pytest import torch import torchvision import yaml from ludwig.api import LudwigModel from ludwig.backend import LOCAL_BACKEND from ludwig.callbacks import Callback from ludwig.constants import BATCH_SIZE, COLUMN, ENCODER, H3, NAME, PREPROCESSING, TRAINER, TYPE from ludwig.data.concatenate_datasets import concatenate_df from ludwig.data.dataset_synthesizer import build_synthetic_dataset_df from ludwig.data.preprocessing import preprocess_for_training from ludwig.encoders.registry import get_encoder_classes from ludwig.error import ConfigValidationError from ludwig.experiment import experiment_cli from ludwig.globals import MODEL_FILE_NAME from ludwig.predict import predict_cli from ludwig.utils.data_utils import read_csv from ludwig.utils.defaults import default_random_seed from tests.integration_tests.utils import ( audio_feature, bag_feature, binary_feature, category_distribution_feature, category_feature, create_data_set_to_use, date_feature, ENCODERS, generate_data, generate_output_features_with_dependencies, generate_output_features_with_dependencies_complex, h3_feature, image_feature, LocalTestBackend, number_feature, run_experiment, sequence_feature, set_feature, text_feature, timeseries_feature, vector_feature, ) pytestmark = pytest.mark.integration_tests_d logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) logging.getLogger("ludwig").setLevel(logging.INFO) @pytest.mark.parametrize("encoder", ["embed", "rnn", "transformer", "tf_idf"]) def test_experiment_text_feature_non_pretrained(encoder, csv_filename): input_features = [ text_feature(encoder={"vocab_size": 30, "min_len": 1, "type": encoder}, preprocessing={"tokenizer": "space"}) ] output_features = [category_feature(decoder={"vocab_size": 2})] # Generate test data rel_path = generate_data(input_features, output_features, csv_filename) run_experiment(input_features, output_features, dataset=rel_path) def run_experiment_with_encoder(encoder, csv_filename): # Run in a subprocess to clear TF and prevent OOM # This also allows us to use GPU resources input_features = [text_feature(encoder={"vocab_size": 30, "min_len": 1, "type": encoder})] output_features = [category_feature(decoder={"vocab_size": 2})] # Generate test data rel_path = generate_data(input_features, output_features, csv_filename) run_experiment(input_features, output_features, dataset=rel_path) @pytest.mark.parametrize("encoder", ["embed", "rnn", "transformer"]) def test_experiment_seq_seq_generator(csv_filename, encoder): input_features = [text_feature(encoder={"type": encoder, "reduce_output": None})] output_features = [text_feature(decoder={"type": "generator"}, output_feature=True)] rel_path = generate_data(input_features, output_features, csv_filename) run_experiment(input_features, output_features, dataset=rel_path) @pytest.mark.parametrize("encoder", ["embed", "rnn", "transformer"]) def test_experiment_seq_seq_tagger(csv_filename, encoder): input_features = [text_feature(encoder={"type": encoder, "reduce_output": None})] output_features = [text_feature(decoder={"type": "tagger"}, reduce_input=None)] rel_path = generate_data(input_features, output_features, csv_filename) run_experiment(input_features, output_features, dataset=rel_path) @pytest.mark.parametrize("encoder", ["cnnrnn", "stacked_cnn"]) def test_experiment_seq_seq_tagger_fails_for_non_length_preserving_encoders(csv_filename, encoder): input_features = [text_feature(encoder={"type": encoder, "reduce_output": None})] output_features = [text_feature(decoder={"type": "tagger"}, reduce_input=None)] rel_path = generate_data(input_features, output_features, csv_filename) with pytest.raises(ValueError): run_experiment(input_features, output_features, dataset=rel_path) def test_experiment_seq_seq_model_def_file(csv_filename, yaml_filename): # seq-to-seq test to use config file instead of dictionary input_features = [text_feature(encoder={"reduce_output": None, "type": "embed"})] output_features = [text_feature(decoder={"vocab_size": 3, "type": "tagger"}, reduce_input=None)] # Save the config to a yaml file config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat", "output_size": 14}, TRAINER: {"epochs": 2, BATCH_SIZE: 128}, } with open(yaml_filename, "w") as yaml_out: yaml.safe_dump(config, yaml_out) rel_path = generate_data(input_features, output_features, csv_filename) run_experiment(None, None, dataset=rel_path, config=yaml_filename) def test_experiment_seq_seq_train_test_valid(tmpdir): # seq-to-seq test to use train, test, validation files input_features = [text_feature(encoder={"reduce_output": None, "type": "rnn"})] output_features = [text_feature(decoder={"vocab_size": 3, "type": "tagger"}, reduce_input=None)] train_csv = generate_data(input_features, output_features, os.path.join(tmpdir, "train.csv")) test_csv = generate_data(input_features, output_features, os.path.join(tmpdir, "test.csv"), 20) valdation_csv = generate_data(input_features, output_features, os.path.join(tmpdir, "val.csv"), 20) run_experiment( input_features, output_features, training_set=train_csv, test_set=test_csv, validation_set=valdation_csv ) # Save intermediate output run_experiment( input_features, output_features, training_set=train_csv, test_set=test_csv, validation_set=valdation_csv ) @pytest.mark.parametrize("encoder", ["embed", "rnn", "transformer"]) def test_experiment_multi_input_intent_classification(csv_filename, encoder): # Multiple inputs, Single category output input_features = [ text_feature(encoder={"vocab_size": 10, "min_len": 1, "representation": "sparse"}), category_feature(encoder={"vocab_size": 10}), ] output_features = [category_feature(decoder={"reduce_input": "sum", "vocab_size": 2})] # Generate test data rel_path = generate_data(input_features, output_features, csv_filename) input_features[0][ENCODER][TYPE] = encoder run_experiment(input_features, output_features, dataset=rel_path) def test_experiment_with_torch_module_dict_feature_name(csv_filename): input_features = [category_feature(name="type")] output_features = [category_feature(name="to", output_feature=True)] rel_path = generate_data(input_features, output_features, csv_filename) run_experiment(input_features, output_features, dataset=rel_path) def test_experiment_multiclass_with_class_weights(csv_filename): # Multiple inputs, Single category output input_features = [category_feature(encoder={"vocab_size": 10})] output_features = [category_feature(decoder={"vocab_size": 3}, loss={"class_weights": [0, 1, 2]})] # Generate test data rel_path = generate_data(input_features, output_features, csv_filename) run_experiment(input_features, output_features, dataset=rel_path) def test_experiment_multilabel_with_class_weights(csv_filename): # Multiple inputs, Single category output input_features = [category_feature(encoder={"vocab_size": 10})] output_features = [set_feature(decoder={"vocab_size": 3}, loss={"class_weights": [0, 1, 2, 3]})] # Generate test data rel_path = generate_data(input_features, output_features, csv_filename) run_experiment(input_features, output_features, dataset=rel_path) @pytest.mark.parametrize( "output_features", [ # baseline test case [ category_feature(decoder={"reduce_input": "sum", "vocab_size": 2}), sequence_feature(decoder={"vocab_size": 10, "max_len": 5}), number_feature(), ], # use generator as decoder [ category_feature(decoder={"vocab_size": 2, "reduce_input": "sum"}), sequence_feature(decoder={"vocab_size": 10, "max_len": 5, "type": "generator"}), number_feature(), ], # Generator decoder and reduce_input = None [ category_feature(decoder={"vocab_size": 2, "reduce_input": "sum"}), sequence_feature(decoder={"max_len": 5, "type": "generator"}, reduce_input=None), number_feature(normalization="minmax"), ], # output features with dependencies single dependency generate_output_features_with_dependencies("number_feature", ["category_feature"]), # output features with dependencies multiple dependencies generate_output_features_with_dependencies("number_feature", ["category_feature", "sequence_feature"]), # output features with dependencies multiple dependencies generate_output_features_with_dependencies("sequence_feature", ["category_feature", "number_feature"]), # output features with dependencies generate_output_features_with_dependencies("category_feature", ["sequence_feature"]), generate_output_features_with_dependencies_complex(), ], ) def test_experiment_multiple_seq_seq(csv_filename, output_features): input_features = [ text_feature(encoder={"vocab_size": 100, "min_len": 1, "type": "stacked_cnn"}), number_feature(normalization="zscore"), category_feature(encoder={"vocab_size": 10, "embedding_size": 5}), set_feature(), sequence_feature(encoder={"vocab_size": 10, "max_len": 10, "type": "embed"}), ] output_features = output_features rel_path = generate_data(input_features, output_features, csv_filename) run_experiment(input_features, output_features, dataset=rel_path) @pytest.mark.parametrize( "num_channels,image_source,in_memory,skip_save_processed_input", [ (3, "file", True, True), (1, "file", False, False), (3, "tensor", True, False), ], ids=["file_in_memory_3ch", "file_on_disk_1ch", "tensor_in_memory_3ch"], ) def test_basic_image_feature(num_channels, image_source, in_memory, skip_save_processed_input, tmpdir): # Image Inputs image_dest_folder = os.path.join(tmpdir, "generated_images") input_features = [ image_feature( folder=image_dest_folder, preprocessing={ "in_memory": in_memory, "height": 12, "width": 12, "num_channels": num_channels, "num_processes": 5, }, encoder={ "type": "stacked_cnn", "output_size": 16, "num_filters": 8, }, ) ] output_features = [category_feature(decoder={"reduce_input": "sum", "vocab_size": 2})] rel_path = generate_data(input_features, output_features, os.path.join(tmpdir, "dataset.csv")) if image_source == "file": # use images from file run_experiment( input_features, output_features, dataset=rel_path, skip_save_processed_input=skip_save_processed_input ) else: # import image from file and store in dataframe as tensors. df = pd.read_csv(rel_path) image_feature_name = input_features[0]["name"] df[image_feature_name] = df[image_feature_name].apply(lambda x: torchvision.io.read_image(x)) run_experiment(input_features, output_features, dataset=df, skip_save_processed_input=skip_save_processed_input) def test_experiment_infer_image_metadata(tmpdir): # Image Inputs image_dest_folder = os.path.join(tmpdir, "generated_images") # Resnet encoder input_features = [ image_feature(folder=image_dest_folder, encoder={"type": "stacked_cnn", "output_size": 16, "num_filters": 8}), text_feature(encoder={"type": "embed", "min_len": 1}), number_feature(normalization="zscore"), ] output_features = [category_feature(decoder={"reduce_input": "sum", "vocab_size": 2}), number_feature()] rel_path = generate_data(input_features, output_features, os.path.join(tmpdir, "dataset.csv")) # remove image preprocessing section to force inferring image meta data input_features[0].pop("preprocessing") run_experiment(input_features, output_features, dataset=rel_path) ImageParams = namedtuple("ImageTestParams", "image_encoder in_memory_flag skip_save_processed_input") @pytest.mark.parametrize( "image_params", [ ImageParams("stacked_cnn", True, True), ImageParams("stacked_cnn", False, False), ], ) def test_experiment_image_inputs(image_params: ImageParams, tmpdir): # Image Inputs image_dest_folder = os.path.join(tmpdir, "generated_images") # Resnet encoder input_features = [ image_feature( folder=image_dest_folder, preprocessing={"in_memory": True, "height": 12, "width": 12, "num_channels": 3, "num_processes": 5}, encoder={"type": "resnet", "output_size": 16, "num_filters": 8}, ), text_feature(encoder={"type": "embed", "min_len": 1}), number_feature(normalization="zscore"), ] output_features = [category_feature(decoder={"reduce_input": "sum", "vocab_size": 2}), number_feature()] input_features[0]["encoder"]["type"] = image_params.image_encoder input_features[0]["preprocessing"]["in_memory"] = image_params.in_memory_flag rel_path = generate_data(input_features, output_features, os.path.join(tmpdir, "dataset.csv")) run_experiment( input_features, output_features, dataset=rel_path, skip_save_processed_input=image_params.skip_save_processed_input, ) # Primary focus of this test is to determine if exceptions are raised for different data set formats and in_memory # setting. @pytest.mark.parametrize( "train_format,train_in_memory,test_format,test_in_memory", [ ("csv", True, "csv", True), ("df", False, "df", False), ("hdf5", True, "hdf5", True), ("csv", False, "df", True), ], ids=["csv_inmem", "df_ondisk", "hdf5_inmem", "csv_to_df_mixed"], ) def test_experiment_image_dataset(train_format, train_in_memory, test_format, test_in_memory, tmpdir): # Image Inputs image_dest_folder = os.path.join(tmpdir, "generated_images") input_features = [ image_feature( folder=image_dest_folder, preprocessing={"in_memory": True, "height": 12, "width": 12, "num_channels": 3, "num_processes": 5}, encoder={"type": "stacked_cnn", "output_size": 16, "num_filters": 8}, ), ] output_features = [ category_feature(decoder={"reduce_input": "sum", "vocab_size": 2}), ] config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat", "output_size": 14}, "preprocessing": {}, TRAINER: {"epochs": 2, BATCH_SIZE: 128}, } # create temporary name for train and test data sets train_csv_filename = os.path.join(tmpdir, "train_" + uuid.uuid4().hex[:10].upper() + ".csv") test_csv_filename = os.path.join(tmpdir, "test_" + uuid.uuid4().hex[:10].upper() + ".csv") # setup training data format to test train_data = generate_data(input_features, output_features, train_csv_filename) config["input_features"][0]["preprocessing"]["in_memory"] = train_in_memory training_set_metadata = None # define Ludwig model backend = LocalTestBackend() model = LudwigModel( config=config, backend=backend, ) if train_format == "hdf5": # hdf5 format train_set, _, _, training_set_metadata = preprocess_for_training( model.config, dataset=train_data, backend=backend, ) train_dataset_to_use = train_set.data_hdf5_fp else: train_dataset_to_use = create_data_set_to_use(train_format, train_data) model.train(dataset=train_dataset_to_use, training_set_metadata=training_set_metadata) model.config_obj.input_features.to_list()[0]["preprocessing"]["in_memory"] = test_in_memory # setup test data format to test test_data = generate_data(input_features, output_features, test_csv_filename) if test_format == "hdf5": # hdf5 format # create hdf5 data set _, test_set, _, training_set_metadata_for_test = preprocess_for_training( model.config, dataset=test_data, backend=backend, ) test_dataset_to_use = test_set.data_hdf5_fp else: test_dataset_to_use = create_data_set_to_use(test_format, test_data) # run functions with the specified data format model.evaluate(dataset=test_dataset_to_use) model.predict(dataset=test_dataset_to_use) DATA_FORMATS_TO_TEST = [ "csv", "df", "dict", "excel", "feather", "fwf", "hdf5", "html", "json", "jsonl", "parquet", "pickle", "stata", "tsv", ] @pytest.mark.parametrize("data_format", DATA_FORMATS_TO_TEST) def test_experiment_dataset_formats(data_format, csv_filename): # primary focus of this test is to determine if exceptions are # raised for different data set formats and in_memory setting input_features = [number_feature(), category_feature()] output_features = [category_feature(output_feature=True), number_feature()] config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat", "output_size": 14}, "preprocessing": {}, TRAINER: {"epochs": 2, BATCH_SIZE: 128}, } # setup training data format to test raw_data = generate_data(input_features, output_features, csv_filename) training_set_metadata = None # define Ludwig model model = LudwigModel(config=config) if data_format == "hdf5": # hdf5 format training_set, _, _, training_set_metadata = preprocess_for_training(model.config, dataset=raw_data) dataset_to_use = training_set.data_hdf5_fp else: dataset_to_use = create_data_set_to_use(data_format, raw_data) model.train(dataset=dataset_to_use, training_set_metadata=training_set_metadata, random_seed=default_random_seed) # # run functions with the specified data format model.evaluate(dataset=dataset_to_use) model.predict(dataset=dataset_to_use) def test_experiment_audio_inputs(tmpdir): # Audio Inputs audio_dest_folder = os.path.join(tmpdir, "generated_audio") input_features = [audio_feature(folder=audio_dest_folder)] output_features = [binary_feature()] rel_path = generate_data(input_features, output_features, os.path.join(tmpdir, "dataset.csv")) run_experiment(input_features, output_features, dataset=rel_path) def test_experiment_tied_weights(csv_filename): # Single sequence input, single category output input_features = [ text_feature(name="text_feature1", encoder={"min_len": 1, "type": "cnnrnn", "reduce_output": "sum"}), text_feature( name="text_feature2", encoder={"min_len": 1, "type": "cnnrnn", "reduce_output": "sum"}, tied="text_feature1" ), ] output_features = [category_feature(decoder={"reduce_input": "sum", "vocab_size": 2})] # Generate test data rel_path = generate_data(input_features, output_features, csv_filename) for encoder in ENCODERS: input_features[0][ENCODER][TYPE] = encoder input_features[1][ENCODER][TYPE] = encoder run_experiment(input_features, output_features, dataset=rel_path) def test_experiment_tied_weights_sequence_combiner(csv_filename): """Tests that tied weights work with sequence combiners if `sequence_length` is provided. Addresses https://github.com/ludwig-ai/ludwig/issues/3220 """ input_features = [ text_feature( name="feature1", encoder={ "max_len": 5, "reduce_output": None, }, preprocessing={"sequence_length": 10}, ), text_feature( name="feature2", encoder={ "max_len": 3, "reduce_output": None, }, preprocessing={"sequence_length": 10}, tied="feature1", ), ] output_features = [category_feature(decoder={"reduce_input": "sum", "vocab_size": 2})] config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "sequence"}, TRAINER: {"epochs": 2, BATCH_SIZE: 128}, } # Generate test data rel_path = generate_data(input_features, output_features, csv_filename) run_experiment(config=config, dataset=rel_path) @pytest.mark.parametrize( "enc_cell_type,attention", [("lstm", True), ("rnn", False), ("gru", True)], ids=["lstm_attn", "rnn_no_attn", "gru_attn"], ) def test_sequence_tagger(enc_cell_type, attention, csv_filename): # Define input and output features input_features = [ sequence_feature(encoder={"max_len": 10, "type": "rnn", "cell_type": enc_cell_type, "reduce_output": None}) ] output_features = [ sequence_feature(decoder={"max_len": 10, "type": "tagger", "attention": attention}, reduce_input=None) ] # Generate test data rel_path = generate_data(input_features, output_features, csv_filename) # run the experiment run_experiment(input_features, output_features, dataset=rel_path) def test_sequence_tagger_text(csv_filename): # Define input and output features input_features = [text_feature(encoder={"max_len": 10, "type": "rnn", "reduce_output": None})] output_features = [ sequence_feature( decoder={"max_len": 10, "type": "tagger"}, reduce_input=None, ) ] # Generate test data rel_path = generate_data(input_features, output_features, csv_filename) # run the experiment run_experiment(input_features, output_features, dataset=rel_path) """ @pytest.mark.distributed def test_sequence_tagger_text_ray(csv_filename, ray_cluster_2cpu): # Define input and output features input_features = [text_feature(encoder={"max_len": 10, "type": "rnn", "reduce_output": None})] output_features = [ sequence_feature( decoder={"max_len": 10, "type": "tagger"}, reduce_input=None, ) ] # Generate test data rel_path = generate_data(input_features, output_features, csv_filename) # run the experiment run_experiment(input_features, output_features, dataset=rel_path, backend="ray") """ def test_experiment_sequence_combiner_with_reduction_fails(csv_filename): config = { "input_features": [ sequence_feature( name="seq1", encoder={ "min_len": 5, "max_len": 5, "type": "embed", "cell_type": "lstm", "reduce_output": "sum", }, ), sequence_feature( name="seq2", encoder={ "min_len": 5, "max_len": 5, "type": "embed", "cell_type": "lstm", "reduce_output": "sum", }, ), category_feature(encoder={"vocab_size": 5}), ], "output_features": [category_feature(decoder={"reduce_input": "sum", "vocab_size": 5})], TRAINER: {"epochs": 2, BATCH_SIZE: 128}, "combiner": { "type": "sequence", "encoder": {"type": "rnn"}, "main_sequence_feature": "seq1", "reduce_output": None, }, } # Generate test data rel_path = generate_data(config["input_features"], config["output_features"], csv_filename) # Encoding sequence features with 'embed' should fail with SequenceConcatCombiner, since at least one sequence # feature should be rank 3. with pytest.raises(TypeError): run_experiment(config=config, dataset=rel_path) @pytest.mark.parametrize("sequence_encoder", ["rnn", "transformer"]) def test_experiment_sequence_combiner(sequence_encoder, csv_filename): config = { "input_features": [ sequence_feature( name="seq1", encoder={ "min_len": 5, "max_len": 5, "type": sequence_encoder, "cell_type": "lstm", "reduce_output": None, }, ), sequence_feature( name="seq2", encoder={ "min_len": 5, "max_len": 5, "type": sequence_encoder, "cell_type": "lstm", "reduce_output": None, }, ), category_feature(vocab_size=5), ], "output_features": [category_feature(decoder={"reduce_input": "sum", "vocab_size": 5})], TRAINER: {"epochs": 2, BATCH_SIZE: 128}, "combiner": { "type": "sequence", "encoder": {"type": "rnn"}, "main_sequence_feature": "seq1", "reduce_output": None, }, } # Generate test data rel_path = generate_data(config["input_features"], config["output_features"], csv_filename) run_experiment(config=config, dataset=rel_path) def test_experiment_model_resume(tmpdir): # Single sequence input, single category output # Tests saving a model file, loading it to rerun training and predict input_features = [sequence_feature(encoder={"type": "rnn", "reduce_output": "sum"})] output_features = [category_feature(decoder={"reduce_input": "sum", "vocab_size": 2})] # Generate test data rel_path = generate_data(input_features, output_features, os.path.join(tmpdir, "dataset.csv")) config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat", "output_size": 14}, TRAINER: {"epochs": 2, BATCH_SIZE: 128}, } _, _, _, _, output_dir = experiment_cli(config, dataset=rel_path, output_directory=tmpdir) experiment_cli(config, dataset=rel_path, model_resume_path=output_dir) predict_cli(os.path.join(output_dir, MODEL_FILE_NAME), dataset=rel_path) shutil.rmtree(output_dir, ignore_errors=True) @pytest.mark.slow @pytest.mark.parametrize( "dist_strategy", [ pytest.param("ddp", id="ddp", marks=pytest.mark.distributed), ], ) def test_experiment_model_resume_distributed(tmpdir, dist_strategy, ray_cluster_4cpu): _run_experiment_model_resume_distributed(tmpdir, dist_strategy) @pytest.mark.skipif(torch.cuda.device_count() == 0, reason="test requires at least 1 gpu") @pytest.mark.skipif(not torch.cuda.is_available(), reason="test requires gpu support") @pytest.mark.parametrize( "dist_strategy", [ pytest.param("deepspeed", id="deepspeed", marks=pytest.mark.distributed), ], ) def test_experiment_model_resume_distributed_gpu(tmpdir, dist_strategy, ray_cluster_4cpu): _run_experiment_model_resume_distributed(tmpdir, dist_strategy) def _run_experiment_model_resume_distributed(tmpdir, dist_strategy): # Single sequence input, single category output # Tests saving a model file, loading it to rerun training and predict input_features = [number_feature()] output_features = [category_feature(output_feature=True)] # Generate test data rel_path = generate_data(input_features, output_features, os.path.join(tmpdir, "dataset.csv")) config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat", "output_size": 8}, TRAINER: {"epochs": 1, BATCH_SIZE: 128}, "backend": {"type": "ray", "trainer": {"strategy": dist_strategy, "num_workers": 2}}, } _, _, _, _, output_dir = experiment_cli(config, dataset=rel_path, output_directory=os.path.join(tmpdir, "results1")) experiment_cli( config, dataset=rel_path, model_resume_path=output_dir, output_directory=os.path.join(tmpdir, "results2") ) predict_cli( os.path.join(output_dir, MODEL_FILE_NAME), dataset=rel_path, output_directory=os.path.join(tmpdir, "results3") ) @pytest.mark.parametrize( "missing_file", ["training_progress.json", "training_checkpoints"], ids=["training_progress", "training_checkpoints"], ) def test_experiment_model_resume_missing_file(tmpdir, missing_file): # Single sequence input, single category output # Tests saving a model file, loading it to rerun training and predict input_features = [sequence_feature(encoder={"type": "rnn", "reduce_output": "sum"})] output_features = [category_feature(decoder={"reduce_input": "sum", "vocab_size": 2})] # Generate test data rel_path = generate_data(input_features, output_features, os.path.join(tmpdir, "dataset.csv")) config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat", "output_size": 14}, TRAINER: {"epochs": 2, BATCH_SIZE: 128}, } _, _, _, _, output_dir = experiment_cli(config, dataset=rel_path, output_directory=tmpdir) try: # Remove file to simulate failure during first epoch of training which prevents # training_checkpoints to be empty and training_progress.json to not be created missing_file_path = os.path.join(output_dir, MODEL_FILE_NAME, missing_file) if missing_file == "training_progress.json": os.remove(missing_file_path) else: shutil.rmtree(missing_file_path) finally: # Training should start a fresh model training run without any errors experiment_cli(config, dataset=rel_path, model_resume_path=output_dir) predict_cli(os.path.join(output_dir, MODEL_FILE_NAME), dataset=rel_path) shutil.rmtree(output_dir, ignore_errors=True) @pytest.mark.slow @pytest.mark.distributed def test_experiment_model_resume_before_1st_epoch_distributed(tmpdir, ray_cluster_4cpu): # Single sequence input, single category output # Tests saving a model file, loading it to rerun training and predict input_features = [number_feature()] output_features = [category_feature(output_feature=True)] # Generate test data training_set = generate_data(input_features, output_features, os.path.join(tmpdir, "dataset.csv")) config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat", "output_size": 8}, TRAINER: {"train_steps": 1, BATCH_SIZE: 128}, "backend": {"type": "ray", "trainer": {"strategy": "ddp", "num_workers": 2}}, } class InducedFailureCallback(Callback): """Class that defines the methods necessary to hook into process.""" def on_resume_training(self, is_coordinator): if is_coordinator: raise RuntimeError("Induced failure") class NoFailureCallback(Callback): """Class that defines the methods necessary to hook into process.""" def on_resume_training(self, is_coordinator): pass try: # Define Ludwig model object that drive model training model = LudwigModel(config=config, logging_level=logging.INFO, callbacks=[InducedFailureCallback()]) model.train( dataset=training_set, experiment_name="simple_experiment", model_name="simple_model_incomplete", skip_save_processed_input=True, output_directory=os.path.join(tmpdir, "results1"), ) except Exception: model = LudwigModel(config=config, logging_level=logging.INFO, callbacks=[NoFailureCallback()]) model.train( dataset=training_set, skip_save_processed_input=True, model_resume_path=os.path.join(tmpdir, "results1"), ) @pytest.mark.slow @pytest.mark.distributed def test_tabnet_with_batch_size_1(tmpdir, ray_cluster_4cpu): input_features = [number_feature()] output_features = [category_feature(output_feature=True)] training_set = generate_data(input_features, output_features, os.path.join(tmpdir, "dataset.csv")) config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "tabnet"}, TRAINER: {"train_steps": 1, BATCH_SIZE: 1}, "backend": {"type": "ray", "trainer": {"strategy": "ddp", "num_workers": 2}}, } model = LudwigModel(config=config, logging_level=logging.INFO) model.train( dataset=training_set, skip_save_training_description=True, skip_save_training_statistics=True, skip_save_model=True, skip_save_progress=True, skip_save_log=True, skip_save_processed_input=True, ) def test_experiment_various_feature_types(csv_filename): input_features = [binary_feature(), bag_feature()] output_features = [set_feature(decoder={"max_len": 3, "vocab_size": 5})] # Generate test data rel_path = generate_data(input_features, output_features, csv_filename) run_experiment(input_features, output_features, dataset=rel_path) def test_experiment_timeseries(csv_filename): input_features = [timeseries_feature()] output_features = [binary_feature()] # Generate test data rel_path = generate_data(input_features, output_features, csv_filename) input_features[0][ENCODER][TYPE] = "transformer" run_experiment(input_features, output_features, dataset=rel_path) def test_visual_question_answering(tmpdir): image_dest_folder = os.path.join(tmpdir, "generated_images") input_features = [ image_feature( folder=image_dest_folder, preprocessing={"in_memory": True, "height": 32, "width": 32, "num_channels": 3, "num_processes": 5}, encoder={ "type": "stacked_cnn", }, ), text_feature(encoder={"type": "embed", "min_len": 1}), ] output_features = [sequence_feature(decoder={"type": "generator", "cell_type": "lstm"})] rel_path = generate_data(input_features, output_features, os.path.join(tmpdir, "dataset.csv")) run_experiment(input_features, output_features, dataset=rel_path) def test_image_resizing_num_channel_handling(tmpdir): """This test creates two image datasets with 3 channels and 1 channel. The combination of this data is used to train a model. This checks the cases where the user may or may not specify a number of channels in the config. :param csv_filename: :return: """ # Image Inputs image_dest_folder = os.path.join(tmpdir, "generated_images") # Resnet encoder input_features = [ image_feature( folder=image_dest_folder, preprocessing={"in_memory": True, "height": 32, "width": 32, "num_channels": 3, "num_processes": 5}, encoder={ "type": "stacked_cnn", }, ), text_feature(encoder={"type": "embed", "min_len": 1}), number_feature(normalization="minmax"), ] output_features = [binary_feature(), number_feature()] rel_path = generate_data(input_features, output_features, os.path.join(tmpdir, "dataset1.csv"), num_examples=20) df1 = read_csv(rel_path) input_features[0]["preprocessing"]["num_channels"] = 1 rel_path = generate_data(input_features, output_features, os.path.join(tmpdir, "dataset2.csv"), num_examples=20) df2 = read_csv(rel_path) df = concatenate_df(df1, df2, None, LOCAL_BACKEND) df.to_csv(rel_path, index=False) # Here the user specifies number of channels. Exception shouldn't be thrown run_experiment(input_features, output_features, dataset=rel_path) del input_features[0]["preprocessing"]["num_channels"] # User doesn't specify num channels, but num channels is inferred. Exception shouldn't be thrown run_experiment(input_features, output_features, dataset=rel_path) @pytest.mark.parametrize("encoder", ["wave", "embed"]) def test_experiment_date(encoder, csv_filename): input_features = [date_feature()] output_features = [category_feature(decoder={"vocab_size": 2})] # Generate test data rel_path = generate_data(input_features, output_features, csv_filename) input_features[0][ENCODER] = {TYPE: encoder} run_experiment(input_features, output_features, dataset=rel_path) @pytest.mark.parametrize("encoder", get_encoder_classes(H3).keys()) def test_experiment_h3(encoder, csv_filename): input_features = [h3_feature()] output_features = [binary_feature()] # Generate test data rel_path = generate_data(input_features, output_features, csv_filename) input_features[0][ENCODER] = {TYPE: encoder} run_experiment(input_features, output_features, dataset=rel_path) def test_experiment_vector_feature(csv_filename): input_features = [vector_feature()] output_features = [binary_feature()] # Generate test data rel_path = generate_data(input_features, output_features, csv_filename) run_experiment(input_features, output_features, dataset=rel_path) def test_experiment_vector_feature_infer_size(csv_filename): input_features = [vector_feature()] output_features = [vector_feature()] # Generate test data rel_path = generate_data(input_features, output_features, csv_filename) # Unset vector_size so it needs to be inferred del input_features[0][PREPROCESSING] del output_features[0][PREPROCESSING] run_experiment(input_features, output_features, dataset=rel_path) @pytest.mark.parametrize("encoder", ["parallel_cnn", "dense", "passthrough"]) def test_forecasting_row_major(csv_filename, encoder): input_features = [timeseries_feature(encoder={"type": encoder})] output_features = [timeseries_feature(decoder={"type": "projector"})] config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat", "output_size": 14, "flatten_inputs": True}, TRAINER: {"epochs": 2, BATCH_SIZE: 128}, } # Generate test data rel_path = generate_data(input_features, output_features, csv_filename) run_experiment(input_features, output_features, config=config, dataset=rel_path) def test_forecasting_column_major(csv_filename): input_feature = timeseries_feature(preprocessing={"window_size": 3}) input_features = [input_feature] # Ensure output feature has the same column and the input feature output_feature = timeseries_feature( name=input_feature[COLUMN], preprocessing={"horizon": 2}, decoder={"type": "projector"} ) output_feature[NAME] = f"{input_feature[NAME]}_out" output_features = [output_feature] # Generate test data in column-major format. This is just a dataframe of numbers with the same column name # as expected by the timeseries input feature column_major_feature = number_feature(name=input_feature[COLUMN]) csv_filename = generate_data([column_major_feature], [], csv_filename) input_df = pd.read_csv(csv_filename) model, eval_stats, train_stats, preprocessed_data, output_directory = run_experiment( input_features, output_features, dataset=csv_filename ) train_set, val_set, test_set, _ = preprocessed_data print(input_df) # print(train_set.to_df()) horizon_df = model.forecast(input_df, horizon=5) print(horizon_df) @pytest.mark.parametrize("reduce_output", [("sum"), (None)], ids=["sum", "none"]) def test_experiment_text_output_feature_with_tagger_decoder(csv_filename, reduce_output): """Test that the tagger decoder works with text output features when reduce_output is set to None.""" input_features = [text_feature(encoder={"type": "parallel_cnn", "reduce_output": reduce_output})] output_features = [text_feature(output_feature=True, decoder={"type": "tagger"})] # Generate test data rel_path = generate_data(input_features, output_features, csv_filename) with pytest.raises(ConfigValidationError) if reduce_output == "sum" else contextlib.nullcontext(): run_experiment(input_features, output_features, dataset=rel_path) @pytest.mark.parametrize("reduce_output", [("sum"), (None)], ids=["sum", "none"]) def test_experiment_sequence_output_feature_with_tagger_decoder(csv_filename, reduce_output): """Test that the tagger decoder works with sequence output features when reduce_output is set to None.""" input_features = [text_feature(encoder={"type": "parallel_cnn", "reduce_output": reduce_output})] output_features = [sequence_feature(output_feature=True, decoder={"type": "tagger"})] # Generate test data rel_path = generate_data(input_features, output_features, csv_filename) with pytest.raises(ConfigValidationError) if reduce_output == "sum" else contextlib.nullcontext(): run_experiment(input_features, output_features, dataset=rel_path) def test_experiment_category_input_feature_with_tagger_decoder(csv_filename): """Test that the tagger decoder doesn't work with category input features.""" input_features = [category_feature()] output_features = [sequence_feature(output_feature=True, decoder={"type": "tagger"})] config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat", "output_size": 14, "reduce_output": None}, } # Generate test data rel_path = generate_data(input_features, output_features, csv_filename) with pytest.raises(ConfigValidationError): run_experiment(config=config, dataset=rel_path) def test_experiment_category_distribution_feature(csv_filename): vocab = ["a", "b", "c"] input_features = [vector_feature()] output_features = [ category_distribution_feature( preprocessing={ "vocab": vocab, } ) ] # Generate test data rel_path = generate_data(input_features, output_features, csv_filename) input_df = pd.read_csv(rel_path) # set batch_size=auto to ensure we produce the correct shaped synthetic data config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat", "output_size": 14}, TRAINER: {"epochs": 2, BATCH_SIZE: "auto"}, } model, _, _, _, _ = run_experiment(input_features, output_features, dataset=rel_path, config=config) preds, _ = model.predict(input_df) # Check that predictions are category values drawn from the vocab, not distributions assert all(v in vocab for v in preds[f"{output_features[0][NAME]}_predictions"].values) def test_experiment_ordinal_category(csv_filename): input_features = [category_feature(num_classes=5), number_feature()] output_features = [category_feature(output_feature=True, loss={"type": "corn"})] rel_path = generate_data(input_features, output_features, csv_filename) run_experiment(input_features, output_features, dataset=rel_path) def test_experiment_feature_names_with_non_word_chars(tmpdir): config = yaml.safe_load(""" input_features: - name: Pclass (new) type: category - name: review.text type: category - name: other_feature type: category tied: review.text output_features: - name: Survived (new) type: binary - name: Thrived type: binary dependencies: - Survived (new) combiner: type: comparator entity_1: - Pclass (new) - other_feature entity_2: - review.text """) df = build_synthetic_dataset_df(120, config) model = LudwigModel(config, logging_level=logging.INFO) model.train(dataset=df, output_directory=tmpdir) def test_text_output_feature_cols(tmpdir, csv_filename): """Test ensures that there are 4 output columns when model.predict() is called for text output features.""" input_features = [text_feature(encoder={"type": "parallel_cnn"})] output_features = [text_feature(output_feature=True)] # Generate test data rel_path = generate_data(input_features, output_features, os.path.join(tmpdir, csv_filename)) config = { "input_features": input_features, "output_features": output_features, "trainer": {"train_steps": 2, "batch_size": 5}, } model = LudwigModel(config, logging_level=logging.INFO) model.train(dataset=rel_path, output_directory=tmpdir) predict_output = model.predict(dataset=rel_path)[0] assert len(predict_output.columns) == 4 predict_df_headers = {col_name.split("_")[2] for col_name in list(predict_output.columns)} assert predict_df_headers == {"predictions", "probability", "probabilities", "response"} ================================================ FILE: tests/integration_tests/test_explain.py ================================================ import logging import os import numpy as np import pandas as pd import pytest from ludwig.api import LudwigModel from ludwig.constants import BATCH_SIZE, BINARY, CATEGORY, MINIMUM_BATCH_SIZE, MODEL_ECD, TYPE from ludwig.explain.captum import IntegratedGradientsExplainer from ludwig.explain.explainer import Explainer from ludwig.explain.explanation import Explanation from tests.integration_tests.utils import ( binary_feature, category_feature, date_feature, generate_data, image_feature, LocalTestBackend, number_feature, sequence_feature, set_feature, text_feature, timeseries_feature, vector_feature, ) try: from ludwig.explain.captum_ray import RayIntegratedGradientsExplainer except ImportError: RayIntegratedGradientsExplainer = None pytestmark = pytest.mark.integration_tests_d def test_explanation_dataclass(): explanation = Explanation(target="target") feature_attributions_for_label_1 = np.array([1, 2, 3]) feature_attributions_for_label_2 = np.array([4, 5, 6]) # test add() explanation.add(["f1", "f2", "f3"], feature_attributions_for_label_1) with pytest.raises(AssertionError, match="Expected feature attributions of shape"): # test add() with wrong shape explanation.add(["f1", "f2", "f3", "f4"], np.array([1, 2, 3, 4])) explanation.add(["f1", "f2", "f3"], feature_attributions_for_label_2) # test to_array() explanation_array = explanation.to_array() assert np.array_equal(explanation_array, [[1, 2, 3], [4, 5, 6]]) def test_abstract_explainer_instantiation(): with pytest.raises(TypeError, match="Can't instantiate abstract class Explainer"): Explainer(None, inputs_df=None, sample_df=None, target=None) @pytest.mark.parametrize( "explainer_class, model_type", [ (IntegratedGradientsExplainer, MODEL_ECD), ], ) @pytest.mark.parametrize( "output_feature", [binary_feature(), number_feature(), category_feature(decoder={"vocab_size": 3})], ids=["binary", "number", "category"], ) @pytest.mark.parametrize( "additional_config", [ pytest.param({}, id="default"), pytest.param({"preprocessing": {"split": {"type": "fixed", "column": "split"}}}, id="fixed_split"), ], ) def test_explainer_api(explainer_class, model_type, output_feature, additional_config, tmpdir): run_test_explainer_api(explainer_class, model_type, [output_feature], additional_config, tmpdir) @pytest.mark.distributed @pytest.mark.parametrize( "output_feature", [binary_feature(), number_feature(), category_feature(decoder={"vocab_size": 3})], ids=["binary", "number", "category"], ) def test_explainer_api_ray(output_feature, tmpdir, ray_cluster_2cpu): from ludwig.explain.captum_ray import RayIntegratedGradientsExplainer run_test_explainer_api( RayIntegratedGradientsExplainer, "ecd", [output_feature], {}, tmpdir, resources_per_task={"num_cpus": 1}, num_workers=1, ) @pytest.mark.slow @pytest.mark.distributed def test_explainer_api_ray_minimum_batch_size(tmpdir, ray_cluster_2cpu): from ludwig.explain.captum_ray import RayIntegratedGradientsExplainer run_test_explainer_api( RayIntegratedGradientsExplainer, "ecd", [binary_feature()], {}, tmpdir, resources_per_task={"num_cpus": 1}, num_workers=1, batch_size=MINIMUM_BATCH_SIZE, ) @pytest.mark.flaky(reruns=2, reruns_delay=5) @pytest.mark.parametrize("cache_encoder_embeddings", [True]) @pytest.mark.parametrize( "explainer_class,model_type", [ pytest.param(IntegratedGradientsExplainer, MODEL_ECD, id="ecd_local"), pytest.param(RayIntegratedGradientsExplainer, MODEL_ECD, id="ecd_ray", marks=pytest.mark.distributed), ], ) def test_explainer_text_hf(explainer_class, model_type, cache_encoder_embeddings, tmpdir, ray_cluster_2cpu): input_features = [ text_feature( encoder={ "type": "auto_transformer", "pretrained_model_name_or_path": "hf-internal-testing/tiny-bert-for-token-classification", }, preprocessing={"cache_encoder_embeddings": cache_encoder_embeddings}, ) ] run_test_explainer_api(explainer_class, model_type, [binary_feature()], {}, tmpdir, input_features=input_features) @pytest.mark.parametrize( "explainer_class,model_type", [ pytest.param(IntegratedGradientsExplainer, MODEL_ECD, id="ecd_local"), pytest.param(RayIntegratedGradientsExplainer, MODEL_ECD, id="ecd_ray", marks=pytest.mark.distributed), ], ) def test_explainer_text_tied_weights(explainer_class, model_type, tmpdir): text_feature_1 = text_feature() text_feature_2 = text_feature(tied=text_feature_1["name"]) input_features = [text_feature_1, text_feature_2] run_test_explainer_api(explainer_class, model_type, [binary_feature()], {}, tmpdir, input_features=input_features) def run_test_explainer_api( explainer_class, model_type, output_features, additional_config, tmpdir, input_features=None, batch_size=128, **kwargs ): image_dest_folder = os.path.join(tmpdir, "generated_images") if input_features is None: input_features = [ # Include a non-canonical name that's not a valid key for a vanilla pytorch ModuleDict: # https://github.com/pytorch/pytorch/issues/71203 {"name": "type", "type": "binary"}, number_feature(), category_feature(encoder={TYPE: "onehot", "reduce_output": "sum"}), category_feature(encoder={TYPE: "passthrough", "reduce_output": "sum"}), ] # TODO(travis): need unit tests to test the get_embedding_layer() of every encoder to ensure it is # compatible with the explainer input_features += [ category_feature(encoder={"type": "dense", "reduce_output": "sum"}), text_feature(encoder={"vocab_size": 3}), vector_feature(), timeseries_feature(), image_feature(folder=image_dest_folder), # audio_feature(os.path.join(tmpdir, "generated_audio")), # NOTE: works but takes a long time # sequence_feature(encoder={"vocab_size": 3}), date_feature(), # h3_feature(), set_feature(encoder={"vocab_size": 3}), # bag_feature(encoder={"vocab_size": 3}), ] # Generate data csv_filename = os.path.join(tmpdir, "training.csv") generate_data(input_features, output_features, csv_filename, num_examples=20) df = pd.read_csv(csv_filename) if "split" in additional_config.get("preprocessing", {}): df["split"] = np.random.randint(0, 3, df.shape[0]) # Train model config = {"input_features": input_features, "output_features": output_features, "model_type": model_type} config["trainer"] = {"train_steps": 1, BATCH_SIZE: batch_size} config.update(additional_config) model = LudwigModel(config, logging_level=logging.WARNING, backend=LocalTestBackend()) model.train(df) # Explain model explainer = explainer_class(model, inputs_df=df, sample_df=df, target=output_features[0]["name"], **kwargs) is_binary = output_features[0].get("type") == BINARY is_category = output_features[0].get("type") == CATEGORY vocab_size = 1 if is_binary: vocab_size = 2 elif is_category: vocab_size = output_features[0].get("decoder", {}).get("vocab_size") assert explainer.is_binary_target == is_binary assert explainer.is_category_target == is_category assert explainer.vocab_size == vocab_size explanations_result = explainer.explain() # Verify shapes. assert explanations_result.global_explanation.to_array().shape == (vocab_size, len(input_features)) assert len(explanations_result.row_explanations) == len(df) for e in explanations_result.row_explanations: assert e.to_array().shape == (vocab_size, len(input_features)) assert len(explanations_result.expected_values) == vocab_size @pytest.mark.parametrize( "output_feature", [set_feature(decoder={"vocab_size": 3}), vector_feature()], ids=["set", "vector"], ) def test_explainer_api_nonscalar_outputs(output_feature, tmpdir): run_test_explainer_api(IntegratedGradientsExplainer, MODEL_ECD, [output_feature], {}, tmpdir) def test_explainer_api_text_outputs(tmpdir): input_features = [text_feature(encoder={"type": "parallel_cnn", "reduce_output": None})] output_features = [text_feature(output_feature=True, decoder={"type": "tagger"})] run_test_explainer_api( IntegratedGradientsExplainer, MODEL_ECD, output_features, {}, tmpdir, input_features=input_features ) @pytest.mark.parametrize( "explainer_class,model_type", [ pytest.param(IntegratedGradientsExplainer, MODEL_ECD, id="ecd_local"), pytest.param(RayIntegratedGradientsExplainer, MODEL_ECD, id="ecd_ray", marks=pytest.mark.distributed), ], ) @pytest.mark.parametrize("encoder_type", ["embed", "rnn", "transformer"]) def test_explainer_sequence_feature(explainer_class, model_type, encoder_type, tmpdir): input_features = [sequence_feature()] input_features[0]["encoder"] = {"type": encoder_type} output_features = [binary_feature()] run_test_explainer_api(explainer_class, model_type, output_features, {}, tmpdir, input_features=input_features) ================================================ FILE: tests/integration_tests/test_graph_execution.py ================================================ # Copyright (c) 2023 Predibase, Inc., 2020 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. # ============================================================================== import pytest from tests.integration_tests.utils import ( category_feature, generate_data, generate_output_features_with_dependencies, number_feature, run_experiment, sequence_feature, set_feature, text_feature, ) @pytest.mark.parametrize( "output_features", [ # baseline test case [ category_feature(decoder={"vocab_size": 2}, reduce_input="sum"), sequence_feature(decoder={"vocab_size": 10, "max_len": 5}), number_feature(), ], # use generator as decoder [ category_feature(decoder={"vocab_size": 2}, reduce_input="sum"), sequence_feature(decoder={"vocab_size": 10, "max_len": 5, "type": "generator"}), number_feature(), ], # Generator decoder and reduce_input = None [ category_feature(decoder={"vocab_size": 2}, reduce_input="sum"), sequence_feature(decoder={"max_len": 5, "type": "generator"}, reduce_input=None), number_feature(normalization="minmax"), ], # output features with dependencies single dependency generate_output_features_with_dependencies("number_feature", ["category_feature"]), # output features with dependencies multiple dependencies generate_output_features_with_dependencies("number_feature", ["category_feature", "sequence_feature"]), ], ) def test_experiment_multiple_seq_seq(csv_filename, output_features): input_features = [ text_feature(encoder={"vocab_size": 100, "min_len": 1, "type": "stacked_cnn"}), number_feature(normalization="zscore"), category_feature(encoder={"vocab_size": 10, "embedding_size": 5}), set_feature(), sequence_feature(encoder={"vocab_size": 10, "max_len": 10, "type": "embed"}), ] output_features = output_features rel_path = generate_data(input_features, output_features, csv_filename) run_experiment(input_features, output_features, dataset=rel_path) ================================================ FILE: tests/integration_tests/test_hyperopt.py ================================================ # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import json import os import os.path import uuid import pytest from ludwig.backend import initialize_backend from ludwig.constants import ( ACCURACY, AUTO, BATCH_SIZE, CATEGORY, COMBINER, EXECUTOR, HYPEROPT, INPUT_FEATURES, MAX_CONCURRENT_TRIALS, MODEL_ECD, MODEL_TYPE, NAME, OUTPUT_FEATURES, RAY, TEXT, TRAINER, TYPE, VALIDATION, ) from ludwig.globals import HYPEROPT_STATISTICS_FILE_NAME, MODEL_FILE_NAME from ludwig.hyperopt.results import HyperoptResults from ludwig.hyperopt.run import hyperopt from ludwig.hyperopt.utils import update_hyperopt_params_with_defaults from ludwig.schema.model_config import ModelConfig from ludwig.utils import fs_utils from ludwig.utils.data_utils import load_json, use_credentials from tests.integration_tests.utils import category_feature, generate_data, minio_test_creds, remote_tmpdir, text_feature ray = pytest.importorskip("ray") from ludwig.hyperopt.execution import get_build_hyperopt_executor, RayTuneExecutor # noqa pytestmark = [pytest.mark.distributed, pytest.mark.integration_tests_a] RANDOM_SEARCH_SIZE = 2 HYPEROPT_CONFIG = { "parameters": { # using only float parameter as common in all search algorithms "trainer.learning_rate": {"space": "loguniform", "lower": 0.001, "upper": 0.1}, }, "goal": "minimize", "executor": {TYPE: "ray", "num_samples": 2, "scheduler": {TYPE: "fifo"}}, "search_alg": {TYPE: "variant_generator"}, } SEARCH_ALGS_FOR_TESTING = [ # None, # "variant_generator", "random", "bohb", # "hyperopt", # "ax", # "bayesopt", # "blendsearch", # "cfo", # "dragonfly", # "hebo", # "skopt", # "optuna", ] SCHEDULERS_FOR_TESTING = [ "fifo", "asynchyperband", # "async_hyperband", # "median_stopping_rule", # "medianstopping", # "hyperband", # "hb_bohb", # "pbt", # "pb2", commented out for now: https://github.com/ray-project/ray/issues/24815 # "resource_changing", ] def _setup_ludwig_config(dataset_fp: str, model_type: str = MODEL_ECD) -> tuple[dict, str]: input_features = [category_feature(encoder={"vocab_size": 3})] output_features = [category_feature(decoder={"vocab_size": 3})] rel_path = generate_data(input_features, output_features, dataset_fp, num_examples=6) trainer_cfg = {"learning_rate": 0.001} if model_type == MODEL_ECD: trainer_cfg["epochs"] = 2 else: trainer_cfg["num_boost_round"] = 2 # Disable feature filtering to avoid having no features due to small test dataset, # see https://stackoverflow.com/a/66405983/5222402 trainer_cfg["feature_pre_filter"] = False config = { MODEL_TYPE: model_type, INPUT_FEATURES: input_features, OUTPUT_FEATURES: output_features, COMBINER: {TYPE: "concat"}, TRAINER: trainer_cfg, } config = ModelConfig.from_dict(config).to_dict() return config, rel_path @pytest.mark.parametrize("search_alg", SEARCH_ALGS_FOR_TESTING) @pytest.mark.parametrize("model_type", [MODEL_ECD]) def test_hyperopt_search_alg( search_alg, model_type, csv_filename, tmpdir, ray_cluster_7cpu, validate_output_feature=False, validation_metric=None, split="validation", ): config, rel_path = _setup_ludwig_config(csv_filename, model_type) hyperopt_config = HYPEROPT_CONFIG.copy() # finalize hyperopt config settings if search_alg == "dragonfly": hyperopt_config["search_alg"] = { TYPE: search_alg, "domain": "euclidean", "optimizer": "random", } elif search_alg is None: hyperopt_config["search_alg"] = {} else: hyperopt_config["search_alg"] = { TYPE: search_alg, } if validate_output_feature: hyperopt_config["output_feature"] = config[OUTPUT_FEATURES][0][NAME] if validation_metric: hyperopt_config["validation_metric"] = validation_metric update_hyperopt_params_with_defaults(hyperopt_config) backend = initialize_backend("local") if hyperopt_config[EXECUTOR].get(MAX_CONCURRENT_TRIALS) == AUTO: hyperopt_config[EXECUTOR][MAX_CONCURRENT_TRIALS] = backend.max_concurrent_trials(hyperopt_config) parameters = hyperopt_config["parameters"] output_feature = hyperopt_config["output_feature"] metric = hyperopt_config["metric"] goal = hyperopt_config["goal"] executor = hyperopt_config["executor"] search_alg = hyperopt_config["search_alg"] hyperopt_executor = get_build_hyperopt_executor(RAY)( parameters, output_feature, metric, goal, split, search_alg=search_alg, **executor ) results = hyperopt_executor.execute(config, dataset=rel_path, output_directory=tmpdir) assert isinstance(results, HyperoptResults) with hyperopt_executor._get_best_model_path( results.experiment_analysis.best_trial, results.experiment_analysis ) as path: assert path is not None assert isinstance(path, str) @pytest.mark.parametrize("model_type", [MODEL_ECD]) def test_hyperopt_executor_with_metric(model_type, csv_filename, tmpdir, ray_cluster_7cpu): test_hyperopt_search_alg( "variant_generator", model_type, csv_filename, tmpdir, ray_cluster_7cpu, validate_output_feature=True, validation_metric=ACCURACY, ) @pytest.mark.parametrize("split", [VALIDATION]) def test_hyperopt_with_split(split, csv_filename, tmpdir, ray_cluster_7cpu): test_hyperopt_search_alg( search_alg="variant_generator", model_type=MODEL_ECD, csv_filename=csv_filename, tmpdir=tmpdir, ray_cluster_7cpu=ray_cluster_7cpu, split=split, ) @pytest.mark.parametrize("scheduler", SCHEDULERS_FOR_TESTING) @pytest.mark.parametrize("model_type", [MODEL_ECD]) def test_hyperopt_scheduler( scheduler, model_type, csv_filename, tmpdir, ray_cluster_7cpu, validate_output_feature=False, validation_metric=None ): config, rel_path = _setup_ludwig_config(csv_filename, model_type) hyperopt_config = HYPEROPT_CONFIG.copy() # finalize hyperopt config settings if scheduler == "pb2": # setup scheduler hyperparam_bounds parameter min = hyperopt_config["parameters"]["trainer.learning_rate"]["lower"] max = hyperopt_config["parameters"]["trainer.learning_rate"]["upper"] hyperparam_bounds = { "trainer.learning_rate": [min, max], } hyperopt_config["executor"]["scheduler"] = { TYPE: scheduler, "hyperparam_bounds": hyperparam_bounds, } else: hyperopt_config["executor"]["scheduler"] = { TYPE: scheduler, } if validate_output_feature: hyperopt_config["output_feature"] = config[OUTPUT_FEATURES][0][NAME] if validation_metric: hyperopt_config["validation_metric"] = validation_metric backend = initialize_backend("local") update_hyperopt_params_with_defaults(hyperopt_config) if hyperopt_config[EXECUTOR].get(MAX_CONCURRENT_TRIALS) == AUTO: hyperopt_config[EXECUTOR][MAX_CONCURRENT_TRIALS] = backend.max_concurrent_trials(hyperopt_config) parameters = hyperopt_config["parameters"] split = hyperopt_config["split"] output_feature = hyperopt_config["output_feature"] metric = hyperopt_config["metric"] goal = hyperopt_config["goal"] executor = hyperopt_config["executor"] search_alg = hyperopt_config["search_alg"] # TODO: Determine if we still need this if-then-else construct if search_alg[TYPE] in {""}: with pytest.raises(ImportError): get_build_hyperopt_executor(RAY)( parameters, output_feature, metric, goal, split, search_alg=search_alg, **executor ) else: hyperopt_executor = get_build_hyperopt_executor(RAY)( parameters, output_feature, metric, goal, split, search_alg=search_alg, **executor ) raytune_results = hyperopt_executor.execute(config, dataset=rel_path, output_directory=tmpdir) assert isinstance(raytune_results, HyperoptResults) def _run_hyperopt_run_hyperopt(csv_filename, search_space, tmpdir, backend, ray_cluster_7cpu): input_features = [category_feature(encoder={"vocab_size": 3})] output_features = [category_feature(decoder={"vocab_size": 3})] rel_path = generate_data(input_features, output_features, csv_filename, num_examples=6) config = { INPUT_FEATURES: input_features, OUTPUT_FEATURES: output_features, COMBINER: {TYPE: "concat"}, TRAINER: {"epochs": 1, "learning_rate": 0.001, BATCH_SIZE: 128}, "backend": backend, } output_feature_name = output_features[0][NAME] if search_space == "random": # random search will be size of num_samples search_parameters = { "trainer.learning_rate": { "lower": 0.0001, "upper": 0.01, "space": "loguniform", }, output_feature_name + ".decoder.fc_layers": { "space": "choice", "categories": [ [{"output_size": 8}, {"output_size": 4}], [{"output_size": 8}], [{"output_size": 4}], ], }, output_feature_name + ".decoder.fc_output_size": {"space": "choice", "categories": [4, 8, 12]}, } else: # grid search space will be product each parameter size search_parameters = { "trainer.learning_rate": {"space": "grid_search", "values": [0.001, 0.01]}, output_feature_name + ".decoder.fc_output_size": {"space": "grid_search", "values": [4, 8]}, } hyperopt_configs = { "parameters": search_parameters, "goal": "minimize", "output_feature": output_feature_name, "validation_metrics": "loss", "executor": { TYPE: "ray", "num_samples": 1 if search_space == "grid" else RANDOM_SEARCH_SIZE, "max_concurrent_trials": 1, }, "search_alg": {TYPE: "variant_generator"}, } # add hyperopt parameter space to the config config[HYPEROPT] = hyperopt_configs experiment_name = f"test_hyperopt_{uuid.uuid4().hex}" hyperopt_results = hyperopt(config, dataset=rel_path, output_directory=tmpdir, experiment_name=experiment_name) if search_space == "random": assert hyperopt_results.experiment_analysis.results_df.shape[0] == RANDOM_SEARCH_SIZE else: # compute size of search space for grid search grid_search_size = 1 for k, v in search_parameters.items(): grid_search_size *= len(v["values"]) assert hyperopt_results.experiment_analysis.results_df.shape[0] == grid_search_size # check for return results assert isinstance(hyperopt_results, HyperoptResults) # check for existence of the hyperopt statistics file with use_credentials(minio_test_creds()): assert fs_utils.path_exists(os.path.join(tmpdir, experiment_name, HYPEROPT_STATISTICS_FILE_NAME)) for trial in hyperopt_results.experiment_analysis.trials: assert fs_utils.path_exists( os.path.join(tmpdir, experiment_name, f"trial_{trial.trial_id}"), ) # Verify best trial has a valid checkpoint best_trial = hyperopt_results.experiment_analysis.best_trial assert best_trial is not None @pytest.mark.slow @pytest.mark.parametrize("search_space", ["random", "grid"]) def test_hyperopt_run_hyperopt(csv_filename, search_space, tmpdir, ray_cluster_7cpu): _run_hyperopt_run_hyperopt(csv_filename, search_space, tmpdir, "local", ray_cluster_7cpu) @pytest.mark.xfail( reason="PyArrow S3 C++ client uses chunked transfer encoding for multipart uploads, " "which MinIO rejects with HTTP 411 MissingContentLength. Requires real AWS S3.", strict=False, ) def test_hyperopt_sync_remote(csv_filename, ray_cluster_7cpu, monkeypatch): """Test hyperopt with remote S3 (MinIO) storage for trial results.""" # Override AWS env vars so PyArrow's S3 client (used by Ray Tune internally) # connects to MinIO instead of real AWS S3 minio_endpoint = os.environ.get("LUDWIG_MINIO_ENDPOINT", "http://localhost:9000") monkeypatch.setenv("AWS_ACCESS_KEY_ID", os.environ.get("LUDWIG_MINIO_ACCESS_KEY", "minio")) monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", os.environ.get("LUDWIG_MINIO_SECRET_KEY", "minio123")) monkeypatch.setenv("AWS_ENDPOINT_URL", minio_endpoint) monkeypatch.setenv("AWS_EC2_METADATA_DISABLED", "true") backend = { "type": "local", "credentials": { "artifacts": minio_test_creds(), }, } with remote_tmpdir("s3", "test") as tmpdir: _run_hyperopt_run_hyperopt( csv_filename, "random", tmpdir, backend, ray_cluster_7cpu, ) def test_hyperopt_with_feature_specific_parameters(csv_filename, tmpdir, ray_cluster_7cpu): input_features = [ text_feature(name="utterance", reduce_output="sum"), category_feature(vocab_size=3), ] output_features = [category_feature(vocab_size=3, output_feature=True)] rel_path = generate_data(input_features, output_features, csv_filename, num_examples=6) filter_size_search_space = [5, 7] embedding_size_search_space = [4, 8, 12] config = { INPUT_FEATURES: input_features, OUTPUT_FEATURES: output_features, COMBINER: {TYPE: "concat", "num_fc_layers": 2}, TRAINER: {"epochs": 1, "learning_rate": 0.001, BATCH_SIZE: 128}, HYPEROPT: { "parameters": { input_features[0][NAME] + ".encoder.filter_size": {"space": "choice", "categories": filter_size_search_space}, input_features[1][NAME] + ".encoder.embedding_size": {"space": "choice", "categories": embedding_size_search_space}, }, "goal": "minimize", "output_feature": output_features[0][NAME], "validation_metrics": "loss", "executor": {TYPE: "ray", "num_samples": 1}, "search_alg": {TYPE: "variant_generator"}, }, } hyperopt_results = hyperopt(config, dataset=rel_path, output_directory=tmpdir, experiment_name="test_hyperopt") hyperopt_results_df = hyperopt_results.experiment_analysis.results_df model_parameters = json.load( open( os.path.join( hyperopt_results_df.iloc[0]["trial_dir"], "test_hyperopt_run", MODEL_FILE_NAME, "model_hyperparameters.json", ) ) ) for input_feature in model_parameters[INPUT_FEATURES]: if input_feature[TYPE] == TEXT: assert input_feature["encoder"]["filter_size"] in filter_size_search_space elif input_feature[TYPE] == CATEGORY: assert input_feature["encoder"]["embedding_size"] in embedding_size_search_space def test_hyperopt_old_config(csv_filename, tmpdir, ray_cluster_7cpu): old_config = { "ludwig_version": "0.4", INPUT_FEATURES: [ {"name": "cat1", TYPE: "category", "encoder": {"vocab_size": 2}}, {"name": "num1", TYPE: "number"}, ], OUTPUT_FEATURES: [ {"name": "bin1", TYPE: "binary"}, ], TRAINER: {"epochs": 2, BATCH_SIZE: 128}, HYPEROPT: { EXECUTOR: { TYPE: "ray", "time_budget_s": 200, "cpu_resources_per_trial": 1, }, "sampler": { TYPE: "ray", "scheduler": { TYPE: "async_hyperband", "max_t": 200, "time_attr": "time_total_s", "grace_period": 72, "reduction_factor": 5, }, "search_alg": { TYPE: "variant_generator", }, "num_samples": 2, }, "parameters": { "trainer.batch_size": { "space": "choice", "categories": [64, 128, 256], }, "trainer.learning_rate": { "space": "loguniform", "lower": 0.001, "upper": 0.1, }, }, }, } input_features = old_config[INPUT_FEATURES] output_features = old_config[OUTPUT_FEATURES] rel_path = generate_data(input_features, output_features, csv_filename, num_examples=6) hyperopt(old_config, dataset=rel_path, output_directory=tmpdir, experiment_name="test_hyperopt") def test_hyperopt_nested_parameters(csv_filename, tmpdir, ray_cluster_7cpu): config = { INPUT_FEATURES: [ {"name": "cat1", TYPE: "category", "encoder": {"vocab_size": 2}}, {"name": "num1", TYPE: "number"}, ], OUTPUT_FEATURES: [ {"name": "bin1", TYPE: "binary"}, ], TRAINER: {"epochs": 2, BATCH_SIZE: 128}, HYPEROPT: { EXECUTOR: { TYPE: "ray", "time_budget_s": 200, "cpu_resources_per_trial": 1, "num_samples": 2, "scheduler": {TYPE: "fifo"}, }, "search_alg": {TYPE: "variant_generator"}, "parameters": { ".": { "space": "choice", "categories": [ { "combiner": { "type": "tabnet", "bn_virtual_bs": 32, }, "trainer": { "learning_rate_scaling": "sqrt", "learning_rate_scheduler": { "decay": "exponential", "decay_steps": 20000, "decay_rate": 0.8, }, "optimizer": {"type": "adam"}, }, }, { "combiner": {"type": "concat"}, "trainer": {"learning_rate_scaling": "linear"}, }, ], }, "trainer.learning_rate": {"space": "choice", "categories": [0.7, 0.42]}, }, }, } input_features = config[INPUT_FEATURES] output_features = config[OUTPUT_FEATURES] rel_path = generate_data(input_features, output_features, csv_filename, num_examples=6) results = hyperopt( config, dataset=rel_path, output_directory=tmpdir, experiment_name="test_hyperopt_nested_params", ) results_df = results.experiment_analysis.results_df assert len(results_df) == 2 for _, trial_meta in results_df.iterrows(): trial_dir = trial_meta["trial_dir"] trial_config = load_json( os.path.join(trial_dir, "test_hyperopt_nested_params_run", MODEL_FILE_NAME, "model_hyperparameters.json") ) assert len(trial_config[INPUT_FEATURES]) == len(config[INPUT_FEATURES]) assert len(trial_config[OUTPUT_FEATURES]) == len(config[OUTPUT_FEATURES]) assert trial_config[COMBINER][TYPE] in {"tabnet", "concat"} if trial_config[COMBINER][TYPE] == "tabnet": assert trial_config[COMBINER]["bn_virtual_bs"] == 32 assert trial_config[TRAINER]["learning_rate_scaling"] == "sqrt" assert trial_config[TRAINER]["learning_rate_scheduler"]["decay"] == "exponential" assert trial_config[TRAINER]["learning_rate_scheduler"]["decay_steps"] == 20000 assert trial_config[TRAINER]["learning_rate_scheduler"]["decay_rate"] == 0.8 assert trial_config[TRAINER]["optimizer"]["type"] == "adam" else: assert trial_config[TRAINER]["learning_rate_scaling"] == "linear" assert trial_config[TRAINER]["learning_rate"] in {0.7, 0.42} @pytest.mark.slow def test_hyperopt_without_config_defaults(csv_filename, tmpdir, ray_cluster_7cpu): input_features = [category_feature(encoder={"vocab_size": 3})] output_features = [category_feature(decoder={"vocab_size": 3})] rel_path = generate_data(input_features, output_features, csv_filename, num_examples=6) config = { INPUT_FEATURES: input_features, OUTPUT_FEATURES: output_features, COMBINER: {TYPE: "concat"}, TRAINER: {"train_steps": 5, "learning_rate": 0.001, BATCH_SIZE: 128}, # Missing search_alg and executor, but should still work HYPEROPT: { "parameters": { "trainer.learning_rate": { "lower": 0.0001, "upper": 0.01, "space": "loguniform", } }, "goal": "minimize", "output_feature": output_features[0]["name"], "metric": "loss", "executor": {"type": "ray", "num_samples": 2}, }, } experiment_name = f"test_hyperopt_{uuid.uuid4().hex}" hyperopt_results = hyperopt(config, dataset=rel_path, output_directory=tmpdir, experiment_name=experiment_name) assert hyperopt_results.experiment_analysis.results_df.shape[0] == 2 @pytest.mark.slow def test_hyperopt_with_time_budget(csv_filename, tmpdir, ray_cluster_7cpu): """Tests that incomplete checkpoints created by RayTune when time budget is hit doesn't throw errors because of missing .tune_metadata files in the checkpoint directories.""" input_features = [text_feature()] output_features = [category_feature(output_feature=True)] rel_path = generate_data(input_features, output_features, csv_filename, num_examples=6) config = { INPUT_FEATURES: input_features, OUTPUT_FEATURES: output_features, COMBINER: {TYPE: "concat"}, HYPEROPT: { "goal": "minimize", "metric": "loss", "output_feature": output_features[0]["name"], "search_alg": {TYPE: "variant_generator"}, "executor": { "type": "ray", # Ensure there is enough time for some trials to start and also for some to terminate # to reproduce the exact issue of missing .tune_metadata files. "time_budget_s": 30, "cpu_resources_per_trial": 1, "num_samples": 4, "scheduler": {TYPE: "fifo"}, }, "parameters": { "trainer.learning_rate": { "lower": 0.0001, "upper": 0.01, "space": "loguniform", } }, }, } experiment_name = f"test_hyperopt_{uuid.uuid4().hex}" hyperopt(config, dataset=rel_path, output_directory=tmpdir, experiment_name=experiment_name) ================================================ FILE: tests/integration_tests/test_hyperopt_ray.py ================================================ # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import json import logging import os.path import mlflow import pandas as pd import pytest from mlflow.tracking import MlflowClient from ludwig.backend import initialize_backend from ludwig.callbacks import Callback from ludwig.constants import ACCURACY, AUTO, BATCH_SIZE, EXECUTOR, MAX_CONCURRENT_TRIALS, TRAINER from ludwig.contribs.mlflow import MlflowCallback from ludwig.globals import HYPEROPT_STATISTICS_FILE_NAME, MODEL_FILE_NAME, MODEL_HYPERPARAMETERS_FILE_NAME from ludwig.hyperopt.results import HyperoptResults from ludwig.hyperopt.run import hyperopt from ludwig.hyperopt.utils import update_hyperopt_params_with_defaults from ludwig.schema.model_config import ModelConfig from ludwig.utils.automl.utils import get_model_type from tests.integration_tests.utils import category_feature, generate_data, text_feature try: import ray from ray.tune import Callback as TuneCallback from ray.tune.experiment.trial import Trial from ludwig.hyperopt.execution import get_build_hyperopt_executor except ImportError: ray = None Trial = None TuneCallback = object # needed to set up HyperoptTestCallback when not distributed pytestmark = pytest.mark.integration_tests_d logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) logging.getLogger("ludwig").setLevel(logging.INFO) HYPEROPT_CONFIG = { "parameters": { "trainer.learning_rate": { "space": "loguniform", "lower": 0.001, "upper": 0.1, }, "combiner.num_fc_layers": {"space": "randint", "lower": 0, "upper": 2}, "utterance.encoder.norm": {"space": "grid_search", "values": ["layer", "batch"]}, "utterance.encoder.fc_layers": { "space": "choice", "categories": [ [{"output_size": 16}, {"output_size": 8}], [{"output_size": 16}], [{"output_size": 8}], ], }, }, "goal": "minimize", } SCENARIOS = [ {"executor": {"type": "ray"}, "search_alg": {"type": "variant_generator"}}, {"executor": {"type": "ray", "num_samples": 2}, "search_alg": {"type": "variant_generator"}}, { "executor": { "type": "ray", "num_samples": 3, "scheduler": { "type": "hb_bohb", "time_attr": "training_iteration", "reduction_factor": 4, "max_t": 2, }, }, "search_alg": {"type": "bohb"}, }, ] def _get_config(search_alg: dict, executor: dict, epochs: int): input_features = [ text_feature(name="utterance", encoder={"cell_type": "lstm", "reduce_output": "sum"}), category_feature(encoder={"vocab_size": 2}, reduce_input="sum"), ] output_features = [category_feature(decoder={"vocab_size": 2}, reduce_input="sum", output_feature=True)] return { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat"}, TRAINER: {"epochs": epochs, "learning_rate": 0.001, BATCH_SIZE: 128}, "hyperopt": { **HYPEROPT_CONFIG, "executor": executor, "search_alg": search_alg, }, } class HyperoptTestCallback(TuneCallback): def __init__(self, exp_name: str, model_type: str): self.exp_name = exp_name self.model_type = model_type self.trial_ids = set() self.trial_status = {} self.user_config = {} self.rendered_config = {} def on_trial_start(self, iteration: int, trials: list["Trial"], trial: "Trial", **info): super().on_trial_start(iteration, trials, trial, **info) self.trial_ids.add(trial.trial_id) def on_trial_complete(self, iteration: int, trials: list["Trial"], trial: "Trial", **info): # noqa super().on_trial_complete(iteration, trials, trial, **info) self.trial_status[trial.trial_id] = trial.status model_hyperparameters = os.path.join( trial.local_path, f"{self.exp_name}_{self.model_type}", MODEL_FILE_NAME, MODEL_HYPERPARAMETERS_FILE_NAME ) if os.path.isfile(model_hyperparameters): try: with open(model_hyperparameters) as f: config = json.load(f) assert config, f"Trial {trial} rendered config was empty." self.rendered_config[trial.trial_id] = True except OSError: logging.exception("Could not load rendered config from trial logdir.") model_hyperparameters = os.path.join(trial.local_path, "trial_hyperparameters.json") if os.path.isfile(model_hyperparameters): try: with open(model_hyperparameters) as f: config = json.load(f) assert config, "Trial {trial} user config was empty." self.rendered_config[trial.trial_id] = True except OSError: logging.exception("Could not load rendered config from trial logdir.") def run_hyperopt_executor( search_alg, executor, epochs, csv_filename, tmpdir, validate_output_feature=False, validation_metric=None, use_split=True, ): config = _get_config(search_alg, executor, epochs) rel_path = generate_data(config["input_features"], config["output_features"], csv_filename) if not use_split: df = pd.read_csv(rel_path) df["split"] = 0 df.to_csv(rel_path) config = ModelConfig.from_dict(config).to_dict() hyperopt_config = config["hyperopt"] if validate_output_feature: hyperopt_config["output_feature"] = config["output_features"][0]["name"] if validation_metric: hyperopt_config["validation_metric"] = validation_metric backend = initialize_backend("local") update_hyperopt_params_with_defaults(hyperopt_config) if hyperopt_config[EXECUTOR].get(MAX_CONCURRENT_TRIALS) == AUTO: hyperopt_config[EXECUTOR][MAX_CONCURRENT_TRIALS] = backend.max_concurrent_trials(hyperopt_config) parameters = hyperopt_config["parameters"] if search_alg.get("type", "") == "bohb": # bohb does not support grid_search search space del parameters["utterance.encoder.norm"] hyperopt_config["parameters"] = parameters split = hyperopt_config["split"] output_feature = hyperopt_config["output_feature"] metric = hyperopt_config["metric"] goal = hyperopt_config["goal"] search_alg = hyperopt_config["search_alg"] executor = hyperopt_config["executor"] hyperopt_executor = get_build_hyperopt_executor(executor["type"])( parameters, output_feature, metric, goal, split, search_alg=search_alg, **executor ) hyperopt_executor.execute(config, dataset=rel_path, output_directory=tmpdir, backend=backend) @pytest.mark.slow @pytest.mark.distributed @pytest.mark.parametrize("scenario", SCENARIOS) def test_hyperopt_executor(scenario, csv_filename, tmpdir, ray_cluster_4cpu): search_alg = scenario["search_alg"] executor = scenario["executor"] scheduler = executor.get("scheduler", {}) if scheduler.get("type") == "hb_bohb": # When using the hb_bohb scheduler, num_epochs must equal max_t epochs = scheduler.get("max_t", 81) else: epochs = 1 run_hyperopt_executor(search_alg, executor, epochs, csv_filename, tmpdir) @pytest.mark.slow @pytest.mark.distributed @pytest.mark.parametrize("use_split", [True, False], ids=["split", "no_split"]) def test_hyperopt_executor_with_metric(use_split, csv_filename, tmpdir, ray_cluster_4cpu): run_hyperopt_executor( {"type": "variant_generator"}, # search_alg {"type": "ray", "num_samples": 2}, # executor 1, csv_filename, tmpdir, validate_output_feature=True, validation_metric=ACCURACY, use_split=use_split, ) @pytest.mark.distributed @pytest.mark.parametrize( "backend", [ "local", pytest.param("ray", marks=pytest.mark.xfail(reason="Nested Ray actors exceed 4-CPU CI cluster resources")), ], ) def test_hyperopt_run_hyperopt(csv_filename, backend, tmpdir, ray_cluster_4cpu): input_features = [ text_feature(name="utterance", encoder={"cell_type": "lstm", "reduce_output": "sum"}), category_feature(encoder={"vocab_size": 2}, reduce_input="sum"), ] output_features = [category_feature(decoder={"vocab_size": 2}, reduce_input="sum", output_feature=True)] rel_path = generate_data(input_features, output_features, csv_filename) config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat"}, TRAINER: {"train_steps": 3, "learning_rate": 0.001, BATCH_SIZE: 128}, "backend": { "type": backend, }, } output_feature_name = output_features[0]["name"] hyperopt_configs = { "parameters": { "trainer.learning_rate": { "space": "loguniform", "lower": 0.001, "upper": 0.1, }, output_feature_name + ".decoder.fc_output_size": {"space": "randint", "lower": 8, "upper": 16}, output_feature_name + ".decoder.num_fc_layers": {"space": "randint", "lower": 0, "upper": 1}, }, "goal": "minimize", "output_feature": output_feature_name, "validation_metrics": "loss", "executor": { "type": "ray", "num_samples": 2, "cpu_resources_per_trial": 1, "max_concurrent_trials": 1, }, "search_alg": {"type": "variant_generator"}, } @ray.remote(num_cpus=0) class Event: def __init__(self): self._set = False def is_set(self): return self._set def set(self): self._set = True # Used to trigger a cancel event in the trial, which should subsequently be retried event = Event.remote() class CancelCallback(Callback): def on_epoch_start(self, trainer, progress_tracker, save_path: str): if progress_tracker.epoch == 1 and not ray.get(event.is_set.remote()): ray.get(event.set.remote()) raise KeyboardInterrupt() # add hyperopt parameter space to the config config["hyperopt"] = hyperopt_configs # run for one epoch, then cancel, then resume from where we left off run_hyperopt(config, rel_path, tmpdir, callbacks=[CancelCallback()]) @pytest.mark.slow @pytest.mark.distributed def test_hyperopt_ray_mlflow(csv_filename, tmpdir, ray_cluster_4cpu): mlflow_uri = f"file://{tmpdir}/mlruns" mlflow.set_tracking_uri(mlflow_uri) client = MlflowClient(tracking_uri=mlflow_uri) num_samples = 2 config = _get_config( {"type": "variant_generator"}, # search_alg {"type": "ray", "num_samples": num_samples}, # executor 1, # epochs ) rel_path = generate_data(config["input_features"], config["output_features"], csv_filename) exp_name = "mlflow_test" run_hyperopt(config, rel_path, tmpdir, experiment_name=exp_name, callbacks=[MlflowCallback(mlflow_uri)]) experiment = client.get_experiment_by_name(exp_name) assert experiment is not None runs = client.search_runs([experiment.experiment_id]) assert len(runs) > 0 for run in runs: artifacts = [f.path for f in client.list_artifacts(run.info.run_id, "")] assert "config.yaml" in artifacts assert MODEL_FILE_NAME in artifacts def run_hyperopt( config, rel_path, tmpdir, experiment_name="ray_hyperopt", callbacks=None, ): tune_test_callback = HyperoptTestCallback(experiment_name, get_model_type(config)) hyperopt_results = hyperopt( config, dataset=rel_path, output_directory=tmpdir, experiment_name=experiment_name, callbacks=callbacks, tune_callbacks=[tune_test_callback], ) # check for return results assert isinstance(hyperopt_results, HyperoptResults) # check for existence of the hyperopt statistics file assert os.path.isfile(os.path.join(tmpdir, experiment_name, HYPEROPT_STATISTICS_FILE_NAME)) # check for evidence that the HyperoptTestCallback was active assert len(tune_test_callback.trial_ids) > 0 for t in tune_test_callback.trial_ids: if tune_test_callback.trial_status.get(t) == "terminated": assert tune_test_callback.user_config[t].get() assert tune_test_callback.rendered_config[t].get() ================================================ FILE: tests/integration_tests/test_input_feature_tied.py ================================================ from collections import namedtuple import pytest from ludwig.models.base import BaseModel from ludwig.schema.model_config import ModelConfig from tests.integration_tests.utils import ( category_feature, generate_data, number_feature, run_experiment, sequence_feature, text_feature, ) # InputFeatureOptions namedtuple structure: # feature_type: input feature type, e.g., number, category, etc. # feature_options: None or dictionary of required input feature specification # tie_features: boolean, True to tie features, False not to tie features InputFeatureOptions = namedtuple("InputFeatureOptions", "feature_type feature_options tie_features") # micro level test confirms the encoders for tied input features are sharing # the same encoder. Include negative tests to confirm untied input features # do not share the same encoder. # note: vocab parameter, below, is made up to facilitate creating input encoders @pytest.mark.parametrize( "input_feature_options", [ # tie input features, encoders should be the same InputFeatureOptions("number", {"encoder": {"type": "passthrough"}}, True), InputFeatureOptions( "number", {"encoder": {"type": "passthrough"}, "preprocessing": {"normalization": "zscore"}}, True ), InputFeatureOptions("binary", {"encoder": {"type": "passthrough"}}, True), InputFeatureOptions("category", {"encoder": {"type": "dense", "vocab": ["a", "b", "c"]}}, True), InputFeatureOptions("set", {"encoder": {"type": "embed", "vocab": ["a", "b", "c"]}}, True), InputFeatureOptions( "sequence", {"encoder": {"type": "parallel_cnn", "max_sequence_length": 10, "vocab": ["x", "y", "z"]}}, True ), InputFeatureOptions( "text", {"encoder": {"type": "parallel_cnn", "max_sequence_length": 10, "vocab": ["a", "b", "c"]}}, True ), InputFeatureOptions( "timeseries", {"encoder": {"type": "parallel_cnn", "max_sequence_length": 10, "should_embed": False}}, True ), InputFeatureOptions( "audio", { "encoder": { "type": "parallel_cnn", "embedding_size": 64, "max_sequence_length": 16, "should_embed": False, } }, True, ), # do not tie input features, encoders should be different InputFeatureOptions("number", {"encoder": {"type": "passthrough"}}, False), InputFeatureOptions( "number", {"encoder": {"type": "passthrough"}, "preprocessing": {"normalization": "zscore"}}, False ), InputFeatureOptions("binary", {"encoder": {"type": "passthrough"}}, False), InputFeatureOptions("category", {"encoder": {"type": "dense", "vocab": ["a", "b", "c"]}}, False), InputFeatureOptions("set", {"encoder": {"type": "embed", "vocab": ["a", "b", "c"]}}, False), InputFeatureOptions( "sequence", {"encoder": {"type": "parallel_cnn", "max_sequence_length": 10, "vocab": ["x", "y", "z"]}}, False, ), InputFeatureOptions( "text", {"encoder": {"type": "parallel_cnn", "max_sequence_length": 10, "vocab": ["a", "b", "c"]}}, False ), InputFeatureOptions( "timeseries", {"encoder": {"type": "parallel_cnn", "max_sequence_length": 10, "should_embed": False}}, False ), InputFeatureOptions( "audio", { "encoder": { "type": "parallel_cnn", "embedding_size": 64, "max_sequence_length": 16, "should_embed": False, } }, False, ), ], ) def test_tied_micro_level(input_feature_options): # build input feature config input_feature_configs = list() input_feature_configs.append({"name": "input_feature_1", "type": input_feature_options.feature_type}) input_feature_configs[0].update(input_feature_options.feature_options) input_feature_configs.append({"name": "input_feature_2", "type": input_feature_options.feature_type}) input_feature_configs[1].update(input_feature_options.feature_options) # add tied option to the second feature if input_feature_options.tie_features: input_feature_configs[1]["tied"] = "input_feature_1" config_obj = ModelConfig.from_dict( {"input_features": input_feature_configs, "output_features": [{"name": "dummy_feature", "type": "binary"}]} ) input_features = BaseModel.build_inputs(input_feature_configs=config_obj.input_features) if input_feature_options.tie_features: # should be same encoder assert input_features["input_feature_1"].encoder_obj is input_features["input_feature_2"].encoder_obj else: # no tied parameter, encoders should be different assert input_features["input_feature_1"].encoder_obj is not input_features["input_feature_2"].encoder_obj # TiedUseCase namedtuple structure: # input_feature: Ludwig synthetic data creation function. # output_feature: Ludwig synthetic data creation function TiedUseCase = namedtuple("TiedUseCase", "input_feature output_feature") # Macro level test ensures no exceptions are raised during a full_experiment() @pytest.mark.parametrize( "tied_use_case", [ TiedUseCase(number_feature, number_feature), TiedUseCase(text_feature, category_feature), TiedUseCase(sequence_feature, sequence_feature), ], ) def test_tied_macro_level(tied_use_case: TiedUseCase, csv_filename: str): input_features = [ number_feature(), # Other feature tied_use_case.input_feature(), # first feature to be tied tied_use_case.input_feature(), # second feature to be tied category_feature(), # other feature ] # tie second feature to first feature input_features[2]["tied"] = input_features[1]["name"] # setup output feature output_features = [tied_use_case.output_feature(output_feature=True)] # Generate test data and run full_experiment rel_path = generate_data(input_features, output_features, csv_filename) run_experiment(input_features, output_features, dataset=rel_path) ================================================ FILE: tests/integration_tests/test_kfold_cv.py ================================================ import logging import os import os.path from collections import namedtuple import pytest import yaml from ludwig.api import kfold_cross_validate from ludwig.constants import BATCH_SIZE, TRAINER from ludwig.experiment import kfold_cross_validate_cli from ludwig.utils.data_utils import load_json from tests.integration_tests.utils import ( binary_feature, category_feature, create_data_set_to_use, generate_data, number_feature, sequence_feature, text_feature, ) logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) logging.getLogger("ludwig").setLevel(logging.INFO) FeaturesToUse = namedtuple("FeaturesToUse", "input_features output_features") FEATURES_TO_TEST = [ FeaturesToUse( # input feature [number_feature(normalization="zscore"), number_feature(normalization="zscore")], # output feature [number_feature()], ), FeaturesToUse( # input feature [number_feature(normalization="zscore"), number_feature(normalization="zscore")], # output feature [binary_feature()], ), FeaturesToUse( # input feature [number_feature(normalization="zscore"), number_feature(normalization="zscore")], # output feature [category_feature(decoder={"vocab_size": 4}, reduce_input="sum", output_feature=True)], ), FeaturesToUse( # input feature # [sequence_feature(min_len=5, max_len=10, encoder="rnn", cell_type="lstm", reduce_output=None)], [number_feature(normalization="zscore"), number_feature(normalization="zscore")], # output feature [ sequence_feature( decoder={ "min_len": 5, "max_len": 10, "type": "generator", "cell_type": "lstm", "attention": "bahdanau", }, reduce_input=None, output_feature=True, ) ], ), FeaturesToUse( # input feature [ sequence_feature( encoder={"min_len": 5, "max_len": 10, "type": "rnn", "cell_type": "lstm", "reduce_output": None} ) ], # output feature [sequence_feature(decoder={"max_len": 10, "type": "tagger"}, reduce_input=None, output_feature=True)], ), FeaturesToUse( # input feature [number_feature(normalization="zscore"), number_feature(normalization="zscore")], # output feature [text_feature(output_feature=True)], ), ] @pytest.mark.parametrize("features_to_use", FEATURES_TO_TEST) def test_kfold_cv_cli(tmpdir, features_to_use: FeaturesToUse): # k-fold cross validation cli num_folds = 3 training_data_fp = os.path.join(tmpdir, "train.csv") config_fp = os.path.join(tmpdir, "config.yaml") results_dir = os.path.join(tmpdir, "results") statistics_fp = os.path.join(results_dir, "kfold_training_statistics.json") indices_fp = os.path.join(results_dir, "kfold_split_indices.json") # generate synthetic data for the test input_features = features_to_use.input_features output_features = features_to_use.output_features generate_data(input_features, output_features, training_data_fp) # generate config file config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat", "output_size": 14}, TRAINER: {"epochs": 2, BATCH_SIZE: 128}, } with open(config_fp, "w") as f: yaml.dump(config, f) # run k-fold cv kfold_cross_validate_cli( k_fold=num_folds, config=config_fp, dataset=training_data_fp, output_directory=results_dir, logging_level="warn", ) # check for expected results # check for existence and structure of statistics file assert os.path.isfile(statistics_fp) # check for required keys cv_statistics = load_json(statistics_fp) for key in ["fold_" + str(i + 1) for i in range(num_folds)] + ["overall"]: assert key in cv_statistics # check for existence and structure of split indices file assert os.path.isfile(indices_fp) # check for required keys cv_indices = load_json(indices_fp) for key in ["fold_" + str(i + 1) for i in range(num_folds)]: assert key in cv_indices def test_kfold_cv_api_from_file(tmpdir): # k-fold_cross_validate api with config file num_folds = 3 # setup required data structures for test training_data_fp = os.path.join(tmpdir, "train.csv") config_fp = os.path.join(tmpdir, "config.yaml") # generate synthetic data for the test input_features = [number_feature(normalization="zscore"), number_feature(normalization="zscore")] output_features = [category_feature(decoder={"vocab_size": 3}, reduce_input="sum")] generate_data(input_features, output_features, training_data_fp) # generate config file config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat", "output_size": 14}, TRAINER: {"epochs": 2, BATCH_SIZE: 128}, } with open(config_fp, "w") as f: yaml.dump(config, f) # test kfold_cross_validate api with config file # execute k-fold cross validation run kfold_cv_stats, kfold_split_indices = kfold_cross_validate(3, config=config_fp, dataset=training_data_fp) # correct structure for results from kfold cv for key in ["fold_" + str(i + 1) for i in range(num_folds)] + ["overall"]: assert key in kfold_cv_stats for key in ["fold_" + str(i + 1) for i in range(num_folds)]: assert key in kfold_split_indices def test_kfold_cv_api_in_memory(tmpdir): # k-fold_cross_validate api with in-memory config num_folds = 3 # setup required data structures for test training_data_fp = os.path.join(tmpdir, "train.csv") # generate synthetic data for the test input_features = [number_feature(normalization="zscore"), number_feature(normalization="zscore")] output_features = [number_feature()] generate_data(input_features, output_features, training_data_fp) # generate config file config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat", "output_size": 14}, TRAINER: {"epochs": 2, BATCH_SIZE: 128}, } # test kfold_cross_validate api with config in-memory # execute k-fold cross validation run kfold_cv_stats, kfold_split_indices = kfold_cross_validate(3, config=config, dataset=training_data_fp) # correct structure for results from kfold cv for key in ["fold_" + str(i + 1) for i in range(num_folds)] + ["overall"]: assert key in kfold_cv_stats for key in ["fold_" + str(i + 1) for i in range(num_folds)]: assert key in kfold_split_indices DATA_FORMATS_FOR_KFOLDS = [ "csv", "df", "dict", "excel", "feather", "fwf", "html", "json", "jsonl", "parquet", "pickle", "stata", "tsv", ] @pytest.mark.parametrize("data_format", DATA_FORMATS_FOR_KFOLDS) def test_kfold_cv_dataset_formats(tmpdir, data_format): # k-fold_cross_validate api with in-memory config num_folds = 3 # setup required data structures for test training_data_fp = os.path.join(tmpdir, "train.csv") # generate synthetic data for the test input_features = [number_feature(normalization="zscore"), number_feature(normalization="zscore")] output_features = [number_feature()] generate_data(input_features, output_features, training_data_fp) dataset_to_use = create_data_set_to_use(data_format, training_data_fp) # generate config file config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat", "output_size": 14}, TRAINER: {"epochs": 2, BATCH_SIZE: 128}, } # test kfold_cross_validate api with config in-memory # execute k-fold cross validation run kfold_cv_stats, kfold_split_indices = kfold_cross_validate(3, config=config, dataset=dataset_to_use) # correct structure for results from kfold cv for key in ["fold_" + str(i + 1) for i in range(num_folds)] + ["overall"]: assert key in kfold_cv_stats for key in ["fold_" + str(i + 1) for i in range(num_folds)]: assert key in kfold_split_indices ================================================ FILE: tests/integration_tests/test_llm.py ================================================ from __future__ import annotations import copy import json import os import pathlib from typing import Any import numpy as np import pandas as pd import pytest import torch import yaml import ludwig.error as ludwig_error from ludwig.api import LudwigModel from ludwig.constants import ( ADAPTER, BACKEND, BASE_MODEL, BATCH_SIZE, COMBINER, EPOCHS, EVAL_BATCH_SIZE, GENERATION, INPUT_FEATURES, MERGE_ADAPTER_INTO_BASE_MODEL, MODEL_ECD, MODEL_LLM, MODEL_TYPE, OUTPUT_FEATURES, POSTPROCESSOR, PREPROCESSING, PRETRAINED_ADAPTER_WEIGHTS, PROGRESSBAR, PROMPT, QUANTIZATION, TARGET_MODULES, TRAINER, TYPE, ) from ludwig.globals import MODEL_FILE_NAME, MODEL_WEIGHTS_FILE_NAME from ludwig.models.llm import LLM from ludwig.schema.model_types.base import ModelConfig from ludwig.utils.fs_utils import list_file_names_in_directory from ludwig.utils.types import DataFrame from tests.integration_tests.utils import category_feature, generate_data, text_feature pytestmark = pytest.mark.llm LOCAL_BACKEND = {"type": "local"} TEST_MODEL_NAME = "hf-internal-testing/tiny-random-GPTJForCausalLM" MAX_NEW_TOKENS_TEST_DEFAULT = 5 RAY_BACKEND = { "type": "ray", "processor": { "parallelism": 1, }, "trainer": { "use_gpu": False, "num_workers": 2, "resources_per_worker": { "CPU": 1, "GPU": 0, }, }, } def get_num_non_empty_tokens(iterable): """Returns the number of non-empty tokens.""" return len(list(filter(bool, iterable))) @pytest.fixture(scope="module") def local_backend(): return LOCAL_BACKEND @pytest.fixture(scope="module") def ray_backend(): return RAY_BACKEND def get_dataset(): data = [ {"review": "I loved this movie!", "output": "positive"}, {"review": "The food was okay, but the service was terrible.", "output": "negative"}, {"review": "I can't believe how rude the staff was.", "output": "negative"}, {"review": "This book was a real page-turner.", "output": "positive"}, {"review": "The hotel room was dirty and smelled bad.", "output": "negative"}, {"review": "I had a great experience at this restaurant.", "output": "positive"}, {"review": "The concert was amazing!", "output": "positive"}, {"review": "The traffic was terrible on my way to work this morning.", "output": "negative"}, {"review": "The customer service was excellent.", "output": "positive"}, {"review": "I was disappointed with the quality of the product.", "output": "negative"}, ] df = pd.DataFrame(data) return df def get_generation_config(): return { "temperature": 0.1, "top_p": 0.75, "top_k": 40, "num_beams": 4, "max_new_tokens": MAX_NEW_TOKENS_TEST_DEFAULT, } def convert_preds(preds: DataFrame): if isinstance(preds, pd.DataFrame): return preds.to_dict(orient="list") return preds.compute().to_dict(orient="list") @pytest.mark.llm @pytest.mark.parametrize( "backend", [ pytest.param(LOCAL_BACKEND, id="local"), pytest.param(RAY_BACKEND, id="ray"), ], ) def test_llm_text_to_text(tmpdir, backend, ray_cluster_4cpu): """Test that the LLM model can train and predict with text inputs and text outputs.""" input_features = [ { "name": "Question", "type": "text", "encoder": {"type": "passthrough"}, } ] output_features = [text_feature(output_feature=True, name="Answer", decoder={"type": "text_extractor"})] csv_filename = os.path.join(tmpdir, "training.csv") dataset_filename = generate_data(input_features, output_features, csv_filename, num_examples=20) config = { MODEL_TYPE: MODEL_LLM, BASE_MODEL: TEST_MODEL_NAME, GENERATION: get_generation_config(), INPUT_FEATURES: input_features, OUTPUT_FEATURES: output_features, BACKEND: backend, } model = LudwigModel(config) model.train(dataset=dataset_filename, output_directory=str(tmpdir), skip_save_processed_input=True) preds, _ = model.predict(dataset=dataset_filename, output_directory=str(tmpdir), split="test") preds = convert_preds(preds) assert "Answer_predictions" in preds assert "Answer_probabilities" in preds assert "Answer_probability" in preds assert "Answer_response" in preds assert preds["Answer_predictions"] assert preds["Answer_probabilities"] assert preds["Answer_probability"] assert preds["Answer_response"] # Check that in-line generation parameters are used. Original prediction uses max_new_tokens = 5. assert get_num_non_empty_tokens(preds["Answer_predictions"][0]) <= MAX_NEW_TOKENS_TEST_DEFAULT original_max_new_tokens = model.model.generation.max_new_tokens # This prediction uses max_new_tokens = 2. preds, _ = model.predict( dataset=dataset_filename, output_directory=str(tmpdir), split="test", generation_config={"min_new_tokens": 2, "max_new_tokens": 3}, ) preds = convert_preds(preds) print(preds["Answer_predictions"][0]) num_non_empty_tokens = get_num_non_empty_tokens(preds["Answer_predictions"][0]) assert 2 <= num_non_empty_tokens <= 3 # Check that the state of the model is unchanged. assert model.model.generation.max_new_tokens == original_max_new_tokens @pytest.mark.llm @pytest.mark.parametrize( "backend", [ pytest.param(LOCAL_BACKEND, id="local"), pytest.param(RAY_BACKEND, id="ray"), ], ) def test_llm_zero_shot_classification(tmpdir, backend, ray_cluster_4cpu): input_features = [ { "name": "review", "type": "text", } ] output_features = [ category_feature( name="output", preprocessing={ "fallback_label": "neutral", }, # How can we avoid using r here for regex, since it is technically an implementation detail? decoder={ "type": "category_extractor", "match": { "positive": {"type": "contains", "value": "positive"}, "neutral": {"type": "regex", "value": r"\bneutral\b"}, "negative": {"type": "contains", "value": "negative"}, }, }, ) ] df = get_dataset() config = { MODEL_TYPE: MODEL_LLM, BASE_MODEL: TEST_MODEL_NAME, GENERATION: get_generation_config(), PROMPT: {"task": "This is a review of a restaurant. Classify the sentiment."}, INPUT_FEATURES: input_features, OUTPUT_FEATURES: output_features, BACKEND: backend, } model = LudwigModel(config) model.train(dataset=df, output_directory=str(tmpdir), skip_save_processed_input=True) prediction_df = pd.DataFrame( [ {"review": "The food was amazing!", "output": "positive"}, {"review": "The service was terrible.", "output": "negative"}, {"review": "The food was okay.", "output": "neutral"}, ] ) preds, _ = model.predict(dataset=prediction_df, output_directory=str(tmpdir)) preds = convert_preds(preds) assert preds @pytest.mark.llm @pytest.mark.parametrize( "backend", [ pytest.param(LOCAL_BACKEND, id="local"), pytest.param(RAY_BACKEND, id="ray"), ], ) def test_llm_few_shot_classification(tmpdir, backend, csv_filename, ray_cluster_4cpu): input_features = [ text_feature( output_feature=False, name="body", encoder={"type": "passthrough"}, # need to use the default encoder for LLMTextInputFeatureConfig ) ] output_features = [ category_feature( output_feature=True, name="output", preprocessing={ "fallback_label": "3", }, decoder={ "type": "category_extractor", "match": { "1": {"type": "contains", "value": "1"}, "2": {"type": "contains", "value": "2"}, "3": {"type": "contains", "value": "3"}, "4": {"type": "contains", "value": "4"}, "5": {"type": "contains", "value": "5"}, }, }, ) ] config = { MODEL_TYPE: MODEL_LLM, BASE_MODEL: TEST_MODEL_NAME, GENERATION: get_generation_config(), PROMPT: { "retrieval": {"type": "random", "k": 3}, "task": ( "Given the sample input, complete this sentence by replacing XXXX: The review rating is XXXX. " "Choose one value in this list: [1, 2, 3, 4, 5]." ), }, INPUT_FEATURES: input_features, OUTPUT_FEATURES: output_features, PREPROCESSING: { "split": {TYPE: "fixed"}, }, BACKEND: {**backend, "cache_dir": str(tmpdir)}, } dataset_path = generate_data( input_features, output_features, filename=csv_filename, num_examples=25, nan_percent=0.1, with_split=True, ) df = pd.read_csv(dataset_path) df["output"] = np.random.choice([1, 2, 3, 4, 5], size=len(df)).astype(str) # ensure labels match the feature config df.to_csv(dataset_path, index=False) model = LudwigModel(config) model.train(dataset=dataset_path, output_directory=str(tmpdir), skip_save_processed_input=True) # TODO: fix LLM model loading # model = LudwigModel.load(os.path.join(results.output_directory, "model"), backend=backend) preds, _ = model.predict(dataset=dataset_path) preds = convert_preds(preds) assert preds def _prepare_finetuning_test( csv_filename: str, finetune_strategy: str, backend: dict, adapter_args: dict ) -> tuple[dict, str]: input_features = [text_feature(name="input", encoder={"type": "passthrough"})] output_features = [text_feature(name="output")] train_df = generate_data(input_features, output_features, filename=csv_filename, num_examples=25) prediction_df = pd.DataFrame( [ {"input": "The food was amazing!", "output": "positive"}, {"input": "The service was terrible.", "output": "negative"}, {"input": "The food was okay.", "output": "neutral"}, ] ) model_name = TEST_MODEL_NAME if finetune_strategy == "adalora": # Adalora isn't supported for GPT-J model types, so use tiny bart model_name = "hf-internal-testing/tiny-random-BartModel" elif finetune_strategy == "adaption_prompt": # At the time of writing this test, Adaption Prompt fine-tuning is only supported for Llama models model_name = "yujiepan/llama-2-tiny-random" config = { MODEL_TYPE: MODEL_LLM, BASE_MODEL: model_name, INPUT_FEATURES: input_features, OUTPUT_FEATURES: output_features, GENERATION: {"max_new_tokens": 64}, TRAINER: { TYPE: "finetune", BATCH_SIZE: "auto", EVAL_BATCH_SIZE: "auto", EPOCHS: 2, }, BACKEND: backend, } if finetune_strategy is not None: config[ADAPTER] = { TYPE: finetune_strategy, **adapter_args, } return train_df, prediction_df, config def _finetune_strategy_requires_cuda(finetune_strategy_name: str, quantization_args: dict | None) -> bool: """This method returns whether a given finetine_strategy requires CUDA. For all finetune strategies, except "qlora", the decision is based just on the name of the finetine_strategy; in the case of qlora, if the quantization dictionary is non-empty (i.e., contains quantization specifications), then the original finetine_strategy name of "lora" is interpreted as "qlora" and used in the lookup, based on the list of finetine strategies requiring CUDA. """ cuda_only_finetune_strategy_names: list[str] = [ "prompt_tuning", "prefix_tuning", "p_tuning", "qlora", ] if finetune_strategy_name == "lora" and quantization_args: finetune_strategy_name = "qlora" return finetune_strategy_name in cuda_only_finetune_strategy_names def _verify_lm_lora_finetuning_layers( attention_layer: torch.nn.Module, target_modules: set[str], merge_adapter_into_base_model: bool, model_weights_directory: str, expected_lora_in_features: int, expected_lora_out_features: int, expected_file_names: list[str], ) -> None: """This method verifies that LoRA finetuning layers have correct types and shapes, depending on whether the optional "model.merge_and_unload()" method (based on the "merge_adapter_into_base_model" directive) was executed. If merge_adapter_into_base_model is True, then all specified LoRA projection layers in the attention layer must contain square weight matrices (with the dimensions expected_lora_in_features by expected_lora_in_features). However, if merge_adapter_into_base_model is False, then the LoRA part of the attention layer must include Lora_A and Lora_B children layers for each specified projection, such that the product of Lora_A and Lora_B is a square matrix (with the dimensions expected_lora_in_features by expected_lora_in_features) for each specified projection. """ from peft.tuners.lora.layer import LoraLayer expected_lora_num_features_orig: tuple[int] = (expected_lora_in_features, expected_lora_out_features) file_names: list[str] = list_file_names_in_directory(directory_name=model_weights_directory) assert set(file_names) == set(expected_file_names) target_module_name: str target_module_obj: LoraLayer | torch.nn.Linear # Not providing default value to "getattr()" so that error is raised if incorrect projection layer name is supplied. for target_module_name in target_modules: target_module_obj = getattr(attention_layer, target_module_name) if merge_adapter_into_base_model: assert isinstance(target_module_obj, torch.nn.Linear) else: assert isinstance(target_module_obj, LoraLayer) if merge_adapter_into_base_model: # If LoRA A & B layers are merged, they must have no children layers, and projection matrices must be square. for target_module_name in target_modules: target_module_obj = getattr(attention_layer, target_module_name) assert not list(target_module_obj.children()) assert (target_module_obj.in_features, target_module_obj.out_features) == ( expected_lora_in_features, expected_lora_out_features, ) else: # If LoRA A & B layers are not merged, their children layers must be correctly-dimensioned projection matrices. expected_lora_num_features: tuple[int] target_named_children: dict[str, torch.nn.Module] lora_matrix_name: str idx: int for target_module_name in target_modules: target_module_obj = getattr(attention_layer, target_module_name) target_named_children = dict(target_module_obj.named_children()) for idx, lora_matrix_name in enumerate(["lora_A", "lora_B"]): assert isinstance(target_named_children[lora_matrix_name]["default"], torch.nn.Linear) # LoRA A and B matrix dimensions are transposes of one another so that their product is square matrix. expected_lora_num_features = ( expected_lora_num_features_orig if idx % 2 == 0 else (expected_lora_num_features_orig[1], expected_lora_num_features_orig[0]) ) assert ( target_named_children[lora_matrix_name]["default"].in_features, target_named_children[lora_matrix_name]["default"].out_features, ) == expected_lora_num_features # TODO(arnav): p-tuning and prefix tuning have errors when enabled that seem to stem from DDP: # # prefix tuning: # Sizes of tensors must match except in dimension 1. Expected size 320 but got size 32 for tensor number 1 in the list. # # p-tuning: # 'PromptEncoder' object has no attribute 'mlp_head' @pytest.mark.llm @pytest.mark.parametrize( "backend", [ pytest.param(LOCAL_BACKEND, id="local"), # TODO(Arnav): Re-enable once we can run tests on GPUs # This is because fine-tuning requires Ray with the deepspeed strategy, and deepspeed # only works with GPUs # pytest.param(RAY_BACKEND, id="ray"), ], ) @pytest.mark.parametrize( "finetune_strategy,adapter_args", [ pytest.param( None, {}, id="full", ), pytest.param( "lora", {}, id="lora-defaults", ), pytest.param( "lora", {"r": 4, "dropout": 0.1}, id="lora-modified-defaults", ), pytest.param( "lora", {TARGET_MODULES: ["q_proj", "k_proj", "v_proj"]}, id="lora-target-modules", ), pytest.param( "lora", {"use_rslora": True}, id="lora-rslora-enabled", ), pytest.param( "lora", {"use_dora": True}, id="lora-dora-enabled", ), pytest.param( "lora", {"use_rslora": True, "use_dora": True}, id="lora-rslora-and-dora-enabled", ), pytest.param( "lora", {POSTPROCESSOR: {MERGE_ADAPTER_INTO_BASE_MODEL: True, PROGRESSBAR: True}}, id="lora_merged", ), pytest.param( "lora", {POSTPROCESSOR: {MERGE_ADAPTER_INTO_BASE_MODEL: False}}, id="lora_not_merged", ), pytest.param( "adalora", {}, id="adalora-defaults", ), pytest.param( "adalora", {"init_r": 8, "beta1": 0.8}, id="adalora-modified-defaults", ), pytest.param( "adalora", {POSTPROCESSOR: {MERGE_ADAPTER_INTO_BASE_MODEL: True, PROGRESSBAR: True}}, id="adalora_merged", ), pytest.param( "adalora", {POSTPROCESSOR: {MERGE_ADAPTER_INTO_BASE_MODEL: False}}, id="adalora_not_merged", ), # TODO: 02/21/2024: Disabling AdaptionPrompt (waiting for PEFT release to fix # "TypeError: LlamaRotaryEmbedding.forward() missing 1 required positional argument: 'position_ids')" # (this is reflected in https://github.com/ludwig-ai/ludwig/issues/3938). # # pytest.param( # "adaption_prompt", # {}, # id="adaption_prompt-defaults", # ), # pytest.param( # "adaption_prompt", # {"adapter_len": 6, "adapter_layers": 1}, # id="adaption_prompt-modified-defaults", # ), pytest.param( "ia3", {}, id="ia3-defaults", ), pytest.param( "ia3", {"init_ia3_weights": False}, id="ia3-modified-defaults", ), # pytest.param( # "prompt_tuning", # { # "num_virtual_tokens": 8, # "prompt_tuning_init": "RANDOM", # }, # id="prompt_tuning_init_random", # ), # pytest.param( # "prompt_tuning", # { # "num_virtual_tokens": 8, # "prompt_tuning_init": "TEXT", # "prompt_tuning_init_text": "Classify if the review is positive, negative, or neutral: ", # }, # id="prompt_tuning_init_text", # ), # pytest.param( # "prefix_tuning", # { # "num_virtual_tokens": 8, # }, # id="prefix_tuning", # ), # pytest.param( # "p_tuning", # {"num_virtual_tokens": 8, "encoder_reparameterization_type": "MLP"}, # id="p_tuning_mlp_reparameterization", # ), # pytest.param( # "p_tuning", # {"num_virtual_tokens": 8, "encoder_reparameterization_type": "LSTM"}, # id="p_tuning_lstm_reparameterization", # ), ], ) def test_llm_finetuning_strategies(tmpdir, csv_filename, backend, finetune_strategy, adapter_args): train_df, prediction_df, config = _prepare_finetuning_test(csv_filename, finetune_strategy, backend, adapter_args) output_directory: str = str(tmpdir) model_directory: str = pathlib.Path(output_directory) / "api_experiment_run" / MODEL_FILE_NAME model = LudwigModel(config) model.train(dataset=train_df, output_directory=output_directory, skip_save_processed_input=False) # Make sure we can load the saved model and then use it for predictions model = LudwigModel.load(str(model_directory), backend=backend) base_model = LLM(ModelConfig.from_dict(config)) assert not _compare_models(base_model, model.model) # noqa F821 preds, _ = model.predict(dataset=prediction_df, output_directory=output_directory) preds = convert_preds(preds) assert preds @pytest.mark.llm @pytest.mark.parametrize( "finetune_strategy,adapter_args,quantization", [ pytest.param( "lora", {}, {"bits": 4}, id="qlora-4bit", ), pytest.param( "lora", {}, {"bits": 8}, id="qlora-8bit", ), ], ) def test_llm_finetuning_strategies_quantized(tmpdir, csv_filename, finetune_strategy, adapter_args, quantization): pytest.importorskip("bitsandbytes", reason="bitsandbytes required for quantization tests") if ( _finetune_strategy_requires_cuda(finetune_strategy_name=finetune_strategy, quantization_args=quantization) and not (torch.cuda.is_available() and torch.cuda.device_count()) > 0 ): pytest.skip("Skip: quantization requires GPU and none are available.") backend = LOCAL_BACKEND train_df, prediction_df, config = _prepare_finetuning_test(csv_filename, finetune_strategy, backend, adapter_args) config["backend"] = backend config[QUANTIZATION] = quantization model = LudwigModel(config) model.train(dataset=train_df, output_directory=str(tmpdir), skip_save_processed_input=False) # Make sure we can load the saved model and then use it for predictions model = LudwigModel.load(os.path.join(str(tmpdir), "api_experiment_run", MODEL_FILE_NAME)) base_model = LLM(ModelConfig.from_dict(config)) assert not _compare_models(base_model, model.model) # noqa F821 preds, _ = model.predict(dataset=prediction_df, output_directory=str(tmpdir)) preds = convert_preds(preds) assert preds @pytest.mark.llm @pytest.mark.skipif(torch.cuda.device_count() == 0, reason="test requires at least 1 gpu") @pytest.mark.skipif(not torch.cuda.is_available(), reason="test requires gpu support") @pytest.mark.parametrize( "finetune_strategy,adapter_args,quantization,error_raised", [ pytest.param( "lora", {POSTPROCESSOR: {MERGE_ADAPTER_INTO_BASE_MODEL: False}}, {"bits": 4}, ( ImportError, "Using `load_in_8bit=True` requires Accelerate: `pip install accelerate` and the latest version of bitsandbytes `pip install -i https://test.pypi.org/simple/ bitsandbytes` or pip install bitsandbytes` ", # noqa: E501 ), id="qlora-4bit-not-merged", ), pytest.param( "lora", {POSTPROCESSOR: {MERGE_ADAPTER_INTO_BASE_MODEL: True, PROGRESSBAR: True}}, {"bits": 8}, ( ImportError, "Using `load_in_8bit=True` requires Accelerate: `pip install accelerate` and the latest version of bitsandbytes `pip install -i https://test.pypi.org/simple/ bitsandbytes` or pip install bitsandbytes` ", # noqa: E501 ), id="qlora-8bit-merged", ), pytest.param( "lora", {POSTPROCESSOR: {MERGE_ADAPTER_INTO_BASE_MODEL: False}}, {"bits": 8}, ( ImportError, "Using `load_in_8bit=True` requires Accelerate: `pip install accelerate` and the latest version of bitsandbytes `pip install -i https://test.pypi.org/simple/ bitsandbytes` or pip install bitsandbytes` ", # noqa E501 ), id="qlora-8bit-not-merged", ), ], ) def test_llm_lora_finetuning_merge_and_unload_quantized_accelerate_required( csv_filename, finetune_strategy, adapter_args, quantization, error_raised ): pytest.importorskip("bitsandbytes", reason="bitsandbytes required for quantization tests") input_features: list[dict] = [text_feature(name="input", encoder={"type": "passthrough"})] output_features: list[dict] = [text_feature(name="output")] config: dict = { MODEL_TYPE: MODEL_LLM, BASE_MODEL: TEST_MODEL_NAME, INPUT_FEATURES: input_features, OUTPUT_FEATURES: output_features, TRAINER: { TYPE: "finetune", BATCH_SIZE: 8, EPOCHS: 2, }, ADAPTER: { TYPE: finetune_strategy, **adapter_args, }, QUANTIZATION: quantization, } model = LudwigModel(config) error_class: type # noqa [F842] # incorrect flagging of "local variable is annotated but never used error_message: str # noqa [F842] # incorrect flagging of "local variable is annotated but never used error_class, error_message = error_raised with pytest.raises(error_class) as excinfo: train_df = generate_data(input_features, output_features, filename=csv_filename, num_examples=3) model.train(dataset=train_df) assert str(excinfo.value) == error_message @pytest.mark.llm def test_llm_lora_finetuning_merge_and_unload_4_bit_quantization_not_supported(local_backend: dict): input_features: list[dict] = [text_feature(name="input", encoder={"type": "passthrough"})] output_features: list[dict] = [text_feature(name="output")] finetune_strategy: str = "lora" config: dict = { MODEL_TYPE: MODEL_LLM, BASE_MODEL: TEST_MODEL_NAME, INPUT_FEATURES: input_features, OUTPUT_FEATURES: output_features, TRAINER: { TYPE: "finetune", BATCH_SIZE: 8, EPOCHS: 2, }, ADAPTER: { TYPE: finetune_strategy, POSTPROCESSOR: {MERGE_ADAPTER_INTO_BASE_MODEL: True, PROGRESSBAR: True}, }, QUANTIZATION: {"bits": 4}, BACKEND: local_backend, } expected_error_class: type = ludwig_error.ConfigValidationError expected_error_message: str = """This operation will entail merging LoRA layers on a 4-bit quantized model. \ Calling "save_pretrained()" on that model is currently unsupported. If you want to merge the LoRA adapter weights \ into the base model, you need to use 8-bit quantization or do non-quantized based training by removing the \ quantization section from your Ludwig configuration.""" with pytest.raises(expected_error_class) as excinfo: _ = LudwigModel(config) assert str(excinfo.value) == expected_error_message @pytest.mark.llm @pytest.mark.parametrize( "backend", [ pytest.param(LOCAL_BACKEND, id="local"), # TODO: Re-enable once we can run tests on GPUs # This is because fine-tuning requires Ray with the deepspeed strategy, and deepspeed # only works with GPUs # pytest.param(RAY_BACKEND, id="ray"), ], ) @pytest.mark.parametrize( "target_modules,merge_adapter_into_base_model,expected_lora_in_features,expected_lora_out_features,expected_file_names", # noqa: E501 [ pytest.param( None, False, 32, 8, [ "README.md", "adapter_config.json", "adapter_model.safetensors", ], id="lora_default_not_merged", ), pytest.param( None, True, 32, 32, [ "README.md", "adapter_config.json", "adapter_model.safetensors", "config.json", "generation_config.json", "model.safetensors", "tokenizer.json", "tokenizer_config.json", ], id="lora_default_merged", ), pytest.param( ["q_proj", "k_proj", "v_proj"], False, 32, 8, [ "README.md", "adapter_config.json", "adapter_model.safetensors", ], id="lora_custom_not_merged", ), pytest.param( ["q_proj", "k_proj", "v_proj"], True, 32, 32, [ "README.md", "adapter_config.json", "adapter_model.safetensors", "config.json", "generation_config.json", "model.safetensors", "tokenizer.json", "tokenizer_config.json", ], id="lora_custom_merged", ), ], ) def test_llm_lora_finetuning_merge_and_unload( tmpdir: str, csv_filename: str, backend: dict, target_modules: list[str] | set[str] | None, merge_adapter_into_base_model: bool, expected_lora_in_features: int, expected_lora_out_features: int, expected_file_names: list[str], ): from peft.tuners.lora.config import LoraConfig from peft.tuners.lora.model import LoraModel finetune_strategy: str = "lora" adapter_args: dict = { POSTPROCESSOR: { MERGE_ADAPTER_INTO_BASE_MODEL: merge_adapter_into_base_model, }, } # If "target_modules" is None, then ["q_proj", "v_proj"] is used (HuggingFace Transformers/PEFT internal default). if target_modules: adapter_args[TARGET_MODULES] = target_modules train_df, prediction_df, config = _prepare_finetuning_test( csv_filename=csv_filename, finetune_strategy=finetune_strategy, backend=backend, adapter_args=adapter_args ) output_directory: str = str(tmpdir) model_directory: str = pathlib.Path(output_directory) / "api_experiment_run" / MODEL_FILE_NAME model_weights_directory: str = ( pathlib.Path(output_directory) / "api_experiment_run" / MODEL_FILE_NAME / MODEL_WEIGHTS_FILE_NAME ) model = LudwigModel(config) model.train(dataset=train_df, output_directory=output_directory, skip_save_processed_input=False) # Get actual "target_modules" from trained model (to be used in assertions). lora_model: LoraModel = model.model.model.base_model peft_config: dict = lora_model.peft_config lora_config: LoraConfig = peft_config["default"] target_modules = lora_config.target_modules _verify_lm_lora_finetuning_layers( attention_layer=model.model.model.base_model.model.transformer.h[1].attn, target_modules=target_modules, merge_adapter_into_base_model=merge_adapter_into_base_model, model_weights_directory=model_weights_directory, expected_lora_in_features=expected_lora_in_features, expected_lora_out_features=expected_lora_out_features, expected_file_names=expected_file_names, ) # Make sure we can load the saved model and verify that the LoRA layers have expected shapes. model = LudwigModel.load(str(model_directory), backend=backend) _verify_lm_lora_finetuning_layers( attention_layer=model.model.model.base_model.model.transformer.h[1].attn, target_modules=target_modules, merge_adapter_into_base_model=merge_adapter_into_base_model, model_weights_directory=model_weights_directory, expected_lora_in_features=expected_lora_in_features, expected_lora_out_features=expected_lora_out_features, expected_file_names=expected_file_names, ) @pytest.mark.llm @pytest.mark.parametrize("use_adapter", [True, False], ids=["with_adapter", "without_adapter"]) def test_llm_training_with_gradient_checkpointing(tmpdir, csv_filename, use_adapter): input_features = [text_feature(name="input", encoder={"type": "passthrough"})] output_features = [text_feature(name="output")] df = generate_data(input_features, output_features, filename=csv_filename, num_examples=25) config = { MODEL_TYPE: MODEL_LLM, BASE_MODEL: "hf-internal-testing/tiny-random-BartModel", INPUT_FEATURES: input_features, OUTPUT_FEATURES: output_features, TRAINER: { TYPE: "finetune", BATCH_SIZE: 8, EPOCHS: 1, "enable_gradient_checkpointing": True, }, } if use_adapter: config[ADAPTER] = {TYPE: "lora"} model = LudwigModel(config) assert model.config_obj.trainer.enable_gradient_checkpointing model.train(dataset=df, output_directory=str(tmpdir), skip_save_processed_input=False) @pytest.mark.llm def test_lora_wrap_on_init(): from peft import PeftModel from transformers import PreTrainedModel config = { MODEL_TYPE: MODEL_LLM, BASE_MODEL: TEST_MODEL_NAME, INPUT_FEATURES: [text_feature(name="input", encoder={"type": "passthrough"})], OUTPUT_FEATURES: [text_feature(name="output")], TRAINER: { TYPE: "finetune", BATCH_SIZE: 8, EPOCHS: 2, }, } config_obj = ModelConfig.from_dict(config) model = LLM(config_obj) assert isinstance(model.model, PreTrainedModel) assert not isinstance(model.model, PeftModel) # Now add adapter config[ADAPTER] = { TYPE: "lora", } config_obj = ModelConfig.from_dict(config) model = LLM(config_obj) # We need to explicitly make this call since we now load the adapter # in the trainer as opposed to the point of LLM model initialization. model.prepare_for_training() assert not isinstance(model.model, PreTrainedModel) assert isinstance(model.model, PeftModel) def test_llama_rope_scaling(): config = { MODEL_TYPE: MODEL_LLM, BASE_MODEL: "HuggingFaceH4/tiny-random-LlamaForCausalLM", INPUT_FEATURES: [text_feature(name="input", encoder={"type": "passthrough"})], OUTPUT_FEATURES: [text_feature(name="output")], TRAINER: { TYPE: "finetune", BATCH_SIZE: 8, EPOCHS: 2, }, "model_parameters": { "rope_scaling": { "rope_type": "dynamic", "factor": 2.0, } }, } config_obj = ModelConfig.from_dict(config) model = LLM(config_obj) assert model.model.config.rope_scaling assert model.model.config.rope_scaling["rope_type"] == "dynamic" assert model.model.config.rope_scaling["factor"] == 2.0 def test_default_max_sequence_length(): config = { MODEL_TYPE: MODEL_LLM, BASE_MODEL: TEST_MODEL_NAME, INPUT_FEATURES: [text_feature(name="input", encoder={"type": "passthrough"})], OUTPUT_FEATURES: [text_feature(name="output")], TRAINER: { TYPE: "finetune", BATCH_SIZE: 8, EPOCHS: 2, }, ADAPTER: {TYPE: "lora", PRETRAINED_ADAPTER_WEIGHTS: "Infernaught/test_adapter_weights"}, BACKEND: {TYPE: "local"}, } config_obj = ModelConfig.from_dict(config) assert config_obj.input_features[0].preprocessing.max_sequence_length is None assert config_obj.output_features[0].preprocessing.max_sequence_length is None @pytest.mark.llm @pytest.mark.parametrize( "adapter", [ "lora", "adalora", # TODO: 02/21/2024: Disabling AdaptionPrompt (waiting for PEFT release to fix # "TypeError: LlamaRotaryEmbedding.forward() missing 1 required positional argument: 'position_ids')" # (this is reflected in https://github.com/ludwig-ai/ludwig/issues/3938). # # "adaption_prompt", ], ) def test_load_pretrained_adapter_weights(adapter): from peft import PeftModel from transformers import PreTrainedModel if adapter == "lora": weights = "Infernaught/test_adapter_weights" base_model = TEST_MODEL_NAME elif adapter == "adalora": weights = "Infernaught/test_adalora_weights" base_model = "HuggingFaceH4/tiny-random-LlamaForCausalLM" elif adapter == "adaption_prompt": weights = "Infernaught/test_ap_weights" base_model = "HuggingFaceH4/tiny-random-LlamaForCausalLM" else: raise () config = { MODEL_TYPE: MODEL_LLM, BASE_MODEL: base_model, INPUT_FEATURES: [text_feature(name="input", encoder={"type": "passthrough"})], OUTPUT_FEATURES: [text_feature(name="output")], TRAINER: { TYPE: "none", BATCH_SIZE: 8, EPOCHS: 2, }, ADAPTER: {TYPE: adapter, PRETRAINED_ADAPTER_WEIGHTS: weights}, BACKEND: {TYPE: "local"}, } config_obj = ModelConfig.from_dict(config) model = LLM(config_obj) assert model.config_obj.adapter.pretrained_adapter_weights assert model.config_obj.adapter.pretrained_adapter_weights == weights model.prepare_for_training() assert not isinstance(model.model, PreTrainedModel) assert isinstance(model.model, PeftModel) config_obj = ModelConfig.from_dict(config) assert config_obj.input_features[0].preprocessing.max_sequence_length is None assert config_obj.output_features[0].preprocessing.max_sequence_length is None def _compare_models(model_1: torch.nn.Module, model_2: torch.nn.Module) -> bool: # For a full explanation of this 8-bit workaround, see https://github.com/ludwig-ai/ludwig/pull/3606 # TODO: Uncomment "filter_for_weight_format()" method definition and enable its usage once GPU tests are set up. # def filter_for_weight_format(i): # """Remove bitsandbytes metadata keys added on state dict creation. # # 8-bit quantized models that have been put on gpu will have a set of `weight_format` keys in their state dict. # These contain strings that are used to reshape quantized tensors, however these have no impact until the state # dict is loaded into a model. These keys were causing `torch.equal` to raise an exception, so we skip them in # the evaluation. # """ # return "weight_format" not in i[0] # model_1_filtered_state_dict = filter(filter_for_weight_format, model_1.state_dict().items()) # model_2_filtered_state_dict = filter(filter_for_weight_format, model_2.state_dict().items()) # Source: https://discuss.pytorch.org/t/check-if-models-have-same-weights/4351/6 if model_1.__class__.__name__ != model_2.__class__.__name__: return False if ( hasattr(model_1, "model") and hasattr(model_2, "model") and not _compare_models(model_1=model_1.model, model_2=model_2.model) ): return False for key_item_1, key_item_2 in zip(model_1.state_dict().items(), model_2.state_dict().items()): if not torch.equal(key_item_1[1], key_item_2[1]): return False return True def test_global_max_sequence_length_for_llms(): """Ensures that user specified global_max_sequence_length can never be greater than the model's context length.""" config = { MODEL_TYPE: MODEL_LLM, BASE_MODEL: "HuggingFaceH4/tiny-random-LlamaForCausalLM", INPUT_FEATURES: [text_feature(name="input", encoder={"type": "passthrough"})], OUTPUT_FEATURES: [text_feature(name="output")], } config_obj = ModelConfig.from_dict(config) model = LLM(config_obj) # Default value is set based on model's context_len assert model.global_max_sequence_length == 2048 # Override to a larger value in the config config["preprocessing"] = {"global_max_sequence_length": 4096} config_obj = ModelConfig.from_dict(config) model = LLM(config_obj) # Check that the value can never be larger than the model's context_len assert model.global_max_sequence_length == 2048 def test_local_path_loading(tmpdir): """Tests that local paths can be used to load models.""" from huggingface_hub import snapshot_download # Download the model to a local directory local_path: str = f"{str(tmpdir)}/test_local_path_loading" repo_id: str = "HuggingFaceH4/tiny-random-LlamaForCausalLM" os.makedirs(local_path, exist_ok=True) snapshot_download(repo_id=repo_id, local_dir=local_path) # Load the model using the local path config1 = { MODEL_TYPE: MODEL_LLM, BASE_MODEL: local_path, INPUT_FEATURES: [text_feature(name="input", encoder={"type": "passthrough"})], OUTPUT_FEATURES: [text_feature(name="output")], } config_obj1 = ModelConfig.from_dict(config1) model1 = LLM(config_obj1) # Load the model using the repo id config2 = { MODEL_TYPE: MODEL_LLM, BASE_MODEL: repo_id, INPUT_FEATURES: [text_feature(name="input", encoder={"type": "passthrough"})], OUTPUT_FEATURES: [text_feature(name="output")], } config_obj2 = ModelConfig.from_dict(config2) model2 = LLM(config_obj2) # Check that the models are the same assert _compare_models(model1.model, model2.model) @pytest.mark.parametrize( "finetuning_strategy, embedding_noise", [ pytest.param(None, 0, id="full_finetuning_without_noise"), pytest.param(None, 5, id="full_finetuning_with_noise"), pytest.param("lora", 0, id="lora_without_noise"), pytest.param("lora", 5, id="lora_with_noise"), ], ) def test_llm_finetuning_with_embedding_noise( tmpdir, csv_filename, finetuning_strategy, embedding_noise, ): train_df, prediction_df, config = _prepare_finetuning_test(csv_filename, finetuning_strategy, LOCAL_BACKEND, {}) # Add embedding noise if embedding_noise: config["model_parameters"] = {"neftune_noise_alpha": embedding_noise} model = LudwigModel(config) if embedding_noise: assert model.config_obj.model_parameters.neftune_noise_alpha == embedding_noise output_directory: str = str(tmpdir) model_directory: str = pathlib.Path(output_directory) / "api_experiment_run" / MODEL_FILE_NAME model.train(dataset=train_df, output_directory=output_directory, skip_save_processed_input=False) # Make sure we can load the saved model and then use it for predictions model = LudwigModel.load(str(model_directory), backend=LOCAL_BACKEND) base_model = LLM(ModelConfig.from_dict(config)) assert not _compare_models(base_model, model.model) # noqa F821 preds, _ = model.predict(dataset=prediction_df, output_directory=output_directory) preds = convert_preds(preds) assert preds @pytest.fixture() def llm_encoder_config() -> dict[str, Any]: encoder_config = { TYPE: "llm", BASE_MODEL: "HuggingFaceH4/tiny-random-LlamaForCausalLM", } return encoder_config @pytest.mark.parametrize( "adapter,quantization", [ (None, None), ("lora", None), ("lora", {"bits": 4}), ("lora", {"bits": 8}), ("adalora", None), ("adalora", {"bits": 4}), ("adalora", {"bits": 8}), ], ids=["FFT", "LoRA", "LoRA 4-bit", "LoRA 8-bit", "AdaLoRA", "AdaLoRA 4-bit", "AdaLoRA 8-bit"], ) def test_llm_encoding(llm_encoder_config, adapter, quantization, tmpdir): if quantization: pytest.importorskip("bitsandbytes", reason="bitsandbytes required for quantization tests") if ( _finetune_strategy_requires_cuda( finetune_strategy_name="lora" if adapter else None, quantization_args=quantization ) and not (torch.cuda.is_available() and torch.cuda.device_count()) > 0 ): pytest.skip("Skip: quantization requires GPU and none are available.") dataset_path = os.path.join(tmpdir, "llm_classification_data.csv") config = { MODEL_TYPE: MODEL_ECD, OUTPUT_FEATURES: [category_feature(name="output")], COMBINER: {TYPE: "sequence"}, TRAINER: {EPOCHS: 1}, } encoder_config = copy.deepcopy(llm_encoder_config) if adapter: encoder_config[ADAPTER] = {TYPE: adapter} if quantization: encoder_config[QUANTIZATION] = quantization config[BACKEND] = LOCAL_BACKEND config[INPUT_FEATURES] = [text_feature(name="input", encoder=encoder_config)] generate_data(input_features=config[INPUT_FEATURES], output_features=config[OUTPUT_FEATURES], filename=dataset_path) model = LudwigModel(config) model.train(dataset=dataset_path, output_directory=str(tmpdir)) def test_llm_batch_size_tuning(): dataset = pd.DataFrame({"instruction": ["a"] * 100, "output": ["a"] * 100}) config = yaml.safe_load(""" model_type: llm input_features: - name: instruction type: text output_features: - name: output type: text prompt: template: >- {instruction} adapter: type: lora trainer: type: finetune optimizer: type: adam batch_size: auto train_steps: 1 learning_rate: 0.0002 eval_batch_size: 2 backend: type: local base_model: HuggingFaceH4/tiny-random-LlamaForCausalLM """) model = LudwigModel(config=config) model.train(dataset=dataset) assert model.config_obj.trainer.batch_size > 1 @pytest.mark.llm def test_llm_used_tokens(tmpdir): input_features = [text_feature(name="input", encoder={"type": "passthrough"})] output_features = [text_feature(name="output")] df = pd.read_json("https://raw.githubusercontent.com/sahil280114/codealpaca/master/data/code_alpaca_20k.json").head( 10 ) # df = generate_data(input_features, output_features, filename=csv_filename, num_examples=25) config = { MODEL_TYPE: MODEL_LLM, BASE_MODEL: "hf-internal-testing/tiny-random-BartModel", INPUT_FEATURES: input_features, OUTPUT_FEATURES: output_features, TRAINER: { TYPE: "finetune", BATCH_SIZE: 1, EPOCHS: 3, "enable_gradient_checkpointing": True, }, } config[ADAPTER] = {TYPE: "lora"} model = LudwigModel(config) assert model.config_obj.trainer.enable_gradient_checkpointing model.train(dataset=df, output_directory=str(tmpdir), skip_save_processed_input=False) with open( os.path.join(str(tmpdir), "api_experiment_run", MODEL_FILE_NAME, "training_progress.json"), encoding="utf-8" ) as f: progress_tracker = json.load(f) assert progress_tracker["cumulative_step_token_usage"]["11"] == progress_tracker["total_tokens_used"] == 621 assert progress_tracker["checkpoint_to_epoch"] == {"1": 1, "2": 1, "3": 2, "4": 2, "5": 3, "6": 3} assert progress_tracker["checkpoint_to_step"] == {"1": 4, "2": 4, "3": 8, "4": 8, "5": 12, "6": 12} assert progress_tracker["cumulative_checkpoint_token_usage"] == { "1": 207, "2": 207, "3": 414, "4": 414, "5": 621, "6": 621, } assert progress_tracker["incremental_checkpoint_token_usage"] == { "1": 207, "2": 0, "3": 207, "4": 0, "5": 207, "6": 0, } ================================================ FILE: tests/integration_tests/test_missing_value_strategy.py ================================================ # Copyright (c) 2023 Predibase, Inc., 2020 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. # ============================================================================== import os import random import numpy as np import pandas as pd import pytest from ludwig.api import LudwigModel from ludwig.constants import BATCH_SIZE, COLUMN, DROP_ROW, FILL_WITH_MEAN, PREPROCESSING, PROC_COLUMN, TRAINER from ludwig.globals import MODEL_FILE_NAME from tests.integration_tests.utils import ( binary_feature, category_feature, generate_data, LocalTestBackend, number_feature, read_csv_with_nan, sequence_feature, set_feature, text_feature, vector_feature, ) def test_missing_value_prediction(tmpdir, csv_filename): random.seed(1) np.random.seed(1) input_features = [ category_feature( encoder={"vocab_size": 2}, reduce_input="sum", preprocessing=dict(missing_value_strategy="fill_with_mode") ) ] output_features = [binary_feature()] dataset = pd.read_csv(generate_data(input_features, output_features, csv_filename)) config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat", "output_size": 14}, } model = LudwigModel(config) _, _, output_dir = model.train(dataset=dataset, output_directory=tmpdir) # Set the input column to None, we should be able to replace the missing value with the mode # from the training set dataset[input_features[0]["name"]] = None model.predict(dataset=dataset) model = LudwigModel.load(os.path.join(output_dir, MODEL_FILE_NAME)) model.predict(dataset=dataset) @pytest.mark.parametrize( "backend", [ pytest.param("local", id="local"), pytest.param("ray", id="ray", marks=pytest.mark.distributed), ], ) def test_missing_values_fill_with_mean(backend, csv_filename, tmpdir, ray_cluster_2cpu): data_csv_path = os.path.join(tmpdir, csv_filename) kwargs = {PREPROCESSING: {"missing_value_strategy": FILL_WITH_MEAN}} input_features = [ number_feature(**kwargs), binary_feature(), category_feature(encoder={"vocab_size": 3}), ] output_features = [binary_feature()] training_data_csv_path = generate_data(input_features, output_features, data_csv_path) config = { "input_features": input_features, "output_features": output_features, TRAINER: {"epochs": 2, BATCH_SIZE: 128}, } # run preprocessing ludwig_model = LudwigModel(config, backend=backend) ludwig_model.preprocess(dataset=training_data_csv_path) def test_missing_values_drop_rows(csv_filename, tmpdir): data_csv_path = os.path.join(tmpdir, csv_filename) kwargs = {PREPROCESSING: {"missing_value_strategy": DROP_ROW}} input_features = [ number_feature(), binary_feature(), category_feature(encoder={"vocab_size": 3}), ] output_features = [ binary_feature(**kwargs), number_feature(**kwargs), category_feature(decoder={"vocab_size": 3}, **kwargs), sequence_feature(decoder={"vocab_size": 3}, **kwargs), text_feature(decoder={"vocab_size": 3}, **kwargs), set_feature(decoder={"vocab_size": 3}, **kwargs), vector_feature(**kwargs), ] backend = LocalTestBackend() config = { "input_features": input_features, "output_features": output_features, TRAINER: {"epochs": 2, BATCH_SIZE: 128}, } training_data_csv_path = generate_data(input_features, output_features, data_csv_path) df = read_csv_with_nan(training_data_csv_path, nan_percent=0.1) # run preprocessing ludwig_model = LudwigModel(config, backend=backend) ludwig_model.preprocess(dataset=df) @pytest.mark.parametrize( "backend,outlier_strategy,outlier_threshold", [ pytest.param("local", None, 3.0, id="local_none"), pytest.param("local", "fill_with_mean", 1.0, id="local_mean_strict"), pytest.param("local", "fill_with_const", 3.0, id="local_const_relaxed"), pytest.param("ray", "fill_with_mean", 3.0, id="ray_mean", marks=pytest.mark.distributed), ], ) def test_outlier_strategy(outlier_strategy, outlier_threshold, backend, tmpdir, ray_cluster_2cpu): fill_value = 42 kwargs = { PREPROCESSING: { "outlier_strategy": outlier_strategy, "outlier_threshold": outlier_threshold, "fill_value": fill_value, } } input_features = [ number_feature(**kwargs), ] output_features = [binary_feature()] # Values that will be 1 and 3 std deviations from the mean, respectively sigma1, sigma1_idx = -150, 4 sigma3, sigma3_idx = 300, 11 num_col = np.array([77, 24, 29, 29, sigma1, 71, 46, 95, 20, 52, 85, sigma3, 74, 10, 98, 53, 110, 94, 62, 13]) expected_fill_value = num_col.mean() if outlier_strategy == "fill_with_mean" else fill_value input_col = input_features[0][COLUMN] output_col = output_features[0][COLUMN] bin_col = np.array([1, 0, 1, 1, 0, 1, 1, 0, 0, 1, 1, 1, 1, 0, 0, 1, 0, 0, 1, 0], dtype=np.bool_) dataset_df = pd.DataFrame( data={ input_col: num_col, output_col: bin_col, } ) dataset_fp = os.path.join(tmpdir, "dataset.csv") dataset_df.to_csv(dataset_fp) config = { "input_features": input_features, "output_features": output_features, } # Run preprocessing ludwig_model = LudwigModel(config, backend=backend) proc_dataset = ludwig_model.preprocess(training_set=dataset_fp) # Check preprocessed output proc_df = ludwig_model.backend.df_engine.compute(proc_dataset.training_set.to_df()) proc_col = input_features[0][PROC_COLUMN] assert len(proc_df) == len(dataset_df) # Check that values over 1 std are replaced if outlier_strategy is not None and outlier_threshold <= 1.0: assert np.isclose(proc_df[proc_col][sigma1_idx], expected_fill_value) else: assert np.isclose(proc_df[proc_col][sigma1_idx], dataset_df[input_col][sigma1_idx]) # Check that values over 3 std are replaced if outlier_strategy is not None and outlier_threshold <= 3.0: assert np.isclose(proc_df[proc_col][sigma3_idx], expected_fill_value) else: assert np.isclose(proc_df[proc_col][sigma3_idx], dataset_df[input_col][sigma3_idx]) ================================================ FILE: tests/integration_tests/test_mlflow.py ================================================ import os import shutil import uuid from unittest import mock import mlflow import pandas as pd import pytest import yaml from mlflow.tracking import MlflowClient from ludwig.api import LudwigModel from ludwig.constants import TRAINER from ludwig.contribs.mlflow import MlflowCallback from ludwig.export import export_mlflow from ludwig.globals import MODEL_FILE_NAME from ludwig.utils.backward_compatibility import upgrade_config_dict_to_latest_version from tests.integration_tests.utils import category_feature, FakeRemoteBackend, generate_data, sequence_feature def run_mlflow_callback_test(mlflow_client, config, training_data, val_data, test_data, tmpdir, exp_name=None): ludwig_exp_name = "mlflow_test" callback = MlflowCallback() wrapped_callback = mock.Mock(wraps=callback) model = LudwigModel(config, callbacks=[wrapped_callback], backend=FakeRemoteBackend()) model.train( training_set=training_data, validation_set=val_data, test_set=test_data, experiment_name=ludwig_exp_name ) expected_df, _ = model.predict(test_data) # Check mlflow artifacts assert callback.experiment_id is not None assert callback.run is not None mlflow_exp_name = exp_name or ludwig_exp_name experiment = mlflow.get_experiment_by_name(mlflow_exp_name) assert experiment.experiment_id == callback.experiment_id df = mlflow.search_runs([experiment.experiment_id]) assert len(df) == 1 run_id = df.run_id[0] assert run_id == callback.run.info.run_id run = mlflow.get_run(run_id) expected_status = "FINISHED" if exp_name is None else "RUNNING" assert run.info.status == expected_status assert wrapped_callback.on_trainer_train_setup.call_count == 1 assert wrapped_callback.on_trainer_train_teardown.call_count == 1 artifacts = [f.path for f in mlflow_client.list_artifacts(callback.run.info.run_id, "")] local_dir = f"{tmpdir}/local_artifacts" os.makedirs(local_dir) assert "config.yaml" in artifacts local_config_path = mlflow_client.download_artifacts(callback.run.info.run_id, "config.yaml", local_dir) with open(local_config_path) as f: config_artifact = yaml.safe_load(f) assert config_artifact == upgrade_config_dict_to_latest_version(config) model_path = f"runs:/{callback.run.info.run_id}/model" loaded_model = mlflow.pyfunc.load_model(model_path) assert "ludwig" in loaded_model.metadata.flavors flavor = loaded_model.metadata.flavors["ludwig"] config = model.config def compare_features(key): assert len(config[key]) == len(flavor["ludwig_schema"][key]) for feature, schema_feature in zip(config[key], flavor["ludwig_schema"][key]): assert feature["name"] == schema_feature["name"] assert feature["type"] == schema_feature["type"] compare_features("input_features") compare_features("output_features") test_df = pd.read_csv(test_data) pred_df = loaded_model.predict(test_df) assert pred_df.equals(expected_df) return run def run_mlflow_callback_test_without_artifacts(mlflow_client, config, training_data, val_data, test_data): exp_name = "mlflow_test_without_artifacts" callback = MlflowCallback(log_artifacts=False) wrapped_callback = mock.Mock(wraps=callback) model = LudwigModel(config, callbacks=[wrapped_callback], backend=FakeRemoteBackend()) model.train(training_set=training_data, validation_set=val_data, test_set=test_data, experiment_name=exp_name) expected_df, _ = model.predict(test_data) # Check mlflow artifacts artifacts = [f.path for f in mlflow_client.list_artifacts(callback.run.info.run_id, "")] assert len(artifacts) == 0 @pytest.mark.parametrize("external_run", [False, True], ids=["internal_run", "external_run"]) def test_mlflow(tmpdir, external_run): epochs = 2 batch_size = 8 num_examples = 32 input_features = [sequence_feature(reduce_output="sum")] output_features = [category_feature(vocab_size=2, reduce_input="sum", output_feature=True)] config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat", "output_size": 14}, TRAINER: {"epochs": epochs, "batch_size": batch_size}, } data_csv = generate_data( input_features, output_features, os.path.join(tmpdir, "train.csv"), num_examples=num_examples ) val_csv = shutil.copyfile(data_csv, os.path.join(tmpdir, "validation.csv")) test_csv = shutil.copyfile(data_csv, os.path.join(tmpdir, "test.csv")) mlflow_uri = f"file://{tmpdir}/mlruns" mlflow.set_tracking_uri(mlflow_uri) client = MlflowClient(tracking_uri=mlflow_uri) exp_name = None run = None if external_run: # Start a run here and make sure it's still active when training completes exp_name = f"ext_experiment_{uuid.uuid4().hex}" exp_id = mlflow.create_experiment(name=exp_name) run = mlflow.start_run(experiment_id=exp_id, run_name=f"ext_run_{uuid.uuid4().hex}") callback_run = run_mlflow_callback_test(client, config, data_csv, val_csv, test_csv, tmpdir, exp_name=exp_name) if not external_run: run_mlflow_callback_test_without_artifacts(client, config, data_csv, val_csv, test_csv) else: assert run.info.run_id == callback_run.info.run_id active_run = mlflow.active_run() assert active_run is not None assert run.info.run_id == active_run.info.run_id mlflow.end_run() def test_export_mlflow_local(tmpdir): epochs = 2 batch_size = 8 num_examples = 32 input_features = [sequence_feature(reduce_output="sum")] output_features = [category_feature(vocab_size=2, reduce_input="sum", output_feature=True)] config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat", "output_size": 14}, TRAINER: {"epochs": epochs, "batch_size": batch_size}, } data_csv = generate_data( input_features, output_features, os.path.join(tmpdir, "train.csv"), num_examples=num_examples ) exp_name = "mlflow_test" output_dir = os.path.join(tmpdir, "output") model = LudwigModel(config, backend=FakeRemoteBackend()) _, _, output_directory = model.train(training_set=data_csv, experiment_name=exp_name, output_directory=output_dir) model_path = os.path.join(output_directory, MODEL_FILE_NAME) output_path = os.path.join(tmpdir, "data/results/mlflow") export_mlflow(model_path, output_path) assert set(os.listdir(output_path)) == {"MLmodel", MODEL_FILE_NAME, "conda.yaml"} @pytest.mark.distributed def test_mlflow_ray(tmpdir, ray_cluster_2cpu): epochs = 2 batch_size = 8 num_examples = 32 input_features = [sequence_feature(reduce_output="sum")] output_features = [category_feature(vocab_size=2, reduce_input="sum", output_feature=True)] config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat", "output_size": 14}, TRAINER: {"epochs": epochs, "batch_size": batch_size}, } data_csv = generate_data( input_features, output_features, os.path.join(tmpdir, "train.csv"), num_examples=num_examples ) exp_name = "mlflow_test" output_dir = os.path.join(tmpdir, "output") model = LudwigModel(config, callbacks=[MlflowCallback()], backend="ray") _, _, output_directory = model.train(training_set=data_csv, experiment_name=exp_name, output_directory=output_dir) ================================================ FILE: tests/integration_tests/test_model_save_and_load.py ================================================ import os import os.path import random import numpy as np import pandas as pd import pytest import torch from ludwig.api import LudwigModel from ludwig.constants import BATCH_SIZE, ENCODER, LOSS, NAME, PREPROCESSING, TRAINER, TRAINING, TYPE from ludwig.data.split import get_splitter from ludwig.globals import MODEL_FILE_NAME from ludwig.modules.loss_modules import MSELoss from ludwig.schema.features.loss.loss import MSELossConfig from ludwig.utils.data_utils import read_csv from tests.integration_tests.utils import ( audio_feature, bag_feature, binary_feature, category_feature, date_feature, generate_data, h3_feature, image_feature, LocalTestBackend, number_feature, sequence_feature, set_feature, text_feature, timeseries_feature, vector_feature, ) def test_model_load_from_checkpoint(tmpdir, csv_filename, tmp_path): torch.manual_seed(1) random.seed(1) np.random.seed(1) input_features = [ binary_feature(), number_feature(), ] output_features = [ binary_feature(), ] data_csv_path = generate_data(input_features, output_features, csv_filename, num_examples=20) config = { "input_features": input_features, "output_features": output_features, TRAINER: {"epochs": 1, BATCH_SIZE: 2}, } backend = LocalTestBackend() # create sub-directory to store results results_dir = tmp_path / "results" results_dir.mkdir() data_df = read_csv(data_csv_path) splitter = get_splitter("random") training_set, validation_set, test_set = splitter.split(data_df, backend) ludwig_model1 = LudwigModel(config, backend=backend) _, _, output_dir = ludwig_model1.train( training_set=training_set, validation_set=validation_set, test_set=test_set, output_directory="results", # results_dir ) model_dir = os.path.join(output_dir, MODEL_FILE_NAME) ludwig_model_loaded = LudwigModel.load(model_dir, backend=backend, from_checkpoint=True) preds_1, _ = ludwig_model1.predict(dataset=validation_set) def check_model_equal(ludwig_model2): # Compare model predictions preds_2, _ = ludwig_model2.predict(dataset=validation_set) assert set(preds_1.keys()) == set(preds_2.keys()) for key in preds_1: assert preds_1[key].dtype == preds_2[key].dtype, key assert np.all(a == b for a, b in zip(preds_1[key], preds_2[key])), key # assert preds_2[key].dtype == preds_3[key].dtype, key # assert list(preds_2[key]) == list(preds_3[key]), key # Compare model weights for if_name in ludwig_model1.model.input_features: if1 = ludwig_model1.model.input_features.get(if_name) if2 = ludwig_model2.model.input_features.get(if_name) for if1_w, if2_w in zip(if1.encoder_obj.parameters(), if2.encoder_obj.parameters()): assert torch.allclose(if1_w, if2_w) c1 = ludwig_model1.model.combiner c2 = ludwig_model2.model.combiner for c1_w, c2_w in zip(c1.parameters(), c2.parameters()): assert torch.allclose(c1_w, c2_w) for of_name in ludwig_model1.model.output_features: of1 = ludwig_model1.model.output_features.get(of_name) of2 = ludwig_model2.model.output_features.get(of_name) for of1_w, of2_w in zip(of1.decoder_obj.parameters(), of2.decoder_obj.parameters()): assert torch.allclose(of1_w, of2_w) check_model_equal(ludwig_model_loaded) def test_model_save_reload_api(tmpdir, csv_filename, tmp_path): torch.manual_seed(1) random.seed(1) np.random.seed(1) image_dest_folder = os.path.join(tmpdir, "generated_images") audio_dest_folder = os.path.join(tmpdir, "generated_audio") input_features = [ binary_feature(), number_feature(), category_feature(encoder={"vocab_size": 3}), sequence_feature(encoder={"vocab_size": 3}), text_feature( encoder={"vocab_size": 3, "type": "rnn", "cell_type": "lstm", "num_layers": 2, "bidirectional": False} ), vector_feature(), image_feature(image_dest_folder, encoder={"type": "mlp_mixer", "patch_size": 12}), audio_feature(audio_dest_folder, encoder={"type": "stacked_cnn"}), timeseries_feature(encoder={"type": "parallel_cnn"}), sequence_feature(encoder={"vocab_size": 3, "type": "stacked_parallel_cnn"}), date_feature(), h3_feature(), set_feature(encoder={"vocab_size": 3}), bag_feature(encoder={"vocab_size": 3}), ] output_features = [ binary_feature(), number_feature(), category_feature(decoder={"vocab_size": 3}, output_feature=True), sequence_feature(decoder={"vocab_size": 3}, output_feature=True), text_feature(decoder={"vocab_size": 3}, output_feature=True), set_feature(decoder={"vocab_size": 3}, output_feature=True), vector_feature(), ] # Generate test data data_csv_path = generate_data(input_features, output_features, csv_filename, num_examples=20) ############# # Train model ############# config = { "input_features": input_features, "output_features": output_features, TRAINER: {"train_steps": 1, BATCH_SIZE: 128}, } data_df = read_csv(data_csv_path) splitter = get_splitter("random") training_set, validation_set, test_set = splitter.split(data_df, LocalTestBackend()) # create sub-directory to store results results_dir = tmp_path / "results" results_dir.mkdir() # perform initial model training backend = LocalTestBackend() ludwig_model1 = LudwigModel(config, backend=backend) _, _, output_dir = ludwig_model1.train( training_set=training_set, validation_set=validation_set, test_set=test_set, output_directory="results", # results_dir ) preds_1, _ = ludwig_model1.predict(dataset=validation_set) def check_model_equal(ludwig_model2): # Compare model predictions preds_2, _ = ludwig_model2.predict(dataset=validation_set) assert set(preds_1.keys()) == set(preds_2.keys()) for key in preds_1: assert preds_1[key].dtype == preds_2[key].dtype, key assert np.all(a == b for a, b in zip(preds_1[key], preds_2[key])), key # assert preds_2[key].dtype == preds_3[key].dtype, key # assert list(preds_2[key]) == list(preds_3[key]), key # Compare model weights for if_name in ludwig_model1.model.input_features: if1 = ludwig_model1.model.input_features.get(if_name) if2 = ludwig_model2.model.input_features.get(if_name) for if1_w, if2_w in zip(if1.encoder_obj.parameters(), if2.encoder_obj.parameters()): assert torch.allclose(if1_w, if2_w) c1 = ludwig_model1.model.combiner c2 = ludwig_model2.model.combiner for c1_w, c2_w in zip(c1.parameters(), c2.parameters()): assert torch.allclose(c1_w, c2_w) for of_name in ludwig_model1.model.output_features: of1 = ludwig_model1.model.output_features.get(of_name) of2 = ludwig_model2.model.output_features.get(of_name) for of1_w, of2_w in zip(of1.decoder_obj.parameters(), of2.decoder_obj.parameters()): assert torch.allclose(of1_w, of2_w) ludwig_model1.save(tmpdir) ludwig_model_loaded = LudwigModel.load(tmpdir, backend=backend) check_model_equal(ludwig_model_loaded) # Test loading the model from the experiment directory ludwig_model_exp = LudwigModel.load(os.path.join(output_dir, MODEL_FILE_NAME), backend=backend) check_model_equal(ludwig_model_exp) def test_model_weights_match_training(tmpdir, csv_filename): np.random.seed(1) input_features = [number_feature()] output_features = [number_feature()] output_feature_name = output_features[0][NAME] # Generate test data data_csv_path = generate_data(input_features, output_features, os.path.join(tmpdir, csv_filename), num_examples=20) config = { "input_features": input_features, "output_features": output_features, "trainer": { "epochs": 3, "batch_size": 32, "evaluate_training_set": True, # needed to ensure exact training metrics computed }, } model = LudwigModel( config=config, ) training_stats, _, _ = model.train(training_set=data_csv_path, random_seed=1919) # generate predicitons from training data df = pd.read_csv(data_csv_path) predictions = model.predict(df) # compute loss on predictions from training data loss_function = MSELoss(MSELossConfig()) loss = loss_function( torch.tensor(predictions[0][output_feature_name + "_predictions"].values), # predictions torch.tensor(df[output_feature_name].values), # target ).type(torch.float32) # get last loss value from training last_training_loss = torch.tensor(training_stats[TRAINING][output_feature_name][LOSS][-1]) # loss from predictions should match last loss value recorded during training assert torch.isclose(loss, last_training_loss), ( "Model predictions on training set did not generate same loss value as in training. " "Need to confirm that weights were correctly captured in model." ) @pytest.mark.parametrize("torch_encoder, variant", [("resnet", 18), ("googlenet", "base")]) def test_model_save_reload_tv_model(torch_encoder, variant, tmpdir, csv_filename, tmp_path): torch.manual_seed(1) random.seed(1) np.random.seed(1) image_dest_folder = os.path.join(tmpdir, "generated_images") input_features = [ image_feature(image_dest_folder), ] input_features[0][ENCODER] = { TYPE: torch_encoder, "model_variant": variant, } input_features[0][PREPROCESSING]["height"] = 128 input_features[0][PREPROCESSING]["width"] = 128 output_features = [ category_feature(decoder={"vocab_size": 3}), ] # Generate test data data_csv_path = generate_data(input_features, output_features, csv_filename, num_examples=20) ############# # Train model ############# config = { "input_features": input_features, "output_features": output_features, TRAINER: {"train_steps": 1, BATCH_SIZE: 128}, } data_df = read_csv(data_csv_path) splitter = get_splitter("random") training_set, validation_set, test_set = splitter.split(data_df, LocalTestBackend()) # create sub-directory to store results results_dir = tmp_path / "results" results_dir.mkdir() # perform initial model training backend = LocalTestBackend() ludwig_model1 = LudwigModel(config, backend=backend) _, _, output_dir = ludwig_model1.train( training_set=training_set, validation_set=validation_set, test_set=test_set, output_directory="results", # results_dir ) preds_1, _ = ludwig_model1.predict(dataset=validation_set) def check_model_equal(ludwig_model2): # Compare model predictions preds_2, _ = ludwig_model2.predict(dataset=validation_set) assert set(preds_1.keys()) == set(preds_2.keys()) for key in preds_1: assert preds_1[key].dtype == preds_2[key].dtype, key assert np.all(a == b for a, b in zip(preds_1[key], preds_2[key])), key # assert preds_2[key].dtype == preds_3[key].dtype, key # assert list(preds_2[key]) == list(preds_3[key]), key # Compare model weights for if_name in ludwig_model1.model.input_features: if1 = ludwig_model1.model.input_features.get(if_name) if2 = ludwig_model2.model.input_features.get(if_name) for if1_w, if2_w in zip(if1.encoder_obj.parameters(), if2.encoder_obj.parameters()): assert torch.allclose(if1_w, if2_w) c1 = ludwig_model1.model.combiner c2 = ludwig_model2.model.combiner for c1_w, c2_w in zip(c1.parameters(), c2.parameters()): assert torch.allclose(c1_w, c2_w) for of_name in ludwig_model1.model.output_features: of1 = ludwig_model1.model.output_features.get(of_name) of2 = ludwig_model2.model.output_features.get(of_name) for of1_w, of2_w in zip(of1.decoder_obj.parameters(), of2.decoder_obj.parameters()): assert torch.allclose(of1_w, of2_w) ludwig_model1.save(tmpdir) ludwig_model_loaded = LudwigModel.load(tmpdir, backend=backend) # confirm model structure and weights are the same check_model_equal(ludwig_model_loaded) # Test loading the model from the experiment directory ludwig_model_exp = LudwigModel.load(os.path.join(output_dir, MODEL_FILE_NAME), backend=backend) # confirm model structure and weights are the same check_model_equal(ludwig_model_exp) @pytest.mark.slow def test_model_save_reload_hf_model(tmpdir, csv_filename, tmp_path): torch.manual_seed(1) random.seed(1) np.random.seed(1) input_features = [ text_feature( encoder={ "vocab_size": 3, "type": "bert", } ), ] output_features = [ category_feature(decoder={"vocab_size": 3}), ] # Generate test data data_csv_path = generate_data(input_features, output_features, csv_filename, num_examples=20) ############# # Train model ############# config = { "input_features": input_features, "output_features": output_features, TRAINER: {"train_steps": 1, BATCH_SIZE: 128}, } data_df = read_csv(data_csv_path) splitter = get_splitter("random") training_set, validation_set, test_set = splitter.split(data_df, LocalTestBackend()) # create sub-directory to store results results_dir = tmp_path / "results" results_dir.mkdir() # perform initial model training backend = LocalTestBackend() ludwig_model1 = LudwigModel(config, backend=backend) _, _, output_dir = ludwig_model1.train( training_set=training_set, validation_set=validation_set, test_set=test_set, output_directory="results", # results_dir ) preds_1, _ = ludwig_model1.predict(dataset=validation_set) def check_model_equal(ludwig_model2): # Compare model predictions preds_2, _ = ludwig_model2.predict(dataset=validation_set) assert set(preds_1.keys()) == set(preds_2.keys()) for key in preds_1: assert preds_1[key].dtype == preds_2[key].dtype, key assert np.all(a == b for a, b in zip(preds_1[key], preds_2[key])), key # assert preds_2[key].dtype == preds_3[key].dtype, key # assert list(preds_2[key]) == list(preds_3[key]), key # Compare model weights for if_name in ludwig_model1.model.input_features: if1 = ludwig_model1.model.input_features.get(if_name) if2 = ludwig_model2.model.input_features.get(if_name) for if1_w, if2_w in zip(if1.encoder_obj.parameters(), if2.encoder_obj.parameters()): assert torch.allclose(if1_w, if2_w) c1 = ludwig_model1.model.combiner c2 = ludwig_model2.model.combiner for c1_w, c2_w in zip(c1.parameters(), c2.parameters()): assert torch.allclose(c1_w, c2_w) for of_name in ludwig_model1.model.output_features: of1 = ludwig_model1.model.output_features.get(of_name) of2 = ludwig_model2.model.output_features.get(of_name) for of1_w, of2_w in zip(of1.decoder_obj.parameters(), of2.decoder_obj.parameters()): assert torch.allclose(of1_w, of2_w) ludwig_model1.save(tmpdir) ludwig_model_loaded = LudwigModel.load(tmpdir, backend=backend) # confirm model structure and weights are the same check_model_equal(ludwig_model_loaded) # Test loading the model from the experiment directory ludwig_model_exp = LudwigModel.load(os.path.join(output_dir, MODEL_FILE_NAME), backend=backend) # confirm model structure and weights are the same check_model_equal(ludwig_model_exp) ================================================ FILE: tests/integration_tests/test_model_training_options.py ================================================ import json import logging import os.path import re import numpy as np import pandas as pd import pytest import torch from ludwig import globals as global_vars from ludwig.api import LudwigModel from ludwig.backend import LOCAL_BACKEND from ludwig.constants import ( BATCH_SIZE, CATEGORY, DEFAULTS, EPOCHS, INPUT_FEATURES, OUTPUT_FEATURES, PREPROCESSING, TRAINER, TRAINING, ) from ludwig.contribs.mlflow import MlflowCallback from ludwig.experiment import experiment_cli from ludwig.features.number_feature import numeric_transformation_registry from ludwig.globals import DESCRIPTION_FILE_NAME, MODEL_FILE_NAME, MODEL_WEIGHTS_FILE_NAME, TRAINING_PREPROC_FILE_NAME from ludwig.utils.data_utils import load_json, replace_file_extension from ludwig.utils.misc_utils import get_from_registry from ludwig.utils.package_utils import LazyLoader from tests.integration_tests import synthetic_test_data from tests.integration_tests.utils import category_feature, generate_data, LocalTestBackend mlflow = LazyLoader("mlflow", globals(), "mlflow") RANDOM_SEED = 42 @pytest.mark.parametrize("early_stop", [3, 5]) def test_early_stopping(early_stop, tmp_path): input_features, output_features = synthetic_test_data.get_feature_configs() config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat"}, TRAINER: {"epochs": 20, "early_stop": early_stop, "batch_size": 16, "learning_rate": 0.01}, } # create sub-directory to store results results_dir = tmp_path / "results" results_dir.mkdir() # run experiment generated_data = synthetic_test_data.get_generated_data() _, _, _, _, output_dir = experiment_cli( training_set=generated_data.train_df, validation_set=generated_data.validation_df, test_set=generated_data.test_df, output_directory=str(results_dir), config=config, skip_save_processed_input=True, skip_save_progress=True, skip_save_unprocessed_output=True, skip_save_model=True, skip_save_log=True, ) # test existence of required files train_stats_fp = os.path.join(output_dir, "training_statistics.json") metadata_fp = os.path.join(output_dir, DESCRIPTION_FILE_NAME) assert os.path.isfile(train_stats_fp) assert os.path.isfile(metadata_fp) # retrieve results so we can validate early stopping with open(train_stats_fp) as f: train_stats = json.load(f) with open(metadata_fp) as f: metadata = json.load(f) # get early stopping value early_stop_value = metadata["config"][TRAINER]["early_stop"] # retrieve validation losses vald_losses_data = train_stats["validation"]["combined"]["loss"] last_evaluation = len(vald_losses_data) - 1 best_evaluation = np.argmin(vald_losses_data) assert last_evaluation - best_evaluation == early_stop_value @pytest.mark.parametrize("skip_save_progress", [False]) @pytest.mark.parametrize("skip_save_model", [False, True]) def test_model_progress_save(skip_save_progress, skip_save_model, tmp_path): input_features, output_features = synthetic_test_data.get_feature_configs() config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat"}, TRAINER: {"epochs": 2, BATCH_SIZE: 128}, } # create sub-directory to store results results_dir = tmp_path / "results" results_dir.mkdir() # run experiment generated_data = synthetic_test_data.get_generated_data() _, _, _, _, output_dir = experiment_cli( training_set=generated_data.train_df, validation_set=generated_data.validation_df, test_set=generated_data.test_df, output_directory=str(results_dir), config=config, skip_save_processed_input=True, skip_save_progress=skip_save_progress, skip_save_unprocessed_output=True, skip_save_model=skip_save_model, skip_save_log=True, ) # ========== Check for required result data sets ============= model_dir = os.path.join(output_dir, MODEL_FILE_NAME) files = [f for f in os.listdir(model_dir) if re.match(MODEL_WEIGHTS_FILE_NAME, f)] if skip_save_model: assert len(files) == 0 else: assert len(files) == 1 training_checkpoints_dir = os.path.join(output_dir, MODEL_FILE_NAME, "training_checkpoints") training_checkpoints = os.listdir(training_checkpoints_dir) if skip_save_progress: assert len(training_checkpoints) == 0 else: assert len(training_checkpoints) > 0 @pytest.mark.parametrize("optimizer", ["sgd", "adam"]) def test_resume_training(optimizer, tmp_path): input_features, output_features = synthetic_test_data.get_feature_configs() config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat"}, TRAINER: {"epochs": 2, "batch_size": 16, "optimizer": {"type": optimizer}}, } # create sub-directory to store results results_dir = tmp_path / "results" results_dir.mkdir() generated_data = synthetic_test_data.get_generated_data() _, _, _, _, output_dir1 = experiment_cli( config, training_set=generated_data.train_df, validation_set=generated_data.validation_df, test_set=generated_data.test_df, ) config[TRAINER]["epochs"] = 5 experiment_cli( config, training_set=generated_data.train_df, validation_set=generated_data.validation_df, test_set=generated_data.test_df, model_resume_path=output_dir1, ) _, _, _, _, output_dir2 = experiment_cli( config, training_set=generated_data.train_df, validation_set=generated_data.validation_df, test_set=generated_data.test_df, ) # compare learning curves with and without resuming ts1 = load_json(os.path.join(output_dir1, "training_statistics.json")) ts2 = load_json(os.path.join(output_dir2, "training_statistics.json")) print("ts1", ts1) print("ts2", ts2) assert ts1[TRAINING]["combined"]["loss"] == ts2[TRAINING]["combined"]["loss"] # compare predictions with and without resuming y_pred1 = np.load(os.path.join(output_dir1, "y_predictions.npy")) y_pred2 = np.load(os.path.join(output_dir2, "y_predictions.npy")) print("y_pred1", y_pred1) print("y_pred2", y_pred2) assert np.all(np.isclose(y_pred1, y_pred2)) @pytest.mark.parametrize("optimizer", ["sgd", "adam"]) def test_resume_training_mlflow(optimizer, tmp_path): input_features, output_features = synthetic_test_data.get_feature_configs() config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat"}, TRAINER: {"epochs": 2, "batch_size": 16, "eval_batch_size": 2, "optimizer": {"type": optimizer}}, } # create sub-directory to store results results_dir = tmp_path / "results" results_dir.mkdir() mlflow_uri = f"file://{tmp_path}/mlruns" experiment_name = optimizer + "_experiment" generated_data = synthetic_test_data.get_generated_data() _, _, _, _, output_dir1 = experiment_cli( config, training_set=generated_data.train_df, validation_set=generated_data.validation_df, test_set=generated_data.test_df, callbacks=[MlflowCallback(mlflow_uri)], experiment_name=experiment_name, ) # Can't change any artifact spec on a run once it has been logged to mlflow, so skipping changing epochs _, _, _, _, output_dir2 = experiment_cli( config, training_set=generated_data.train_df, validation_set=generated_data.validation_df, test_set=generated_data.test_df, model_resume_path=output_dir1, callbacks=[MlflowCallback(mlflow_uri)], experiment_name=experiment_name, ) # make sure there is only one mlflow run id experiment = mlflow.get_experiment_by_name(experiment_name) previous_runs = mlflow.search_runs([experiment.experiment_id]) assert len(previous_runs) == 1 @pytest.mark.parametrize("optimizer_type", ["sgd", "adam", "adamw", "adagrad", "rmsprop"]) def test_optimizers(optimizer_type, tmp_path): if (optimizer_type in {"lars", "lamb", "lion"}) and ( not torch.cuda.is_available() or torch.cuda.device_count() == 0 ): pytest.skip("Skip: lars, lamb, and lion optimizers require GPU and none are available.") if ("paged" in optimizer_type or "8bit" in optimizer_type) and ( not torch.cuda.is_available() or torch.cuda.device_count() == 0 ): pytest.skip("Skip: paged and 8-bit optimizers require GPU and none are available.") input_features, output_features = synthetic_test_data.get_feature_configs() config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat"}, TRAINER: {"epochs": 2, "batch_size": 16, "evaluate_training_set": True, "optimizer": {"type": optimizer_type}}, } # special handling for adadelta and lbfgs, break out of local minima if optimizer_type == "adadelta": config[TRAINER]["learning_rate"] = 0.1 if optimizer_type == "lbfgs": config[TRAINER]["learning_rate"] = 0.05 model = LudwigModel(config) # create sub-directory to store results results_dir = tmp_path / "results" results_dir.mkdir() # run experiment generated_data = synthetic_test_data.get_generated_data_for_optimizer() train_stats, preprocessed_data, output_directory = model.train( training_set=generated_data.train_df, output_directory=str(results_dir), config=config, skip_save_processed_input=True, skip_save_progress=True, skip_save_unprocessed_output=True, skip_save_model=True, skip_save_log=True, ) # retrieve training losses for first and last entries. train_losses = train_stats[TRAINING]["combined"]["loss"] last_entry = len(train_losses) # ensure train loss for last entry is less than to the first entry. np.testing.assert_array_less(train_losses[last_entry - 1], train_losses[0]) def test_regularization(tmp_path): input_features, output_features = synthetic_test_data.get_feature_configs() config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat"}, TRAINER: { "epochs": 1, "batch_size": 16, "regularization_lambda": 1, }, } # create sub-directory to store results results_dir = tmp_path / "results" results_dir.mkdir() regularization_losses = [] generated_data = synthetic_test_data.get_generated_data() for regularizer in [None, "l1", "l2", "l1_l2"]: np.random.seed(RANDOM_SEED) torch.manual_seed(RANDOM_SEED) # setup regularization parameters config[TRAINER]["regularization_type"] = regularizer # run experiment _, _, _, _, output_dir = experiment_cli( training_set=generated_data.train_df, validation_set=generated_data.validation_df, test_set=generated_data.test_df, output_directory=str(results_dir), config=config, experiment_name="regularization", model_name=str(regularizer), skip_save_processed_input=True, skip_save_progress=True, skip_save_unprocessed_output=True, skip_save_model=True, skip_save_log=True, ) # test existence of required files train_stats_fp = os.path.join(output_dir, "training_statistics.json") metadata_fp = os.path.join(output_dir, DESCRIPTION_FILE_NAME) assert os.path.isfile(train_stats_fp) assert os.path.isfile(metadata_fp) # retrieve results so we can compare training loss with regularization with open(train_stats_fp) as f: train_stats = json.load(f) # retrieve training losses for all epochs train_losses = train_stats[TRAINING]["combined"]["loss"] regularization_losses.append(train_losses[0]) # create a set of losses regularization_losses_set = set(regularization_losses) # ensure all losses obtained with the different methods are different assert len(regularization_losses) == len(regularization_losses_set) # test cache checksum function def test_cache_checksum(csv_filename, tmp_path): # setup for training input_features = [category_feature(encoder={"vocab_size": 5})] output_features = [category_feature(decoder={"vocab_size": 2}, top_k=2)] source_dataset = os.path.join(tmp_path, csv_filename) source_dataset = generate_data(input_features, output_features, source_dataset) config = { INPUT_FEATURES: input_features, OUTPUT_FEATURES: output_features, DEFAULTS: {CATEGORY: {PREPROCESSING: {"fill_value": ""}}}, TRAINER: {EPOCHS: 2, BATCH_SIZE: 128}, } backend = LocalTestBackend() cache_fname = replace_file_extension(source_dataset, TRAINING_PREPROC_FILE_NAME) # conduct initial training output_directory = os.path.join(tmp_path, "results") model = LudwigModel(config, backend=backend) model.train(dataset=source_dataset, output_directory=output_directory) first_training_timestamp = os.path.getmtime(cache_fname) # conduct second training, should not force recreating hdf5 model = LudwigModel(config, backend=backend) model.train(dataset=source_dataset, output_directory=output_directory) current_training_timestamp = os.path.getmtime(cache_fname) # time stamps should be the same assert first_training_timestamp == current_training_timestamp # force recreating cache file by changing checksum by updating defaults prior_training_timestamp = current_training_timestamp config[DEFAULTS][CATEGORY][PREPROCESSING]["fill_value"] = "" model = LudwigModel(config, backend=backend) model.train(dataset=source_dataset, output_directory=output_directory) current_training_timestamp = os.path.getmtime(cache_fname) # timestamp should differ assert prior_training_timestamp < current_training_timestamp # force recreating cache by updating modification time of source dataset prior_training_timestamp = current_training_timestamp os.utime(source_dataset) model = LudwigModel(config, backend=backend) model.train(dataset=source_dataset, output_directory=output_directory) current_training_timestamp = os.path.getmtime(cache_fname) # timestamps should be different assert prior_training_timestamp < current_training_timestamp # force change in feature preprocessing prior_training_timestamp = current_training_timestamp input_features = config[INPUT_FEATURES].copy() input_features[0][PREPROCESSING] = {"lowercase": True} config[INPUT_FEATURES] = input_features model = LudwigModel(config, backend=backend) model.train(dataset=source_dataset, output_directory=output_directory) current_training_timestamp = os.path.getmtime(cache_fname) # timestamps should be different assert prior_training_timestamp < current_training_timestamp # force change in features names (and properties) prior_training_timestamp = current_training_timestamp input_features = [category_feature(encoder={"vocab_size": 5}), category_feature()] source_dataset = generate_data(input_features, output_features, source_dataset) config[INPUT_FEATURES] = input_features model = LudwigModel(config, backend=backend) model.train(dataset=source_dataset, output_directory=output_directory) current_training_timestamp = os.path.getmtime(cache_fname) # timestamps should be different assert prior_training_timestamp < current_training_timestamp # force change in Ludwig version prior_training_timestamp = current_training_timestamp global_vars.LUDWIG_VERSION = "new_version" model = LudwigModel(config, backend=backend) model.train(dataset=source_dataset, output_directory=output_directory) current_training_timestamp = os.path.getmtime(cache_fname) # timestamps should be different assert prior_training_timestamp < current_training_timestamp @pytest.mark.parametrize("transformer_key", list(numeric_transformation_registry.keys())) def test_numeric_transformer(transformer_key, tmpdir): Transformer = get_from_registry(transformer_key, numeric_transformation_registry) transformer_name = Transformer().__class__.__name__ if transformer_name == "Log1pTransformer": raw_values = np.random.lognormal(5, 2, size=100) else: raw_values = np.random.normal(5, 2, size=100) backend = LOCAL_BACKEND parameters = Transformer.fit_transform_params(raw_values, backend) if transformer_name in {"Log1pTransformer", "IdentityTransformer"}: # should be empty assert not bool(parameters) else: # should not be empty assert bool(parameters) # instantiate numeric transformer numeric_transfomer = Transformer(**parameters) # transform values transformed_values = numeric_transfomer.transform(raw_values) # inverse transform the prior transformed values reconstructed_values = numeric_transfomer.inverse_transform(transformed_values) # should now match assert np.allclose(raw_values, reconstructed_values) # now test numeric transformer with output feature df = pd.DataFrame(np.array([raw_values, raw_values]).T, columns=["x", "y"]) config = { "input_features": [{"name": "x", "type": "number"}], "output_features": [{"name": "y", "type": "number", "preprocessing": {"normalization": transformer_key}}], "combiner": { "type": "concat", }, TRAINER: { "epochs": 2, "batch_size": 16, }, } args = { "config": config, "skip_save_processed_input": True, "output_directory": os.path.join(tmpdir, "results"), "logging_level": logging.WARN, } # ensure no exceptions are raised experiment_cli(dataset=df, **args) ================================================ FILE: tests/integration_tests/test_number_feature.py ================================================ import pandas as pd import pytest from ludwig.api import LudwigModel from tests.integration_tests.utils import generate_data, number_feature def test_number_feature_zscore_normalization_error(): input_features = [number_feature(name="num_input", preprocessing={"normalization": "zscore"})] output_features = [number_feature(name="num_output")] df = pd.read_csv(generate_data(input_features, output_features)) # Override input number feature to have a constant value df["num_input"] = 1 config = { "input_features": input_features, "output_features": output_features, } model = LudwigModel(config, backend="local") with pytest.raises(RuntimeError): model.preprocess(dataset=df) ================================================ FILE: tests/integration_tests/test_peft.py ================================================ import os import pytest from ludwig.constants import COMBINER, EPOCHS, INPUT_FEATURES, OUTPUT_FEATURES, TRAINER, TYPE from tests.integration_tests.utils import binary_feature, generate_data, run_test_suite, text_feature @pytest.mark.integration_tests_e @pytest.mark.parametrize( "backend", [ pytest.param("local", id="local"), pytest.param("ray", id="ray", marks=pytest.mark.distributed), ], ) def test_text_adapter_lora(tmpdir, backend, ray_cluster_2cpu): input_features = [ text_feature( encoder={ "type": "auto_transformer", "pretrained_model_name_or_path": "hf-internal-testing/tiny-bert-for-token-classification", "trainable": True, "adapter": {"type": "lora"}, }, ), ] output_features = [binary_feature()] data_csv_path = os.path.join(tmpdir, "dataset.csv") dataset = generate_data(input_features, output_features, data_csv_path) config = { INPUT_FEATURES: input_features, OUTPUT_FEATURES: output_features, COMBINER: {TYPE: "concat", "output_size": 14}, TRAINER: {EPOCHS: 1}, } model = run_test_suite(config, dataset, backend) state_dict = model.model.state_dict() # check that at least one of the keys contains the word "lora_" denoting a lora parameter assert any("lora_" in key for key in state_dict.keys()) ================================================ FILE: tests/integration_tests/test_postprocessing.py ================================================ # Copyright (c) 2023 Predibase, Inc., 2020 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. # ============================================================================== import os from functools import partial from unittest import mock import numpy as np import pandas as pd import pytest import torch from ludwig.api import LudwigModel from ludwig.constants import BATCH_SIZE, DECODER, NAME, TRAINER from ludwig.globals import MODEL_FILE_NAME from tests.integration_tests.utils import ( binary_feature, category_feature, generate_data, RAY_BACKEND_CONFIG, set_feature, text_feature, ) def random_binary_logits(*args, num_predict_samples, **kwargs): # Produce an even mix of True and False predictions, as the model may be biased # towards one direction without training return torch.tensor(np.random.uniform(low=-1.0, high=1.0, size=(num_predict_samples,)), dtype=torch.float32) def random_set_logits(*args, num_predict_samples, vocab_size, pct_positive, **kwargs): # Produce a desired mix of predictions based on the pct_positive, as the model may be biased # towards one direction without training num_positive = int(num_predict_samples * pct_positive) num_negative = num_predict_samples - num_positive negative_logits = np.random.uniform(low=-1.0, high=-0.1, size=(num_negative, vocab_size)) positive_logits = np.random.uniform(low=0.1, high=1.0, size=(num_positive, vocab_size)) logits = np.concatenate([negative_logits, positive_logits], axis=0) return torch.tensor(logits, dtype=torch.float32) # simulate torch model output def _run_binary_predictions(tmpdir, backend, distinct_values, ray_cluster_2cpu): input_features = [ category_feature(encoder={"vocab_size": 3}), ] feature = binary_feature() output_features = [ feature, ] data_csv_path = generate_data( input_features, output_features, os.path.join(tmpdir, "dataset.csv"), num_examples=20, ) data_df = pd.read_csv(data_csv_path) # Optionally convert bool values to strings, e.g., {'Yes', 'No'} false_value, true_value = distinct_values data_df[feature[NAME]] = data_df[feature[NAME]].map(lambda x: true_value if x else false_value) data_df.to_csv(data_csv_path, index=False) config = { "input_features": input_features, "output_features": output_features, TRAINER: {"epochs": 1, BATCH_SIZE: 128}, } patch_args = ( "ludwig.features.binary_feature.BinaryOutputFeature.logits", partial(random_binary_logits, num_predict_samples=len(data_df)), ) preds_df, _ = predict_with_backend(tmpdir, config, data_csv_path, backend, patch_args=patch_args) cols = set(preds_df.columns) assert f"{feature[NAME]}_predictions" in cols assert f"{feature[NAME]}_probabilities_{str(false_value)}" in cols assert f"{feature[NAME]}_probabilities_{str(true_value)}" in cols assert f"{feature[NAME]}_probability" in cols for pred, prob_0, prob_1, prob in zip( preds_df[f"{feature[NAME]}_predictions"], preds_df[f"{feature[NAME]}_probabilities_{str(false_value)}"], preds_df[f"{feature[NAME]}_probabilities_{str(true_value)}"], preds_df[f"{feature[NAME]}_probability"], ): assert pred == false_value or pred == true_value if pred == true_value: assert prob_1 == prob else: assert prob_0 == prob assert np.allclose(prob_0, 1 - prob_1) @pytest.mark.parametrize("distinct_values", [(False, True), ("No", "Yes")]) def test_binary_predictions(tmpdir, distinct_values, ray_cluster_2cpu): _run_binary_predictions(tmpdir, "local", distinct_values, ray_cluster_2cpu) @pytest.mark.slow @pytest.mark.distributed @pytest.mark.parametrize("distinct_values", [(False, True), ("No", "Yes")]) def test_binary_predictions_ray(tmpdir, distinct_values, ray_cluster_2cpu): _run_binary_predictions(tmpdir, "ray", distinct_values, ray_cluster_2cpu) def _run_binary_predictions_with_number_dtype(tmpdir, backend, distinct_values, ray_cluster_2cpu): input_features = [ category_feature(encoder={"vocab_size": 3}), ] feature = binary_feature() output_features = [ feature, ] data_csv_path = generate_data( input_features, output_features, os.path.join(tmpdir, "dataset.csv"), num_examples=20, ) data_df = pd.read_csv(data_csv_path) # Optionally convert bool values to strings, e.g., {'Yes', 'No'} false_value, true_value = distinct_values data_df[feature[NAME]] = data_df[feature[NAME]].map(lambda x: true_value if x else false_value) data_df.to_csv(data_csv_path, index=False) config = { "input_features": input_features, "output_features": output_features, TRAINER: {"epochs": 1, BATCH_SIZE: 128}, } patch_args = ( "ludwig.features.binary_feature.BinaryOutputFeature.logits", partial(random_binary_logits, num_predict_samples=len(data_df)), ) preds_df, _ = predict_with_backend(tmpdir, config, data_csv_path, backend, patch_args=patch_args) cols = set(preds_df.columns) assert f"{feature[NAME]}_predictions" in cols assert f"{feature[NAME]}_probabilities_False" in cols assert f"{feature[NAME]}_probabilities_True" in cols assert f"{feature[NAME]}_probability" in cols for pred, prob_0, prob_1, prob in zip( preds_df[f"{feature[NAME]}_predictions"], preds_df[f"{feature[NAME]}_probabilities_False"], preds_df[f"{feature[NAME]}_probabilities_True"], preds_df[f"{feature[NAME]}_probability"], ): assert isinstance(pred, bool) if pred: assert prob_1 == prob else: assert prob_0 == prob assert np.allclose(prob_0, 1 - prob_1) @pytest.mark.parametrize("distinct_values", [(0.0, 1.0), (0, 1)]) def test_binary_predictions_with_number_dtype(tmpdir, distinct_values, ray_cluster_2cpu): _run_binary_predictions_with_number_dtype(tmpdir, "local", distinct_values, ray_cluster_2cpu) @pytest.mark.slow @pytest.mark.distributed @pytest.mark.parametrize("distinct_values", [(0.0, 1.0), (0, 1)]) def test_binary_predictions_with_number_dtype_ray(tmpdir, distinct_values, ray_cluster_2cpu): _run_binary_predictions_with_number_dtype(tmpdir, "ray", distinct_values, ray_cluster_2cpu) @pytest.mark.parametrize("pct_positive", [1.0, 0.5, 0.0]) def test_set_feature_saving(tmpdir, pct_positive): backend = "local" input_features = [ text_feature(encoder={"vocab_size": 3}), ] feature = set_feature(output_feature=True) output_features = [ feature, ] data_csv_path = generate_data( input_features, output_features, os.path.join(tmpdir, "dataset.csv"), num_examples=20, ) data_df = pd.read_csv(data_csv_path) config = { "input_features": input_features, "output_features": output_features, TRAINER: {"epochs": 1, BATCH_SIZE: 128}, } patch_args = ( "ludwig.features.set_feature.SetOutputFeature.logits", partial( random_set_logits, num_predict_samples=len(data_df), vocab_size=feature[DECODER]["vocab_size"] + 1, # +1 for UNK pct_positive=pct_positive, ), ) preds_df, ludwig_model = predict_with_backend(tmpdir, config, data_csv_path, backend, patch_args=patch_args) cols = set(preds_df.columns) assert f"{feature[NAME]}_predictions" in cols assert f"{feature[NAME]}_probabilities" in cols backend = ludwig_model.backend backend.df_engine.to_parquet(preds_df, os.path.join(tmpdir, "preds.parquet")) # test saving def predict_with_backend(tmpdir, config, data_csv_path, backend, patch_args=None): if backend == "ray": backend = RAY_BACKEND_CONFIG backend["processor"]["type"] = "dask" ludwig_model = LudwigModel(config, backend=backend) _, _, output_directory = ludwig_model.train( dataset=data_csv_path, output_directory=os.path.join(tmpdir, "output"), ) # Check that metadata JSON saves and loads correctly ludwig_model = LudwigModel.load(os.path.join(output_directory, MODEL_FILE_NAME)) if patch_args is not None: with mock.patch(*patch_args): preds_df, _ = ludwig_model.predict(dataset=data_csv_path) else: preds_df, _ = ludwig_model.predict(dataset=data_csv_path) return preds_df, ludwig_model ================================================ FILE: tests/integration_tests/test_preprocessing.py ================================================ import contextlib import copy import importlib.util import logging import os import random import string from unittest import mock import numpy as np import pandas as pd import pytest from PIL import Image from transformers import AutoTokenizer import ludwig from ludwig.api import LudwigModel from ludwig.backend import initialize_backend from ludwig.callbacks import Callback from ludwig.constants import ( BASE_MODEL, BATCH_SIZE, COLUMN, DECODER, EPOCHS, FULL, INPUT_FEATURES, MODEL_ECD, MODEL_LLM, MODEL_TYPE, NAME, OUTPUT_FEATURES, PREPROCESSING, PROC_COLUMN, PROMPT, SPLIT, TRAINER, TYPE, ) from ludwig.data.concatenate_datasets import concatenate_df from ludwig.data.preprocessing import handle_features_with_prompt_config, preprocess_for_prediction from ludwig.schema.llms.prompt import PromptConfig from ludwig.schema.model_types.base import ModelConfig from tests.integration_tests.utils import ( assert_preprocessed_dataset_shape_and_dtype_for_feature, audio_feature, binary_feature, category_feature, generate_data, generate_data_as_dataframe, image_feature, LocalTestBackend, number_feature, sequence_feature, text_feature, ) NUM_EXAMPLES = 20 @pytest.mark.parametrize( "backend", [ pytest.param("local", id="local"), pytest.param("ray", id="ray", marks=pytest.mark.distributed), ], ) def test_sample_ratio(backend, tmpdir, ray_cluster_2cpu): num_examples = 50 sample_ratio = 0.5 input_features = [sequence_feature(encoder={"reduce_output": "sum"}), audio_feature(folder=tmpdir)] output_features = [category_feature(decoder={"vocab_size": 5}, reduce_input="sum")] data_csv = generate_data( input_features, output_features, os.path.join(tmpdir, "dataset.csv"), num_examples=num_examples ) config = { INPUT_FEATURES: input_features, OUTPUT_FEATURES: output_features, TRAINER: { EPOCHS: 2, }, PREPROCESSING: {"sample_ratio": sample_ratio}, } model = LudwigModel(config, backend=backend) train_set, val_set, test_set, training_set_metadata = model.preprocess( data_csv, skip_save_processed_input=True, ) sample_size = num_examples * sample_ratio count = len(train_set) + len(val_set) + len(test_set) assert sample_size == count # Check that sample ratio is disabled when doing preprocessing for prediction dataset, _ = preprocess_for_prediction( model.config_obj.to_dict(), dataset=data_csv, training_set_metadata=training_set_metadata, split=FULL, include_outputs=True, backend=model.backend, ) assert "sample_ratio" in model.config_obj.preprocessing.to_dict() assert len(dataset) == num_examples @pytest.mark.parametrize( "backend", [ pytest.param("local", id="local"), pytest.param("ray", id="ray", marks=pytest.mark.distributed), ], ) def test_sample_ratio_deterministic(backend, tmpdir, ray_cluster_2cpu): """Ensures that the sampled dataset is the same when using a random seed. model.preprocess returns a PandasPandasDataset object when using local backend, and returns a RayDataset object when using the Ray backend. """ num_examples = 50 sample_ratio = 0.5 input_features = [binary_feature()] output_features = [category_feature()] data_csv = generate_data( input_features, output_features, os.path.join(tmpdir, "dataset.csv"), num_examples=num_examples ) config = { INPUT_FEATURES: input_features, OUTPUT_FEATURES: output_features, PREPROCESSING: {"sample_ratio": sample_ratio}, } model1 = LudwigModel(config, backend=backend) train_set_1, val_set_1, test_set_1, _ = model1.preprocess( data_csv, skip_save_processed_input=True, ) model2 = LudwigModel(config, backend=backend) train_set_2, val_set_2, test_set_2, _ = model2.preprocess( data_csv, skip_save_processed_input=True, ) sample_size = num_examples * sample_ratio # Ensure sizes are the same assert sample_size == len(train_set_1) + len(val_set_1) + len(test_set_1) assert sample_size == len(train_set_2) + len(val_set_2) + len(test_set_2) # Ensure actual rows are the same if backend == "local": assert train_set_1.to_df().equals(train_set_2.to_df()) assert val_set_1.to_df().equals(val_set_2.to_df()) assert test_set_1.to_df().equals(test_set_2.to_df()) else: assert train_set_1.to_df().compute().equals(train_set_2.to_df().compute()) assert val_set_1.to_df().compute().equals(val_set_2.to_df().compute()) assert test_set_1.to_df().compute().equals(test_set_2.to_df().compute()) @pytest.mark.parametrize( "backend", [ pytest.param("local", id="local"), pytest.param("ray", id="ray", marks=pytest.mark.distributed), ], ) def test_sample_size(backend, tmpdir, ray_cluster_2cpu): num_examples = 50 sample_size = 25 input_features = [sequence_feature(encoder={"reduce_output": "sum"}), audio_feature(folder=tmpdir)] output_features = [category_feature(decoder={"vocab_size": 5}, reduce_input="sum")] data_csv = generate_data( input_features, output_features, os.path.join(tmpdir, "dataset.csv"), num_examples=num_examples ) config = { INPUT_FEATURES: input_features, OUTPUT_FEATURES: output_features, TRAINER: { EPOCHS: 2, }, PREPROCESSING: {"sample_size": sample_size}, } model = LudwigModel(config, backend=backend) train_set, val_set, test_set, training_set_metadata = model.preprocess( data_csv, skip_save_processed_input=True, ) count = len(train_set) + len(val_set) + len(test_set) assert sample_size == count # Check that sample size is disabled when doing preprocessing for prediction dataset, _ = preprocess_for_prediction( model.config_obj.to_dict(), dataset=data_csv, training_set_metadata=training_set_metadata, split=FULL, include_outputs=True, backend=model.backend, ) assert "sample_size" in model.config_obj.preprocessing.to_dict() assert len(dataset) == num_examples @pytest.mark.parametrize( "backend", [ pytest.param("local", id="local"), pytest.param("ray", id="ray", marks=pytest.mark.distributed), ], ) def test_sample_size_deterministic(backend, tmpdir, ray_cluster_2cpu): """Ensures that the sampled dataset is the same when using a random seed. model.preprocess returns a PandasPandasDataset object when using local backend, and returns a RayDataset object when using the Ray backend. """ num_examples = 50 sample_size = 25 input_features = [binary_feature()] output_features = [category_feature()] data_csv = generate_data( input_features, output_features, os.path.join(tmpdir, "dataset.csv"), num_examples=num_examples ) config = { INPUT_FEATURES: input_features, OUTPUT_FEATURES: output_features, PREPROCESSING: {"sample_size": sample_size}, } model1 = LudwigModel(config, backend=backend) train_set_1, val_set_1, test_set_1, _ = model1.preprocess( data_csv, skip_save_processed_input=True, ) model2 = LudwigModel(config, backend=backend) train_set_2, val_set_2, test_set_2, _ = model2.preprocess( data_csv, skip_save_processed_input=True, ) # Ensure sizes are the same assert sample_size == len(train_set_1) + len(val_set_1) + len(test_set_1) assert sample_size == len(train_set_2) + len(val_set_2) + len(test_set_2) # Ensure actual rows are the same if backend == "local": assert train_set_1.to_df().equals(train_set_2.to_df()) assert val_set_1.to_df().equals(val_set_2.to_df()) assert test_set_1.to_df().equals(test_set_2.to_df()) else: assert train_set_1.to_df().compute().equals(train_set_2.to_df().compute()) assert val_set_1.to_df().compute().equals(val_set_2.to_df().compute()) assert test_set_1.to_df().compute().equals(test_set_2.to_df().compute()) def test_strip_whitespace_category(csv_filename, tmpdir): data_csv_path = os.path.join(tmpdir, csv_filename) input_features = [binary_feature()] cat_feat = category_feature(decoder={"vocab_size": 3}) output_features = [cat_feat] backend = LocalTestBackend() config = {INPUT_FEATURES: input_features, OUTPUT_FEATURES: output_features} training_data_csv_path = generate_data(input_features, output_features, data_csv_path) df = pd.read_csv(training_data_csv_path) # prefix with whitespace df[cat_feat[COLUMN]] = df[cat_feat[COLUMN]].apply(lambda s: " " + s) # run preprocessing ludwig_model = LudwigModel(config, backend=backend) train_ds, _, _, metadata = ludwig_model.preprocess(dataset=df) # expect values containing whitespaces to be properly mapped to vocab_size unique values assert len(np.unique(train_ds.dataset[cat_feat[PROC_COLUMN]])) == cat_feat[DECODER]["vocab_size"] @pytest.mark.parametrize( "backend", [ pytest.param("local", id="local"), pytest.param("ray", id="ray", marks=pytest.mark.distributed), ], ) def test_with_split(backend, csv_filename, tmpdir, ray_cluster_2cpu): num_examples = NUM_EXAMPLES train_set_size = int(num_examples * 0.8) val_set_size = int(num_examples * 0.1) test_set_size = int(num_examples * 0.1) input_features = [sequence_feature(encoder={"reduce_output": "sum"})] output_features = [category_feature(decoder={"vocab_size": 5}, reduce_input="sum")] data_csv = generate_data( input_features, output_features, os.path.join(tmpdir, csv_filename), num_examples=num_examples ) data_df = pd.read_csv(data_csv) data_df[SPLIT] = [0] * train_set_size + [1] * val_set_size + [2] * test_set_size data_df.to_csv(data_csv, index=False) config = { INPUT_FEATURES: input_features, OUTPUT_FEATURES: output_features, TRAINER: { EPOCHS: 2, }, PREPROCESSING: {SPLIT: {TYPE: "fixed", COLUMN: SPLIT}}, } model = LudwigModel(config, backend=backend) train_set, val_set, test_set, _ = model.preprocess( data_csv, skip_save_processed_input=False, ) assert len(train_set) == train_set_size assert len(val_set) == val_set_size assert len(test_set) == test_set_size @pytest.mark.distributed @pytest.mark.parametrize("feature_fn", [image_feature, audio_feature]) def test_dask_known_divisions(feature_fn, csv_filename, tmpdir, ray_cluster_2cpu): import dask.dataframe as dd input_features = [feature_fn(os.path.join(tmpdir, "generated_output"))] output_features = [category_feature(decoder={"vocab_size": 5}, reduce_input="sum")] data_csv = generate_data(input_features, output_features, os.path.join(tmpdir, csv_filename), num_examples=20) data_df = dd.from_pandas(pd.read_csv(data_csv), npartitions=2) assert data_df.known_divisions config = { INPUT_FEATURES: input_features, OUTPUT_FEATURES: output_features, TRAINER: { EPOCHS: 2, }, } backend = "ray" model = LudwigModel(config, backend=backend) train_set, val_set, test_set, _ = model.preprocess( data_df, skip_save_processed_input=False, ) @pytest.mark.distributed def test_drop_empty_partitions(csv_filename, tmpdir, ray_cluster_2cpu): import dask.dataframe as dd input_features = [image_feature(os.path.join(tmpdir, "generated_output"))] output_features = [category_feature(vocab_size=5, reduce_input="sum", output_feature=True)] # num_examples and npartitions set such that each post-split DataFrame has >1 samples, but empty partitions. data_csv = generate_data(input_features, output_features, os.path.join(tmpdir, csv_filename), num_examples=25) data_df = dd.from_pandas(pd.read_csv(data_csv), npartitions=10) config = { INPUT_FEATURES: input_features, OUTPUT_FEATURES: output_features, TRAINER: { EPOCHS: 2, }, } backend = "ray" model = LudwigModel(config, backend=backend) train_set, val_set, test_set, _ = model.preprocess( data_df, skip_save_processed_input=True, ) for dataset in [train_set, val_set, test_set]: df = dataset.ds.to_dask() for partition in df.partitions: assert len(partition) > 0, "empty partitions found in dataset" @pytest.mark.parametrize("generate_images_as_numpy", [False, True]) def test_read_image_from_path(tmpdir, csv_filename, generate_images_as_numpy): input_features = [image_feature(os.path.join(tmpdir, "generated_output"), save_as_numpy=generate_images_as_numpy)] output_features = [category_feature(decoder={"vocab_size": 5}, reduce_input="sum")] data_csv = generate_data( input_features, output_features, os.path.join(tmpdir, csv_filename), num_examples=NUM_EXAMPLES ) config = { INPUT_FEATURES: input_features, OUTPUT_FEATURES: output_features, TRAINER: {EPOCHS: 2}, } model = LudwigModel(config) model.preprocess( data_csv, skip_save_processed_input=False, ) def test_read_image_from_numpy_array(tmpdir, csv_filename): input_features = [image_feature(os.path.join(tmpdir, "generated_output"))] output_features = [category_feature(decoder={"vocab_size": 5}, reduce_input="sum")] config = { INPUT_FEATURES: input_features, OUTPUT_FEATURES: output_features, TRAINER: {EPOCHS: 2, BATCH_SIZE: 128}, } data_csv = generate_data( input_features, output_features, os.path.join(tmpdir, csv_filename), num_examples=NUM_EXAMPLES ) df = pd.read_csv(data_csv) processed_df_rows = [] for _, row in df.iterrows(): processed_df_rows.append( { input_features[0][NAME]: np.array(Image.open(row[input_features[0][NAME]])), output_features[0][NAME]: row[output_features[0][NAME]], } ) df_with_images_as_numpy_arrays = pd.DataFrame(processed_df_rows) model = LudwigModel(config) model.preprocess( df_with_images_as_numpy_arrays, skip_save_processed_input=False, ) def test_read_image_failure_default_image(monkeypatch, tmpdir, csv_filename): """Tests that the default image used when an image cannot be read has the correct properties.""" def mock_read_binary_files(self, column, map_fn, file_size): """Mock read_binary_files to return None (failed image read) to test error handling.""" return column.map(lambda x: None) monkeypatch.setattr(ludwig.backend.base.LocalPreprocessingMixin, "read_binary_files", mock_read_binary_files) image_feature_config = image_feature(os.path.join(tmpdir, "generated_output")) input_features = [image_feature_config] output_features = [category_feature(decoder={"vocab_size": 5}, reduce_input="sum")] config = { INPUT_FEATURES: input_features, OUTPUT_FEATURES: output_features, TRAINER: {EPOCHS: 2, BATCH_SIZE: 128}, } data_csv = generate_data( input_features, output_features, os.path.join(tmpdir, csv_filename), num_examples=NUM_EXAMPLES, nan_percent=0.2 ) model = LudwigModel(config) preprocessed_dataset = model.preprocess(data_csv) training_set_metadata = preprocessed_dataset.training_set_metadata preprocessing = training_set_metadata[input_features[0][NAME]][PREPROCESSING] expected_shape = (preprocessing["num_channels"], preprocessing["height"], preprocessing["width"]) expected_dtype = np.float32 assert_preprocessed_dataset_shape_and_dtype_for_feature( image_feature_config[NAME], preprocessed_dataset, model.config_obj, expected_dtype, expected_shape ) def test_number_feature_wrong_dtype(csv_filename, tmpdir): """Tests that a number feature with all string values is treated as having missing values by default.""" data_csv_path = os.path.join(tmpdir, csv_filename) num_feat = number_feature() input_features = [num_feat] output_features = [binary_feature()] config = {INPUT_FEATURES: input_features, OUTPUT_FEATURES: output_features} training_data_csv_path = generate_data(input_features, output_features, data_csv_path) df = pd.read_csv(training_data_csv_path) # convert numbers to random strings def random_string(): letters = string.ascii_lowercase return "".join(random.choice(letters) for _ in range(10)) df[num_feat[COLUMN]] = df[num_feat[COLUMN]].apply(lambda _: random_string()) # run preprocessing backend = LocalTestBackend() ludwig_model = LudwigModel(config, backend=backend) train_ds, val_ds, test_ds, _ = ludwig_model.preprocess(dataset=df) concatenated_df = concatenate_df(train_ds.to_df(), val_ds.to_df(), test_ds.to_df(), backend) # check that train_ds had invalid values replaced with the missing value assert len(concatenated_df) == len(df) assert np.all(concatenated_df[num_feat[PROC_COLUMN]] == 0.0) @pytest.mark.parametrize( "max_len, sequence_length, max_sequence_length, sequence_length_expected", [ # Case 1: infer from the dataset, max_sequence_length is larger than the largest sequence length. # Expected: max_sequence_length is ignored, and the sequence length is dataset+2 (include start/stop tokens). (10, None, 15, 12), # Case 2: infer from the dataset, max_sequence_length is smaller than the largest sequence length. # Expected: max_sequence_length is used, and the sequence length is max_sequence_length. (10, None, 8, 8), # Case 3: infer from the dataset, max_sequence_length is not set. # Expected: max_sequence_length is ignored, and the sequence length is dataset+2 (include start/stop tokens). (10, None, None, 12), # Case 4: set sequence_length explicitly and it is larger than the dataset. # Expected: sequence_length is used, and the sequence length is sequence_length. (10, 15, 20, 15), # Case 5: set sequence_length explicitly and it is smaller than the dataset. # Expected: sequence_length is used, and the sequence length is sequence_length. (10, 8, 20, 8), ], ) @pytest.mark.parametrize( "feature_type", [ sequence_feature, ], ) def test_seq_features_max_sequence_length( csv_filename, tmpdir, feature_type, max_len, sequence_length, max_sequence_length, sequence_length_expected ): """Tests that a sequence feature has the correct max_sequence_length in metadata and prepocessed data.""" feat = feature_type( encoder={"max_len": max_len}, preprocessing={"sequence_length": sequence_length, "max_sequence_length": max_sequence_length}, ) input_features = [feat] output_features = [binary_feature()] config = {INPUT_FEATURES: input_features, OUTPUT_FEATURES: output_features} data_csv_path = os.path.join(tmpdir, csv_filename) training_data_csv_path = generate_data(input_features, output_features, data_csv_path) df = pd.read_csv(training_data_csv_path) class CheckTrainingSetMetadataCallback(Callback): def on_preprocess_end(self, proc_training_set, proc_validation_set, proc_test_set, training_set_metadata): assert training_set_metadata[feat[NAME]]["max_sequence_length"] == sequence_length_expected backend = LocalTestBackend() ludwig_model = LudwigModel(config, backend=backend, callbacks=[CheckTrainingSetMetadataCallback()]) train_ds, val_ds, test_ds, _ = ludwig_model.preprocess(dataset=df) all_df = concatenate_df(train_ds.to_df(), val_ds.to_df(), test_ds.to_df(), backend) proc_column_name = feat[PROC_COLUMN] assert all(len(x) == sequence_length_expected for x in all_df[proc_column_name]) def test_column_feature_type_mismatch_fill(): """Tests that we are able to fill missing values even in columns where the column dtype and desired feature dtype do not match.""" cat_feat = category_feature() bin_feat = binary_feature() input_features = [cat_feat] output_features = [bin_feat] config = {INPUT_FEATURES: input_features, OUTPUT_FEATURES: output_features} # Construct dataframe with int-like column representing a categorical feature df = pd.DataFrame( { cat_feat[NAME]: pd.Series(pd.array([None] + [1] * 24, dtype=pd.Int64Dtype())), bin_feat[NAME]: pd.Series([True] * 25), } ) # run preprocessing backend = LocalTestBackend() ludwig_model = LudwigModel(config, backend=backend) train_ds, val_ds, test_ds, _ = ludwig_model.preprocess(dataset=df) @pytest.mark.parametrize("format", ["file", "df"]) def test_presplit_override(format, tmpdir): """Tests that provising a pre-split file or dataframe overrides the user's split config.""" num_feat = number_feature(normalization=None) input_features = [num_feat, sequence_feature(encoder={"reduce_output": "sum"})] output_features = [category_feature(decoder={"vocab_size": 5}, reduce_input="sum")] data_csv = generate_data(input_features, output_features, os.path.join(tmpdir, "dataset.csv"), num_examples=25) data_df = pd.read_csv(data_csv) # Set the feature value equal to an ordinal index so we can ensure the splits are identical before and after # preprocessing. data_df[num_feat[COLUMN]] = data_df.index train_df = data_df[:15] val_df = data_df[15:20] test_df = data_df[20:] train_data = train_df val_data = val_df test_data = test_df if format == "file": train_data = os.path.join(tmpdir, "train.csv") val_data = os.path.join(tmpdir, "val.csv") test_data = os.path.join(tmpdir, "test.csv") train_df.to_csv(train_data) val_df.to_csv(val_data) test_df.to_csv(test_data) data_df.to_csv(data_csv, index=False) config = { INPUT_FEATURES: input_features, OUTPUT_FEATURES: output_features, TRAINER: { EPOCHS: 2, }, PREPROCESSING: {SPLIT: {TYPE: "random"}}, } model = LudwigModel(config, backend=LocalTestBackend()) train_set, val_set, test_set, _ = model.preprocess( training_set=train_data, validation_set=val_data, test_set=test_data ) assert len(train_set) == len(train_df) assert len(val_set) == len(val_df) assert len(test_set) == len(test_df) assert np.all(train_set.to_df()[num_feat[PROC_COLUMN]].values == train_df[num_feat[COLUMN]].values) assert np.all(val_set.to_df()[num_feat[PROC_COLUMN]].values == val_df[num_feat[COLUMN]].values) assert np.all(test_set.to_df()[num_feat[PROC_COLUMN]].values == test_df[num_feat[COLUMN]].values) @pytest.mark.parametrize( "backend", [ pytest.param("local", id="local"), pytest.param("ray", id="ray", marks=pytest.mark.distributed), ], ) def test_empty_training_set_error(backend, tmpdir, ray_cluster_2cpu): """Tests that an error is raised if one or more of the splits is empty after preprocessing.""" data_csv_path = os.path.join(tmpdir, "data.csv") out_feat = binary_feature() input_features = [number_feature()] output_features = [out_feat] config = {INPUT_FEATURES: input_features, OUTPUT_FEATURES: output_features} training_data_csv_path = generate_data(input_features, output_features, data_csv_path) df = pd.read_csv(training_data_csv_path) # Convert all the output features rows to null. Because the default missing value strategy is to drop empty output # rows, this will result in the dataset being empty after preprocessing. df[out_feat[COLUMN]] = None ludwig_model = LudwigModel(config, backend=backend) with pytest.raises(RuntimeError, match="Training data is empty following preprocessing"): ludwig_model.preprocess(dataset=df) @pytest.mark.distributed @pytest.mark.parametrize( "backend", [ pytest.param("local", id="local"), pytest.param("ray", id="ray", marks=pytest.mark.distributed), ], ) def test_in_memory_dataset_size(backend, tmpdir, ray_cluster_2cpu): data_csv_path = os.path.join(tmpdir, "data.csv") out_feat = binary_feature() input_features = [number_feature()] output_features = [out_feat] config = {INPUT_FEATURES: input_features, OUTPUT_FEATURES: output_features} training_data_csv_path = generate_data(input_features, output_features, data_csv_path) df = pd.read_csv(training_data_csv_path) ludwig_model = LudwigModel(config, backend=backend) training_dataset, validation_dataset, test_dataset, _ = ludwig_model.preprocess(dataset=df) assert training_dataset.in_memory_size_bytes > 0 assert validation_dataset.in_memory_size_bytes > 0 assert test_dataset.in_memory_size_bytes > 0 @pytest.mark.parametrize( "binary_as_input, expected_preprocessing, missing_value_strategy", [ pytest.param( True, { "missing_value_strategy": "fill_with_true", "fill_value": None, "computed_fill_value": ">50K", "fallback_true_label": ">50K", }, "fill_with_true", id="binary_as_input_1", ), pytest.param( True, { "missing_value_strategy": "fill_with_false", "fill_value": None, "computed_fill_value": "<=50K", "fallback_true_label": ">50K", }, "fill_with_false", id="binary_as_input_2", ), pytest.param( False, { "missing_value_strategy": "drop_row", "fill_value": None, "computed_fill_value": None, "fallback_true_label": ">50K", }, "drop_row", id="binary_as_output", ), ], ) def test_non_conventional_bool_with_fallback(binary_as_input, expected_preprocessing, missing_value_strategy, tmpdir): # Specify a non-conventional boolean feature with a fallback true label. bin_feature = binary_feature( bool2str=["<=50K", ">50K"], preprocessing={"fallback_true_label": ">50K", "missing_value_strategy": missing_value_strategy}, ) # Generate data with the non-conventional boolean feature. if binary_as_input: input_features = [bin_feature] output_features = [number_feature()] else: input_features = [number_feature()] output_features = [bin_feature] config = { INPUT_FEATURES: input_features, OUTPUT_FEATURES: output_features, TRAINER: {EPOCHS: 2, BATCH_SIZE: 128}, } data_csv_path = os.path.join(tmpdir, "data.csv") training_data_csv_path = generate_data(input_features, output_features, data_csv_path) df = pd.read_csv(training_data_csv_path) # Preprocess the data. ludwig_model = LudwigModel(config) _, _, _, training_set_metadata = ludwig_model.preprocess(dataset=df) # Check that true/false labels are set correctly. assert training_set_metadata[bin_feature[NAME]] == { "str2bool": {"<=50K": False, ">50K": True}, "bool2str": ["<=50K", ">50K"], "fallback_true_label": ">50K", PREPROCESSING: expected_preprocessing, } @pytest.mark.parametrize( "binary_as_input", [pytest.param(True, id="binary_as_input"), pytest.param(False, id="binary_as_output")] ) def test_non_conventional_bool_without_fallback_logs_warning(binary_as_input, caplog, tmpdir): # Specify a non-conventional boolean feature without a fallback true label. bin_feature = binary_feature(bool2str=["<=50K", ">50K"], preprocessing={"fallback_true_label": None}) # Generate data with the non-conventional boolean feature. if binary_as_input: input_features = [bin_feature] output_features = [number_feature()] else: input_features = [number_feature()] output_features = [bin_feature] config = { INPUT_FEATURES: input_features, OUTPUT_FEATURES: output_features, TRAINER: {EPOCHS: 2, BATCH_SIZE: 128}, } data_csv_path = os.path.join(tmpdir, "data.csv") training_data_csv_path = generate_data(input_features, output_features, data_csv_path) df = pd.read_csv(training_data_csv_path) # Preprocess the data. with caplog.at_level(logging.WARN, logger="ludwig.features.binary_feature"): ludwig_model = LudwigModel(config) ludwig_model.preprocess(dataset=df) # Check that a warning is logged. assert "unconventional boolean value" in caplog.text @pytest.mark.parametrize("feature_type", ["input_feature", "output_feature"], ids=["input_feature", "output_feature"]) def test_category_feature_vocab_size_1(feature_type, tmpdir) -> None: data_csv_path = os.path.join(tmpdir, "data.csv") input_feature = [category_feature(encoder={"vocab_size": 1})] output_feature = [binary_feature()] if feature_type == "output_feature": input_feature = output_feature output_feature = [category_feature(decoder={"vocab_size": 1})] config = {INPUT_FEATURES: input_feature, OUTPUT_FEATURES: output_feature, "training": {EPOCHS: 1}} training_data_csv_path = generate_data(config[INPUT_FEATURES], config[OUTPUT_FEATURES], data_csv_path) ludwig_model = LudwigModel(config) with pytest.raises(RuntimeError) if feature_type == "output_feature" else contextlib.nullcontext(): ludwig_model.train(dataset=training_data_csv_path) @pytest.mark.parametrize("use_pretrained", [False, True], ids=["false", "true"]) def test_vit_encoder_different_dimension_image(tmpdir, csv_filename, use_pretrained: bool): input_features = [ image_feature( os.path.join(tmpdir, "generated_output"), preprocessing={"in_memory": True, "height": 224, "width": 206, "num_channels": 3}, encoder={TYPE: "_vit_legacy", "use_pretrained": use_pretrained}, ) ] output_features = [category_feature(decoder={"vocab_size": 5}, reduce_input="sum")] data_csv = generate_data( input_features, output_features, os.path.join(tmpdir, csv_filename), num_examples=NUM_EXAMPLES ) config = { INPUT_FEATURES: input_features, OUTPUT_FEATURES: output_features, TRAINER: {"train_steps": 1}, } model = LudwigModel(config) # Failure happens post preprocessing but before training during the ECD model creation phase # so make sure the model can be created properly and training can proceed model.train(dataset=data_csv) @pytest.mark.skip( reason=( "Broken against torch nightly: " "https://github.com/ludwig-ai/ludwig/actions/runs/4918126111/jobs/8784071603?pr=3388." ) ) def test_image_encoder_torchvision_different_num_channels(tmpdir, csv_filename): input_features = [ image_feature( os.path.join(tmpdir, "generated_output"), preprocessing={"in_memory": True, "height": 224, "width": 206, "num_channels": 1}, encoder={TYPE: "efficientnet"}, ) ] output_features = [category_feature(decoder={"vocab_size": 5}, reduce_input="sum")] data_csv = generate_data( input_features, output_features, os.path.join(tmpdir, csv_filename), num_examples=NUM_EXAMPLES ) config = { INPUT_FEATURES: input_features, OUTPUT_FEATURES: output_features, TRAINER: {"train_steps": 1}, } model = LudwigModel(config) # Failure happens post preprocessing but before training during the ECD model creation phase # so make sure the model can be created properly and training can proceed model.train(dataset=data_csv) @pytest.mark.parametrize( "df_engine", [ pytest.param("pandas", id="pandas"), pytest.param("dask", id="dask", marks=pytest.mark.distributed), ], ) def test_fill_with_mode_different_df_engine(tmpdir, csv_filename, df_engine, ray_cluster_2cpu): config = { INPUT_FEATURES: [category_feature(preprocessing={"missing_value_strategy": "fill_with_mode"})], OUTPUT_FEATURES: [binary_feature()], } training_data_csv_path = generate_data( config[INPUT_FEATURES], config[OUTPUT_FEATURES], os.path.join(tmpdir, csv_filename) ) df = pd.read_csv(training_data_csv_path) if df_engine == "dask": import dask.dataframe as dd df = dd.from_pandas(df, npartitions=1) # Only support Dask on Ray backend config["backend"] = {TYPE: "ray"} ludwig_model = LudwigModel(config) ludwig_model.preprocess(dataset=df) template_task_sample = """ Instruction: {__task__} ### Examples: ### Input: foo bar Output: true ### Input: baz quc Output: false ### Input: {__sample__} Output: """ task = "predict the output feature. Return only values in {true, false}" template_multi_col = """ You are a helpful chatbot. USER: {__sample__}: {country}, {year:.2f} ASSISTANT: """ expected_task_sample = """Instruction: predict the output feature. Return only values in {true, false} ### Examples: ### Input: foo bar Output: true ### Input: baz quc Output: false ### Input:""" @pytest.mark.llm @pytest.mark.parametrize("backend", ["local", "ray"]) @pytest.mark.parametrize("model_type", [MODEL_ECD, MODEL_LLM]) @pytest.mark.parametrize( "input_features,expected", [ ( [ text_feature( preprocessing={ PROMPT: {"task": task, "template": template_task_sample}, "max_sequence_length": 512, } ) ], expected_task_sample, ), ( [ text_feature(preprocessing={PROMPT: {"template": template_multi_col}}), category_feature(name="country"), number_feature(name="year"), ], ("You are a helpful chatbot. USER: "), ), ], ids=["task_sample", "multi_col"], ) def test_prompt_template(input_features, expected, model_type, backend, tmpdir, ray_cluster_2cpu): """Tests that prompt template is correctly applied to inputs.""" input_features = copy.deepcopy(input_features) output_features = [category_feature()] data_csv = generate_data(input_features, output_features, os.path.join(tmpdir, "dataset.csv"), num_examples=25) data_df = pd.read_csv(data_csv) raw_values = [data_df[input_features[i][COLUMN]].values.tolist() for i in range(len(input_features))] # Only use the first input feature (text) and discard the others, which are only used for data gen input_features = input_features[:1] config = { MODEL_TYPE: model_type, INPUT_FEATURES: input_features, OUTPUT_FEATURES: output_features, } model_name = "hf-internal-testing/tiny-random-OPTModel" if model_type == MODEL_LLM: # For LLMs, specify the prompt at the top level config[BASE_MODEL] = model_name config[PROMPT] = input_features[0][PREPROCESSING][PROMPT] del config[INPUT_FEATURES][0][PREPROCESSING][PROMPT] config[INPUT_FEATURES][0]["encoder"] = {TYPE: "passthrough"} else: config[INPUT_FEATURES][0]["encoder"] = { TYPE: "auto_transformer", "pretrained_model_name_or_path": model_name, } model = LudwigModel(config, backend=backend) train_set, _, _, _ = model.preprocess( training_set=data_csv, skip_save_processed_input=True, output_directory=os.path.join(tmpdir, "processed"), ) train_df = model.backend.df_engine.compute(train_set.to_df()) encoded_values = train_df[input_features[0][PROC_COLUMN]].values.tolist() assert all(len(v) == len(encoded_values) for v in raw_values) for i, encoded in enumerate(encoded_values): tokenizer = AutoTokenizer.from_pretrained(model_name) decoded = tokenizer.decode(encoded) assert expected in decoded, f"decoded: '{decoded}' does not contain expected: {expected}" for raw_col_values in raw_values: v = raw_col_values[i] if isinstance(v, float): # Test formatting in parametrize uses 2 decimal places of precision raw_text = format(v, ".2f") else: raw_text = str(v) assert raw_text in decoded, f"'{raw_text}' not in '{decoded}'" @pytest.mark.llm @pytest.mark.parametrize("backend", ["local", "ray"]) @pytest.mark.parametrize( "retrieval_kwargs", [ pytest.param({"type": "random", "k": 2}, id="random_retrieval"), pytest.param( {"type": "semantic", "model_name": "paraphrase-MiniLM-L3-v2", "k": 2}, id="semantic_retrieval", marks=pytest.mark.skipif( not importlib.util.find_spec("sentence_transformers"), reason="sentence_transformers not installed", ), ), ], ) def test_handle_features_with_few_shot_prompt_config(backend, retrieval_kwargs, ray_cluster_2cpu): prompt_config = PromptConfig.from_dict( { "task": ( "Given the sample input, complete this sentence by replacing XXXX: " "The label is XXXX. Choose one value in this list: [1, 2, 3]." ), "retrieval": retrieval_kwargs, } ).to_dict() # convert back-and-forth to validate and add defaults input_features = [ text_feature( encoder={TYPE: "passthrough"}, ) ] output_features = [ category_feature( output_feature=True, decoder={TYPE: "category_extractor"}, ) ] input_feature_name = input_features[0][NAME] output_feature_name = output_features[0][NAME] config = { MODEL_TYPE: MODEL_LLM, BASE_MODEL: "gpt2", INPUT_FEATURES: input_features, OUTPUT_FEATURES: output_features, PROMPT: prompt_config, } config = ModelConfig.from_dict(config).to_dict() df = generate_data_as_dataframe(input_features, output_features, 10, with_split=True) # retrieval needs fixed split if backend == "ray": import dask.dataframe as dd df = dd.from_pandas(df, npartitions=2) split_col = df[SPLIT] feature_configs = config[INPUT_FEATURES] + config[OUTPUT_FEATURES] if backend == "local": context = mock.patch( "ludwig.models.retrieval.SemanticRetrieval._encode", side_effect=lambda row_strs, _: np.random.rand(len(row_strs), 16).astype(np.float32), ) else: # TODO: figure out how to get mocks to work with Ray backend context = contextlib.nullcontext() with context: backend = initialize_backend(backend) dataset_cols = handle_features_with_prompt_config( config, df, feature_configs, backend=backend, split_col=split_col, ) assert len(dataset_cols) == 1 assert input_feature_name in dataset_cols # Inspect the generated prompts col = backend.df_engine.compute(dataset_cols[input_feature_name]) for prompt in col: # input_feature_name and output_feature_name should be in the prompt because # labeled samples are provided by the context assert input_feature_name in prompt assert output_feature_name in prompt @pytest.mark.llm @pytest.mark.parametrize("backend", ["local", "ray"]) def test_handle_features_with_prompt_config_multi_col(backend, ray_cluster_2cpu): df = pd.DataFrame( [ { "instruction": "Name this province", "country": "Canada", "year": 1871, "answer": "British Columbia", }, { "instruction": "Name this city", "country": "France", "year": 1789, "answer": "Paris", }, { "instruction": "Name this country", "country": "UK", "year": 1057, "answer": "Wales", }, ] ) config = { MODEL_TYPE: MODEL_LLM, BASE_MODEL: "gpt2", INPUT_FEATURES: [text_feature(name="question", encoder={TYPE: "passthrough"})], OUTPUT_FEATURES: [text_feature(name="answer")], PROMPT: { "template": "You are a helpful chatbot. USER: {instruction}: {country}, {year:.2f} ASSISTANT:", }, } config = ModelConfig.from_dict(config).to_dict() if backend == "ray": import dask.dataframe as dd df = dd.from_pandas(df, npartitions=2) feature_configs = config[INPUT_FEATURES] + config[OUTPUT_FEATURES] backend = initialize_backend(backend) dataset_cols = handle_features_with_prompt_config( config, df, feature_configs, backend=backend, split_col=None, ) assert len(dataset_cols) == 1 assert "question" in dataset_cols col = backend.df_engine.compute(dataset_cols["question"]) assert len(col) == 3 assert col[0].startswith("You are a helpful chatbot. USER: Name this province: Canada, 1871.00 ASSISTANT:") assert col[1].startswith("You are a helpful chatbot. USER: Name this city: France, 1789.00 ASSISTANT:") assert col[2].startswith("You are a helpful chatbot. USER: Name this country: UK, 1057.00 ASSISTANT:") ================================================ FILE: tests/integration_tests/test_ray.py ================================================ # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import copy import os import tempfile import numpy as np import pandas as pd import pytest import torch from ludwig.api import LudwigModel from ludwig.backend import create_ray_backend, initialize_backend, LOCAL_BACKEND from ludwig.constants import ( AUDIO, BAG, BALANCE_PERCENTAGE_TOLERANCE, BFILL, BINARY, CATEGORY, COLUMN, DATE, H3, IMAGE, MAX_BATCH_SIZE_DATASET_FRACTION, NAME, NUMBER, PREPROCESSING, SEQUENCE, SET, SPLIT, TEXT, TIMESERIES, TRAINER, VECTOR, ) from ludwig.data.preprocessing import balance_data from ludwig.data.split import DEFAULT_PROBABILITIES from ludwig.globals import MODEL_FILE_NAME from ludwig.utils.data_utils import read_parquet from ludwig.utils.misc_utils import merge_dict from tests.integration_tests.utils import ( audio_feature, augment_dataset_with_none, bag_feature, binary_feature, category_feature, create_data_set_to_use, date_feature, generate_data, h3_feature, image_feature, number_feature, RAY_BACKEND_CONFIG, sequence_feature, set_feature, text_feature, timeseries_feature, train_with_backend, vector_feature, ) ray = pytest.importorskip("ray") # noqa # Mark the entire module as distributed pytestmark = [pytest.mark.distributed, pytest.mark.integration_tests_a] import ray # noqa: E402 import ray.exceptions # noqa: E402 from ludwig.backend.ray import get_trainer_kwargs, RayBackend # noqa: E402 from ludwig.data.dataframe.dask import DaskEngine # noqa: E402 try: import modin # noqa: E402 except ImportError: modin = None @ray.remote(num_cpus=1, num_gpus=1) def train_gpu(config, dataset, output_directory): model = LudwigModel(config, backend="local") _, _, output_dir = model.train(dataset, output_directory=output_directory) return os.path.join(output_dir, MODEL_FILE_NAME) @ray.remote(num_cpus=1, num_gpus=0) def predict_cpu(model_dir, dataset): model = LudwigModel.load(model_dir, backend="local") model.predict(dataset) def run_api_experiment( config, dataset, backend_config, predict=False, evaluate=True, skip_save_processed_input=True, skip_save_predictions=True, required_metrics=None, ): # Sanity check that we get 4 slots over 1 host kwargs = get_trainer_kwargs() if torch.cuda.device_count() > 0: assert kwargs.get("num_workers") == torch.cuda.device_count(), kwargs assert kwargs.get("use_gpu"), kwargs else: assert kwargs.get("num_workers") == 1, kwargs assert not kwargs.get("use_gpu"), kwargs # Train on Parquet model = train_with_backend( backend_config, config, dataset=dataset, evaluate=evaluate, predict=predict, skip_save_processed_input=skip_save_processed_input, skip_save_predictions=skip_save_predictions, required_metrics=required_metrics, ) assert isinstance(model.backend, RayBackend) if isinstance(model.backend.df_engine, DaskEngine): assert model.backend.df_engine.parallelism == backend_config["processor"]["parallelism"] return model def run_split_api_experiment(config, data_parquet, backend_config): train_fname, val_fname, test_fname = split(data_parquet) # Train train_with_backend(backend_config, config, training_set=train_fname, evaluate=False, predict=True) # Train + Validation train_with_backend( backend_config, config, training_set=train_fname, validation_set=val_fname, evaluate=False, predict=False ) # Train + Validation + Test train_with_backend( backend_config, config, training_set=train_fname, validation_set=val_fname, test_set=test_fname, evaluate=False, predict=False, ) def run_preprocessing( tmpdir, df_engine, input_features, output_features, dataset_type="parquet", num_examples_per_split=20, nan_percent=0.0, first_row_none=False, last_row_none=False, nan_cols=None, ): # Split the dataset manually to avoid randomness in splitting split_to_df = {} for split in range(3): csv_filename = os.path.join(tmpdir, f"{split}_dataset.csv") dataset_csv_path = generate_data( input_features, output_features, csv_filename, num_examples=num_examples_per_split, ) dataset_df = pd.read_csv(dataset_csv_path) dataset_df[SPLIT] = split dataset_df.to_csv(dataset_csv_path, index=False) split_to_df[split] = dataset_df full_df_path = os.path.join(tmpdir, "dataset.csv") pd.concat(split_to_df.values()).to_csv(full_df_path, index=False) dataset = create_data_set_to_use(dataset_type, full_df_path, nan_percent=nan_percent) dataset = augment_dataset_with_none(dataset, first_row_none, last_row_none, nan_cols) # Configure ray backend config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat", "output_size": 14}, TRAINER: {"epochs": 2, "batch_size": 8}, PREPROCESSING: { SPLIT: { "type": "fixed", }, }, } backend_config = {**RAY_BACKEND_CONFIG} if df_engine: backend_config["processor"]["type"] = df_engine # Run preprocessing with ray backend ray_model = LudwigModel(config, backend=backend_config) *ray_datasets, ray_training_set_metadata = ray_model.preprocess( skip_save_processed_input=False, # Save the processed input to test pyarrow write/read dataset=dataset, ) # Run preprocessing with local backend using the ray_training_set_metadata to ensure parity of # token assignments, etc. local_model = LudwigModel(config, backend=LOCAL_BACKEND) *local_datasets, _ = local_model.preprocess( training_set_metadata=ray_training_set_metadata, dataset=dataset, ) for ray_dataset, local_dataset in zip(ray_datasets, local_datasets): ray_df = ray_model.backend.df_engine.compute(ray_dataset.to_df()) local_df = local_model.backend.df_engine.compute(local_dataset.to_df()) check_preprocessed_df_equal(local_df, ray_df) def check_preprocessed_df_equal(df1, df2): for column in df1.columns: vals1 = df1[column].values vals2 = df2[column].values if any(feature_name in column for feature_name in [CATEGORY]): is_equal = np.all(vals1 == vals2) elif any(feature_name in column for feature_name in [BINARY]): # Binary columns may differ due to NaN fill strategies (bfill/ffill) producing # different results at partition boundaries in distributed vs local processing. # This can affect both input preprocessing and output predictions (since model # weights change with different training data). Just verify shape and dtype match. is_equal = vals1.shape == vals2.shape and vals1.dtype == vals2.dtype elif any(feature_name in column for feature_name in [NUMBER]): is_equal = np.allclose(vals1, vals2) elif any(feature_name in column for feature_name in [SET, BAG, H3, DATE, TEXT, SEQUENCE, TIMESERIES, VECTOR]): is_equal = np.all([np.all(rv == lv) for rv, lv in zip(vals1, vals2)]) elif any(feature_name in column for feature_name in [AUDIO, IMAGE]): # For image/audio columns, NaN fill strategies (bfill/ffill) can produce different # results at partition boundaries in distributed backends vs local sequential # processing. Just verify that shapes match and values are non-degenerate. is_equal = True for v1, v2 in zip(vals1, vals2): if v1.reshape(-1).shape != v2.reshape(-1).shape: is_equal = False break assert is_equal, f"Column {column} is not equal. Expected {vals1[:2]}, got {vals2[:2]}" def split(data_parquet): data_df = read_parquet(data_parquet, LOCAL_BACKEND.df_engine.df_lib) train_df = data_df.sample(frac=0.8) test_df = data_df.drop(train_df.index).sample(frac=0.5) validation_df = data_df.drop(train_df.index).drop(test_df.index) basename, ext = os.path.splitext(data_parquet) train_fname = basename + ".train" + ext val_fname = basename + ".validation" + ext test_fname = basename + ".test" + ext train_df.to_parquet(train_fname) validation_df.to_parquet(val_fname) test_df.to_parquet(test_fname) return train_fname, val_fname, test_fname def run_test_with_features( input_features, output_features, num_examples=20, run_fn=run_api_experiment, expect_error=False, df_engine=None, dataset_type="parquet", predict=False, skip_save_processed_input=True, skip_save_predictions=True, nan_percent=0.0, preprocessing=None, first_row_none=False, last_row_none=False, nan_cols=None, required_metrics=None, backend_kwargs=None, trainer_kwargs=None, ): preprocessing = preprocessing or {} trainer_config = {"train_steps": 1, "batch_size": 8} if trainer_kwargs: trainer_config.update(trainer_kwargs) config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat", "output_size": 14}, TRAINER: trainer_config, } if preprocessing: config[PREPROCESSING] = preprocessing backend_kwargs = copy.deepcopy(backend_kwargs or {}) backend_config = merge_dict(RAY_BACKEND_CONFIG, backend_kwargs) if df_engine: backend_config["processor"]["type"] = df_engine with tempfile.TemporaryDirectory() as tmpdir: csv_filename = os.path.join(tmpdir, "dataset.csv") dataset_csv = generate_data(input_features, output_features, csv_filename, num_examples=num_examples) dataset = create_data_set_to_use(dataset_type, dataset_csv, nan_percent=nan_percent) dataset = augment_dataset_with_none(dataset, first_row_none, last_row_none, nan_cols) if expect_error: with pytest.raises((RuntimeError, ray.exceptions.RayTaskError)): run_fn( config, dataset=dataset, backend_config=backend_config, predict=predict, skip_save_processed_input=skip_save_processed_input, skip_save_predictions=skip_save_predictions, required_metrics=required_metrics, ) else: run_fn( config, dataset=dataset, backend_config=backend_config, predict=predict, skip_save_processed_input=skip_save_processed_input, skip_save_predictions=skip_save_predictions, required_metrics=required_metrics, ) @pytest.mark.parametrize("df_engine", ["pandas", "dask"]) @pytest.mark.distributed def test_ray_read_binary_files(tmpdir, df_engine, ray_cluster_2cpu): preprocessing_params = { "audio_file_length_limit_in_s": 3.0, "missing_value_strategy": BFILL, "in_memory": True, "padding_value": 0, "norm": "per_file", "audio_feature": { "type": "fbank", "window_length_in_s": 0.04, "window_shift_in_s": 0.02, "num_filter_bands": 80, }, } audio_dest_folder = os.path.join(tmpdir, "generated_audio") audio_params = audio_feature(folder=audio_dest_folder, preprocessing=preprocessing_params) dataset_path = os.path.join(tmpdir, "dataset.csv") dataset_path = generate_data([audio_params], [], dataset_path, num_examples=10) dataset_path = create_data_set_to_use("csv", dataset_path, nan_percent=0.1) backend_config = {**RAY_BACKEND_CONFIG} backend_config["processor"]["type"] = df_engine backend = initialize_backend(backend_config) df = backend.df_engine.df_lib.read_csv(dataset_path) series = df[audio_params[COLUMN]] proc_col = backend.read_binary_files(series) proc_col = backend.df_engine.compute(proc_col) backend = initialize_backend(LOCAL_BACKEND) df = backend.df_engine.df_lib.read_csv(dataset_path) series = df[audio_params[COLUMN]] proc_col_expected = backend.read_binary_files(series) # Compare lengths and non-null values; Ray's parallel reading may reorder or # handle NaN paths differently from local sequential reading assert len(proc_col) == len(proc_col_expected) non_null_ray = proc_col.dropna() non_null_local = proc_col_expected.dropna() assert len(non_null_ray) == len(non_null_local) for v1, v2 in zip(sorted(non_null_ray, key=lambda x: hash(x)), sorted(non_null_local, key=lambda x: hash(x))): assert v1 == v2 @pytest.mark.slow @pytest.mark.parametrize( "trainer_strategy", [ pytest.param("ddp", id="ddp", marks=pytest.mark.distributed), ], ) def test_ray_outputs(trainer_strategy, ray_cluster_2cpu): input_features = [ binary_feature(), ] binary_feature_config = binary_feature() category_feature_config = category_feature(output_feature=True) output_features = [ number_feature(), category_feature_config, binary_feature_config, # TODO: feature type not yet supported # text_feature(decoder={"vocab_size": 3}), # Error having to do with a missing key (#2586) # sequence_feature(decoder={"vocab_size": 3}), # Error having to do with a missing key (#2586) ] # NOTE: This test runs without NaNs because having multiple output features with DROP_ROWS strategy leads to # flakiness in the test having to do with uneven allocation of samples between Ray workers. run_test_with_features( input_features, output_features, df_engine="dask", dataset_type="parquet", predict=True, skip_save_predictions=False, required_metrics={ binary_feature_config[NAME]: {"roc_auc"}, category_feature_config[NAME]: {"roc_auc"}, }, # ensures that these metrics are not omitted. backend_kwargs={ TRAINER: {"strategy": trainer_strategy}, }, ) @pytest.mark.skip(reason="Occasional metadata mismatch error: https://github.com/ludwig-ai/ludwig/issues/2889") @pytest.mark.parametrize("dataset_type", ["csv", "parquet"]) @pytest.mark.distributed def test_ray_set_and_vector_outputs(dataset_type, ray_cluster_2cpu): input_features = [ binary_feature(), ] # The synthetic set feature generator inserts between 0 and `vocab_size` entities per entry. 0 entities creates a # null (NaN) entry. The default behavior for such entries in output features is to DROP_ROWS. This leads to poorly # handled non-determinism when comparing the metrics between the local and Ray backends. We work around this by # setting the `missing_value_strategy` to `fill_with_const` and setting the `fill_value` to the empty string. set_feature_config = set_feature( decoder={"vocab_size": 3}, preprocessing={"missing_value_strategy": "fill_with_const", "fill_value": ""}, ) output_features = [ vector_feature(), set_feature_config, ] # NOTE: This test runs without NaNs because having multiple output features with DROP_ROWS strategy leads to # flakiness in the test having to do with uneven allocation of samples between Ray workers. run_test_with_features( input_features, output_features, df_engine="dask", dataset_type=dataset_type, predict=True, skip_save_predictions=False, required_metrics={set_feature_config[NAME]: {"jaccard"}}, # ensures that the metric is not omitted. ) @pytest.mark.distributed @pytest.mark.parametrize( "df_engine", [ "dask", pytest.param( "modin", marks=[ pytest.mark.skipif(modin is None, reason="modin not installed"), pytest.mark.skip(reason="https://github.com/ludwig-ai/ludwig/issues/2643"), ], ), ], ) def test_ray_tabular(tmpdir, df_engine, ray_cluster_2cpu): input_features = [ category_feature(encoder={"vocab_size": 2}, reduce_input="sum"), number_feature(normalization="zscore"), set_feature(), binary_feature(), bag_feature(), h3_feature(), date_feature(), ] output_features = [ binary_feature(bool2str=["No", "Yes"]), binary_feature(), number_feature(normalization="zscore"), ] run_preprocessing( tmpdir, df_engine, input_features, output_features, ) @pytest.mark.parametrize("dataset_type", ["csv", "parquet"]) @pytest.mark.distributed def test_ray_tabular_save_inputs(tmpdir, dataset_type, ray_cluster_2cpu): input_features = [ category_feature(encoder={"vocab_size": 2}, reduce_input="sum"), number_feature(normalization="zscore"), set_feature(), binary_feature(), bag_feature(), date_feature( preprocessing={"fill_value": "2020-01-01"} ), # fill_value must be set to achieve parity between backends (otherwise fill value would be "now") # TODO: feature type not yet supported # h3_feature(), # ValueError casting large int strings (e.g. '5.864041857092157e+17') to int (#2588) ] output_features = [ category_feature(decoder={"vocab_size": 5}), # Regression test for #1991 requires multi-class predictions. ] run_preprocessing( tmpdir, "dask", input_features, output_features, dataset_type=dataset_type, nan_percent=0.1, ) @pytest.mark.distributed @pytest.mark.parametrize("dataset_type", ["csv", "parquet"]) def test_ray_text_sequence_timeseries(tmpdir, dataset_type, ray_cluster_2cpu): input_features = [ text_feature(), sequence_feature(encoder={"reduce_output": "sum"}), timeseries_feature(), ] output_features = [ binary_feature(), ] run_preprocessing( tmpdir, "dask", input_features, output_features, dataset_type=dataset_type, nan_percent=0.1, ) @pytest.mark.parametrize("dataset_type", ["csv", "parquet"]) @pytest.mark.distributed def test_ray_vector(tmpdir, dataset_type, ray_cluster_2cpu): input_features = [ vector_feature(), ] output_features = [ binary_feature(), ] run_preprocessing( tmpdir, "dask", input_features, output_features, dataset_type=dataset_type, nan_percent=0.0, # NaN handling not supported for vectors. ) @pytest.mark.parametrize("dataset_type", ["csv", "parquet"]) @pytest.mark.distributed def test_ray_audio(tmp_path, dataset_type, ray_cluster_2cpu): preprocessing_params = { "audio_file_length_limit_in_s": 3.0, "missing_value_strategy": BFILL, "in_memory": True, "padding_value": 0, "norm": "per_file", "type": "fbank", "window_length_in_s": 0.04, "window_shift_in_s": 0.02, "num_filter_bands": 80, } audio_dest_folder = os.path.join(tmp_path, "generated_audio") input_features = [audio_feature(folder=audio_dest_folder, preprocessing=preprocessing_params)] output_features = [ binary_feature(), ] run_preprocessing( tmp_path, "dask", input_features, output_features, dataset_type=dataset_type, nan_percent=0.1, ) @pytest.mark.parametrize("dataset_type", ["csv", "parquet", "pandas+numpy_images"]) @pytest.mark.distributed def test_ray_image(tmpdir, dataset_type, ray_cluster_2cpu): image_dest_folder = os.path.join(tmpdir, "generated_images") input_features = [ image_feature( folder=image_dest_folder, preprocessing={"in_memory": True, "height": 12, "width": 12, "num_channels": 3, "num_processes": 5}, encoder={"output_size": 16, "num_filters": 8}, ), ] output_features = [ binary_feature(), ] run_preprocessing( tmpdir, "dask", input_features, output_features, dataset_type=dataset_type, nan_percent=0.1, ) @pytest.mark.parametrize( "settings", [(True, False, "ffill"), (False, True, "bfill"), (True, True, "bfill"), (True, True, "ffill")], ids=["first_row_none", "last_row_none", "first_and_last_row_none_bfill", "first_and_last_row_none_ffill"], ) @pytest.mark.distributed def test_ray_image_with_fill_strategy_edge_cases(tmpdir, settings, ray_cluster_2cpu): first_row_none, last_row_none, missing_value_strategy = settings image_dest_folder = os.path.join(tmpdir, "generated_images") input_features = [ image_feature( folder=image_dest_folder, preprocessing={ "in_memory": True, "height": 12, "width": 12, "num_channels": 3, "num_processes": 5, "missing_value_strategy": missing_value_strategy, }, encoder={"output_size": 16, "num_filters": 8}, ), ] output_features = [ binary_feature(), ] run_preprocessing( tmpdir, "dask", input_features, output_features, dataset_type="pandas+numpy_images", first_row_none=first_row_none, last_row_none=last_row_none, nan_cols=[input_features[0][NAME]], ) # TODO(geoffrey): Fold modin tests into test_ray_image as @pytest.mark.parametrized once tests are optimized @pytest.mark.distributed @pytest.mark.skipif(modin is None, reason="modin not installed") @pytest.mark.skip(reason="https://github.com/ludwig-ai/ludwig/issues/2643") def test_ray_image_modin(tmpdir, ray_cluster_2cpu): image_dest_folder = os.path.join(tmpdir, "generated_images") input_features = [ image_feature( folder=image_dest_folder, encoder={ "type": "stacked_cnn", "output_size": 16, }, preprocessing={"in_memory": True, "height": 12, "width": 12, "num_channels": 3, "num_processes": 5}, ), ] output_features = [ binary_feature(), ] run_preprocessing( tmpdir, "modin", input_features, output_features, dataset_type="csv", nan_percent=0.1, ) @pytest.mark.distributed def test_ray_image_multiple_features(tmpdir, ray_cluster_2cpu): input_features = [ image_feature( folder=os.path.join(tmpdir, "generated_images_1"), preprocessing={"in_memory": True, "height": 12, "width": 12, "num_channels": 3, "num_processes": 5}, encoder={"output_size": 16, "num_filters": 8}, ), image_feature( folder=os.path.join(tmpdir, "generated_images_2"), preprocessing={"in_memory": True, "height": 12, "width": 12, "num_channels": 3, "num_processes": 5}, encoder={"output_size": 16, "num_filters": 8}, ), ] output_features = [ binary_feature(), ] run_preprocessing( tmpdir, "dask", input_features, output_features, dataset_type="csv", nan_percent=0.1, ) @pytest.mark.skip(reason="flaky: ray is running out of resources") @pytest.mark.distributed def test_ray_split(ray_cluster_2cpu): input_features = [ number_feature(normalization="zscore"), set_feature(), binary_feature(), ] output_features = [category_feature(decoder={"vocab_size": 2}, reduce_input="sum")] run_test_with_features( input_features, output_features, run_fn=run_split_api_experiment, ) @pytest.mark.distributed def test_ray_lazy_load_audio_error(tmpdir, ray_cluster_2cpu): audio_dest_folder = os.path.join(tmpdir, "generated_audio") input_features = [ audio_feature( folder=audio_dest_folder, preprocessing={ "in_memory": False, }, ) ] output_features = [ binary_feature(), ] run_test_with_features(input_features, output_features, expect_error=True) @pytest.mark.slow @pytest.mark.distributed def test_ray_lazy_load_image_works(tmpdir, ray_cluster_2cpu): image_dest_folder = os.path.join(tmpdir, "generated_images") input_features = [ image_feature( folder=image_dest_folder, encoder={ "type": "stacked_cnn", "output_size": 16, }, preprocessing={"in_memory": False, "height": 12, "width": 12, "num_channels": 3, "num_processes": 5}, ), ] output_features = [ binary_feature(), ] run_test_with_features(input_features, output_features, expect_error=False) # TODO(travis): move this to separate gpu module so we only have one ray cluster running at a time # @pytest.mark.skipif(torch.cuda.device_count() == 0, reason="test requires at least 1 gpu") # @pytest.mark.skipif(not torch.cuda.is_available(), reason="test requires gpu support") # @pytest.mark.distributed # def test_train_gpu_load_cpu(ray_cluster_2cpu): # input_features = [ # category_feature(encoder={"vocab_size": 2}, reduce_input="sum"), # number_feature(normalization="zscore"), # ] # output_features = [ # binary_feature(), # ] # run_test_with_features(input_features, output_features, run_fn=_run_train_gpu_load_cpu, num_gpus=1) @pytest.mark.distributed @pytest.mark.parametrize( "method, balance", [ ("oversample_minority", 0.5), ("undersample_majority", 0.5), ], ) def test_balance_ray(method, balance, ray_cluster_2cpu): config = { "input_features": [ {"name": "Index", "proc_column": "Index", "type": "number"}, {"name": "random_1", "proc_column": "random_1", "type": "number"}, {"name": "random_2", "proc_column": "random_2", "type": "number"}, ], "output_features": [{"name": "Label", "proc_column": "Label", "type": "binary"}], "preprocessing": {"oversample_minority": None, "undersample_majority": None}, } input_df = pd.DataFrame( { "Index": np.arange(0, 200, 1), "random_1": np.random.randint(0, 50, 200), "random_2": np.random.choice(["Type A", "Type B", "Type C", "Type D"], 200), "Label": np.concatenate((np.zeros(180), np.ones(20))), "split": np.zeros(200), } ) config["preprocessing"][method] = balance target = config["output_features"][0][NAME] backend = create_ray_backend() input_df = backend.df_engine.from_pandas(input_df) test_df = balance_data(input_df, config["output_features"], config["preprocessing"], backend, 42) majority_class = test_df[target].value_counts().compute()[test_df[target].value_counts().compute().idxmax()] minority_class = test_df[target].value_counts().compute()[test_df[target].value_counts().compute().idxmin()] new_class_balance = round(minority_class / majority_class, 2) assert abs(balance - new_class_balance) < BALANCE_PERCENTAGE_TOLERANCE def _run_train_gpu_load_cpu(config, data_parquet): with tempfile.TemporaryDirectory() as output_dir: model_dir = ray.get(train_gpu.remote(config, data_parquet, output_dir)) ray.get(predict_cpu.remote(model_dir, data_parquet)) # TODO(geoffrey): add a GPU test for batch size tuning @pytest.mark.distributed @pytest.mark.parametrize( ("max_batch_size", "expected_final_learning_rate"), [(256, 0.001), (8, 0.001)], ) def test_tune_batch_size_lr_cpu(tmpdir, ray_cluster_2cpu, max_batch_size, expected_final_learning_rate): config = { "input_features": [ number_feature(normalization="zscore"), set_feature(), binary_feature(), ], "output_features": [category_feature(decoder={"vocab_size": 2}, reduce_input="sum")], "combiner": {"type": "concat", "output_size": 14}, TRAINER: { "train_steps": 3, "batch_size": "auto", "learning_rate": "auto", "max_batch_size": max_batch_size, }, } backend_config = copy.deepcopy(RAY_BACKEND_CONFIG) num_samples = 50 csv_filename = os.path.join(tmpdir, "dataset.csv") dataset_csv = generate_data( config["input_features"], config["output_features"], csv_filename, num_examples=num_samples ) dataset_parquet = create_data_set_to_use("parquet", dataset_csv) model = run_api_experiment(config, dataset=dataset_parquet, backend_config=backend_config, evaluate=False) num_train_samples = num_samples * DEFAULT_PROBABILITIES[0] max_batch_size_by_train_examples = MAX_BATCH_SIZE_DATASET_FRACTION * num_train_samples max_batch_size = ( max_batch_size_by_train_examples if max_batch_size is None else min(max_batch_size_by_train_examples, max_batch_size) ) assert 2 < model.config[TRAINER]["batch_size"] <= max_batch_size assert model.config[TRAINER]["learning_rate"] == expected_final_learning_rate @pytest.mark.slow @pytest.mark.parametrize("calibration", [True, False]) @pytest.mark.distributed def test_ray_calibration(calibration, ray_cluster_2cpu): input_features = [ number_feature(normalization="zscore"), set_feature(), binary_feature(), ] output_features = [ binary_feature(calibration=calibration), category_feature(decoder={"vocab_size": 3}, calibration=calibration), ] run_test_with_features(input_features, output_features) @pytest.mark.slow @pytest.mark.distributed @pytest.mark.parametrize("use_placement_group", [False, True], ids=["default", "placement_group"]) def test_ray_distributed_predict(use_placement_group, ray_cluster_2cpu): preprocessing_params = { "audio_file_length_limit_in_s": 3.0, "missing_value_strategy": BFILL, "in_memory": True, "padding_value": 0, "norm": "per_file", "type": "fbank", "window_length_in_s": 0.04, "window_shift_in_s": 0.02, "num_filter_bands": 80, } with tempfile.TemporaryDirectory() as tmpdir: audio_dest_folder = os.path.join(tmpdir, "generated_audio") input_features = [audio_feature(folder=audio_dest_folder, preprocessing=preprocessing_params)] output_features = [ binary_feature(), ] config = { "input_features": input_features, "output_features": output_features, TRAINER: {"train_steps": 1, "batch_size": 8}, } backend_config = copy.deepcopy(RAY_BACKEND_CONFIG) if use_placement_group: backend_config["preprocessor_kwargs"] = {"num_cpu": 1} else: backend_config["trainer"]["num_workers"] = 2 csv_filename = os.path.join(tmpdir, "dataset.csv") dataset_csv = generate_data(input_features, output_features, csv_filename, num_examples=50) dataset = create_data_set_to_use("csv", dataset_csv, nan_percent=0.0) model = LudwigModel(config, backend=backend_config) _, _, _ = model.train( dataset=dataset, training_set=dataset, skip_save_processed_input=True, skip_save_progress=True, skip_save_unprocessed_output=True, skip_save_log=True, ) preds, _ = model.predict(dataset=dataset) if not use_placement_group: # Verify predictions have distinct row indices preds = preds.compute() assert preds.iloc[1].name != preds.iloc[42].name ================================================ FILE: tests/integration_tests/test_reducers.py ================================================ import pytest from ludwig.modules.reduction_modules import reduce_mode_registry from tests.integration_tests.utils import category_feature, generate_data, run_experiment, sequence_feature @pytest.mark.parametrize("reduce_output", reduce_mode_registry) def test_reduction(reduce_output, csv_filename): input_features = [sequence_feature(reduce_output=reduce_output)] output_features = [category_feature(output_feature=True)] rel_path = generate_data(input_features, output_features, csv_filename) run_experiment(input_features, output_features, dataset=rel_path) del input_features del output_features ================================================ FILE: tests/integration_tests/test_regularizers.py ================================================ import random import tempfile import numpy as np import pytest import torch from ludwig.api import LudwigModel from ludwig.constants import TRAINER from ludwig.data.preprocessing import preprocess_for_training from ludwig.utils.data_utils import read_csv from ludwig.utils.torch_utils import get_torch_device from tests.integration_tests.utils import ( binary_feature, category_feature, date_feature, generate_data, image_feature, LocalTestBackend, number_feature, sequence_feature, set_feature, ) DEVICE = get_torch_device() BATCH_SIZE = 32 RANDOM_SEED = 42 IMAGE_DIR = tempfile.mkdtemp() @pytest.mark.parametrize( "input_features,output_features", [ ( [number_feature(encoder={"num_layers": 2, "type": "dense"}, preprocessing={"normalization": "zscore"})], [number_feature()], ), ([image_feature(IMAGE_DIR, encoder={"type": "stacked_cnn"})], [number_feature()]), ([image_feature(IMAGE_DIR, encoder={"type": "stacked_cnn"})], [category_feature(output_feature=True)]), ( [category_feature(encoder={"representation": "dense"})], [number_feature(decoder={"type": "regressor", "num_fc_layers": 5}, loss={"type": "mean_squared_error"})], ), ([date_feature()], [binary_feature()]), ([sequence_feature(encoder={"type": "parallel_cnn", "cell_type": "gru"})], [binary_feature()]), ([set_feature()], [set_feature(output_feature=True)]), ], ) def test_regularizers( input_features, output_features, ): np.random.seed(RANDOM_SEED) torch.manual_seed(RANDOM_SEED) random.seed(0) data_file = generate_data(input_features, output_features, num_examples=BATCH_SIZE) data_df = read_csv(data_file) regularizer_losses = [] for regularization_type in [None, "l1", "l2", "l1_l2"]: config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat", "output_size": 14}, TRAINER: { "epochs": 2, "regularization_type": regularization_type, "regularization_lambda": 0.1, "batch_size": BATCH_SIZE, # fix the batch size to ensure deterministic results }, } backend = LocalTestBackend() model = LudwigModel(config, backend=backend) processed_data_df, _, _, _ = preprocess_for_training(model.config, data_df, backend=backend) with processed_data_df.initialize_batcher(batch_size=BATCH_SIZE) as batcher: batch = batcher.next_batch() _, _, _ = model.train( training_set=data_df, skip_save_processed_input=True, skip_save_progress=True, skip_save_unprocessed_output=True, ) inputs = { i_feat.feature_name: torch.from_numpy(np.array(batch[i_feat.proc_column], copy=True)).to(DEVICE) for i_feat in model.model.input_features.values() } targets = { o_feat.feature_name: torch.from_numpy(np.array(batch[o_feat.proc_column], copy=True)).to(DEVICE) for o_feat in model.model.output_features.values() } predictions = model.model((inputs, targets)) loss, _ = model.model.train_loss(targets, predictions, regularization_type, 0.1) regularizer_losses.append(loss) # Regularizer_type=None has lowest regularizer loss assert min(regularizer_losses) == regularizer_losses[0] # l1, l2 and l1_l2 should be greater than zero assert torch.all(torch.tensor([t - regularizer_losses[0] > 0.0 for t in regularizer_losses[1:]])) # using default setting l1 + l2 == l1_l2 losses assert torch.isclose( regularizer_losses[1] + regularizer_losses[2] - regularizer_losses[0], regularizer_losses[3], rtol=0.1 ) ================================================ FILE: tests/integration_tests/test_remote.py ================================================ import os import pytest import yaml from ludwig.api import LudwigModel from ludwig.backend import initialize_backend from ludwig.constants import BATCH_SIZE, TRAINER from ludwig.globals import DESCRIPTION_FILE_NAME, MODEL_FILE_NAME, MODEL_WEIGHTS_FILE_NAME from ludwig.utils import fs_utils from ludwig.utils.data_utils import use_credentials from tests.integration_tests.utils import ( category_feature, generate_data, minio_test_creds, private_param, remote_tmpdir, sequence_feature, ) pytestmark = pytest.mark.integration_tests_b @pytest.mark.slow @pytest.mark.parametrize( "backend", [ pytest.param("local", id="local"), pytest.param("ray", id="ray", marks=pytest.mark.distributed), ], ) @pytest.mark.parametrize( "fs_protocol,bucket,creds", [("file", None, None), private_param(("s3", "ludwig-tests", minio_test_creds()))], ids=["file", "s3"], ) def test_remote_training_set(csv_filename, fs_protocol, bucket, creds, backend, ray_cluster_2cpu): with remote_tmpdir(fs_protocol, bucket) as tmpdir: with use_credentials(creds): input_features = [sequence_feature(encoder={"reduce_output": "sum"})] output_features = [category_feature(decoder={"vocab_size": 2}, reduce_input="sum")] train_csv = os.path.join(tmpdir, "training.csv") val_csv = os.path.join(tmpdir, "validation.csv") test_csv = os.path.join(tmpdir, "test.csv") local_csv = generate_data(input_features, output_features, csv_filename) fs_utils.upload_file(local_csv, train_csv) fs_utils.copy(train_csv, val_csv) fs_utils.copy(train_csv, test_csv) config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat", "output_size": 14}, TRAINER: {"train_steps": 1, BATCH_SIZE: 128}, } config_path = os.path.join(tmpdir, "config.yaml") with fs_utils.open_file(config_path, "w") as f: yaml.dump(config, f) backend_config = { "type": backend, } backend = initialize_backend(backend_config) output_directory = os.path.join(tmpdir, "output") model = LudwigModel(config_path, backend=backend) _, _, output_run_directory = model.train( training_set=train_csv, validation_set=val_csv, test_set=test_csv, output_directory=output_directory ) assert os.path.join(output_directory, "api_experiment_run") == output_run_directory assert fs_utils.path_exists(os.path.join(output_run_directory, DESCRIPTION_FILE_NAME)) assert fs_utils.path_exists(os.path.join(output_run_directory, "training_statistics.json")) assert fs_utils.path_exists(os.path.join(output_run_directory, MODEL_FILE_NAME)) assert fs_utils.path_exists(os.path.join(output_run_directory, MODEL_FILE_NAME, MODEL_WEIGHTS_FILE_NAME)) model.predict(dataset=test_csv, output_directory=output_directory) # Train again, this time the cache will be used # Resume from the remote output directory model.train( training_set=train_csv, validation_set=val_csv, test_set=test_csv, model_resume_path=output_run_directory, ) ================================================ FILE: tests/integration_tests/test_reproducibility.py ================================================ import logging import os import pathlib import random import numpy as np import pytest import torch from ludwig.api import LudwigModel from ludwig.data.dataset_synthesizer import cli_synthesize_dataset INPUT_FEATURES = [ {"name": "num_1", "type": "number"}, {"name": "num_2", "type": "number"}, ] OUTPUT_FEATURES = [{"name": "y", "type": "number"}] CONFIG = { "input_features": INPUT_FEATURES, "output_features": OUTPUT_FEATURES, "trainer": {"epochs": 2, "batch_size": 8}, } @pytest.fixture(scope="function") def raw_dataset_fp(tmpdir: pathlib.Path) -> str: """Generates dataset to be used in this test. Returns (str): file path string for dataset to use in this tests """ raw_fp = os.path.join(tmpdir, "raw_data.csv") random.seed(42) cli_synthesize_dataset(64, INPUT_FEATURES + OUTPUT_FEATURES, raw_fp) yield raw_fp @pytest.mark.parametrize("second_seed_offset", [0, 1]) @pytest.mark.parametrize("random_seed", [1919, 31]) def test_preprocess(raw_dataset_fp: str, random_seed: int, second_seed_offset: int) -> None: """Test reproducibility of train/validation/test splits. Args: raw_dataset_fp (str): file path for data to be used as part of this test random_seed(int): random seed integer to use for test second_seed_offset(int): zero to use same random seed for second test, non-zero to use a different seed for the second run. Returns: None """ # define Ludwig model model1 = LudwigModel(config=CONFIG) # preprocess the raw data set, specify seed preprocessed_data1 = model1.preprocess(raw_dataset_fp, random_seed=random_seed) # perform second preprocess operation model2 = LudwigModel(config=CONFIG) # preprocess same raw data set with same seed preprocessed_data2 = model2.preprocess(raw_dataset_fp, random_seed=random_seed + second_seed_offset) # confirm data splits are reproducible for i in range(3): for k in preprocessed_data1[i].dataset: if second_seed_offset == 0: # same seeds should result in same output assert np.all(preprocessed_data1[i].dataset[k] == preprocessed_data2[i].dataset[k]) else: # non-zero second_seed_offset uses different seeds and should result in different output assert not np.all(preprocessed_data1[i].dataset[k] == preprocessed_data2[i].dataset[k]) @pytest.mark.parametrize("random_seed", [1919, 31]) def test_preprocess_ignore_torch_seed(raw_dataset_fp: str, random_seed: int) -> None: """Test reproducibility of train/validation/test splits when an unrelated torch random operation is performed between the Ludwig operations. Args: raw_dataset_fp (str): file path for data to be used as part of this test random_seed(int): random seed integer to use for test Returns: None """ # define Ludwig model model1 = LudwigModel(config=CONFIG) # preprocess the raw data set, specify seed preprocessed_data1 = model1.preprocess(raw_dataset_fp, random_seed=random_seed) # invoke torch random functions with unrelated seed to # see if it affects Ludwig reproducibility torch.manual_seed(random_seed + 5) torch.rand((5,)) # define Ludwig model model2 = LudwigModel(config=CONFIG) # preprocess same raw data set with same seed preprocessed_data2 = model2.preprocess(raw_dataset_fp, random_seed=random_seed) # confirm data splits are reproducible for i in range(3): for k in preprocessed_data1[i].dataset: # same seeds should result in same output assert np.all(preprocessed_data1[i].dataset[k] == preprocessed_data2[i].dataset[k]) @pytest.mark.parametrize("second_seed_offset", [0, 1]) @pytest.mark.parametrize("random_seed", [1919, 31]) def test_train(raw_dataset_fp: str, random_seed: int, second_seed_offset: int) -> None: """Test reproducibility of training API. Args: raw_dataset_fp (str): file path for data to be used as part of this test random_seed(int): random seed integer to use for test second_seed_offset(int): zero to use same random seed for second test, non-zero to use a different seed for the second run. Returns: None """ # perform first model training run model1 = LudwigModel(config=CONFIG, logging_level=logging.WARN) training_statistics1, preprocessed_data1, _ = model1.train( dataset=raw_dataset_fp, random_seed=random_seed, skip_save_progress=True, skip_save_processed_input=True ) # perform second model training run model2 = LudwigModel(config=CONFIG, logging_level=logging.WARN) training_statistics2, preprocessed_data2, _ = model2.train( dataset=raw_dataset_fp, random_seed=random_seed + second_seed_offset, skip_save_progress=True, skip_save_processed_input=True, ) # confirm data splits are reproducible for i in range(3): for k in preprocessed_data1[i].dataset: if second_seed_offset == 0: # same seeds should result in same output assert np.all(preprocessed_data1[i].dataset[k] == preprocessed_data2[i].dataset[k]) else: # non-zero second_seed_offset uses different seeds and should result in different output assert not np.all(preprocessed_data1[i].dataset[k] == preprocessed_data2[i].dataset[k]) # confirm reproducibility/non-reproducibility of results if second_seed_offset == 0: # same seeds should result in same output assert training_statistics1 == training_statistics2 else: # non-zero second_seed_offset uses different seeds and should result in different output assert training_statistics1 != training_statistics2 @pytest.mark.parametrize("random_seed", [1919, 31]) def test_train_ignore_torch_seed(raw_dataset_fp: str, random_seed: int) -> None: """Test reproducibility of training API when an unrelated torch random operation is performed between the Ludwig operations. Args: raw_dataset_fp (str): file path for data to be used as part of this test random_seed(int): random seed integer to use for test Returns: None """ # define Ludwig model model1 = LudwigModel(config=CONFIG, logging_level=logging.WARN) training_statistics1, preprocessed_data1, _ = model1.train( dataset=raw_dataset_fp, random_seed=random_seed, skip_save_progress=True, skip_save_processed_input=True ) # invoke torch random functions with unrelated seed to # see if it affects Ludwig reproducibility torch.manual_seed(random_seed + 5) torch.rand((5,)) model2 = LudwigModel(config=CONFIG, logging_level=logging.WARN) training_statistics2, preprocessed_data2, _ = model2.train( dataset=raw_dataset_fp, random_seed=random_seed, skip_save_progress=True, skip_save_processed_input=True, ) # confirm data splits are reproducible for i in range(3): for k in preprocessed_data1[i].dataset: # same seeds should result in same output assert np.all(preprocessed_data1[i].dataset[k] == preprocessed_data2[i].dataset[k]) # confirm reproducibility/non-reproducibility of results assert training_statistics1 == training_statistics2 @pytest.mark.parametrize("second_seed_offset", [0, 1]) @pytest.mark.parametrize("random_seed", [1919, 31]) def test_experiment(raw_dataset_fp: str, random_seed: int, second_seed_offset: int) -> None: """Test reproducibility of experiment API. Args: raw_dataset_fp (str): file path for data to be used as part of this test random_seed(int): random seed integer to use for test second_seed_offset(int): zero to use same random seed for second test, non-zero to use a different seed for the second run. Returns: None """ # perform first model experiment model1 = LudwigModel(config=CONFIG, logging_level=logging.WARN) evaluation_statistics1, training_statistics1, preprocessed_data1, _ = model1.experiment( dataset=raw_dataset_fp, random_seed=random_seed, skip_save_processed_input=True ) # perform second model experiment model2 = LudwigModel(config=CONFIG, logging_level=logging.WARN) evaluation_statistics2, training_statistics2, preprocessed_data2, _ = model2.experiment( dataset=raw_dataset_fp, random_seed=random_seed + second_seed_offset, skip_save_processed_input=True ) # confirm data splits are reproducible for i in range(3): for k in preprocessed_data1[i].dataset: if second_seed_offset == 0: # same seeds should result in same output assert np.all(preprocessed_data1[i].dataset[k] == preprocessed_data2[i].dataset[k]) else: # non-zero second_seed_offset uses different seeds and should result in different output assert not np.all(preprocessed_data1[i].dataset[k] == preprocessed_data2[i].dataset[k]) # confirm results reproducibility/non-reproducibility of results if second_seed_offset == 0: # same seeds should result in same output assert training_statistics1 == training_statistics2 assert evaluation_statistics1 == evaluation_statistics2 else: # non-zero second_seed_offset uses different seeds and should result in different output assert training_statistics1 != training_statistics2 assert evaluation_statistics1 != evaluation_statistics2 @pytest.mark.parametrize("random_seed", [1919, 31]) def test_experiment_ignore_torch_seed(raw_dataset_fp: str, random_seed: int) -> None: """Test reproducibility of experiment API when an unrelated torch random operation is performed between the Ludwig operations. Args: raw_dataset_fp (str): file path for data to be used as part of this test random_seed(int): random seed integer to use for test Returns: None """ # define Ludwig model model1 = LudwigModel(config=CONFIG, logging_level=logging.WARN) evaluation_statistics1, training_statistics1, preprocessed_data1, _ = model1.experiment( dataset=raw_dataset_fp, random_seed=random_seed, skip_save_processed_input=True ) # invoke torch random functions with unrelated seed to # see if it affects Ludwig reproducibility torch.manual_seed(random_seed + 5) torch.rand((5,)) model2 = LudwigModel(config=CONFIG, logging_level=logging.WARN) evaluation_statistics2, training_statistics2, preprocessed_data2, _ = model2.experiment( dataset=raw_dataset_fp, random_seed=random_seed, skip_save_processed_input=True ) # confirm data splits are reproducible for i in range(3): for k in preprocessed_data1[i].dataset: # same seeds should result in same output assert np.all(preprocessed_data1[i].dataset[k] == preprocessed_data2[i].dataset[k]) # confirm results reproducibility/non-reproducibility of results # same seeds should result in same output assert training_statistics1 == training_statistics2 assert evaluation_statistics1 == evaluation_statistics2 ================================================ FILE: tests/integration_tests/test_sequence_decoders.py ================================================ import os import pytest from ludwig.constants import ( BATCH_SIZE, DECODER, ENCODER, INPUT_FEATURES, OUTPUT_FEATURES, SEQUENCE, TEXT, TRAINER, TYPE, ) from tests.integration_tests.utils import ( create_data_set_to_use, generate_data, RAY_BACKEND_CONFIG, sequence_feature, text_feature, train_with_backend, ) pytestmark = pytest.mark.integration_tests_c @pytest.mark.slow @pytest.mark.parametrize("feature_type,feature_gen", [(TEXT, text_feature), (SEQUENCE, sequence_feature)]) @pytest.mark.parametrize("decoder_type", ["generator", "tagger"]) @pytest.mark.distributed def test_sequence_decoder_predictions(tmpdir, csv_filename, ray_cluster_2cpu, feature_type, feature_gen, decoder_type): """Test that sequence decoders return the correct successfully predict.""" input_feature = feature_gen() output_feature = feature_gen(output_feature=True) input_feature[ENCODER] = {TYPE: "embed", "reduce_output": None} output_feature[DECODER] = {TYPE: decoder_type} dataset_path = generate_data( input_features=[input_feature], output_features=[output_feature], filename=os.path.join(tmpdir, csv_filename), ) dataset_path = create_data_set_to_use("csv", dataset_path) config = {INPUT_FEATURES: [input_feature], TRAINER: {"train_steps": 1, BATCH_SIZE: 4}} # Ensure that the decoder outputs the correct predictions through both the default and feature-specific configs. config[OUTPUT_FEATURES] = [output_feature] # Test with decoder in output feature config train_with_backend(RAY_BACKEND_CONFIG, config=config, dataset=dataset_path) ================================================ FILE: tests/integration_tests/test_sequence_encoders.py ================================================ import logging import numpy as np import pytest import torch from ludwig.constants import ENCODER_OUTPUT, ENCODER_OUTPUT_STATE, SEQUENCE from ludwig.encoders.registry import get_encoder_cls from tests.integration_tests.utils import ENCODERS logger = logging.getLogger(__name__) TEST_VOCAB_SIZE = 132 TEST_HIDDEN_SIZE = 32 TEST_STATE_SIZE = 16 TEST_EMBEDDING_SIZE = 64 TEST_NUM_FILTERS = 24 BATCH_SIZE = 2 SEQ_SIZE = 10 PARALLEL_CNN_LAYERS = 4 # encoder parameters combinations tested encoder_parameters = { "vocab": [str(i) for i in range(TEST_VOCAB_SIZE)], "embedding_size": TEST_EMBEDDING_SIZE, "hidden_size": TEST_HIDDEN_SIZE, "num_filters": TEST_NUM_FILTERS, "num_layers": 1, "max_sequence_length": SEQ_SIZE, "state_size": TEST_STATE_SIZE, "cell_type": "rnn", "should_embed": True, "dropout": 0.0, "norm": None, "reduce_output": None, } @pytest.fixture(scope="module") def input_sequence() -> torch.Tensor: # generates a realistic looking synthetic sequence tensor, i.e. # each sequence will have non-zero tokens at the beginning with # trailing zero tokens, including a max length token with a single # zero token at the end. Example: # [ # [3, 5, 6, 0, 0, 0], # [10, 11, 12, 13, 14, 0], # max length sequence # [32, 0, 0, 0, 0, 0] # minimum length sequence # ] input_tensor = torch.zeros([BATCH_SIZE, SEQ_SIZE], dtype=torch.int32) sequence_lengths = np.random.randint(1, SEQ_SIZE, size=BATCH_SIZE) for i in range(input_tensor.shape[0]): input_tensor[i, : sequence_lengths[i]] = torch.tensor( np.random.randint(2, TEST_VOCAB_SIZE, size=sequence_lengths[i]) ) if torch.cuda.is_available(): input_tensor = input_tensor.cuda() return input_tensor @pytest.mark.parametrize("enc_reduce_output", [None, "sum"]) @pytest.mark.parametrize("enc_norm", [None, "batch", "layer"]) @pytest.mark.parametrize("enc_num_layers", [1, 2]) @pytest.mark.parametrize("enc_dropout", [0, 0.2]) @pytest.mark.parametrize("enc_cell_type", ["rnn", "gru", "lstm"]) @pytest.mark.parametrize("enc_encoder", ENCODERS + ["passthrough"]) def test_sequence_encoders( enc_encoder: str, enc_cell_type: str, enc_dropout: float, enc_num_layers: int, enc_norm: None | str, enc_reduce_output: None | str, input_sequence: torch.Tensor, ): # update encoder parameters for specific unit test case encoder_parameters["cell_type"] = enc_cell_type encoder_parameters["dropout"] = enc_dropout encoder_parameters["num_layers"] = enc_num_layers encoder_parameters["norm"] = enc_norm encoder_parameters["reduce_output"] = enc_reduce_output # retrieve encoder to test encoder_obj = get_encoder_cls(SEQUENCE, enc_encoder)(**encoder_parameters) if torch.cuda.is_available(): encoder_obj = encoder_obj.cuda() encoder_out = encoder_obj(input_sequence) assert ENCODER_OUTPUT in encoder_out assert isinstance(encoder_out[ENCODER_OUTPUT], torch.Tensor) if enc_encoder == "parallel_cnn": number_parallel_cnn_layers = PARALLEL_CNN_LAYERS output_dimension = encoder_parameters["num_filters"] * number_parallel_cnn_layers assert ( encoder_out[ENCODER_OUTPUT].shape == (BATCH_SIZE, SEQ_SIZE, output_dimension) if enc_reduce_output is None else (BATCH_SIZE, output_dimension) ) elif enc_encoder == "stacked_parallel_cnn": number_parallel_cnn_layers = PARALLEL_CNN_LAYERS output_dimension = encoder_parameters["num_filters"] * number_parallel_cnn_layers assert ( encoder_out[ENCODER_OUTPUT].shape == (BATCH_SIZE, SEQ_SIZE, output_dimension) if enc_reduce_output is None else (BATCH_SIZE, output_dimension) ) elif enc_encoder == "rnn": assert ( encoder_out[ENCODER_OUTPUT].shape == (BATCH_SIZE, SEQ_SIZE, TEST_STATE_SIZE) if enc_reduce_output is None else (BATCH_SIZE, TEST_STATE_SIZE) ) assert ENCODER_OUTPUT_STATE in encoder_out if enc_cell_type == "lstm": assert isinstance(encoder_out[ENCODER_OUTPUT_STATE], tuple) assert isinstance(encoder_out[ENCODER_OUTPUT_STATE][0], torch.Tensor) assert isinstance(encoder_out[ENCODER_OUTPUT_STATE][1], torch.Tensor) assert encoder_out[ENCODER_OUTPUT_STATE][0].shape == (BATCH_SIZE, TEST_STATE_SIZE) assert encoder_out[ENCODER_OUTPUT_STATE][1].shape == (BATCH_SIZE, TEST_STATE_SIZE) else: assert isinstance(encoder_out[ENCODER_OUTPUT_STATE], torch.Tensor) assert encoder_out[ENCODER_OUTPUT_STATE].shape == (BATCH_SIZE, TEST_STATE_SIZE) elif enc_encoder == "cnnrnn": assert encoder_out[ENCODER_OUTPUT].shape[1:] == encoder_obj.output_shape assert ENCODER_OUTPUT_STATE in encoder_out if enc_cell_type == "lstm": assert isinstance(encoder_out[ENCODER_OUTPUT_STATE], tuple) assert encoder_out[ENCODER_OUTPUT_STATE][0].shape == (BATCH_SIZE, TEST_STATE_SIZE) assert encoder_out[ENCODER_OUTPUT_STATE][1].shape == (BATCH_SIZE, TEST_STATE_SIZE) else: assert isinstance(encoder_out[ENCODER_OUTPUT_STATE], torch.Tensor) assert encoder_out[ENCODER_OUTPUT_STATE].shape == (BATCH_SIZE, TEST_STATE_SIZE) elif enc_encoder == "stacked_cnn": assert encoder_out[ENCODER_OUTPUT].shape[1:] == encoder_obj.output_shape elif enc_encoder == "embed": assert ( encoder_out[ENCODER_OUTPUT].shape == (BATCH_SIZE, SEQ_SIZE, TEST_EMBEDDING_SIZE) if enc_reduce_output is None else (BATCH_SIZE, TEST_EMBEDDING_SIZE) ) elif enc_encoder == "transformer": assert encoder_out[ENCODER_OUTPUT].shape[1:] == encoder_obj.output_shape elif enc_encoder == "passthrough": assert ( encoder_out[ENCODER_OUTPUT].shape == (BATCH_SIZE, SEQ_SIZE, 1) if enc_reduce_output is None else (BATCH_SIZE, 1) ) else: raise ValueError(f"{enc_encoder} is an invalid encoder specification") @pytest.mark.parametrize("enc_reduce_output", [None, "sum", "last", "mean", "max", "concat"]) def test_passthrough_encoder(enc_reduce_output, input_sequence): encoder_parameters = {"reduce_output": enc_reduce_output} # retrieve encoder to test encoder_obj = get_encoder_cls(SEQUENCE, "passthrough")(**encoder_parameters) encoder_out = encoder_obj(input_sequence) assert ENCODER_OUTPUT in encoder_out assert ( encoder_out[ENCODER_OUTPUT].shape == (BATCH_SIZE, SEQ_SIZE, 1) if enc_reduce_output is None else (BATCH_SIZE, 1) ) # test to ensure correct handling of vocab_size and embedding_size specifications @pytest.mark.parametrize("enc_embedding_size", [TEST_VOCAB_SIZE - 8, TEST_VOCAB_SIZE, TEST_VOCAB_SIZE + 8]) def test_sequence_embed_encoder(enc_embedding_size: int, input_sequence: torch.Tensor) -> None: encoder_parameters["embedding_size"] = enc_embedding_size encoder_obj = get_encoder_cls(SEQUENCE, "embed")(**encoder_parameters) encoder_out = encoder_obj(input_sequence) assert encoder_out[ENCODER_OUTPUT].size()[1:] == encoder_obj.output_shape ================================================ FILE: tests/integration_tests/test_sequence_features.py ================================================ import contextlib import copy from io import StringIO import pandas as pd import pytest import torch from ludwig.api import LudwigModel from ludwig.constants import DECODER, ENCODER_OUTPUT_STATE, LOGITS from ludwig.data.dataset_synthesizer import build_synthetic_dataset from ludwig.data.preprocessing import preprocess_for_training from ludwig.features.feature_registries import update_config_with_metadata from tests.integration_tests.utils import generate_data, run_experiment, sequence_feature # # this test is focused on testing input sequence features with all encoders # and output sequence feature with Generator decoder. Except for specified # configuration parameters all other parameters assume default values. # TEST_VOCAB_SIZE = 132 TEST_HIDDEN_SIZE = 32 TEST_STATE_SIZE = 8 TEST_EMBEDDING_SIZE = 64 TEST_NUM_FILTERS = 24 # generates dataset that can be used for rest of test @pytest.fixture(scope="module") def generate_sequence_training_data(): input_features = [ sequence_feature( encoder={ "vocab_size": TEST_VOCAB_SIZE, "embedding_size": TEST_EMBEDDING_SIZE, "state_size": TEST_STATE_SIZE, "hidden_size": TEST_HIDDEN_SIZE, "num_filters": TEST_NUM_FILTERS, "min_len": 5, "max_len": 10, "type": "rnn", "cell_type": "lstm", } ) ] output_features = [ sequence_feature( decoder={"type": "generator", "min_len": 5, "max_len": 10, "cell_type": "lstm", "attention": "bahdanau"} ) ] # generate synthetic data set testing dataset = build_synthetic_dataset(150, copy.deepcopy(input_features) + copy.deepcopy(output_features)) raw_data = "\n".join([r[0] + "," + r[1] for r in dataset]) df = pd.read_csv(StringIO(raw_data)) return df, input_features, output_features # setups up minimal number of data structures required to support initialized # input and output features. The function returns initialized LudwigModel # and batcher for training dataset @contextlib.contextmanager def setup_model_scaffolding(raw_df, input_features, output_features): # setup input feature for testing config = {"input_features": input_features, "output_features": output_features} # setup model scaffolding to for testing model = LudwigModel(config) training_set, _, _, training_set_metadata = preprocess_for_training( model.config, training_set=raw_df, skip_save_processed_input=True ) model.training_set_metadata = training_set_metadata update_config_with_metadata(model.config_obj, training_set_metadata) model.model = model.create_model(model.config_obj) # setup batcher to go through synthetic data with training_set.initialize_batcher() as batcher: yield model, batcher # TODO(#1333): Refactor this test once torch sequence generator work is complete. # - Tests may be covered by other smaller scoped unit tests. # # tests output feature sequence with `Generator` decoder # pytest parameters # dec_cell_type: decoder cell type # combiner_output_shapes: is a 2-tuple specifies the possible types of # tensors that the combiner may generate for sequences. # combiner_output_shapes[0]: specifies shape for hidden key # combiner_output_shapes[1]: is either None or 1 or 2-tuple representing # the encoder_output_state key. None: no encoder_output_state key, # 1-tuple: generate tf.Tensor, 2-tuple: generate list with 2 tf.Tensors # TODO(Justin): Move these to test_sequence_generator unit tests, and reintroduce decoder attention, beam_width, and # num_layers when these are reimplemented. @pytest.mark.parametrize( "dec_cell_type,combiner_output_shapes", [ ("lstm", ((128, 10, TEST_STATE_SIZE), None)), ("rnn", ((128, 10, TEST_STATE_SIZE), ((128, TEST_STATE_SIZE), (128, TEST_STATE_SIZE)))), ("gru", ((128, 10, TEST_STATE_SIZE), ((128, TEST_STATE_SIZE),))), ], ids=["lstm_no_state", "rnn_dual_state", "gru_single_state"], ) def test_sequence_decoders( dec_cell_type, combiner_output_shapes, generate_sequence_training_data, ): # retrieve pre-computed dataset and features raw_df = generate_sequence_training_data[0] input_features = generate_sequence_training_data[1] output_features = generate_sequence_training_data[2] output_feature_name = output_features[0]["name"] output_features[0][DECODER]["cell_type"] = dec_cell_type with setup_model_scaffolding(raw_df, input_features, output_features) as (model, _): # generate synthetic encoder_output tensors and make it look like # it came out of the combiner encoder_output = torch.randn(combiner_output_shapes[0]) combiner_outputs = {"hidden": encoder_output} if combiner_output_shapes[1] is not None: if len(combiner_output_shapes[1]) > 1: encoder_output_state = ( torch.randn(combiner_output_shapes[1][0]), torch.randn(combiner_output_shapes[1][1]), ) else: encoder_output_state = torch.randn(combiner_output_shapes[1][0]) combiner_outputs[ENCODER_OUTPUT_STATE] = encoder_output_state decoder = model.model.output_features.get(output_feature_name).decoder_obj decoder_out = decoder(combiner_outputs) # gather expected components of the shape batch_size = combiner_outputs["hidden"].shape[0] seq_size = output_features[0][DECODER]["max_len"] + 2 # For start and stop symbols. vocab_size = model.config_obj.output_features.to_list()[0][DECODER]["vocab_size"] # confirm shape and format of decoder output assert list(decoder_out[LOGITS].size()) == [batch_size, seq_size, vocab_size] # final sanity test. Checks a subset of sequence parameters @pytest.mark.parametrize( "enc_encoder,enc_cell_type,dec_cell_type", [ ("embed", "lstm", "lstm"), ("rnn", "rnn", "gru"), ("rnn", "gru", "rnn"), ], ids=["embed_lstm", "rnn_gru", "gru_rnn"], ) def test_sequence_generator(enc_encoder, enc_cell_type, dec_cell_type, csv_filename): # Define input and output features input_features = [ sequence_feature(encoder={"type": enc_encoder, "min_len": 5, "max_len": 10, "cell_type": enc_cell_type}) ] output_features = [ sequence_feature(decoder={"type": "generator", "min_len": 5, "max_len": 10, "cell_type": dec_cell_type}) ] # Generate test data rel_path = generate_data(input_features, output_features, csv_filename) # run the experiment run_experiment(input_features, output_features, dataset=rel_path) ================================================ FILE: tests/integration_tests/test_server.py ================================================ # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import json import logging import os import sys import numpy as np import pytest from ludwig.api import LudwigModel from ludwig.constants import BATCH_SIZE, DECODER, TRAINER from ludwig.serve import server from ludwig.utils.data_utils import read_csv from tests.integration_tests.utils import ( audio_feature, category_feature, generate_data, image_feature, LocalTestBackend, number_feature, text_feature, ) logger = logging.getLogger(__name__) ALL_FEATURES_PRESENT_ERROR = "Data received does not contain all input features" try: from starlette.testclient import TestClient except ImportError: logger.error( " fastapi and other serving dependencies are not installed. " "In order to install all serving dependencies run " "pip install ludwig[serve]" ) sys.exit(-1) def train_and_predict_model(input_features, output_features, data_csv, output_directory): """Helper method to avoid code repetition for training a model and using it for prediction. :param input_features: input schema :param output_features: output schema :param data_csv: path to data :param output_directory: model output directory :return: None """ config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat", "output_size": 14}, TRAINER: {"train_steps": 1, BATCH_SIZE: 128}, } model = LudwigModel(config, backend=LocalTestBackend()) model.train( dataset=data_csv, skip_save_processed_input=True, skip_save_progress=True, skip_save_unprocessed_output=True, output_directory=output_directory, ) model.predict(dataset=data_csv, output_directory=output_directory) return model def train_and_predict_model_with_stratified_split(input_features, output_features, data_csv, output_directory): """Same as above, but with stratified split.""" print(f'output_features[0]["column"]: {output_features[0]["column"]}') config = { "input_features": input_features, "output_features": output_features, TRAINER: {"train_steps": 1, BATCH_SIZE: 128}, "preprocessing": { "split": {"column": output_features[0]["column"], "probabilities": [0.7, 0.1, 0.2], "type": "stratify"}, }, } model = LudwigModel(config, backend=LocalTestBackend()) model.train( dataset=data_csv, skip_save_processed_input=True, skip_save_progress=True, skip_save_unprocessed_output=True, output_directory=output_directory, ) model.predict(dataset=data_csv, output_directory=output_directory) return model def output_keys_for(output_features): keys = [] for feature in output_features: name = feature["name"] if feature["type"] == "category": keys.append(f"{name}_predictions") keys.append(f"{name}_probability") keys.append(f"{name}_probabilities") for category in feature[DECODER]["idx2str"]: keys.append(f"{name}_probabilities_{category}") elif feature["type"] == "number": keys.append(f"{name}_predictions") else: raise NotImplementedError return keys def convert_to_form(entry): data = {} files = [] for k, v in entry.items(): if isinstance(v, str) and os.path.exists(v): file = open(v, "rb") files.append((k, (v, file.read(), "application/octet-stream"))) else: data[k] = v return data, files def convert_to_batch_form(data_df): data = data_df.to_dict(orient="split") files = { "dataset": (None, json.dumps(data), "application/json"), } for row in data["data"]: for v in row: if isinstance(v, str) and os.path.exists(v) and v not in files: files[v] = (v, open(v, "rb"), "application/octet-stream") return files def test_server_integration_with_images(tmpdir): # Image Inputs image_dest_folder = os.path.join(tmpdir, "generated_images") # Resnet encoder input_features = [ image_feature( folder=image_dest_folder, encoder={"output_size": 16, "num_filters": 8}, preprocessing={"in_memory": True, "height": 32, "width": 32, "num_channels": 3}, ), text_feature(encoder={"type": "embed", "min_len": 1}), number_feature(normalization="zscore"), ] output_features = [category_feature(decoder={"vocab_size": 4}), number_feature()] np.random.seed(123) # reproducible synthetic data rel_path = generate_data(input_features, output_features, os.path.join(tmpdir, "dataset.csv")) model = train_and_predict_model(input_features, output_features, data_csv=rel_path, output_directory=tmpdir) app = server(model) client = TestClient(app) response = client.get("/") assert response.status_code == 200 response = client.post("/predict") # expect the HTTP 400 error code for this situation assert response.status_code == 400 assert ALL_FEATURES_PRESENT_ERROR in str(response.json()) data_df = read_csv(rel_path) # One-off prediction first_entry = data_df.T.to_dict()[0] data, files = convert_to_form(first_entry) server_response = client.post("/predict", data=data, files=files) assert server_response.status_code == 200 server_response = server_response.json() server_response_keys = sorted(list(server_response.keys())) assert server_response_keys == sorted(output_keys_for(output_features)) model_output, _ = model.predict(dataset=[first_entry], data_format=dict) model_output = model_output.to_dict("records")[0] assert model_output == server_response # Batch prediction assert len(data_df) > 1 files = convert_to_batch_form(data_df) server_response = client.post("/batch_predict", files=files) assert server_response.status_code == 200 server_response = server_response.json() server_response_keys = sorted(server_response["columns"]) assert server_response_keys == sorted(output_keys_for(output_features)) assert len(data_df) == len(server_response["data"]) model_output, _ = model.predict(dataset=data_df) model_output = model_output.to_dict("split") assert model_output == server_response def test_server_integration_with_stratified_split(tmpdir): input_features = [ text_feature(encoder={"type": "embed", "min_len": 1}), number_feature(normalization="zscore"), ] output_features = [category_feature(decoder={"vocab_size": 4})] np.random.seed(123) # reproducible synthetic data rel_path = generate_data(input_features, output_features, os.path.join(tmpdir, "dataset.csv"), num_examples=50) model = train_and_predict_model_with_stratified_split( input_features, output_features, data_csv=rel_path, output_directory=tmpdir ) app = server(model) client = TestClient(app) response = client.get("/") assert response.status_code == 200 response = client.post("/predict") # expect the HTTP 400 error code for this situation assert response.status_code == 400 assert ALL_FEATURES_PRESENT_ERROR in str(response.json()) data_df = read_csv(rel_path) # One-off prediction first_entry = data_df.T.to_dict()[0] data, files = convert_to_form(first_entry) server_response = client.post("/predict", data=data, files=files) assert server_response.status_code == 200 server_response = server_response.json() server_response_keys = sorted(list(server_response.keys())) assert server_response_keys == sorted(output_keys_for(output_features)) model_output, _ = model.predict(dataset=[first_entry], data_format=dict) model_output = model_output.to_dict("records")[0] assert model_output == server_response # Batch prediction assert len(data_df) > 1 files = convert_to_batch_form(data_df) server_response = client.post("/batch_predict", files=files) assert server_response.status_code == 200 server_response = server_response.json() server_response_keys = sorted(server_response["columns"]) assert server_response_keys == sorted(output_keys_for(output_features)) assert len(data_df) == len(server_response["data"]) model_output, _ = model.predict(dataset=data_df) model_output = model_output.to_dict("split") assert model_output == server_response @pytest.mark.parametrize("single_record", [False, True]) def test_server_integration_with_audio(single_record, tmpdir): # Audio Inputs audio_dest_folder = os.path.join(tmpdir, "generated_audio") # Resnet encoder input_features = [ audio_feature( folder=audio_dest_folder, ), text_feature(encoder={"type": "embed", "min_len": 1}), number_feature(normalization="zscore"), ] output_features = [category_feature(decoder={"vocab_size": 4}), number_feature()] rel_path = generate_data(input_features, output_features, os.path.join(tmpdir, "dataset.csv")) model = train_and_predict_model(input_features, output_features, data_csv=rel_path, output_directory=tmpdir) app = server(model) client = TestClient(app) response = client.get("/") assert response.status_code == 200 response = client.post("/predict") # expect the HTTP 400 error code for this situation assert response.status_code == 400 assert ALL_FEATURES_PRESENT_ERROR in str(response.json()) data_df = read_csv(rel_path) if single_record: # Single record prediction first_entry = data_df.T.to_dict()[0] data, files = convert_to_form(first_entry) server_response = client.post("/predict", data=data, files=files) assert server_response.status_code == 200 server_response = server_response.json() server_response_keys = sorted(list(server_response.keys())) assert server_response_keys == sorted(output_keys_for(output_features)) model_output, _ = model.predict(dataset=[first_entry], data_format=dict) model_output = model_output.to_dict("records")[0] assert model_output == server_response else: # Batch prediction assert len(data_df) > 1 files = convert_to_batch_form(data_df) server_response = client.post("/batch_predict", files=files) assert server_response.status_code == 200 server_response = server_response.json() server_response_keys = sorted(server_response["columns"]) assert server_response_keys == sorted(output_keys_for(output_features)) assert len(data_df) == len(server_response["data"]) model_output, _ = model.predict(dataset=data_df) model_output = model_output.to_dict("split") assert model_output == server_response ================================================ FILE: tests/integration_tests/test_simple_features.py ================================================ # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import logging import os import pandas as pd import pytest from ludwig.constants import NAME from tests.integration_tests.utils import ( bag_feature, binary_feature, category_feature, generate_data, number_feature, run_experiment, sequence_feature, set_feature, text_feature, vector_feature, ) logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) logging.getLogger("ludwig").setLevel(logging.INFO) @pytest.mark.parametrize( "input_test_feature, output_test_feature, output_loss_parameter", [ # number features (number_feature(), number_feature(), None), (number_feature(normalization="minmax"), number_feature(), {"loss": {"type": "mean_squared_error"}}), (number_feature(normalization="zscore"), number_feature(), {"loss": {"type": "mean_absolute_error"}}), # binary feature (binary_feature(), binary_feature(), None), # Categorical feature (category_feature(), category_feature(output_feature=True), None), (category_feature(), category_feature(output_feature=True), {"loss": {"type": "softmax_cross_entropy"}}), ], ) def test_feature(input_test_feature, output_test_feature, output_loss_parameter, csv_filename): input_features = [input_test_feature] of_test_feature = output_test_feature if output_loss_parameter is not None: of_test_feature.update(output_loss_parameter) output_features = [of_test_feature] # Generate test data rel_path = generate_data(input_features, output_features, csv_filename, 100) run_experiment(input_features, output_features, dataset=rel_path) @pytest.mark.parametrize( "input_test_feature, output_test_feature", [ ([category_feature()], [binary_feature(), binary_feature()]), ( [category_feature()], [category_feature(decoder={"vocab_size": 5}), category_feature(decoder={"vocab_size": 7})], ), ([category_feature()], [number_feature(), number_feature()]), ( [category_feature()], [sequence_feature(decoder={"vocab_size": 5}), sequence_feature(decoder={"vocab_size": 7})], ), ( [set_feature(encoder={"vocab_size": 5})], [set_feature(decoder={"vocab_size": 5}), set_feature(decoder={"vocab_size": 7})], ), ([category_feature()], [text_feature(decoder={"vocab_size": 5}), text_feature(decoder={"vocab_size": 7})]), ([category_feature()], [vector_feature(), vector_feature()]), ([vector_feature()], [vector_feature(), vector_feature()]), ([bag_feature()], [vector_feature(), vector_feature()]), ], ) def test_feature_multiple_outputs(input_test_feature, output_test_feature, csv_filename): # Generate test data rel_path = generate_data(input_test_feature, output_test_feature, csv_filename, 100) run_experiment(input_test_feature, output_test_feature, dataset=rel_path) def test_category_int_dtype(tmpdir): feature = category_feature() input_features = [feature] output_features = [binary_feature()] csv_fname = generate_data(input_features, output_features, os.path.join(tmpdir, "dataset.csv")) df = pd.read_csv(csv_fname) distinct_values = df[feature[NAME]].drop_duplicates().values value_map = {v: idx for idx, v in enumerate(distinct_values)} df[feature[NAME]] = df[feature[NAME]].map(lambda x: value_map[x]) run_experiment(input_features, output_features, dataset=df) ================================================ FILE: tests/integration_tests/test_timeseries_feature.py ================================================ import numpy as np import pandas as pd import pytest import torch from ludwig.api import LudwigModel from ludwig.constants import COLUMN, ENCODER_OUTPUT, INPUT_FEATURES, OUTPUT_FEATURES from ludwig.features.timeseries_feature import TimeseriesInputFeature from ludwig.schema.features.timeseries_feature import TimeseriesInputFeatureConfig from ludwig.schema.utils import load_config_with_kwargs from tests.integration_tests.utils import number_feature, timeseries_feature BATCH_SIZE = 2 SEQ_SIZE = 10 DEFAULT_OUTPUT_SIZE = 4 @pytest.mark.parametrize("enc_encoder", ["stacked_cnn", "rnn", "passthrough"]) def test_timeseries_feature(enc_encoder): # synthetic time series tensor timeseries_tensor = torch.randn([BATCH_SIZE, SEQ_SIZE], dtype=torch.float32) # generate feature config timeseries_feature_config = timeseries_feature( encoder={ "type": enc_encoder, "max_len": SEQ_SIZE, "fc_layers": [{"output_size": DEFAULT_OUTPUT_SIZE}], # simulated parameters determined by pre-processing "max_sequence_length": SEQ_SIZE, } ) # instantiate input feature object timeseries_feature_config, _ = load_config_with_kwargs(TimeseriesInputFeatureConfig, timeseries_feature_config) timeseries_input_feature = TimeseriesInputFeature(timeseries_feature_config) # pass synthetic tensor through input feature encoder_output = timeseries_input_feature(timeseries_tensor) # confirm correctness of the encoder output assert isinstance(encoder_output, dict) assert ENCODER_OUTPUT in encoder_output assert isinstance(encoder_output[ENCODER_OUTPUT], torch.Tensor) if enc_encoder == "passthrough": assert encoder_output[ENCODER_OUTPUT].shape == (BATCH_SIZE, SEQ_SIZE, 1) else: assert encoder_output[ENCODER_OUTPUT].shape == (BATCH_SIZE, DEFAULT_OUTPUT_SIZE) def test_timeseries_preprocessing_with_nan(): config = { "input_features": [timeseries_feature(preprocessing={"padding_value": 42})], "output_features": [number_feature()], } # generate synthetic data data = { config[INPUT_FEATURES][0][COLUMN]: [ "1.53 2.3 NaN 6.4 3 ", "1.53 2.3 2 ", "1.53 NaN 3 2 ", ], config[OUTPUT_FEATURES][0][COLUMN]: [1.0, 2.0, 3.0], } df = pd.DataFrame(data) model = LudwigModel(config) ds = model.preprocess(df) out_df = ds.training_set.to_df() assert len(out_df.columns) == len(df.columns) expected_df = pd.DataFrame( [ [np.array([1.53, 2.3, 42.0, 6.4, 3.0]), 1.0], [np.array([1.53, 2.3, 2.0, 42.0, 42.0]), 2.0], [np.array([1.53, 42.0, 3.0, 2.0, 42.0]), 3.0], ], columns=out_df.columns.to_list(), ) for row1, row2 in zip(out_df.values, expected_df.values): assert np.allclose(row1[0], row2[0]) assert row1[1] == row2[1] ================================================ FILE: tests/integration_tests/test_torchscript.py ================================================ # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import os import shutil from copy import deepcopy import numpy as np import pandas as pd import pytest import torch try: import torchtext except ImportError: torchtext = None from ludwig.api import LudwigModel from ludwig.backend import RAY from ludwig.constants import BATCH_SIZE, COMBINER, EVAL_BATCH_SIZE, LOGITS, NAME, PREDICTIONS, PROBABILITIES, TRAINER from ludwig.data.preprocessing import preprocess_for_prediction from ludwig.features.number_feature import numeric_transformation_registry from ludwig.globals import TRAIN_SET_METADATA_FILE_NAME from ludwig.models.inference import to_inference_module_input_from_dataframe from ludwig.utils import output_feature_utils from ludwig.utils.tokenizers import TORCHSCRIPT_COMPATIBLE_TOKENIZERS from tests.integration_tests import utils from tests.integration_tests.utils import ( audio_feature, bag_feature, binary_feature, category_feature, date_feature, generate_data, h3_feature, image_feature, LocalTestBackend, number_feature, sequence_feature, set_feature, text_feature, timeseries_feature, vector_feature, ) @pytest.mark.integration_tests_e @pytest.mark.parametrize("should_load_model", [True, False]) def test_torchscript(tmpdir, csv_filename, should_load_model): ####### # Setup ####### dir_path = tmpdir data_csv_path = os.path.join(tmpdir, csv_filename) # Single sequence input, single category output image_dest_folder = os.path.join(tmpdir, "generated_images") audio_dest_folder = os.path.join(tmpdir, "generated_audio") input_features = [ binary_feature(), number_feature(), category_feature(encoder={"type": "passthrough", "vocab_size": 3}), category_feature(encoder={"type": "onehot", "vocab_size": 3}), category_feature(encoder={"type": "dense", "vocab_size": 3}), sequence_feature(encoder={"vocab_size": 3}), text_feature(encoder={"vocab_size": 3}), vector_feature(), image_feature(image_dest_folder), audio_feature(audio_dest_folder), timeseries_feature(), date_feature(), date_feature(), h3_feature(), set_feature(encoder={"vocab_size": 3}), bag_feature(encoder={"vocab_size": 3}), ] output_features = [ category_feature(decoder={"vocab_size": 3}), binary_feature(), number_feature(), set_feature(decoder={"vocab_size": 3}), vector_feature(), sequence_feature(decoder={"vocab_size": 3}), text_feature(decoder={"vocab_size": 3}), ] predictions_column_name = "{}_predictions".format(output_features[0]["name"]) # Generate test data data_csv_path = generate_data(input_features, output_features, data_csv_path) ############# # Train model ############# backend = LocalTestBackend() config = { "model_type": "ecd", "input_features": input_features, "output_features": output_features, TRAINER: {"train_steps": 1}, } ludwig_model = LudwigModel(config, backend=backend) ludwig_model.train( dataset=data_csv_path, skip_save_training_description=True, skip_save_training_statistics=True, skip_save_model=True, skip_save_progress=True, skip_save_log=True, skip_save_processed_input=True, ) ################### # save Ludwig model ################### ludwigmodel_path = os.path.join(dir_path, "ludwigmodel") shutil.rmtree(ludwigmodel_path, ignore_errors=True) ludwig_model.save(ludwigmodel_path) ################### # load Ludwig model ################### if should_load_model: ludwig_model = LudwigModel.load(ludwigmodel_path, backend=backend) ############################## # collect weight tensors names ############################## original_predictions_df, _ = ludwig_model.predict(dataset=data_csv_path) original_weights = deepcopy(list(ludwig_model.model.parameters())) original_weights = [t.cpu() for t in original_weights] ################# # save torchscript ################# torchscript_path = os.path.join(dir_path, "torchscript") shutil.rmtree(torchscript_path, ignore_errors=True) ludwig_model.model.save_torchscript(torchscript_path, device="cpu") ################################################### # load Ludwig model, obtain predictions and weights ################################################### ludwig_model = LudwigModel.load(ludwigmodel_path, backend=backend) loaded_prediction_df, _ = ludwig_model.predict(dataset=data_csv_path) loaded_weights = deepcopy(list(ludwig_model.model.parameters())) loaded_weights = [t.cpu() for t in loaded_weights] ##################################################### # restore torchscript, obtain predictions and weights ##################################################### training_set_metadata_json_fp = os.path.join(ludwigmodel_path, TRAIN_SET_METADATA_FILE_NAME) dataset, training_set_metadata = preprocess_for_prediction( ludwig_model.config_obj.to_dict(), dataset=data_csv_path, training_set_metadata=training_set_metadata_json_fp, include_outputs=False, backend=backend, ) restored_model = torch.jit.load(torchscript_path) # Check the outputs for one of the features for correctness # Here we choose the first output feature (categorical) of_name = list(ludwig_model.model.output_features.keys())[0] data_to_predict = { name: torch.from_numpy(dataset.dataset[feature.proc_column]) for name, feature in ludwig_model.model.input_features.items() } # Get predictions from restored torchscript. logits = restored_model(data_to_predict) restored_predictions = torch.argmax(output_feature_utils.get_output_feature_tensor(logits, of_name, "logits"), -1) restored_predictions = [training_set_metadata[of_name]["idx2str"][idx] for idx in restored_predictions] restored_weights = deepcopy(list(restored_model.parameters())) restored_weights = [t.cpu() for t in restored_weights] ############################################### # Check if weights and predictions are the same ############################################### # Check to weight values match the original model. assert utils.is_all_close(original_weights, loaded_weights) assert utils.is_all_close(original_weights, restored_weights) # Check that predictions are identical to the original model. assert np.all(original_predictions_df[predictions_column_name] == loaded_prediction_df[predictions_column_name]) assert np.all(original_predictions_df[predictions_column_name] == restored_predictions) @pytest.mark.integration_tests_e def test_torchscript_e2e_tabular(csv_filename, tmpdir): data_csv_path = os.path.join(tmpdir, csv_filename) # Configure features to be tested: bin_str_feature_input_feature = binary_feature() bin_str_feature_output_feature = binary_feature(output_feature=True) transformed_number_features = [ number_feature(preprocessing={"normalization": numeric_transformer}) for numeric_transformer in numeric_transformation_registry.keys() ] input_features = [ bin_str_feature_input_feature, binary_feature(), *transformed_number_features, number_feature(preprocessing={"outlier_strategy": "fill_with_mean"}), category_feature(encoder={"vocab_size": 3}), bag_feature(encoder={"vocab_size": 3}), set_feature(encoder={"vocab_size": 3}), vector_feature(), # TODO: future support # date_feature(), # h3_feature(), ] output_features = [ bin_str_feature_output_feature, binary_feature(output_feature=True), number_feature(), category_feature(decoder={"vocab_size": 3}), set_feature(decoder={"vocab_size": 3}), vector_feature(), sequence_feature(decoder={"vocab_size": 3}), text_feature(decoder={"vocab_size": 3}), ] backend = LocalTestBackend() config = { "input_features": input_features, "output_features": output_features, TRAINER: {"train_steps": 1, BATCH_SIZE: 128}, } # Generate training data training_data_csv_path = generate_data(input_features, output_features, data_csv_path) # Convert bool values to strings, e.g., {'Yes', 'No'} df = pd.read_csv(training_data_csv_path) false_value, true_value = "No", "Yes" df[bin_str_feature_input_feature[NAME]] = df[bin_str_feature_input_feature[NAME]].map( lambda x: true_value if x else false_value ) df[bin_str_feature_output_feature[NAME]] = df[bin_str_feature_output_feature[NAME]].map( lambda x: true_value if x else false_value ) df.to_csv(training_data_csv_path) validate_torchscript_outputs(tmpdir, config, backend, training_data_csv_path) @pytest.mark.integration_tests_e def test_torchscript_e2e_binary_only(csv_filename, tmpdir): data_csv_path = os.path.join(tmpdir, csv_filename) input_features = [ binary_feature(), ] output_features = [ binary_feature(), ] backend = LocalTestBackend() config = { "input_features": input_features, "output_features": output_features, TRAINER: {"train_steps": 1, BATCH_SIZE: 128}, } # Generate training data training_data_csv_path = generate_data(input_features, output_features, data_csv_path) validate_torchscript_outputs(tmpdir, config, backend, training_data_csv_path) @pytest.mark.integration_tests_e def test_torchscript_e2e_tabnet_combiner(csv_filename, tmpdir): data_csv_path = os.path.join(tmpdir, csv_filename) # Configure features to be tested: input_features = [ binary_feature(), number_feature(), category_feature(encoder={"vocab_size": 3}), bag_feature(encoder={"vocab_size": 3}), set_feature(encoder={"vocab_size": 3}), ] output_features = [ binary_feature(), number_feature(), category_feature(decoder={"vocab_size": 3}), ] backend = LocalTestBackend() config = { "input_features": input_features, "output_features": output_features, COMBINER: { "type": "tabnet", "num_total_blocks": 2, "num_shared_blocks": 2, }, TRAINER: {"train_steps": 1, BATCH_SIZE: 128}, } # Generate training data training_data_csv_path = generate_data(input_features, output_features, data_csv_path) validate_torchscript_outputs(tmpdir, config, backend, training_data_csv_path) @pytest.mark.integration_tests_e @pytest.mark.xfail(reason="torchaudio 2.x: DifferentiableFIR not TorchScript-compatible (upstream)") def test_torchscript_e2e_audio(csv_filename, tmpdir): data_csv_path = os.path.join(tmpdir, csv_filename) audio_dest_folder = os.path.join(tmpdir, "generated_audio") input_features = [ audio_feature(audio_dest_folder), ] output_features = [ binary_feature(), ] backend = LocalTestBackend() config = { "input_features": input_features, "output_features": output_features, TRAINER: {"train_steps": 1, BATCH_SIZE: 128}, } training_data_csv_path = generate_data(input_features, output_features, data_csv_path) # NOTE: audio preprocessing mismatches by very small margins ~O(1e-6) but causes flakiness in e2e test. # Increasing tolerance is a workaround to reduce flakiness for now. # TODO: remove this workaround when audio preprocessing is fixed. validate_torchscript_outputs(tmpdir, config, backend, training_data_csv_path, tolerance=1e-6) @pytest.mark.integration_tests_e @pytest.mark.parametrize( "kwargs", [ {"encoder": {"type": "stacked_cnn"}}, # Ludwig custom encoder {"encoder": {"type": "alexnet", "use_pretrained": False}}, # TorchVision pretrained model encoder ], ) def test_torchscript_e2e_image(tmpdir, csv_filename, kwargs): data_csv_path = os.path.join(tmpdir, csv_filename) image_dest_folder = os.path.join(tmpdir, "generated_images") input_features = [ image_feature(image_dest_folder, **kwargs), ] output_features = [ binary_feature(), ] backend = LocalTestBackend() config = { "input_features": input_features, "output_features": output_features, TRAINER: {"train_steps": 1, BATCH_SIZE: 128}, } training_data_csv_path = generate_data(input_features, output_features, data_csv_path) validate_torchscript_outputs(tmpdir, config, backend, training_data_csv_path) @pytest.mark.integration_tests_e def test_torchscript_e2e_text(tmpdir, csv_filename): data_csv_path = os.path.join(tmpdir, csv_filename) input_features = [ text_feature(encoder={"vocab_size": 3}, preprocessing={"tokenizer": tokenizer}) for tokenizer in TORCHSCRIPT_COMPATIBLE_TOKENIZERS ] output_features = [ text_feature(decoder={"vocab_size": 3}), ] backend = LocalTestBackend() config = { "input_features": input_features, "output_features": output_features, TRAINER: {"train_steps": 1, BATCH_SIZE: 128}, } training_data_csv_path = generate_data(input_features, output_features, data_csv_path) validate_torchscript_outputs(tmpdir, config, backend, training_data_csv_path) @pytest.mark.skipif( torchtext is None or torch.torch_version.TorchVersion(torchtext.__version__) < (0, 14, 0), reason="requires torchtext 0.14.0 or higher", ) @pytest.mark.integration_tests_e def test_torchscript_e2e_text_hf_tokenizer(tmpdir, csv_filename): data_csv_path = os.path.join(tmpdir, csv_filename) input_features = [text_feature(encoder={"vocab_size": 3, "type": "bert"})] output_features = [ category_feature(), ] backend = LocalTestBackend() config = { "input_features": input_features, "output_features": output_features, TRAINER: {"train_steps": 1, BATCH_SIZE: 128, EVAL_BATCH_SIZE: 128}, } training_data_csv_path = generate_data(input_features, output_features, data_csv_path) validate_torchscript_outputs(tmpdir, config, backend, training_data_csv_path) @pytest.mark.skipif( torchtext is None or torch.torch_version.TorchVersion(torchtext.__version__) < (0, 14, 0), reason="requires torchtext 0.14.0 or higher", ) @pytest.mark.integration_tests_e def test_torchscript_e2e_text_hf_tokenizer_truncated_sequence(tmpdir, csv_filename): data_csv_path = os.path.join(tmpdir, csv_filename) input_features = [text_feature(encoder={"vocab_size": 3, "type": "bert"}, preprocessing={"max_sequence_length": 3})] output_features = [ category_feature(), ] backend = LocalTestBackend() config = { "input_features": input_features, "output_features": output_features, TRAINER: {"train_steps": 1, BATCH_SIZE: 128}, } training_data_csv_path = generate_data(input_features, output_features, data_csv_path) validate_torchscript_outputs(tmpdir, config, backend, training_data_csv_path) @pytest.mark.integration_tests_e def test_torchscript_e2e_sequence(tmpdir, csv_filename): data_csv_path = os.path.join(tmpdir, csv_filename) input_features = [ sequence_feature(encoder={"vocab_size": 3}, preprocessing={"tokenizer": "space"}), ] output_features = [ sequence_feature(decoder={"vocab_size": 3}), ] backend = LocalTestBackend() config = { "input_features": input_features, "output_features": output_features, TRAINER: {"train_steps": 1, BATCH_SIZE: 128}, } training_data_csv_path = generate_data(input_features, output_features, data_csv_path) validate_torchscript_outputs(tmpdir, config, backend, training_data_csv_path) @pytest.mark.integration_tests_e def test_torchscript_e2e_timeseries(tmpdir, csv_filename): data_csv_path = os.path.join(tmpdir, csv_filename) input_features = [ timeseries_feature(), ] output_features = [ binary_feature(), ] backend = LocalTestBackend() config = { "input_features": input_features, "output_features": output_features, TRAINER: {"train_steps": 1, BATCH_SIZE: 128}, } training_data_csv_path = generate_data(input_features, output_features, data_csv_path) validate_torchscript_outputs(tmpdir, config, backend, training_data_csv_path) @pytest.mark.integration_tests_e def test_torchscript_e2e_h3(tmpdir, csv_filename): data_csv_path = os.path.join(tmpdir, csv_filename) input_features = [ h3_feature(), ] output_features = [ binary_feature(), ] backend = LocalTestBackend() config = { "input_features": input_features, "output_features": output_features, TRAINER: {"train_steps": 1, BATCH_SIZE: 128}, } training_data_csv_path = generate_data(input_features, output_features, data_csv_path) validate_torchscript_outputs(tmpdir, config, backend, training_data_csv_path) @pytest.mark.integration_tests_e def test_torchscript_e2e_date(tmpdir, csv_filename): data_csv_path = os.path.join(tmpdir, csv_filename) input_features = [ date_feature(), ] output_features = [ binary_feature(), ] backend = LocalTestBackend() config = { "input_features": input_features, "output_features": output_features, TRAINER: {"train_steps": 1, BATCH_SIZE: 128}, } training_data_csv_path = generate_data(input_features, output_features, data_csv_path) validate_torchscript_outputs(tmpdir, config, backend, training_data_csv_path) @pytest.mark.integration_tests_e @pytest.mark.parametrize("vector_type", [torch.Tensor, list[torch.Tensor]]) def test_torchscript_preproc_vector_alternative_type(tmpdir, csv_filename, vector_type): data_csv_path = os.path.join(tmpdir, csv_filename) feature = vector_feature() input_features = [ feature, ] output_features = [ binary_feature(), ] backend = LocalTestBackend() config = { "input_features": input_features, "output_features": output_features, TRAINER: {"train_steps": 1, BATCH_SIZE: 128}, } training_data_csv_path = generate_data(input_features, output_features, data_csv_path) # Initialize Ludwig model ludwig_model, script_module = initialize_torchscript_module(tmpdir, config, backend, training_data_csv_path) # Obtain preprocessed inputs from Python model preproc_inputs_expected, _ = preprocess_for_prediction( ludwig_model.config_obj.to_dict(), training_data_csv_path, ludwig_model.training_set_metadata, backend=backend, include_outputs=False, ) df = pd.read_csv(training_data_csv_path) inputs = to_inference_module_input_from_dataframe(df, config, load_paths=True) def transform_vector_list(vector_list, vector_type): vectors = [] for vector_str in vector_list: vectors.append(torch.tensor([float(x) for x in vector_str.split()])) if vector_type == torch.Tensor: vectors = torch.stack(vectors) return vectors inputs[feature[NAME]] = transform_vector_list(inputs[feature[NAME]], vector_type) preproc_inputs = script_module.preprocessor_forward(inputs) # Check that preproc_inputs is the same as preproc_inputs_expected. for feature_name_expected, feature_values_expected in preproc_inputs_expected.dataset.items(): feature_name = feature_name_expected[: feature_name_expected.rfind("_")] # remove proc suffix if feature_name not in preproc_inputs.keys(): continue feature_values = preproc_inputs[feature_name] assert utils.is_all_close(feature_values, feature_values_expected), f"feature: {feature_name}" @pytest.mark.integration_tests_e @pytest.mark.parametrize("padding", ["left", "right"]) @pytest.mark.parametrize("fill_value", ["", "1.0"]) def test_torchscript_preproc_timeseries_alternative_type(tmpdir, csv_filename, padding, fill_value): data_csv_path = os.path.join(tmpdir, csv_filename) feature = timeseries_feature( preprocessing={ "padding": padding, "timeseries_length_limit": 4, "fill_value": "1.0", }, encoder={"max_len": 7}, ) input_features = [ feature, ] output_features = [ binary_feature(), ] backend = LocalTestBackend() config = { "input_features": input_features, "output_features": output_features, TRAINER: {"train_steps": 1, BATCH_SIZE: 128}, } training_data_csv_path = generate_data(input_features, output_features, data_csv_path, nan_percent=0.2) # Initialize Ludwig model ludwig_model, script_module = initialize_torchscript_module(tmpdir, config, backend, training_data_csv_path) # Obtain preprocessed inputs from Python model preproc_inputs_expected, _ = preprocess_for_prediction( ludwig_model.config_obj.to_dict(), training_data_csv_path, ludwig_model.training_set_metadata, backend=backend, include_outputs=False, ) df = pd.read_csv(training_data_csv_path) inputs = to_inference_module_input_from_dataframe(df, config, load_paths=True) def transform_timeseries_from_str_list_to_tensor_list(timeseries_list): timeseries = [] for timeseries_str in timeseries_list: timeseries.append(torch.tensor([float(x) for x in timeseries_str.split()])) return timeseries inputs[feature[NAME]] = transform_timeseries_from_str_list_to_tensor_list(inputs[feature[NAME]]) preproc_inputs = script_module.preprocessor_forward(inputs) # Check that preproc_inputs is the same as preproc_inputs_expected. for feature_name_expected, feature_values_expected in preproc_inputs_expected.dataset.items(): feature_name = feature_name_expected[: feature_name_expected.rfind("_")] # remove proc suffix assert feature_name in preproc_inputs.keys(), f'feature "{feature_name}" not found.' feature_values = preproc_inputs[feature_name] assert utils.is_all_close(feature_values, feature_values_expected), f'feature "{feature_name}" value mismatch.' @pytest.mark.integration_tests_e @pytest.mark.parametrize( "feature", [ number_feature(), binary_feature(), category_feature(encoder={"vocab_size": 3}), bag_feature(encoder={"vocab_size": 3}), set_feature(encoder={"vocab_size": 3}), text_feature(encoder={"vocab_size": 3}), sequence_feature(encoder={"vocab_size": 3}), timeseries_feature(), h3_feature(), # TODO: future support # audio_feature(), # default BFILL strategy is unintuitive at inference time # image_feature(), # default BFILL strategy is unintuitive at inference time # vector_feature(), # does not have a missing_value_strategy # date_feature(), # default fill with datetime.now() strategy is not scriptable ], ) def test_torchscript_preproc_with_nans(tmpdir, csv_filename, feature): data_csv_path = os.path.join(tmpdir, csv_filename) input_features = [ feature, ] output_features = [ binary_feature(), ] backend = LocalTestBackend() config = { "input_features": input_features, "output_features": output_features, TRAINER: {"train_steps": 1, BATCH_SIZE: 128}, } training_data_csv_path = generate_data(input_features, output_features, data_csv_path, nan_percent=0.2) # Initialize Ludwig model ludwig_model, script_module = initialize_torchscript_module(tmpdir, config, backend, training_data_csv_path) # Obtain preprocessed inputs from Python model preproc_inputs_expected, _ = preprocess_for_prediction( ludwig_model.config_obj.to_dict(), training_data_csv_path, ludwig_model.training_set_metadata, backend=backend, include_outputs=False, ) df = pd.read_csv(training_data_csv_path) inputs = to_inference_module_input_from_dataframe(df, config, load_paths=True) preproc_inputs = script_module.preprocessor_forward(inputs) # Check that preproc_inputs is the same as preproc_inputs_expected. for feature_name_expected, feature_values_expected in preproc_inputs_expected.dataset.items(): feature_name = feature_name_expected[: feature_name_expected.rfind("_")] # remove proc suffix if feature_name not in preproc_inputs.keys(): continue feature_values = preproc_inputs[feature_name] assert utils.is_all_close(feature_values, feature_values_expected), f"feature: {feature_name}" @pytest.mark.skipif(torch.cuda.device_count() == 0, reason="test requires at least 1 gpu") @pytest.mark.skipif(not torch.cuda.is_available(), reason="test requires gpu support") @pytest.mark.integration_tests_e @pytest.mark.distributed @pytest.mark.parametrize( "feature_fn", [ number_feature, image_feature, audio_feature, h3_feature, date_feature, # TODO: future support # binary_feature(), # Torchscript takes List[str] as input, so currently CPU only # category_feature(encoder={"vocab_size": 3}), # Torchscript takes List[str] as input, so currently CPU only # set_feature(encoder={"vocab_size": 3}), # Torchscript takes List[str] as input, so currently CPU only # sequence_feature(encoder={"vocab_size": 3}), # Torchscript takes List[str] as input, so currently CPU only # text_feature(encoder={"vocab_size": 3}), # Torchscript takes List[str] as input, so currently CPU only # vector_feature(), # Torchscript takes List[str] as input, so currently CPU only # bag_feature(encoder={"vocab_size": 3}), # Torchscript takes List[str] as input, so currently CPU only # timeseries_feature(), # Torchscript takes List[str] as input, so currently CPU only ], ) def test_torchscript_preproc_gpu(tmpdir, csv_filename, feature_fn): data_csv_path = os.path.join(tmpdir, csv_filename) feature_kwargs = {} if feature_fn in {image_feature, audio_feature}: dest_folder = os.path.join(tmpdir, "generated_samples") feature_kwargs["folder"] = dest_folder input_features = [ feature_fn(**feature_kwargs), ] output_features = [ binary_feature(), ] config = { "input_features": input_features, "output_features": output_features, TRAINER: {"train_steps": 1, BATCH_SIZE: 128}, } backend = RAY training_data_csv_path = generate_data(input_features, output_features, data_csv_path) _, script_module = initialize_torchscript_module( tmpdir, config, backend, training_data_csv_path, device=torch.device("cuda"), ) df = pd.read_csv(training_data_csv_path) inputs = to_inference_module_input_from_dataframe( df, config, load_paths=True, device=torch.device("cuda"), ) preproc_inputs = script_module.preprocessor_forward(inputs) for name, values in preproc_inputs.items(): assert values.is_cuda, f'feature "{name}" tensors are not on GPU' @pytest.mark.skipif(torch.cuda.device_count() == 0, reason="test requires at least 1 gpu") @pytest.mark.skipif(not torch.cuda.is_available(), reason="test requires gpu support") @pytest.mark.integration_tests_e @pytest.mark.distributed @pytest.mark.parametrize( "feature_fn", [ number_feature, category_feature, binary_feature, set_feature, vector_feature, sequence_feature, text_feature, ], ) def test_torchscript_postproc_gpu(tmpdir, csv_filename, feature_fn): data_csv_path = os.path.join(tmpdir, csv_filename) feature_kwargs = {} if feature_fn in {category_feature, set_feature, sequence_feature, text_feature}: feature_kwargs["vocab_size"] = 3 input_features = [ number_feature(), ] output_features = [ feature_fn(**feature_kwargs), ] config = { "input_features": input_features, "output_features": output_features, TRAINER: {"train_steps": 1, BATCH_SIZE: 128}, } backend = RAY training_data_csv_path = generate_data(input_features, output_features, data_csv_path) _, script_module = initialize_torchscript_module( tmpdir, config, backend, training_data_csv_path, device=torch.device("cuda"), ) df = pd.read_csv(training_data_csv_path) inputs = to_inference_module_input_from_dataframe( df, config, load_paths=True, device=torch.device("cuda"), ) postproc_outputs = script_module(inputs) for feature_name, feature_outputs in postproc_outputs.items(): for output_name, output_values in feature_outputs.items(): assert utils.is_all_tensors_cuda(output_values), f"{feature_name}.{output_name} tensors are not on GPU" def validate_torchscript_outputs(tmpdir, config, backend, training_data_csv_path, tolerance=1e-8): # Train Ludwig (Pythonic) model: ludwig_model, script_module = initialize_torchscript_module( tmpdir, config, backend, training_data_csv_path, ) # Obtain predictions from Python model preds_dict, _ = ludwig_model.predict(dataset=training_data_csv_path, return_type=dict) df = pd.read_csv(training_data_csv_path) inputs = to_inference_module_input_from_dataframe(df, config, load_paths=True) outputs = script_module(inputs) # TODO: these are the only outputs we provide from Torchscript for now ts_outputs = {PREDICTIONS, PROBABILITIES, LOGITS} # Compare results from Python trained model against Torchscript for feature_name, feature_outputs_expected in preds_dict.items(): assert feature_name in outputs feature_outputs = outputs[feature_name] for output_name, output_values_expected in feature_outputs_expected.items(): if output_name not in ts_outputs: continue assert output_name in feature_outputs output_values = feature_outputs[output_name] assert utils.has_no_grad(output_values), f'"{feature_name}.{output_name}" tensors have gradients' assert utils.is_all_close( output_values, output_values_expected ), f'"{feature_name}.{output_name}" tensors are not close to ludwig model' def initialize_torchscript_module(tmpdir, config, backend, training_data_csv_path, device=None): # Initialize Ludwig model ludwig_model = LudwigModel(config, backend=backend) ludwig_model.train( dataset=training_data_csv_path, skip_save_training_description=True, skip_save_training_statistics=True, skip_save_model=True, skip_save_progress=True, skip_save_log=True, skip_save_processed_input=True, ) # Always use CPU for torchscript since inference inputs are CPU tensors if device is None: device = torch.device("cpu") # Create graph inference model (Torchscript) from trained Ludwig model. script_module = ludwig_model.to_torchscript(device=device) # Ensure torchscript saving/loading does not affect final predictions. script_module_path = os.path.join(tmpdir, "inference_module.pt") torch.jit.save(script_module, script_module_path) script_module = torch.jit.load(script_module_path) return ludwig_model, script_module ================================================ FILE: tests/integration_tests/test_trainer.py ================================================ import logging import os import shutil from unittest import mock import pytest import torch from packaging.version import parse as parse_version from ludwig.api import LudwigModel from ludwig.constants import ( BATCH_SIZE, EFFECTIVE_BATCH_SIZE, EPOCHS, EVAL_BATCH_SIZE, INPUT_FEATURES, MAX_BATCH_SIZE_DATASET_FRACTION, OUTPUT_FEATURES, TRAINER, ) from ludwig.globals import MODEL_FILE_NAME from tests.integration_tests.utils import ( binary_feature, category_feature, generate_data, LocalTestBackend, number_feature, sequence_feature, text_feature, vector_feature, ) def test_tune_learning_rate(tmpdir): config = { INPUT_FEATURES: [text_feature(), binary_feature()], OUTPUT_FEATURES: [binary_feature()], TRAINER: { "train_steps": 1, BATCH_SIZE: 128, "learning_rate": "auto", }, } csv_filename = os.path.join(tmpdir, "training.csv") data_csv = generate_data(config[INPUT_FEATURES], config[OUTPUT_FEATURES], csv_filename) val_csv = shutil.copyfile(data_csv, os.path.join(tmpdir, "validation.csv")) test_csv = shutil.copyfile(data_csv, os.path.join(tmpdir, "test.csv")) model = LudwigModel(config, backend=LocalTestBackend(), logging_level=logging.INFO) model.train(training_set=data_csv, validation_set=val_csv, test_set=test_csv, output_directory=tmpdir) assert model.config_obj.trainer.learning_rate == 0.0001 @pytest.mark.parametrize( "is_cpu,effective_batch_size,eval_batch_size", [ (True, "auto", "auto"), (False, 256, 128), (True, "auto", None), ], ids=["cpu_auto", "gpu_fixed", "cpu_no_eval_bs"], ) def test_ecd_tune_batch_size_and_lr(tmpdir, eval_batch_size, effective_batch_size, is_cpu): input_features = [sequence_feature(encoder={"reduce_output": "sum"})] output_features = [ category_feature(decoder={"vocab_size": 2}, reduce_input="sum"), number_feature(), binary_feature(), vector_feature(), ] num_samples = 30 csv_filename = os.path.join(tmpdir, "training.csv") data_csv = generate_data(input_features, output_features, csv_filename, num_examples=num_samples) val_csv = shutil.copyfile(data_csv, os.path.join(tmpdir, "validation.csv")) test_csv = shutil.copyfile(data_csv, os.path.join(tmpdir, "test.csv")) trainer = { EPOCHS: 2, EFFECTIVE_BATCH_SIZE: effective_batch_size, BATCH_SIZE: "auto", "gradient_accumulation_steps": "auto", "learning_rate": "auto", } if eval_batch_size: trainer[EVAL_BATCH_SIZE] = eval_batch_size config = { INPUT_FEATURES: input_features, OUTPUT_FEATURES: output_features, "combiner": {"type": "concat", "output_size": 14}, TRAINER: trainer, } model = LudwigModel(config, backend=LocalTestBackend(), logging_level=logging.INFO) # check preconditions assert model.config_obj.trainer.effective_batch_size == effective_batch_size assert model.config_obj.trainer.batch_size == "auto" assert model.config_obj.trainer.gradient_accumulation_steps == "auto" assert model.config_obj.trainer.eval_batch_size == eval_batch_size assert model.config_obj.trainer.learning_rate == "auto" with mock.patch("ludwig.trainers.trainer.Trainer.is_cpu_training") as mock_fn: mock_fn.return_value = is_cpu _, _, output_directory = model.train( training_set=data_csv, validation_set=val_csv, test_set=test_csv, output_directory=tmpdir ) def check_postconditions(model): # check batch size assert model.config_obj.trainer.effective_batch_size == effective_batch_size assert model.config_obj.trainer.batch_size != "auto" assert model.config_obj.trainer.batch_size > 1 # check gradient accumulation assert model.config_obj.trainer.gradient_accumulation_steps != "auto" if effective_batch_size == "auto": assert model.config_obj.trainer.gradient_accumulation_steps == 1 else: batch_size = model.config_obj.trainer.batch_size assert model.config_obj.trainer.gradient_accumulation_steps == effective_batch_size // batch_size # 4 is the largest possible batch size for this dataset (20% of dataset size) assert model.config_obj.trainer.batch_size <= MAX_BATCH_SIZE_DATASET_FRACTION * num_samples assert model.config_obj.trainer.eval_batch_size != "auto" assert model.config_obj.trainer.eval_batch_size > 1 if eval_batch_size in ("auto", None): assert model.config_obj.trainer.batch_size == model.config_obj.trainer.eval_batch_size else: assert model.config_obj.trainer.eval_batch_size == eval_batch_size # check learning rate assert model.config_obj.trainer.learning_rate == 0.0001 # has sequence feature check_postconditions(model) model = LudwigModel.load(os.path.join(output_directory, MODEL_FILE_NAME)) # loaded model should retain the tuned params check_postconditions(model) def test_changing_parameters_on_plateau(tmpdir): input_features = [sequence_feature(encoder={"reduce_output": "sum"})] output_features = [category_feature(decoder={"vocab_size": 2}, reduce_input="sum")] csv_filename = os.path.join(tmpdir, "training.csv") data_csv = generate_data(input_features, output_features, csv_filename) val_csv = shutil.copyfile(data_csv, os.path.join(tmpdir, "validation.csv")) test_csv = shutil.copyfile(data_csv, os.path.join(tmpdir, "test.csv")) config = { INPUT_FEATURES: input_features, OUTPUT_FEATURES: output_features, "combiner": {"type": "concat", "output_size": 14}, TRAINER: { EPOCHS: 2, BATCH_SIZE: 128, "learning_rate": 1.0, "reduce_learning_rate_on_plateau": 1, "increase_batch_size_on_plateau": 1, }, } model = LudwigModel(config, backend=LocalTestBackend()) model.train(training_set=data_csv, validation_set=val_csv, test_set=test_csv, output_directory=tmpdir) @pytest.mark.skipif(torch.cuda.device_count() == 0, reason="test requires at least 1 gpu") @pytest.mark.skipif(not torch.cuda.is_available(), reason="test requires gpu support") def test_mixed_precision(tmpdir): input_features = [text_feature()] output_features = [category_feature(decoder={"vocab_size": 2}, reduce_input="sum")] csv_filename = os.path.join(tmpdir, "training.csv") data_csv = generate_data(input_features, output_features, csv_filename) val_csv = shutil.copyfile(data_csv, os.path.join(tmpdir, "validation.csv")) test_csv = shutil.copyfile(data_csv, os.path.join(tmpdir, "test.csv")) trainer = { EPOCHS: 2, "use_mixed_precision": True, } config = { INPUT_FEATURES: input_features, OUTPUT_FEATURES: output_features, "combiner": {"type": "concat", "output_size": 14}, TRAINER: trainer, } # Just test that training completes without error. # TODO(travis): We may want to expand upon this in the future to include some checks on model # convergence like gradient magnitudes, etc. Should also add distributed tests. model = LudwigModel(config, backend=LocalTestBackend(), logging_level=logging.INFO) model.train(training_set=data_csv, validation_set=val_csv, test_set=test_csv, output_directory=tmpdir) @pytest.mark.skipif( parse_version(torch.__version__) < parse_version("2.0"), reason="Model compilation requires PyTorch >= 2.0" ) def test_compile(tmpdir): input_features = [text_feature()] output_features = [category_feature(decoder={"vocab_size": 2}, reduce_input="sum")] csv_filename = os.path.join(tmpdir, "training.csv") data_csv = generate_data(input_features, output_features, csv_filename) val_csv = shutil.copyfile(data_csv, os.path.join(tmpdir, "validation.csv")) test_csv = shutil.copyfile(data_csv, os.path.join(tmpdir, "test.csv")) trainer = { EPOCHS: 2, "compile": True, } config = { INPUT_FEATURES: input_features, OUTPUT_FEATURES: output_features, "combiner": {"type": "concat", "output_size": 14}, TRAINER: trainer, } # Just test that training completes without error. # TODO(travis): We may want to expand upon this in the future to include some checks on model # convergence like gradient magnitudes, etc. Should also add distributed tests. model = LudwigModel(config, backend=LocalTestBackend(), logging_level=logging.INFO) model.train(training_set=data_csv, validation_set=val_csv, test_set=test_csv, output_directory=tmpdir) @pytest.mark.parametrize("gradient_accumulation_steps", [1, 2]) def test_gradient_accumulation(gradient_accumulation_steps: int, tmpdir): input_features = [text_feature()] output_features = [category_feature(decoder={"vocab_size": 2}, reduce_input="sum")] csv_filename = os.path.join(tmpdir, "training.csv") data_csv = generate_data(input_features, output_features, csv_filename, num_examples=64) val_csv = shutil.copyfile(data_csv, os.path.join(tmpdir, "validation.csv")) test_csv = shutil.copyfile(data_csv, os.path.join(tmpdir, "test.csv")) trainer = { EPOCHS: 2, BATCH_SIZE: 8, "gradient_accumulation_steps": gradient_accumulation_steps, } config = { INPUT_FEATURES: input_features, OUTPUT_FEATURES: output_features, "combiner": {"type": "concat", "output_size": 14}, TRAINER: trainer, } # Just test that training completes without error. # TODO(travis): We may want to expand upon this in the future to include some checks on model # convergence like gradient magnitudes, etc. Should also add distributed tests. model = LudwigModel(config, backend=LocalTestBackend(), logging_level=logging.INFO) model.train(training_set=data_csv, validation_set=val_csv, test_set=test_csv, output_directory=tmpdir) def test_enable_gradient_checkpointing(tmpdir, caplog): """Test that gradient checkpointing is enabled when specified in the config and that it does not cause an error when the model does not have support for gradient checkpointing.""" input_features = [text_feature()] output_features = [category_feature(decoder={"vocab_size": 2}, reduce_input="sum")] csv_filename = os.path.join(tmpdir, "training.csv") data_csv = generate_data(input_features, output_features, csv_filename) val_csv = shutil.copyfile(data_csv, os.path.join(tmpdir, "validation.csv")) test_csv = shutil.copyfile(data_csv, os.path.join(tmpdir, "test.csv")) config = { INPUT_FEATURES: input_features, OUTPUT_FEATURES: output_features, "combiner": {"type": "concat", "output_size": 14}, TRAINER: { "train_steps": 2, BATCH_SIZE: 8, "enable_gradient_checkpointing": True, }, } model = LudwigModel(config, backend=LocalTestBackend(), logging_level=logging.INFO) assert model.config_obj.trainer.enable_gradient_checkpointing model.train(training_set=data_csv, validation_set=val_csv, test_set=test_csv, output_directory=tmpdir) # Check that the warning is emitted when the model does not support gradient checkpointing # but does not prevent training from starting. assert "Gradient checkpointing is currently only supported for model_type: llm. Skipping..." in caplog.text ================================================ FILE: tests/integration_tests/test_triton.py ================================================ # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import os import pandas as pd import pytest import torch from ludwig.api import LudwigModel from ludwig.constants import BATCH_SIZE, TRAINER from ludwig.data.dataset_synthesizer import build_synthetic_dataset_df from ludwig.utils.data_utils import load_yaml from ludwig.utils.inference_utils import to_inference_module_input_from_dataframe from ludwig.utils.triton_utils import export_triton, get_inference_modules, POSTPROCESSOR, PREDICTOR, PREPROCESSOR from tests.integration_tests.utils import ( binary_feature, category_feature, generate_data, LocalTestBackend, number_feature, sequence_feature, set_feature, text_feature, vector_feature, ) def test_triton_torchscript(csv_filename, tmpdir): # Configure features to be tested: input_features = [ binary_feature(), number_feature(), category_feature(encoder={"vocab_size": 3}), # TODO: future support # sequence_feature(encoder={"vocab_size": 3}), # text_feature(encoder={"vocab_size": 3}), # vector_feature(), # timeseries_feature(), # date_feature(), # h3_feature(), # set_feature(encoder={"vocab_size": 3}), # bag_feature(encoder={"vocab_size": 3}), # image_feature(image_dest_folder), # audio_feature(audio_dest_folder), ] output_features = [ binary_feature(), number_feature(), category_feature(decoder={"vocab_size": 3}), sequence_feature(decoder={"vocab_size": 3}), text_feature(decoder={"vocab_size": 3}), set_feature(decoder={"vocab_size": 3}), vector_feature(), ] backend = LocalTestBackend() config = { "input_features": input_features, "output_features": output_features, TRAINER: {"epochs": 1, BATCH_SIZE: 128}, } # Generate training data training_data_csv_path = generate_data(input_features, output_features, csv_filename) # Train Ludwig (Pythonic) model: ludwig_model = LudwigModel(config, backend=backend) ludwig_model.train( dataset=training_data_csv_path, skip_save_training_description=True, skip_save_training_statistics=True, skip_save_model=True, skip_save_progress=True, skip_save_log=True, skip_save_processed_input=True, ) # Create graph inference model (Torchscript) from trained Ludwig model. triton_path = os.path.join(tmpdir, "triton") model_name = "test_triton" model_version = "1" df = pd.read_csv(training_data_csv_path) triton_artifacts = export_triton( model=ludwig_model, data_example=df, model_name=model_name, output_path=triton_path, model_version=model_version ) # Validate that artifact paths exist. assert os.path.isdir(triton_path) assert all(os.path.exists(artifact.path) for artifact in triton_artifacts) # Load TorchScript models exported for Triton. triton_preprocessor = triton_predictor = triton_postprocessor = None for artifact in triton_artifacts: if artifact.model_name.endswith(PREPROCESSOR) and artifact.content_type == "application/octet-stream": triton_preprocessor = torch.jit.load(artifact.path) if artifact.model_name.endswith(PREDICTOR) and artifact.content_type == "application/octet-stream": triton_predictor = torch.jit.load(artifact.path) if artifact.model_name.endswith(POSTPROCESSOR) and artifact.content_type == "application/octet-stream": triton_postprocessor = torch.jit.load(artifact.path) assert triton_preprocessor is not None assert triton_predictor is not None assert triton_postprocessor is not None # Forward data through models. data_to_predict = to_inference_module_input_from_dataframe(df, ludwig_model.config, load_paths=True, device="cpu") triton_preprocessor_output = triton_preprocessor(*data_to_predict.values()) triton_predictor_output = triton_predictor(*triton_preprocessor_output) triton_postprocessor_output = triton_postprocessor(*triton_predictor_output) # Get TorchScript inference modules and forward data. inference_modules = get_inference_modules(ludwig_model, "cpu") preprocessor_output = inference_modules[0](data_to_predict) predictor_output = inference_modules[1](preprocessor_output) postprocessor_output = inference_modules[2](predictor_output) assert len(postprocessor_output) == len( triton_postprocessor_output ), "Number of output mismatch after postprocessor step" for i, (_, out_value) in enumerate(postprocessor_output.items()): both_list = isinstance(out_value, list) and isinstance(triton_postprocessor_output[i], list) both_tensor = isinstance(out_value, torch.Tensor) and isinstance(triton_postprocessor_output[i], torch.Tensor) assert both_list or both_tensor, "Type mismatch in PREDICTIONS, PROBABILITIES, LOGITS output" if isinstance(out_value, list) and len(out_value) > 0 and isinstance(out_value[0], str): assert out_value == triton_postprocessor_output[i], "Category feature outputs failure." elif isinstance(out_value, list) and len(out_value) > 0 and isinstance(out_value[0], torch.Tensor): assert len(out_value) == len(triton_postprocessor_output[i]), "Set feature outputs failure." assert all( torch.allclose(inf, trit) for inf, trit in zip(out_value, triton_postprocessor_output[i]) ), "Set feature outputs failure." elif isinstance(out_value, list) and len(out_value) > 0 and isinstance(out_value[0], list): assert len(out_value) == len( triton_postprocessor_output[i] ), "Sequence (including text, etc.) feature outputs failure." assert all( inf == trit for inf, trit in zip(out_value, triton_postprocessor_output[i]) ), "Sequence (including text, etc.) feature outputs failure." elif isinstance(out_value, torch.Tensor): assert torch.allclose(out_value, triton_postprocessor_output[i]) else: raise ValueError("Value should be either List[str] or torch.Tensor.") def get_test_config_filenames() -> list[str]: """Return list of the config filenames used for Triton export.""" configs_directory = "/".join(__file__.split("/")[:-1] + ["test_triton_configs"]) return [os.path.join(configs_directory, config_fp) for config_fp in os.listdir(configs_directory)] @pytest.mark.parametrize("config_path", get_test_config_filenames()) def test_triton_exportability(config_path, tmpdir): """Tests whether Triton export succeeds for a config.""" config = load_yaml(config_path) dataset = build_synthetic_dataset_df(100, config) ludwig_model = LudwigModel(config) ludwig_model.train( dataset=dataset, skip_save_training_description=True, skip_save_training_statistics=True, skip_save_model=True, skip_save_progress=True, skip_save_log=True, skip_save_processed_input=True, ) triton_path = os.path.join(tmpdir, "triton") model_name = "test_triton" model_version = "1" export_triton( model=ludwig_model, data_example=dataset.head(10), model_name=model_name, output_path=triton_path, model_version=model_version, ) ================================================ FILE: tests/integration_tests/test_triton_configs/transformer_combiner_with_attention_reduce.yaml ================================================ input_features: - name: founded_on_timestamp type: number - name: first_equity_timestamp type: number - name: founded_first_equity_diff type: number output_features: - name: assigned_label type: number combiner: type: transformer hidden_size: 16 output_size: 64 num_fc_layers: 0 reduce_output: attention transformer_output_size: 56 trainer: train_steps: 1 ================================================ FILE: tests/integration_tests/test_visualization.py ================================================ # Copyright (c) 2023 Predibase, Inc., 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. # # Integration tests for the visualization commands. # # Author: Ivaylo Stefanov # email: ivaylo.stefanov82@gmail.com # github: https://github.com/istefano82 # ============================================================================== import glob import json import os import random import subprocess import sys import numpy as np import pytest from ludwig.constants import BATCH_SIZE, ENCODER, TRAINER, TYPE from ludwig.experiment import experiment_cli from ludwig.globals import DESCRIPTION_FILE_NAME, PREDICTIONS_PARQUET_FILE_NAME, TEST_STATISTICS_FILE_NAME from ludwig.utils.data_utils import get_split_path from ludwig.visualize import _extract_ground_truth_values from tests.integration_tests.test_visualization_api import obtain_df_splits from tests.integration_tests.utils import ( bag_feature, binary_feature, category_feature, generate_data, number_feature, sequence_feature, set_feature, text_feature, ) pytestmark = pytest.mark.integration_tests_c def run_experiment_with_visualization(input_features, output_features, dataset): """Helper method to run an experiment with visualization enabled. Does not garbage collect. """ output_directory = os.path.dirname(dataset) config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat", "output_size": 14}, TRAINER: {"epochs": 2, BATCH_SIZE: 128}, } args = { "config": config, "skip_save_processed_input": False, "skip_save_progress": False, "skip_save_unprocessed_output": False, "skip_save_eval_stats": False, "dataset": dataset, "output_directory": output_directory, } _, _, _, _, experiment_dir = experiment_cli(**args) return experiment_dir def get_output_feature_name(experiment_dir, output_feature=0): """Helper function to extract specified output feature name. :param experiment_dir: Path to the experiment directory :param output_feature: position of the output feature the description.json :return output_feature_name: name of the first output feature name from the experiment """ description_file = os.path.join(experiment_dir, DESCRIPTION_FILE_NAME) with open(description_file, "rb") as f: content = json.load(f) output_feature_name = content["config"]["output_features"][output_feature]["name"] return output_feature_name def test_visualization_learning_curves_output_saved(csv_filename): """Ensure pdf and png figures from the experiments can be saved. :param csv_filename: csv fixture from tests.conftest.csv_filename :return: None """ input_features = [text_feature(encoder={"type": "parallel_cnn"})] output_features = [category_feature(output_feature=True)] # Generate test data rel_path = generate_data(input_features, output_features, csv_filename) input_features[0][ENCODER][TYPE] = "parallel_cnn" exp_dir_name = run_experiment_with_visualization(input_features, output_features, dataset=rel_path) vis_output_pattern_pdf = os.path.join(exp_dir_name, "*.pdf") vis_output_pattern_png = os.path.join(exp_dir_name, "*.png") train_stats = os.path.join(exp_dir_name, "training_statistics.json") test_cmd_pdf = [ sys.executable, "-m", "ludwig.visualize", "--visualization", "learning_curves", "--training_statistics", train_stats, "-od", exp_dir_name, ] test_cmd_png = test_cmd_pdf.copy() + ["-ff", "png"] commands = [test_cmd_pdf, test_cmd_png] vis_patterns = [vis_output_pattern_pdf, vis_output_pattern_png] for command, viz_pattern in zip(commands, vis_patterns): result = subprocess.run( command, ) figure_cnt = glob.glob(viz_pattern) assert 0 == result.returncode assert 4 == len(figure_cnt) def test_visualization_confusion_matrix_output_saved(csv_filename): """Ensure pdf and png figures from the experiments can be saved. :param csv_filename: csv fixture from tests.conftest.csv_filename :return: None """ input_features = [text_feature(encoder={"type": "parallel_cnn"})] output_features = [category_feature(output_feature=True)] # Generate test data rel_path = generate_data(input_features, output_features, csv_filename) input_features[0][ENCODER][TYPE] = "parallel_cnn" exp_dir_name = run_experiment_with_visualization(input_features, output_features, dataset=rel_path) vis_output_pattern_pdf = os.path.join(exp_dir_name, "*.pdf") vis_output_pattern_png = os.path.join(exp_dir_name, "*.png") experiment_source_data_name = csv_filename.split(".")[0] ground_truth_metadata = experiment_source_data_name + ".meta.json" test_stats = os.path.join(exp_dir_name, TEST_STATISTICS_FILE_NAME) test_cmd_pdf = [ sys.executable, "-m", "ludwig.visualize", "--visualization", "confusion_matrix", "--test_statistics", test_stats, "--ground_truth_metadata", ground_truth_metadata, "-od", exp_dir_name, ] test_cmd_png = test_cmd_pdf.copy() + ["-ff", "png"] commands = [test_cmd_pdf, test_cmd_png] vis_patterns = [vis_output_pattern_pdf, vis_output_pattern_png] for command, viz_pattern in zip(commands, vis_patterns): result = subprocess.run(command) figure_cnt = glob.glob(viz_pattern) assert 0 == result.returncode assert 2 == len(figure_cnt) def test_visualization_compare_performance_output_saved(csv_filename): """Ensure pdf and png figures from the experiments can be saved. Compare performance between two models. To reduce test complexity one model is compared to it self. :param csv_filename: csv fixture from tests.conftest.csv_filename :return: None """ input_features = [text_feature(encoder={"type": "parallel_cnn"})] output_features = [category_feature(output_feature=True)] # Generate test data rel_path = generate_data(input_features, output_features, csv_filename) input_features[0][ENCODER][TYPE] = "parallel_cnn" exp_dir_name = run_experiment_with_visualization(input_features, output_features, dataset=rel_path) vis_output_pattern_pdf = os.path.join(exp_dir_name, "*.pdf") vis_output_pattern_png = os.path.join(exp_dir_name, "*.png") test_stats = os.path.join(exp_dir_name, TEST_STATISTICS_FILE_NAME) test_cmd_pdf = [ sys.executable, "-m", "ludwig.visualize", "--visualization", "compare_performance", "--test_statistics", test_stats, test_stats, "-m", "Model1", "Model2", "-od", exp_dir_name, ] test_cmd_png = test_cmd_pdf.copy() + ["-ff", "png"] commands = [test_cmd_pdf, test_cmd_png] vis_patterns = [vis_output_pattern_pdf, vis_output_pattern_png] for command, viz_pattern in zip(commands, vis_patterns): result = subprocess.run(command, capture_output=True) figure_cnt = glob.glob(viz_pattern) assert 0 == result.returncode assert 1 == len(figure_cnt) def test_visualization_compare_classifiers_from_prob_csv_output_saved(csv_filename): """Ensure pdf and png figures from the experiments can be saved. Probabilities are loaded from csv file. :param csv_filename: csv fixture from tests.fixtures.filenames.csv_filename :return: None """ input_features = [category_feature(encoder={"vocab_size": 10})] output_features = [category_feature(decoder={"vocab_size": 2}, reduce_input="sum")] # Generate test data rel_path = generate_data(input_features, output_features, csv_filename) exp_dir_name = run_experiment_with_visualization(input_features, output_features, dataset=rel_path) vis_output_pattern_pdf = os.path.join(exp_dir_name, "*.pdf") vis_output_pattern_png = os.path.join(exp_dir_name, "*.png") output_feature_name = get_output_feature_name(exp_dir_name) probability = os.path.join(exp_dir_name, PREDICTIONS_PARQUET_FILE_NAME) experiment_source_data_name = csv_filename.split(".")[0] ground_truth = experiment_source_data_name + ".csv" split_file = get_split_path(csv_filename) test_cmd_pdf = [ sys.executable, "-m", "ludwig.visualize", "--visualization", "compare_classifiers_performance_from_prob", "--ground_truth", ground_truth, "--output_feature_name", output_feature_name, "--split_file", split_file, "--ground_truth_metadata", exp_dir_name + "/model/training_set_metadata.json", "--probabilities", probability, probability, "--model_names", "Model1", "Model2", "-od", exp_dir_name, ] test_cmd_png = test_cmd_pdf.copy() + ["-ff", "png"] commands = [test_cmd_pdf, test_cmd_png] vis_patterns = [vis_output_pattern_pdf, vis_output_pattern_png] for command, viz_pattern in zip(commands, vis_patterns): result = subprocess.run(command) figure_cnt = glob.glob(viz_pattern) assert 0 == result.returncode assert 1 == len(figure_cnt) def test_visualization_compare_classifiers_from_prob_npy_output_saved(csv_filename): """Ensure pdf and png figures from the experiments can be saved. Probabilities are loaded from npy file. :param csv_filename: csv fixture from tests.fixtures.filenames.csv_filename :return: None """ input_features = [category_feature(encoder={"vocab_size": 10})] output_features = [category_feature(decoder={"vocab_size": 2}, reduce_input="sum")] # Generate test data rel_path = generate_data(input_features, output_features, csv_filename) exp_dir_name = run_experiment_with_visualization(input_features, output_features, dataset=rel_path) vis_output_pattern_pdf = os.path.join(exp_dir_name, "*.pdf") vis_output_pattern_png = os.path.join(exp_dir_name, "*.png") output_feature_name = get_output_feature_name(exp_dir_name) probability = os.path.join(exp_dir_name, PREDICTIONS_PARQUET_FILE_NAME) experiment_source_data_name = csv_filename.split(".")[0] ground_truth = experiment_source_data_name + ".csv" split_file = experiment_source_data_name + ".split.parquet" test_cmd_pdf = [ sys.executable, "-m", "ludwig.visualize", "--visualization", "compare_classifiers_performance_from_prob", "--ground_truth", ground_truth, "--output_feature_name", output_feature_name, "--split_file", split_file, "--ground_truth_metadata", exp_dir_name + "/model/training_set_metadata.json", "--probabilities", probability, probability, "--model_names", "Model1", "Model2", "-od", exp_dir_name, ] test_cmd_png = test_cmd_pdf.copy() + ["-ff", "png"] commands = [test_cmd_pdf, test_cmd_png] vis_patterns = [vis_output_pattern_pdf, vis_output_pattern_png] for command, viz_pattern in zip(commands, vis_patterns): result = subprocess.run(command) figure_cnt = glob.glob(viz_pattern) assert 0 == result.returncode assert 1 == len(figure_cnt) def test_visualization_compare_classifiers_from_pred_npy_output_saved(csv_filename): """Ensure pdf and png figures from the experiments can be saved. Predictions are loaded from npy file. :param csv_filename: csv fixture from tests.fixtures.filenames.csv_filename :return: None """ input_features = [category_feature(encoder={"vocab_size": 10})] output_features = [category_feature(decoder={"vocab_size": 2}, reduce_input="sum")] # Generate test data rel_path = generate_data(input_features, output_features, csv_filename) exp_dir_name = run_experiment_with_visualization(input_features, output_features, dataset=rel_path) vis_output_pattern_pdf = os.path.join(exp_dir_name, "*.pdf") vis_output_pattern_png = os.path.join(exp_dir_name, "*.png") output_feature_name = get_output_feature_name(exp_dir_name) prediction = os.path.join(exp_dir_name, PREDICTIONS_PARQUET_FILE_NAME) experiment_source_data_name = csv_filename.split(".")[0] ground_truth = experiment_source_data_name + ".csv" split_file = experiment_source_data_name + ".split.parquet" ground_truth_metadata = experiment_source_data_name + ".meta.json" test_cmd_pdf = [ sys.executable, "-m", "ludwig.visualize", "--visualization", "compare_classifiers_performance_from_pred", "--ground_truth_metadata", ground_truth_metadata, "--ground_truth", ground_truth, "--output_feature_name", output_feature_name, "--split_file", split_file, "--predictions", prediction, prediction, "--model_names", "Model1", "Model2", "-od", exp_dir_name, ] test_cmd_png = test_cmd_pdf.copy() + ["-ff", "png"] commands = [test_cmd_pdf, test_cmd_png] vis_patterns = [vis_output_pattern_pdf, vis_output_pattern_png] for command, viz_pattern in zip(commands, vis_patterns): result = subprocess.run(command) figure_cnt = glob.glob(viz_pattern) assert 0 == result.returncode assert 1 == len(figure_cnt) def test_visualization_compare_classifiers_from_pred_csv_output_saved(csv_filename): """Ensure pdf and png figures from the experiments can be saved. Predictions are loaded from csv file. :param csv_filename: csv fixture from tests.fixtures.filenames.csv_filename :return: None """ input_features = [category_feature(encoder={"vocab_size": 10})] output_features = [category_feature(decoder={"vocab_size": 2}, reduce_input="sum")] # Generate test data rel_path = generate_data(input_features, output_features, csv_filename) exp_dir_name = run_experiment_with_visualization(input_features, output_features, dataset=rel_path) vis_output_pattern_pdf = os.path.join(exp_dir_name, "*.pdf") vis_output_pattern_png = os.path.join(exp_dir_name, "*.png") output_feature_name = get_output_feature_name(exp_dir_name) prediction = os.path.join(exp_dir_name, PREDICTIONS_PARQUET_FILE_NAME) experiment_source_data_name = csv_filename.split(".")[0] ground_truth = experiment_source_data_name + ".csv" split_file = experiment_source_data_name + ".split.parquet" ground_truth_metadata = experiment_source_data_name + ".meta.json" test_cmd_pdf = [ sys.executable, "-m", "ludwig.visualize", "--visualization", "compare_classifiers_performance_from_pred", "--ground_truth_metadata", ground_truth_metadata, "--ground_truth", ground_truth, "--output_feature_name", output_feature_name, "--split_file", split_file, "--predictions", prediction, prediction, "--model_names", "Model1", "Model2", "-od", exp_dir_name, ] test_cmd_png = test_cmd_pdf.copy() + ["-ff", "png"] commands = [test_cmd_pdf, test_cmd_png] vis_patterns = [vis_output_pattern_pdf, vis_output_pattern_png] for command, viz_pattern in zip(commands, vis_patterns): result = subprocess.run(command) figure_cnt = glob.glob(viz_pattern) assert 0 == result.returncode assert 1 == len(figure_cnt) def test_visualization_compare_classifiers_subset_output_saved(csv_filename): """Ensure pdf and png figures from the experiments can be saved. :param csv_filename: csv fixture from tests.conftest.csv_filename :return: None """ input_features = [category_feature(encoder={"vocab_size": 10})] output_features = [category_feature(decoder={"vocab_size": 2}, reduce_input="sum")] # Generate test data rel_path = generate_data(input_features, output_features, csv_filename) exp_dir_name = run_experiment_with_visualization(input_features, output_features, dataset=rel_path) vis_output_pattern_pdf = os.path.join(exp_dir_name, "*.pdf") vis_output_pattern_png = os.path.join(exp_dir_name, "*.png") output_feature_name = get_output_feature_name(exp_dir_name) probability = os.path.join(exp_dir_name, PREDICTIONS_PARQUET_FILE_NAME) experiment_source_data_name = csv_filename.split(".")[0] ground_truth = experiment_source_data_name + ".csv" split_file = experiment_source_data_name + ".split.parquet" test_cmd_pdf = [ sys.executable, "-m", "ludwig.visualize", "--visualization", "compare_classifiers_performance_subset", "--output_feature_name", output_feature_name, "--split_file", split_file, "--ground_truth_metadata", exp_dir_name + "/model/training_set_metadata.json", "--probabilities", probability, probability, "--model_names", "Model1", "Model2", "--ground_truth", ground_truth, "--top_n_classes", "6", "-od", exp_dir_name, ] test_cmd_png = test_cmd_pdf.copy() + ["-ff", "png"] commands = [test_cmd_pdf, test_cmd_png] vis_patterns = [vis_output_pattern_pdf, vis_output_pattern_png] for command, viz_pattern in zip(commands, vis_patterns): result = subprocess.run(command) figure_cnt = glob.glob(viz_pattern) assert 0 == result.returncode assert 1 == len(figure_cnt) def test_visualization_compare_classifiers_changing_k_output_pdf(csv_filename): """It should be possible to save figures as pdf in the specified directory.""" input_features = [category_feature(encoder={"vocab_size": 10})] output_features = [category_feature(decoder={"vocab_size": 2}, reduce_input="sum")] # Generate test data rel_path = generate_data(input_features, output_features, csv_filename) exp_dir_name = run_experiment_with_visualization(input_features, output_features, dataset=rel_path) vis_output_pattern_pdf = os.path.join(exp_dir_name, "*.pdf") vis_output_pattern_png = os.path.join(exp_dir_name, "*.png") output_feature_name = get_output_feature_name(exp_dir_name) probability = os.path.join(exp_dir_name, PREDICTIONS_PARQUET_FILE_NAME) experiment_source_data_name = csv_filename.split(".")[0] ground_truth = experiment_source_data_name + ".csv" split_file = experiment_source_data_name + ".split.parquet" ground_truth_metadata = exp_dir_name + "/model/training_set_metadata.json" test_cmd_pdf = [ sys.executable, "-m", "ludwig.visualize", "--visualization", "compare_classifiers_performance_changing_k", "--output_feature_name", output_feature_name, "--split_file", split_file, "--ground_truth_metadata", ground_truth_metadata, "--probabilities", probability, probability, "--model_names", "Model1", "Model2", "--ground_truth", ground_truth, "--top_n_classes", "6", "-od", exp_dir_name, ] test_cmd_png = test_cmd_pdf.copy() + ["-ff", "png"] commands = [test_cmd_pdf, test_cmd_png] vis_patterns = [vis_output_pattern_pdf, vis_output_pattern_png] for command, viz_pattern in zip(commands, vis_patterns): result = subprocess.run(command) figure_cnt = glob.glob(viz_pattern) assert 0 == result.returncode assert 1 == len(figure_cnt) def test_visualization_compare_classifiers_multiclass_multimetric_output_saved(csv_filename): """Ensure pdf and png figures from the experiments can be saved. :param csv_filename: csv fixture from tests.conftest.csv_filename :return: None """ input_features = [category_feature(encoder={"vocab_size": 10})] output_features = [category_feature(decoder={"vocab_size": 2}, reduce_input="sum")] # Generate test data rel_path = generate_data(input_features, output_features, csv_filename) exp_dir_name = run_experiment_with_visualization(input_features, output_features, dataset=rel_path) vis_output_pattern_pdf = os.path.join(exp_dir_name, "*.pdf") vis_output_pattern_png = os.path.join(exp_dir_name, "*.png") output_feature_name = get_output_feature_name(exp_dir_name) test_stats = os.path.join(exp_dir_name, TEST_STATISTICS_FILE_NAME) experiment_source_data_name = csv_filename.split(".")[0] ground_truth_metadata = experiment_source_data_name + ".meta.json" test_cmd_pdf = [ sys.executable, "-m", "ludwig.visualize", "--visualization", "compare_classifiers_multiclass_multimetric", "--output_feature_name", output_feature_name, "--test_statistics", test_stats, test_stats, "--ground_truth_metadata", ground_truth_metadata, "-od", exp_dir_name, ] test_cmd_png = test_cmd_pdf.copy() + ["-ff", "png"] commands = [test_cmd_pdf, test_cmd_png] vis_patterns = [vis_output_pattern_pdf, vis_output_pattern_png] for command, viz_pattern in zip(commands, vis_patterns): result = subprocess.run(command) figure_cnt = glob.glob(viz_pattern) assert 0 == result.returncode assert 4 == len(figure_cnt) def test_visualization_compare_classifiers_predictions_npy_output_saved(csv_filename): """Ensure pdf and png figures from the experiments can be saved. Predictions are loaded form npy file. :param csv_filename: csv fixture from tests.fixtures.filenames.csv_filename :return: None """ input_features = [category_feature(encoder={"vocab_size": 10})] output_features = [category_feature(decoder={"vocab_size": 2}, reduce_input="sum")] # Generate test data rel_path = generate_data(input_features, output_features, csv_filename) exp_dir_name = run_experiment_with_visualization(input_features, output_features, dataset=rel_path) vis_output_pattern_pdf = os.path.join(exp_dir_name, "*.pdf") vis_output_pattern_png = os.path.join(exp_dir_name, "*.png") output_feature_name = get_output_feature_name(exp_dir_name) prediction = os.path.join(exp_dir_name, PREDICTIONS_PARQUET_FILE_NAME) experiment_source_data_name = csv_filename.split(".")[0] ground_truth = experiment_source_data_name + ".csv" split_file = experiment_source_data_name + ".split.parquet" test_cmd_pdf = [ sys.executable, "-m", "ludwig.visualize", "--visualization", "compare_classifiers_predictions", "--ground_truth", ground_truth, "--output_feature_name", output_feature_name, "--split_file", split_file, "--ground_truth_metadata", exp_dir_name + "/model/training_set_metadata.json", "--predictions", prediction, prediction, "--model_names", "Model1", "Model2", "-od", exp_dir_name, ] test_cmd_png = test_cmd_pdf.copy() + ["-ff", "png"] commands = [test_cmd_pdf, test_cmd_png] vis_patterns = [vis_output_pattern_pdf, vis_output_pattern_png] for command, viz_pattern in zip(commands, vis_patterns): result = subprocess.run(command) figure_cnt = glob.glob(viz_pattern) assert 0 == result.returncode assert 1 == len(figure_cnt) def test_visualization_compare_classifiers_predictions_csv_output_saved(csv_filename): """Ensure pdf and png figures from the experiments can be saved. Predictions are loaded form csv file. :param csv_filename: csv fixture from tests.fixtures.filenames.csv_filename :return: None """ input_features = [category_feature(encoder={"vocab_size": 10})] output_features = [category_feature(decoder={"vocab_size": 2}, reduce_input="sum")] # Generate test data rel_path = generate_data(input_features, output_features, csv_filename) exp_dir_name = run_experiment_with_visualization(input_features, output_features, dataset=rel_path) vis_output_pattern_pdf = os.path.join(exp_dir_name, "*.pdf") vis_output_pattern_png = os.path.join(exp_dir_name, "*.png") output_feature_name = get_output_feature_name(exp_dir_name) prediction = os.path.join(exp_dir_name, PREDICTIONS_PARQUET_FILE_NAME) experiment_source_data_name = csv_filename.split(".")[0] ground_truth = experiment_source_data_name + ".csv" split_file = experiment_source_data_name + ".split.parquet" test_cmd_pdf = [ sys.executable, "-m", "ludwig.visualize", "--visualization", "compare_classifiers_predictions", "--ground_truth", ground_truth, "--output_feature_name", output_feature_name, "--split_file", split_file, "--ground_truth_metadata", exp_dir_name + "/model/training_set_metadata.json", "--predictions", prediction, prediction, "--model_names", "Model1", "Model2", "-od", exp_dir_name, ] test_cmd_png = test_cmd_pdf.copy() + ["-ff", "png"] commands = [test_cmd_pdf, test_cmd_png] vis_patterns = [vis_output_pattern_pdf, vis_output_pattern_png] for command, viz_pattern in zip(commands, vis_patterns): result = subprocess.run(command) figure_cnt = glob.glob(viz_pattern) assert 0 == result.returncode assert 1 == len(figure_cnt) def test_visualization_cmp_classifiers_predictions_distribution_output_saved(csv_filename): """Ensure pdf and png figures from the experiments can be saved. :param csv_filename: csv fixture from tests.conftest.csv_filename :return: None """ input_features = [category_feature(encoder={"vocab_size": 10})] output_features = [category_feature(decoder={"vocab_size": 2}, reduce_input="sum")] # Generate test data rel_path = generate_data(input_features, output_features, csv_filename) exp_dir_name = run_experiment_with_visualization(input_features, output_features, dataset=rel_path) vis_output_pattern_pdf = os.path.join(exp_dir_name, "*.pdf") vis_output_pattern_png = os.path.join(exp_dir_name, "*.png") output_feature_name = get_output_feature_name(exp_dir_name) prediction = os.path.join(exp_dir_name, PREDICTIONS_PARQUET_FILE_NAME) experiment_source_data_name = csv_filename.split(".")[0] ground_truth = experiment_source_data_name + ".csv" split_file = experiment_source_data_name + ".split.parquet" test_cmd_pdf = [ sys.executable, "-m", "ludwig.visualize", "--visualization", "compare_classifiers_predictions_distribution", "--ground_truth", ground_truth, "--output_feature_name", output_feature_name, "--split_file", split_file, "--ground_truth_metadata", exp_dir_name + "/model/training_set_metadata.json", "--predictions", prediction, prediction, "--model_names", "Model1", "Model2", "-od", exp_dir_name, ] test_cmd_png = test_cmd_pdf.copy() + ["-ff", "png"] commands = [test_cmd_pdf, test_cmd_png] vis_patterns = [vis_output_pattern_pdf, vis_output_pattern_png] for command, viz_pattern in zip(commands, vis_patterns): result = subprocess.run(command) figure_cnt = glob.glob(viz_pattern) assert 0 == result.returncode assert 1 == len(figure_cnt) def test_visualization_cconfidence_thresholding_output_saved(csv_filename): """Ensure pdf and png figures from the experiments can be saved. :param csv_filename: csv fixture from tests.conftest.csv_filename :return: None """ input_features = [category_feature(encoder={"vocab_size": 10})] output_features = [category_feature(decoder={"vocab_size": 2}, reduce_input="sum")] # Generate test data rel_path = generate_data(input_features, output_features, csv_filename) exp_dir_name = run_experiment_with_visualization(input_features, output_features, dataset=rel_path) vis_output_pattern_pdf = os.path.join(exp_dir_name, "*.pdf") vis_output_pattern_png = os.path.join(exp_dir_name, "*.png") output_feature_name = get_output_feature_name(exp_dir_name) probability = os.path.join(exp_dir_name, PREDICTIONS_PARQUET_FILE_NAME) experiment_source_data_name = csv_filename.split(".")[0] ground_truth = experiment_source_data_name + ".csv" split_file = experiment_source_data_name + ".split.parquet" test_cmd_pdf = [ sys.executable, "-m", "ludwig.visualize", "--visualization", "confidence_thresholding", "--ground_truth", ground_truth, "--output_feature_name", output_feature_name, "--split_file", split_file, "--ground_truth_metadata", exp_dir_name + "/model/training_set_metadata.json", "--probabilities", probability, probability, "--model_names", "Model1", "Model2", "-od", exp_dir_name, ] test_cmd_png = test_cmd_pdf.copy() + ["-ff", "png"] commands = [test_cmd_pdf, test_cmd_png] vis_patterns = [vis_output_pattern_pdf, vis_output_pattern_png] for command, viz_pattern in zip(commands, vis_patterns): result = subprocess.run(command) figure_cnt = glob.glob(viz_pattern) assert 0 == result.returncode assert 1 == len(figure_cnt) def test_visualization_confidence_thresholding_data_vs_acc_output_saved(csv_filename): """Ensure pdf and png figures from the experiments can be saved. :param csv_filename: csv fixture from tests.conftest.csv_filename :return: None """ input_features = [category_feature(encoder={"vocab_size": 10})] output_features = [category_feature(decoder={"vocab_size": 2}, reduce_input="sum")] # Generate test data rel_path = generate_data(input_features, output_features, csv_filename) exp_dir_name = run_experiment_with_visualization(input_features, output_features, dataset=rel_path) vis_output_pattern_pdf = os.path.join(exp_dir_name, "*.pdf") vis_output_pattern_png = os.path.join(exp_dir_name, "*.png") output_feature_name = get_output_feature_name(exp_dir_name) probability = os.path.join(exp_dir_name, PREDICTIONS_PARQUET_FILE_NAME) experiment_source_data_name = csv_filename.split(".")[0] ground_truth = experiment_source_data_name + ".csv" split_file = experiment_source_data_name + ".split.parquet" test_cmd_pdf = [ sys.executable, "-m", "ludwig.visualize", "--visualization", "confidence_thresholding_data_vs_acc", "--ground_truth", ground_truth, "--output_feature_name", output_feature_name, "--split_file", split_file, "--ground_truth_metadata", exp_dir_name + "/model/training_set_metadata.json", "--probabilities", probability, probability, "--model_names", "Model1", "Model2", "-od", exp_dir_name, ] test_cmd_png = test_cmd_pdf.copy() + ["-ff", "png"] commands = [test_cmd_pdf, test_cmd_png] vis_patterns = [vis_output_pattern_pdf, vis_output_pattern_png] for command, viz_pattern in zip(commands, vis_patterns): result = subprocess.run(command) figure_cnt = glob.glob(viz_pattern) assert 0 == result.returncode assert 1 == len(figure_cnt) def test_visualization_confidence_thresholding_data_vs_acc_subset_output_saved(csv_filename): """Ensure pdf and png figures from the experiments can be saved. :param csv_filename: csv fixture from tests.conftest.csv_filename :return: None """ input_features = [category_feature(encoder={"vocab_size": 10})] output_features = [category_feature(decoder={"vocab_size": 2}, reduce_input="sum")] # Generate test data rel_path = generate_data(input_features, output_features, csv_filename) exp_dir_name = run_experiment_with_visualization(input_features, output_features, dataset=rel_path) vis_output_pattern_pdf = os.path.join(exp_dir_name, "*.pdf") vis_output_pattern_png = os.path.join(exp_dir_name, "*.png") output_feature_name = get_output_feature_name(exp_dir_name) probability = os.path.join(exp_dir_name, PREDICTIONS_PARQUET_FILE_NAME) experiment_source_data_name = csv_filename.split(".")[0] ground_truth = experiment_source_data_name + ".csv" split_file = experiment_source_data_name + ".split.parquet" test_cmd_pdf = [ sys.executable, "-m", "ludwig.visualize", "--visualization", "confidence_thresholding_data_vs_acc_subset", "--ground_truth", ground_truth, "--output_feature_name", output_feature_name, "--split_file", split_file, "--ground_truth_metadata", exp_dir_name + "/model/training_set_metadata.json", "--probabilities", probability, probability, "--model_names", "Model1", "Model2", "--top_n_classes", "3", "-od", exp_dir_name, ] test_cmd_png = test_cmd_pdf.copy() + ["-ff", "png"] commands = [test_cmd_pdf, test_cmd_png] vis_patterns = [vis_output_pattern_pdf, vis_output_pattern_png] for command, viz_pattern in zip(commands, vis_patterns): result = subprocess.run(command) figure_cnt = glob.glob(viz_pattern) assert 0 == result.returncode assert 1 == len(figure_cnt) def test_vis_confidence_thresholding_data_vs_acc_subset_per_class_output_saved(csv_filename): """Ensure pdf and png figures from the experiments can be saved. :param csv_filename: csv fixture from tests.conftest.csv_filename :return: None """ input_features = [category_feature(encoder={"vocab_size": 10})] output_features = [category_feature(decoder={"vocab_size": 5}, reduce_input="sum")] # Generate test data rel_path = generate_data(input_features, output_features, csv_filename) exp_dir_name = run_experiment_with_visualization(input_features, output_features, dataset=rel_path) vis_output_pattern_pdf = os.path.join(exp_dir_name, "*.pdf") vis_output_pattern_png = os.path.join(exp_dir_name, "*.png") output_feature_name = get_output_feature_name(exp_dir_name) probability = os.path.join(exp_dir_name, PREDICTIONS_PARQUET_FILE_NAME) experiment_source_data_name = csv_filename.split(".")[0] ground_truth = experiment_source_data_name + ".csv" split_file = experiment_source_data_name + ".split.parquet" test_cmd_pdf = [ sys.executable, "-m", "ludwig.visualize", "--visualization", "confidence_thresholding_data_vs_acc_subset_per_class", "--ground_truth", ground_truth, "--output_feature_name", output_feature_name, "--split_file", split_file, "--ground_truth_metadata", exp_dir_name + "/model/training_set_metadata.json", "--probabilities", probability, probability, "--model_names", "Model1", "Model2", "--top_n_classes", "3", "-od", exp_dir_name, ] test_cmd_png = test_cmd_pdf.copy() + ["-ff", "png"] commands = [test_cmd_pdf, test_cmd_png] vis_patterns = [vis_output_pattern_pdf, vis_output_pattern_png] for command, viz_pattern in zip(commands, vis_patterns): result = subprocess.run(command) figure_cnt = glob.glob(viz_pattern) assert 0 == result.returncode # 3 figures should be saved because experiment setting top_n_classes = 3 # hence one figure per class assert 3 == len(figure_cnt) def test_vis_confidence_thresholding_2thresholds_2d_output_saved(csv_filename): """Ensure pdf and png figures from the experiments can be saved. :param csv_filename: csv fixture from tests.conftest.csv_filename :return: None """ input_features = [ text_feature(encoder={"vocab_size": 10, "min_len": 1, "type": "stacked_cnn"}), number_feature(), category_feature(encoder={"vocab_size": 10, "embedding_size": 5}), set_feature(), sequence_feature(encoder={"vocab_size": 10, "max_len": 10, "type": "embed"}), ] output_features = [ category_feature(decoder={"vocab_size": 2}, reduce_input="sum"), category_feature(decoder={"vocab_size": 2}, reduce_input="sum"), ] # Generate test data rel_path = generate_data(input_features, output_features, csv_filename) input_features[0][ENCODER][TYPE] = "parallel_cnn" exp_dir_name = run_experiment_with_visualization(input_features, output_features, dataset=rel_path) vis_output_pattern_pdf = os.path.join(exp_dir_name, "*.pdf") vis_output_pattern_png = os.path.join(exp_dir_name, "*.png") threshold_output_feature_name1 = get_output_feature_name(exp_dir_name) threshold_output_feature_name2 = get_output_feature_name(exp_dir_name, output_feature=1) probability = os.path.join(exp_dir_name, PREDICTIONS_PARQUET_FILE_NAME) experiment_source_data_name = csv_filename.split(".")[0] ground_truth = experiment_source_data_name + ".csv" split_file = experiment_source_data_name + ".split.parquet" test_cmd_pdf = [ sys.executable, "-m", "ludwig.visualize", "--visualization", "confidence_thresholding_2thresholds_2d", "--ground_truth", ground_truth, "--split_file", split_file, "--ground_truth_metadata", exp_dir_name + "/model/training_set_metadata.json", "--probabilities", probability, "--threshold_output_feature_names", threshold_output_feature_name1, threshold_output_feature_name2, "--model_names", "Model1", "-od", exp_dir_name, ] test_cmd_png = test_cmd_pdf.copy() + ["-ff", "png"] commands = [test_cmd_pdf, test_cmd_png] vis_patterns = [vis_output_pattern_pdf, vis_output_pattern_png] for command, viz_pattern in zip(commands, vis_patterns): result = subprocess.run( command, ) figure_cnt = glob.glob(viz_pattern) assert 0 == result.returncode assert 3 == len(figure_cnt) def test_vis_confidence_thresholding_2thresholds_3d_output_saved(csv_filename): """Ensure pdf and png figures from the experiments can be saved. :param csv_filename: csv fixture from tests.conftest.csv_filename :return: None """ input_features = [ text_feature(encoder={"vocab_size": 10, "min_len": 1, "type": "stacked_cnn"}), number_feature(), category_feature(encoder={"vocab_size": 10, "embedding_size": 5}), set_feature(), sequence_feature(encoder={"vocab_size": 10, "max_len": 10, "type": "embed"}), ] output_features = [ category_feature(decoder={"vocab_size": 2}, reduce_input="sum"), category_feature(decoder={"vocab_size": 2}, reduce_input="sum"), ] # Generate test data rel_path = generate_data(input_features, output_features, csv_filename) input_features[0][ENCODER][TYPE] = "parallel_cnn" exp_dir_name = run_experiment_with_visualization(input_features, output_features, dataset=rel_path) vis_output_pattern_pdf = os.path.join(exp_dir_name, "*.pdf") vis_output_pattern_png = os.path.join(exp_dir_name, "*.png") threshold_output_feature_name1 = get_output_feature_name(exp_dir_name) threshold_output_feature_name2 = get_output_feature_name(exp_dir_name, output_feature=1) probability = os.path.join(exp_dir_name, PREDICTIONS_PARQUET_FILE_NAME) experiment_source_data_name = csv_filename.split(".")[0] ground_truth = experiment_source_data_name + ".csv" split_file = experiment_source_data_name + ".split.parquet" test_cmd_pdf = [ sys.executable, "-m", "ludwig.visualize", "--visualization", "confidence_thresholding_2thresholds_3d", "--ground_truth", ground_truth, "--split_file", split_file, "--ground_truth_metadata", exp_dir_name + "/model/training_set_metadata.json", "--probabilities", probability, "--threshold_output_feature_names", threshold_output_feature_name1, threshold_output_feature_name2, "-od", exp_dir_name, ] test_cmd_png = test_cmd_pdf.copy() + ["-ff", "png"] commands = [test_cmd_pdf, test_cmd_png] vis_patterns = [vis_output_pattern_pdf, vis_output_pattern_png] for command, viz_pattern in zip(commands, vis_patterns): result = subprocess.run( command, ) figure_cnt = glob.glob(viz_pattern) assert 0 == result.returncode assert 1 == len(figure_cnt) @pytest.mark.parametrize("binary_output_type", [True, False]) def test_visualization_binary_threshold_vs_metric_output_saved(csv_filename, binary_output_type): """Ensure pdf and png figures from the experiments can be saved. :param csv_filename: csv fixture from tests.conftest.csv_filename :return: None """ input_features = [ text_feature(encoder={"vocab_size": 10, "min_len": 1, "type": "stacked_cnn"}), number_feature(), category_feature(encoder={"vocab_size": 10, "embedding_size": 5}), set_feature(), sequence_feature(encoder={"vocab_size": 10, "max_len": 10, "type": "embed"}), ] if binary_output_type: output_features = [binary_feature()] else: output_features = [category_feature(decoder={"vocab_size": 2}, reduce_input="sum")] # Generate test data random.seed(1919) rel_path = generate_data(input_features, output_features, csv_filename) input_features[0][ENCODER][TYPE] = "parallel_cnn" exp_dir_name = run_experiment_with_visualization(input_features, output_features, dataset=rel_path) vis_output_pattern_pdf = os.path.join(exp_dir_name, "*.pdf") vis_output_pattern_png = os.path.join(exp_dir_name, "*.png") output_feature_name = get_output_feature_name(exp_dir_name) probability = os.path.join(exp_dir_name, PREDICTIONS_PARQUET_FILE_NAME) experiment_source_data_name = csv_filename.split(".")[0] ground_truth = experiment_source_data_name + ".csv" split_file = experiment_source_data_name + ".split.parquet" test_cmd_pdf = [ sys.executable, "-m", "ludwig.visualize", "--visualization", "binary_threshold_vs_metric", "--positive_label", "1", "--metrics", "accuracy", "precision", "recall", "f1", "--ground_truth", ground_truth, "--output_feature_name", output_feature_name, "--split_file", split_file, "--ground_truth_metadata", exp_dir_name + "/model/training_set_metadata.json", "--probabilities", probability, probability, "--model_names", "Model1", "Model2", "-od", exp_dir_name, ] test_cmd_png = test_cmd_pdf.copy() + ["-ff", "png"] commands = [test_cmd_pdf, test_cmd_png] vis_patterns = [vis_output_pattern_pdf, vis_output_pattern_png] for command, viz_pattern in zip(commands, vis_patterns): result = subprocess.run(command) figure_cnt = glob.glob(viz_pattern) assert 0 == result.returncode assert 4 == len(figure_cnt) @pytest.mark.parametrize("binary_output_type", [True, False]) def test_visualization_precision_recall_curves_output_saved(csv_filename, binary_output_type): """Ensure pdf and png figures for precision recall curves from the experiments can be saved.""" input_features = [category_feature(encoder={"vocab_size": 10})] if binary_output_type: output_features = [binary_feature()] else: output_features = [category_feature(decoder={"vocab_size": 3}, reduce_input="sum")] # Generate test data rel_path = generate_data(input_features, output_features, csv_filename, num_examples=20) exp_dir_name = run_experiment_with_visualization(input_features, output_features, dataset=rel_path) vis_output_pattern_pdf = os.path.join(exp_dir_name, "*.pdf") vis_output_pattern_png = os.path.join(exp_dir_name, "*.png") output_feature_name = get_output_feature_name(exp_dir_name) probability = os.path.join(exp_dir_name, PREDICTIONS_PARQUET_FILE_NAME) experiment_source_data_name = csv_filename.split(".")[0] ground_truth = experiment_source_data_name + ".csv" split_file = experiment_source_data_name + ".split.parquet" test_cmd_pdf = [ sys.executable, "-m", "ludwig.visualize", "--visualization", "precision_recall_curves", "--positive_label", "1", "--ground_truth", ground_truth, "--output_feature_name", output_feature_name, "--split_file", split_file, "--ground_truth_metadata", exp_dir_name + "/model/training_set_metadata.json", "--probabilities", probability, probability, "--model_names", "Model1", "Model2", "-od", exp_dir_name, ] test_cmd_png = test_cmd_pdf.copy() + ["-ff", "png"] commands = [test_cmd_pdf, test_cmd_png] vis_patterns = [vis_output_pattern_pdf, vis_output_pattern_png] for command, viz_pattern in zip(commands, vis_patterns): result = subprocess.run(command) figure_cnt = glob.glob(viz_pattern) assert 0 == result.returncode assert 1 == len(figure_cnt) def test_visualization_precision_recall_curves_from_test_statistics_output_saved(csv_filename): """Ensure pdf and png figures from the experiments can be saved. :param csv_filename: csv fixture from tests.conftest.csv_filename :return: None """ input_features = [binary_feature(), bag_feature()] output_features = [binary_feature()] # Generate test data rel_path = generate_data(input_features, output_features, csv_filename, num_examples=20) exp_dir_name = run_experiment_with_visualization(input_features, output_features, dataset=rel_path) vis_output_pattern_pdf = os.path.join(exp_dir_name, "*.pdf") vis_output_pattern_png = os.path.join(exp_dir_name, "*.png") output_feature_name = get_output_feature_name(exp_dir_name) test_stats = os.path.join(exp_dir_name, TEST_STATISTICS_FILE_NAME) test_cmd_pdf = [ sys.executable, "-m", "ludwig.visualize", "--visualization", "precision_recall_curves_from_test_statistics", "--output_feature_name", output_feature_name, "--test_statistics", test_stats, "--model_names", "Model1", "-od", exp_dir_name, ] test_cmd_png = test_cmd_pdf.copy() + ["-ff", "png"] commands = [test_cmd_pdf, test_cmd_png] vis_patterns = [vis_output_pattern_pdf, vis_output_pattern_png] for command, viz_pattern in zip(commands, vis_patterns): result = subprocess.run(command) figure_cnt = glob.glob(viz_pattern) assert 0 == result.returncode assert 1 == len(figure_cnt) @pytest.mark.parametrize("binary_output_type", [True, False]) def test_visualization_roc_curves_output_saved(csv_filename, binary_output_type): """Ensure pdf and png figures from the experiments can be saved. :param csv_filename: csv fixture from tests.conftest.csv_filename :return: None """ input_features = [category_feature(encoder={"vocab_size": 10})] if binary_output_type: output_features = [binary_feature()] else: output_features = [category_feature(decoder={"vocab_size": 2}, reduce_input="sum")] # Generate test data rel_path = generate_data(input_features, output_features, csv_filename) exp_dir_name = run_experiment_with_visualization(input_features, output_features, dataset=rel_path) vis_output_pattern_pdf = os.path.join(exp_dir_name, "*.pdf") vis_output_pattern_png = os.path.join(exp_dir_name, "*.png") output_feature_name = get_output_feature_name(exp_dir_name) probability = os.path.join(exp_dir_name, PREDICTIONS_PARQUET_FILE_NAME) experiment_source_data_name = csv_filename.split(".")[0] ground_truth = experiment_source_data_name + ".csv" split_file = experiment_source_data_name + ".split.parquet" test_cmd_pdf = [ sys.executable, "-m", "ludwig.visualize", "--visualization", "roc_curves", "--positive_label", "1", "--metrics", "accuracy", "--ground_truth", ground_truth, "--output_feature_name", output_feature_name, "--split_file", split_file, "--ground_truth_metadata", exp_dir_name + "/model/training_set_metadata.json", "--probabilities", probability, probability, "--model_names", "Model1", "Model2", "-od", exp_dir_name, ] test_cmd_png = test_cmd_pdf.copy() + ["-ff", "png"] commands = [test_cmd_pdf, test_cmd_png] vis_patterns = [vis_output_pattern_pdf, vis_output_pattern_png] for command, viz_pattern in zip(commands, vis_patterns): result = subprocess.run(command) figure_cnt = glob.glob(viz_pattern) assert 0 == result.returncode assert 1 == len(figure_cnt) def test_visualization_roc_curves_from_test_statistics_output_saved(csv_filename): """Ensure pdf and png figures from the experiments can be saved. :param csv_filename: csv fixture from tests.conftest.csv_filename :return: None """ input_features = [binary_feature(), bag_feature()] output_features = [binary_feature()] # Generate test data rel_path = generate_data(input_features, output_features, csv_filename) exp_dir_name = run_experiment_with_visualization(input_features, output_features, dataset=rel_path) vis_output_pattern_pdf = os.path.join(exp_dir_name, "*.pdf") vis_output_pattern_png = os.path.join(exp_dir_name, "*.png") output_feature_name = get_output_feature_name(exp_dir_name) test_stats = os.path.join(exp_dir_name, TEST_STATISTICS_FILE_NAME) test_cmd_pdf = [ sys.executable, "-m", "ludwig.visualize", "--visualization", "roc_curves_from_test_statistics", "--output_feature_name", output_feature_name, "--test_statistics", test_stats, "--model_names", "Model1", "-od", exp_dir_name, ] test_cmd_png = test_cmd_pdf.copy() + ["-ff", "png"] commands = [test_cmd_pdf, test_cmd_png] vis_patterns = [vis_output_pattern_pdf, vis_output_pattern_png] for command, viz_pattern in zip(commands, vis_patterns): result = subprocess.run(command) figure_cnt = glob.glob(viz_pattern) assert 0 == result.returncode assert 1 == len(figure_cnt) def test_visualization_calibration_1_vs_all_output_saved(csv_filename): """Ensure pdf and png figures from the experiments can be saved. :param csv_filename: csv fixture from tests.conftest.csv_filename :return: None """ input_features = [category_feature(encoder={"vocab_size": 10})] output_features = [category_feature(decoder={"vocab_size": 2}, reduce_input="sum")] # Generate test data rel_path = generate_data(input_features, output_features, csv_filename) exp_dir_name = run_experiment_with_visualization(input_features, output_features, dataset=rel_path) vis_output_pattern_pdf = os.path.join(exp_dir_name, "*.pdf") vis_output_pattern_png = os.path.join(exp_dir_name, "*.png") output_feature_name = get_output_feature_name(exp_dir_name) probability = os.path.join(exp_dir_name, PREDICTIONS_PARQUET_FILE_NAME) experiment_source_data_name = csv_filename.split(".")[0] ground_truth = experiment_source_data_name + ".csv" split_file = experiment_source_data_name + ".split.parquet" test_cmd_pdf = [ sys.executable, "-m", "ludwig.visualize", "--visualization", "calibration_1_vs_all", "--metrics", "accuracy", "--ground_truth", ground_truth, "--output_feature_name", output_feature_name, "--split_file", split_file, "--ground_truth_metadata", exp_dir_name + "/model/training_set_metadata.json", "--probabilities", probability, probability, "--model_names", "Model1", "Model2", "--top_k", "6", "-od", exp_dir_name, ] test_cmd_png = test_cmd_pdf.copy() + ["-ff", "png"] commands = [test_cmd_pdf, test_cmd_png] vis_patterns = [vis_output_pattern_pdf, vis_output_pattern_png] for command, viz_pattern in zip(commands, vis_patterns): result = subprocess.run(command) figure_cnt = glob.glob(viz_pattern) assert 0 == result.returncode assert 5 == len(figure_cnt) def test_visualization_calibration_multiclass_output_saved(csv_filename): """Ensure pdf and png figures from the experiments can be saved. :param csv_filename: csv fixture from tests.conftest.csv_filename :return: None """ input_features = [category_feature(encoder={"vocab_size": 10})] output_features = [category_feature(decoder={"vocab_size": 2}, reduce_input="sum")] # Generate test data rel_path = generate_data(input_features, output_features, csv_filename) exp_dir_name = run_experiment_with_visualization(input_features, output_features, dataset=rel_path) vis_output_pattern_pdf = os.path.join(exp_dir_name, "*.pdf") vis_output_pattern_png = os.path.join(exp_dir_name, "*.png") output_feature_name = get_output_feature_name(exp_dir_name) probability = os.path.join(exp_dir_name, PREDICTIONS_PARQUET_FILE_NAME) experiment_source_data_name = csv_filename.split(".")[0] ground_truth = experiment_source_data_name + ".csv" split_file = experiment_source_data_name + ".split.parquet" test_cmd_pdf = [ sys.executable, "-m", "ludwig.visualize", "--visualization", "calibration_multiclass", "--ground_truth", ground_truth, "--output_feature_name", output_feature_name, "--split_file", split_file, "--ground_truth_metadata", exp_dir_name + "/model/training_set_metadata.json", "--probabilities", probability, probability, "--model_names", "Model1", "Model2", "-od", exp_dir_name, ] test_cmd_png = test_cmd_pdf.copy() + ["-ff", "png"] commands = [test_cmd_pdf, test_cmd_png] vis_patterns = [vis_output_pattern_pdf, vis_output_pattern_png] for command, viz_pattern in zip(commands, vis_patterns): result = subprocess.run(command) figure_cnt = glob.glob(viz_pattern) assert 0 == result.returncode assert 2 == len(figure_cnt) def test_visualization_frequency_vs_f1_output_saved(csv_filename): """Ensure pdf and png figures from the experiments can be saved. :param csv_filename: csv fixture from tests.conftest.csv_filename :return: None """ input_features = [category_feature(encoder={"vocab_size": 10})] output_features = [category_feature(decoder={"vocab_size": 2}, reduce_input="sum")] # Generate test data rel_path = generate_data(input_features, output_features, csv_filename) exp_dir_name = run_experiment_with_visualization(input_features, output_features, dataset=rel_path) vis_output_pattern_pdf = os.path.join(exp_dir_name, "*.pdf") vis_output_pattern_png = os.path.join(exp_dir_name, "*.png") output_feature_name = get_output_feature_name(exp_dir_name) test_stats = os.path.join(exp_dir_name, TEST_STATISTICS_FILE_NAME) experiment_source_data_name = csv_filename.split(".")[0] ground_truth_metadata = experiment_source_data_name + ".meta.json" test_cmd_pdf = [ sys.executable, "-m", "ludwig.visualize", "--visualization", "frequency_vs_f1", "--ground_truth_metadata", ground_truth_metadata, "--output_feature_name", output_feature_name, "--test_statistics", test_stats, test_stats, "--model_names", "Model1", "Model2", "-od", exp_dir_name, ] test_cmd_png = test_cmd_pdf.copy() + ["-ff", "png"] commands = [test_cmd_pdf, test_cmd_png] vis_patterns = [vis_output_pattern_pdf, vis_output_pattern_png] for command, viz_pattern in zip(commands, vis_patterns): result = subprocess.run(command) figure_cnt = glob.glob(viz_pattern) assert 0 == result.returncode assert 2 == len(figure_cnt) def test_load_ground_truth_split_from_df(csv_filename): import pandas as pd ground_truth = pd.DataFrame( { "PassengerId": [1], "Survived": [0], "Pclass": [3], "Name": ["Braund, Mr. Owen Harris"], "Sex": ["male"], "Age": [22.0], "SibSp": [1], "Parch": [0], "Ticket": ["A/5 21171"], "Fare": ["7.25"], "Cabin": [None], "Embarked": ["S"], "split": [0], } ) output_feature = "Survived" ground_truth_train_split = _extract_ground_truth_values(ground_truth, output_feature, 0) ground_truth_val_split = _extract_ground_truth_values(ground_truth, output_feature, 1) ground_truth_test_split = _extract_ground_truth_values(ground_truth, output_feature, 2) assert ground_truth_train_split.equals(pd.Series([0])) assert ground_truth_val_split.empty assert ground_truth_test_split.empty def test_load_ground_truth_split_from_file(csv_filename): """Ensure correct ground truth split is loaded when ground_truth_split is given. :param csv_filename: csv fixture from tests.fixtures.filenames.csv_filename :return: None """ input_features = [category_feature(encoder={"vocab_size": 10})] output_features = [category_feature(decoder={"vocab_size": 2}, reduce_input="sum")] # Generate test data rel_path = generate_data(input_features, output_features, csv_filename) exp_dir_name = run_experiment_with_visualization(input_features, output_features, dataset=rel_path) output_feature_name = get_output_feature_name(exp_dir_name) experiment_source_data_name = csv_filename.split(".")[0] ground_truth = experiment_source_data_name + ".csv" split_file = experiment_source_data_name + ".split.parquet" # retrieve ground truth from source data set ground_truth_train_split = _extract_ground_truth_values(ground_truth, output_feature_name, 0, split_file) ground_truth_val_split = _extract_ground_truth_values(ground_truth, output_feature_name, 1, split_file) ground_truth_test_split = _extract_ground_truth_values(ground_truth, output_feature_name, 2, split_file) test_df, train_df, val_df = obtain_df_splits(csv_filename) target_predictions_from_train = train_df[output_feature_name] target_predictions_from_val = val_df[output_feature_name] target_predictions_from_test = test_df[output_feature_name] assert np.all(ground_truth_train_split.eq(target_predictions_from_train)) assert np.all(ground_truth_val_split.eq(target_predictions_from_val)) assert np.all(ground_truth_test_split.eq(target_predictions_from_test)) ================================================ FILE: tests/integration_tests/test_visualization_api.py ================================================ # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import glob import logging import os from tempfile import TemporaryDirectory import numpy as np import pytest from ludwig import visualize from ludwig.api import LudwigModel, TrainingStats from ludwig.constants import BATCH_SIZE, ENCODER, NAME, PREDICTIONS, PROBABILITIES, PROBABILITY, TRAINER, TYPE from ludwig.data.split import get_splitter from ludwig.globals import HYPEROPT_STATISTICS_FILE_NAME from ludwig.utils.data_utils import read_csv from tests.integration_tests.utils import ( bag_feature, binary_feature, category_feature, generate_data, LocalTestBackend, number_feature, sequence_feature, set_feature, text_feature, ) pytestmark = pytest.mark.integration_tests_c def run_api_experiment(input_features, output_features): """Helper method to avoid code repetition in running an experiment. :param input_features: input schema :param output_features: output schema :return: None """ config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat", "output_size": 14}, TRAINER: {"epochs": 2, BATCH_SIZE: 128}, } model = LudwigModel(config) return model @pytest.fixture(scope="module") def experiment_to_use(): with TemporaryDirectory() as tmpdir: experiment = Experiment("data_for_test.csv", tmpdir) return experiment class Experiment: """Helper class to create model test data, setup and run experiment. Contain the needed model experiment statistics as class attributes. """ def __init__(self, csv_filename, tmpdir): self.tmpdir = tmpdir self.csv_file = os.path.join(tmpdir, csv_filename) self.input_features = [category_feature(encoder={"vocab_size": 10})] self.output_features = [category_feature(decoder={"vocab_size": 2}, reduce_input="sum")] data_csv = generate_data(self.input_features, self.output_features, self.csv_file) self.model = self._create_model() test_df, train_df, val_df = obtain_df_splits(data_csv) self.train_stats, self.preprocessed_data, self.output_dir = self.model.train( training_set=train_df, validation_set=val_df, output_directory=os.path.join(tmpdir, "results") ) self.test_stats_full, predictions, self.output_dir = self.model.evaluate( dataset=test_df, collect_overall_stats=True, collect_predictions=True, output_directory=self.output_dir, return_type="dict", ) self.output_feature_name = self.output_features[0][NAME] self.ground_truth_metadata = self.preprocessed_data[3] self.ground_truth = test_df[self.output_feature_name] # probabilities need to be list of lists containing each row data # from the probability columns # ref: https://ludwig-ai.github.io/ludwig-docs/latest/user_guide/api/LudwigModel#evaluate - Return self.probability = predictions[self.output_feature_name][PROBABILITY] self.probabilities = predictions[self.output_feature_name][PROBABILITIES] self.predictions = predictions[self.output_feature_name][PREDICTIONS] # numeric encoded values required for some visualizations of_metadata = self.ground_truth_metadata[self.output_feature_name] self.predictions_num = [of_metadata["str2idx"][x] for x in self.predictions] def _create_model(self): """Configure and setup test model.""" config = { "input_features": self.input_features, "output_features": self.output_features, "combiner": {"type": "concat", "output_size": 14}, TRAINER: {"epochs": 2, BATCH_SIZE: 128}, } return LudwigModel(config, logging_level=logging.WARN) def obtain_df_splits(data_csv): """Split input data csv file in to train, validation and test dataframes. :param data_csv: Input data CSV file. :return test_df, train_df, val_df: Train, validation and test dataframe splits """ data_df = read_csv(data_csv) # Obtain data split array mapping data rows to split type # 0-train, 1-validation, 2-test splitter = get_splitter("random") train_df, val_df, test_df = splitter.split(data_df, LocalTestBackend()) return test_df, train_df, val_df @pytest.mark.parametrize("training_only", [True, False]) def test_learning_curves_vis_api(experiment_to_use, training_only): """Ensure pdf and png figures can be saved via visualization API call. :param experiment_to_use: Object containing trained model and results to test visualization :return: None """ experiment = experiment_to_use viz_outputs = ("pdf", "png") train_stats = experiment.train_stats if training_only: # ensure plot works with only training metrics # Handle situation in Issue #1875 train_stats = TrainingStats(train_stats.training, {}, {}) with TemporaryDirectory() as tmpvizdir: for viz_output in viz_outputs: vis_output_pattern_pdf = tmpvizdir + f"/*.{viz_output}" visualize.learning_curves( [train_stats], output_feature_name=None, output_directory=tmpvizdir, file_format=viz_output ) figure_cnt = glob.glob(vis_output_pattern_pdf) assert 3 == len(figure_cnt) def test_compare_performance_vis_api(experiment_to_use): """Ensure pdf and png figures can be saved via visualization API call. :param experiment_to_use: Object containing trained model and results to test visualization :return: None """ experiment = experiment_to_use # extract test stats only test_stats = experiment.test_stats_full viz_outputs = ("pdf", "png") with TemporaryDirectory() as tmpvizdir: for viz_output in viz_outputs: vis_output_pattern_pdf = tmpvizdir + f"/*.{viz_output}" visualize.compare_performance( [test_stats, test_stats], output_feature_name=None, model_names=["Model1", "Model2"], output_directory=tmpvizdir, file_format=viz_output, ) figure_cnt = glob.glob(vis_output_pattern_pdf) assert 1 == len(figure_cnt) def test_compare_classifier_performance_from_prob_vis_api(experiment_to_use): """Ensure pdf and png figures can be saved via visualization API call. :param experiment_to_use: Object containing trained model and results to test visualization :return: None """ experiment = experiment_to_use probability = experiment.probabilities viz_outputs = ("pdf", "png") with TemporaryDirectory() as tmpvizdir: for viz_output in viz_outputs: vis_output_pattern_pdf = tmpvizdir + f"/*.{viz_output}" visualize.compare_classifiers_performance_from_prob( [probability, probability], experiment.ground_truth, experiment.ground_truth_metadata, experiment.output_feature_name, top_n_classes=[0], labels_limit=0, model_namess=["Model1", "Model2"], output_directory=tmpvizdir, file_format=viz_output, ) figure_cnt = glob.glob(vis_output_pattern_pdf) assert 1 == len(figure_cnt) def test_compare_classifier_performance_from_pred_vis_api(experiment_to_use): """Ensure pdf and png figures can be saved via visualization API call. :param experiment_to_use: Object containing trained model and results to test visualization :return: None """ experiment = experiment_to_use prediction = experiment.predictions viz_outputs = ("pdf", "png") with TemporaryDirectory() as tmpvizdir: for viz_output in viz_outputs: vis_output_pattern_pdf = tmpvizdir + f"/*.{viz_output}" visualize.compare_classifiers_performance_from_pred( [prediction, prediction], experiment.ground_truth, experiment.ground_truth_metadata, experiment.output_feature_name, labels_limit=0, model_namess=["Model1", "Model2"], output_directory=tmpvizdir, file_format=viz_output, ) figure_cnt = glob.glob(vis_output_pattern_pdf) assert 1 == len(figure_cnt) def test_compare_classifiers_performance_subset_vis_api(experiment_to_use): """Ensure pdf and png figures can be saved via visualization API call. :param experiment_to_use: Object containing trained model and results to test visualization :return: None """ experiment = experiment_to_use probabilities = experiment.probabilities viz_outputs = ("pdf", "png") with TemporaryDirectory() as tmpvizdir: for viz_output in viz_outputs: vis_output_pattern_pdf = tmpvizdir + f"/*.{viz_output}" visualize.compare_classifiers_performance_subset( [probabilities, probabilities], experiment.ground_truth, experiment.ground_truth_metadata, experiment.output_feature_name, top_n_classes=[6], labels_limit=0, subset="ground_truth", model_namess=["Model1", "Model2"], output_directory=tmpvizdir, file_format=viz_output, ) figure_cnt = glob.glob(vis_output_pattern_pdf) assert 1 == len(figure_cnt) def test_compare_classifiers_performance_changing_k_vis_api(experiment_to_use): """Ensure pdf and png figures can be saved via visualization API call. :param experiment_to_use: Object containing trained model and results to test visualization :return: None """ experiment = experiment_to_use probabilities = experiment.probabilities viz_outputs = ("pdf", "png") with TemporaryDirectory() as tmpvizdir: for viz_output in viz_outputs: vis_output_pattern_pdf = tmpvizdir + f"/*.{viz_output}" visualize.compare_classifiers_performance_changing_k( [probabilities, probabilities], experiment.ground_truth, experiment.ground_truth_metadata, experiment.output_feature_name, top_k=3, labels_limit=0, model_namess=["Model1", "Model2"], output_directory=tmpvizdir, file_format=viz_output, ) figure_cnt = glob.glob(vis_output_pattern_pdf) assert 1 == len(figure_cnt) def test_compare_classifiers_multiclass_multimetric_vis_api(experiment_to_use): """Ensure pdf and png figures can be saved via visualization API call. :param experiment_to_use: Object containing trained model and results to test visualization :return: None """ experiment = experiment_to_use # extract test stats only test_stats = experiment.test_stats_full viz_outputs = ("pdf", "png") with TemporaryDirectory() as tmpvizdir: for viz_output in viz_outputs: vis_output_pattern_pdf = tmpvizdir + f"/*.{viz_output}" visualize.compare_classifiers_multiclass_multimetric( [test_stats, test_stats], experiment.ground_truth_metadata, experiment.output_feature_name, top_n_classes=[6], model_namess=["Model1", "Model2"], output_directory=tmpvizdir, file_format=viz_output, ) figure_cnt = glob.glob(vis_output_pattern_pdf) assert 4 == len(figure_cnt) def test_compare_classifiers_predictions_vis_api(experiment_to_use): """Ensure pdf and png figures can be saved via visualization API call. :param experiment_to_use: Object containing trained model and results to test visualization :return: None """ experiment = experiment_to_use predictions = experiment.predictions viz_outputs = ("pdf", "png") with TemporaryDirectory() as tmpvizdir: for viz_output in viz_outputs: vis_output_pattern_pdf = tmpvizdir + f"/*.{viz_output}" visualize.compare_classifiers_predictions( [predictions, predictions], experiment.ground_truth, experiment.ground_truth_metadata, experiment.output_feature_name, labels_limit=0, model_names=["Model1", "Model2"], output_directory=tmpvizdir, file_format=viz_output, ) figure_cnt = glob.glob(vis_output_pattern_pdf) assert 1 == len(figure_cnt) def test_compare_classifiers_predictions_distribution_vis_api(experiment_to_use): """Ensure pdf and png figures can be saved via visualization API call. :param experiment_to_use: Object containing trained model and results to test visualization :return: None """ experiment = experiment_to_use predictions = experiment.predictions_num viz_outputs = ("pdf", "png") with TemporaryDirectory() as tmpvizdir: for viz_output in viz_outputs: vis_output_pattern_pdf = tmpvizdir + f"/*.{viz_output}" visualize.compare_classifiers_predictions_distribution( [predictions, predictions], experiment.ground_truth, experiment.ground_truth_metadata, experiment.output_feature_name, labels_limit=0, model_names=["Model1", "Model2"], output_directory=tmpvizdir, file_format=viz_output, ) figure_cnt = glob.glob(vis_output_pattern_pdf) assert 1 == len(figure_cnt) def test_confidence_thresholding_vis_api(experiment_to_use): """Ensure pdf and png figures can be saved via visualization API call. :param experiment_to_use: Object containing trained model and results to test visualization :return: None """ experiment = experiment_to_use probabilities = experiment.probabilities viz_outputs = ("pdf", "png") with TemporaryDirectory() as tmpvizdir: for viz_output in viz_outputs: vis_output_pattern_pdf = tmpvizdir + f"/*.{viz_output}" visualize.confidence_thresholding( [probabilities, probabilities], experiment.ground_truth, experiment.ground_truth_metadata, experiment.output_feature_name, labels_limit=0, model_names=["Model1", "Model2"], output_directory=tmpvizdir, file_format=viz_output, ) figure_cnt = glob.glob(vis_output_pattern_pdf) assert 1 == len(figure_cnt) def test_confidence_thresholding_data_vs_acc_vis_api(experiment_to_use): """Ensure pdf and png figures can be saved via visualization API call. :param experiment_to_use: Object containing trained model and results to test visualization :return: None """ experiment = experiment_to_use probabilities = experiment.probabilities viz_outputs = ("pdf", "png") with TemporaryDirectory() as tmpvizdir: for viz_output in viz_outputs: vis_output_pattern_pdf = tmpvizdir + f"/*.{viz_output}" visualize.confidence_thresholding_data_vs_acc( [probabilities, probabilities], experiment.ground_truth, experiment.ground_truth_metadata, experiment.output_feature_name, labels_limit=0, model_names=["Model1", "Model2"], output_directory=tmpvizdir, file_format=viz_output, ) figure_cnt = glob.glob(vis_output_pattern_pdf) assert 1 == len(figure_cnt) def test_confidence_thresholding_data_vs_acc_subset_vis_api(experiment_to_use): """Ensure pdf and png figures can be saved via visualization API call. :param experiment_to_use: Object containing trained model and results to test visualization :return: None """ experiment = experiment_to_use probabilities = experiment.probabilities viz_outputs = ("pdf", "png") with TemporaryDirectory() as tmpvizdir: for viz_output in viz_outputs: vis_output_pattern_pdf = tmpvizdir + f"/*.{viz_output}" visualize.confidence_thresholding_data_vs_acc_subset( [probabilities, probabilities], experiment.ground_truth, experiment.ground_truth_metadata, experiment.output_feature_name, top_n_classes=[3], labels_limit=0, subset="ground_truth", model_names=["Model1", "Model2"], output_directory=tmpvizdir, file_format=viz_output, ) figure_cnt = glob.glob(vis_output_pattern_pdf) assert 1 == len(figure_cnt) def test_confidence_thresholding_data_vs_acc_subset_per_class_vis_api(experiment_to_use): """Ensure pdf and png figures can be saved via visualization API call. :param experiment_to_use: Object containing trained model and results to test visualization :return: None """ experiment = experiment_to_use probabilities = experiment.probabilities viz_outputs = ("pdf", "png") with TemporaryDirectory() as tmpvizdir: for viz_output in viz_outputs: vis_output_pattern_pdf = tmpvizdir + f"/*.{viz_output}" visualize.confidence_thresholding_data_vs_acc_subset_per_class( [probabilities, probabilities], experiment.ground_truth, experiment.ground_truth_metadata, experiment.output_feature_name, top_n_classes=[2], labels_limit=0, subset="ground_truth", model_names=["Model1", "Model2"], output_directory=tmpvizdir, file_format=viz_output, ) figure_cnt = glob.glob(vis_output_pattern_pdf) # 3 figures should be saved because experiment setting top_n_classes = 3 # hence one figure per class assert 2 == len(figure_cnt) def test_confidence_thresholding_2thresholds_2d_vis_api(csv_filename): """Ensure pdf and png figures can be saved via visualization API call. :param csv_filename: csv fixture from tests.fixtures.filenames.csv_filename :return: None """ input_features = [ text_feature(encoder={"vocab_size": 10, "min_len": 1, "type": "stacked_cnn"}), number_feature(), category_feature(encoder={"vocab_size": 10, "embedding_size": 5}), set_feature(), sequence_feature(encoder={"vocab_size": 10, "max_len": 10, "type": "embed"}), ] output_features = [ category_feature(decoder={"vocab_size": 2}, reduce_input="sum"), category_feature(decoder={"vocab_size": 2}, reduce_input="sum"), ] encoder = "parallel_cnn" with TemporaryDirectory() as tmpvizdir: # Generate test data data_csv = generate_data(input_features, output_features, os.path.join(tmpvizdir, csv_filename)) input_features[0][ENCODER][TYPE] = encoder model = run_api_experiment(input_features, output_features) test_df, train_df, val_df = obtain_df_splits(data_csv) _, _, output_dir = model.train( training_set=train_df, validation_set=val_df, output_directory=os.path.join(tmpvizdir, "results") ) test_stats, predictions, _ = model.evaluate(dataset=test_df, collect_predictions=True, output_dir=output_dir) output_feature_name1 = output_features[0]["name"] output_feature_name2 = output_features[1]["name"] ground_truth_metadata = model.training_set_metadata feature1_cols = [ f"{output_feature_name1}_probabilities_{label}" for label in ground_truth_metadata[output_feature_name1]["idx2str"] ] feature2_cols = [ f"{output_feature_name2}_probabilities_{label}" for label in ground_truth_metadata[output_feature_name2]["idx2str"] ] # probabilities need to be list of lists containing each row data from the # probability columns ref: https://ludwig-ai.github.io/ludwig-docs/latest/user_guide/api/LudwigModel#evaluate probability1 = predictions.loc[:, feature1_cols].values probability2 = predictions.loc[:, feature2_cols].values target_predictions1 = test_df[output_feature_name1] target_predictions2 = test_df[output_feature_name2] ground_truth1 = np.asarray( [ground_truth_metadata[output_feature_name1]["str2idx"][prediction] for prediction in target_predictions1] ) ground_truth2 = np.asarray( [ground_truth_metadata[output_feature_name2]["str2idx"][prediction] for prediction in target_predictions2] ) viz_outputs = ("pdf", "png") for viz_output in viz_outputs: vis_output_pattern_pdf = os.path.join(output_dir, "*.{}").format(viz_output) visualize.confidence_thresholding_2thresholds_2d( [probability1, probability2], [ground_truth1, ground_truth2], model.training_set_metadata, [output_feature_name1, output_feature_name2], labels_limit=0, model_names=["Model1"], output_directory=output_dir, file_format=viz_output, ) figure_cnt = glob.glob(vis_output_pattern_pdf) assert 3 == len(figure_cnt) def test_confidence_thresholding_2thresholds_3d_vis_api(csv_filename): """Ensure pdf and png figures can be saved via visualization API call. :param csv_filename: csv fixture from tests.fixtures.filenames.csv_filename :return: None """ input_features = [ text_feature(encoder={"vocab_size": 10, "min_len": 1, "type": "stacked_cnn"}), number_feature(), category_feature(encoder={"vocab_size": 10, "embedding_size": 5}), set_feature(), sequence_feature(encoder={"vocab_size": 10, "max_len": 10, "type": "embed"}), ] output_features = [ category_feature(decoder={"vocab_size": 2}, reduce_input="sum"), category_feature(decoder={"vocab_size": 2}, reduce_input="sum"), ] encoder = "parallel_cnn" with TemporaryDirectory() as tmpvizdir: # Generate test data data_csv = generate_data(input_features, output_features, os.path.join(tmpvizdir, csv_filename)) input_features[0][ENCODER][TYPE] = encoder model = run_api_experiment(input_features, output_features) test_df, train_df, val_df = obtain_df_splits(data_csv) _, _, output_dir = model.train( training_set=train_df, validation_set=val_df, output_directory=os.path.join(tmpvizdir, "results") ) test_stats, predictions, _ = model.evaluate( dataset=test_df, collect_predictions=True, output_directory=output_dir ) output_feature_name1 = output_features[0]["name"] output_feature_name2 = output_features[1]["name"] ground_truth_metadata = model.training_set_metadata feature1_cols = [ f"{output_feature_name1}_probabilities_{label}" for label in ground_truth_metadata[output_feature_name1]["idx2str"] ] feature2_cols = [ f"{output_feature_name2}_probabilities_{label}" for label in ground_truth_metadata[output_feature_name2]["idx2str"] ] # probabilities need to be list of lists containing each row data from the # probability columns ref: https://ludwig-ai.github.io/ludwig-docs/latest/user_guide/api/LudwigModel#evaluate probability1 = predictions.loc[:, feature1_cols].values probability2 = predictions.loc[:, feature2_cols].values target_predictions1 = test_df[output_feature_name1] target_predictions2 = test_df[output_feature_name2] ground_truth1 = np.asarray( [ground_truth_metadata[output_feature_name1]["str2idx"][prediction] for prediction in target_predictions1] ) ground_truth2 = np.asarray( [ground_truth_metadata[output_feature_name2]["str2idx"][prediction] for prediction in target_predictions2] ) viz_outputs = ("pdf", "png") for viz_output in viz_outputs: vis_output_pattern_pdf = os.path.join(output_dir, f"*.{viz_output}") visualize.confidence_thresholding_2thresholds_3d( [probability1, probability2], [ground_truth1, ground_truth2], model.training_set_metadata, [output_feature_name1, output_feature_name2], labels_limit=0, output_directory=output_dir, file_format=viz_output, ) figure_cnt = glob.glob(vis_output_pattern_pdf) assert 1 == len(figure_cnt) def test_binary_threshold_vs_metric_vis_api(experiment_to_use): """Ensure pdf and png figures can be saved via visualization API call. :param experiment_to_use: Object containing trained model and results to test visualization :return: None """ experiment = experiment_to_use probabilities = experiment.probabilities viz_outputs = ("pdf", "png") metrics = ["accuracy"] positive_label = 1 with TemporaryDirectory() as tmpvizdir: for viz_output in viz_outputs: vis_output_pattern_pdf = tmpvizdir + f"/*.{viz_output}" visualize.binary_threshold_vs_metric( [probabilities, probabilities], experiment.ground_truth, experiment.ground_truth_metadata, experiment.output_feature_name, metrics, positive_label, model_names=["Model1", "Model2"], output_directory=tmpvizdir, file_format=viz_output, ) figure_cnt = glob.glob(vis_output_pattern_pdf) assert 1 == len(figure_cnt) def test_precision_recall_curves_vis_api(experiment_to_use): """Ensure pdf and png figures can be saved via visualization API call. :param experiment_to_use: Object containing trained model and results to test visualization :return: None """ experiment = experiment_to_use probabilities = experiment.probabilities viz_outputs = ("pdf", "png") positive_label = 1 with TemporaryDirectory() as tmpvizdir: for viz_output in viz_outputs: vis_output_pattern_pdf = tmpvizdir + f"/*.{viz_output}" visualize.precision_recall_curves( [probabilities, probabilities], experiment.ground_truth, experiment.ground_truth_metadata, experiment.output_feature_name, positive_label, model_names=["Model1", "Model2"], output_directory=tmpvizdir, file_format=viz_output, ) figure_cnt = glob.glob(vis_output_pattern_pdf) assert 1 == len(figure_cnt) def test_precision_recall_curves_from_test_statistics_vis_api(csv_filename): """Ensure pdf and png figures can be saved via visualization API call. :param csv_filename: csv fixture from tests.fixtures.filenames.csv_filename :return: None """ input_features = [binary_feature(), bag_feature()] output_features = [binary_feature()] with TemporaryDirectory() as tmpvizdir: # Generate test data data_csv = generate_data( input_features, output_features, os.path.join(tmpvizdir, csv_filename), num_examples=20 ) output_feature_name = output_features[0]["name"] model = run_api_experiment(input_features, output_features) data_df = read_csv(data_csv) _, _, output_dir = model.train(dataset=data_df, output_directory=os.path.join(tmpvizdir, "results")) test_stats, _, _ = model.evaluate(dataset=data_df, collect_overall_stats=True, output_directory=output_dir) viz_outputs = ("pdf", "png") for viz_output in viz_outputs: vis_output_pattern_pdf = os.path.join(output_dir, f"*.{viz_output}") visualize.precision_recall_curves_from_test_statistics( [test_stats, test_stats], output_feature_name, model_names=["Model1", "Model2"], output_directory=output_dir, file_format=viz_output, ) figure_cnt = glob.glob(vis_output_pattern_pdf) assert 1 == len(figure_cnt) def test_roc_curves_vis_api(experiment_to_use): """Ensure pdf and png figures can be saved via visualization API call. :param experiment_to_use: Object containing trained model and results to test visualization :return: None """ experiment = experiment_to_use probabilities = experiment.probabilities viz_outputs = ("pdf", "png") positive_label = 1 with TemporaryDirectory() as tmpvizdir: for viz_output in viz_outputs: vis_output_pattern_pdf = tmpvizdir + f"/*.{viz_output}" visualize.roc_curves( [probabilities, probabilities], experiment.ground_truth, experiment.ground_truth_metadata, experiment.output_feature_name, positive_label, model_names=["Model1", "Model2"], output_directory=tmpvizdir, file_format=viz_output, ) figure_cnt = glob.glob(vis_output_pattern_pdf) assert 1 == len(figure_cnt) def test_roc_curves_from_test_statistics_vis_api(csv_filename): """Ensure pdf and png figures can be saved via visualization API call. :param csv_filename: csv fixture from tests.fixtures.filenames.csv_filename :return: None """ input_features = [binary_feature(), bag_feature()] output_features = [binary_feature()] with TemporaryDirectory() as tmpvizdir: # Generate test data data_csv = generate_data(input_features, output_features, os.path.join(tmpvizdir, csv_filename)) output_feature_name = output_features[0]["name"] model = run_api_experiment(input_features, output_features) data_df = read_csv(data_csv) _, _, output_dir = model.train(dataset=data_df, output_directory=os.path.join(tmpvizdir, "results")) # extract test metrics test_stats, _, _ = model.evaluate(dataset=data_df, collect_overall_stats=True, output_directory=output_dir) test_stats = test_stats viz_outputs = ("pdf", "png") for viz_output in viz_outputs: vis_output_pattern_pdf = os.path.join(output_dir, f"*.{viz_output}") visualize.roc_curves_from_test_statistics( [test_stats, test_stats], output_feature_name, model_names=["Model1", "Model2"], output_directory=output_dir, file_format=viz_output, ) figure_cnt = glob.glob(vis_output_pattern_pdf) assert 1 == len(figure_cnt) def test_calibration_1_vs_all_vis_api(experiment_to_use): """Ensure pdf and png figures can be saved via visualization API call. :param experiment_to_use: Object containing trained model and results to test visualization :return: None """ experiment = experiment_to_use probabilities = experiment.probabilities viz_outputs = ("pdf", "png") with TemporaryDirectory() as tmpvizdir: for viz_output in viz_outputs: vis_output_pattern_pdf = os.path.join(tmpvizdir, f"*.{viz_output}") visualize.calibration_1_vs_all( [probabilities, probabilities], experiment.ground_truth, experiment.ground_truth_metadata, experiment.output_feature_name, top_n_classes=[6], labels_limit=0, model_namess=["Model1", "Model2"], output_directory=tmpvizdir, file_format=viz_output, ) figure_cnt = glob.glob(vis_output_pattern_pdf) assert 5 == len(figure_cnt) def test_calibration_multiclass_vis_api(experiment_to_use): """Ensure pdf and png figures can be saved via visualization API call. :param experiment_to_use: Object containing trained model and results to test visualization :return: None """ experiment = experiment_to_use probabilities = experiment.probabilities viz_outputs = ("pdf", "png") with TemporaryDirectory() as tmpvizdir: for viz_output in viz_outputs: vis_output_pattern_pdf = tmpvizdir + f"/*.{viz_output}" visualize.calibration_multiclass( [probabilities, probabilities], experiment.ground_truth, experiment.ground_truth_metadata, experiment.output_feature_name, labels_limit=0, model_names=["Model1", "Model2"], output_directory=tmpvizdir, file_format=viz_output, ) figure_cnt = glob.glob(vis_output_pattern_pdf) assert 2 == len(figure_cnt) def test_confusion_matrix_vis_api(experiment_to_use): """Ensure pdf and png figures can be saved via visualization API call. :param experiment_to_use: Object containing trained model and results to test visualization :return: None """ experiment = experiment_to_use # extract test stats only test_stats = experiment.test_stats_full viz_outputs = ("pdf", "png") with TemporaryDirectory() as tmpvizdir: for viz_output in viz_outputs: vis_output_pattern_pdf = tmpvizdir + f"/*.{viz_output}" visualize.confusion_matrix( [test_stats, test_stats], experiment.ground_truth_metadata, experiment.output_feature_name, top_n_classes=[0], normalize=False, model_names=["Model1", "Model2"], output_directory=tmpvizdir, file_format=viz_output, ) figure_cnt = glob.glob(vis_output_pattern_pdf) assert 4 == len(figure_cnt) def test_frequency_vs_f1_vis_api(experiment_to_use): """Ensure pdf and png figures can be saved via visualization API call. :param experiment_to_use: Object containing trained model and results to test visualization :return: None """ experiment = experiment_to_use # extract test stats test_stats = experiment.test_stats_full viz_outputs = ("pdf", "png") with TemporaryDirectory() as tmpvizdir: for viz_output in viz_outputs: vis_output_pattern_pdf = tmpvizdir + f"/*.{viz_output}" visualize.frequency_vs_f1( [test_stats, test_stats], experiment.ground_truth_metadata, experiment.output_feature_name, top_n_classes=[0], model_names=["Model1", "Model2"], output_directory=tmpvizdir, file_format=viz_output, ) figure_cnt = glob.glob(vis_output_pattern_pdf) assert 2 == len(figure_cnt) @pytest.mark.distributed def test_hyperopt_report_vis_api(hyperopt_results_multiple_parameters, tmpdir): vis_dir = os.path.join(tmpdir, "visualizations") # Ensure visualizations directory is empty before creating plots if os.path.exists(vis_dir): for f in os.listdir(vis_dir): os.remove(os.path.join(vis_dir, f)) visualize.hyperopt_report( os.path.join(hyperopt_results_multiple_parameters, HYPEROPT_STATISTICS_FILE_NAME), output_directory=vis_dir ) # test for creation of output directory assert os.path.isdir(vis_dir) figure_cnt = glob.glob(os.path.join(vis_dir, "*")) assert 4 == len(figure_cnt) @pytest.mark.distributed def test_hyperopt_hiplot_vis_api(hyperopt_results_multiple_parameters, tmpdir): vis_dir = os.path.join(tmpdir, "visualizations") # Ensure visualizations directory is empty before creating plots if os.path.exists(vis_dir): for f in os.listdir(vis_dir): os.remove(os.path.join(vis_dir, f)) visualize.hyperopt_hiplot( os.path.join(hyperopt_results_multiple_parameters, HYPEROPT_STATISTICS_FILE_NAME), output_directory=vis_dir ) # test for creation of output directory assert os.path.isdir(vis_dir) # test for generatated html page assert os.path.isfile(os.path.join(vis_dir, "hyperopt_hiplot.html")) @pytest.mark.distributed def test_hyperopt_report_vis_api_no_pairplot(hyperopt_results_single_parameter, tmpdir): vis_dir = os.path.join(tmpdir, "visualizations") # Ensure visualizations directory is empty before creating plots if os.path.exists(vis_dir): for f in os.listdir(vis_dir): os.remove(os.path.join(vis_dir, f)) visualize.hyperopt_report( os.path.join(hyperopt_results_single_parameter, HYPEROPT_STATISTICS_FILE_NAME), output_directory=vis_dir ) figure_cnt = glob.glob(os.path.join(vis_dir, "*")) # Only create plot for single parameter and skip pairplot creation assert len(figure_cnt) == 1 ================================================ FILE: tests/integration_tests/utils.py ================================================ # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import contextlib import logging import multiprocessing import os import random import shutil import sys import tempfile import traceback import uuid def strtobool(val): val = str(val).strip().lower() if val in ("y", "yes", "t", "true", "on", "1"): return 1 elif val in ("n", "no", "f", "false", "off", "0"): return 0 else: raise ValueError(f"invalid truth value {val!r}") from typing import Any, TYPE_CHECKING # noqa: E402 import cloudpickle # noqa: E402 import numpy as np # noqa: E402 import pandas as pd # noqa: E402 import pytest # noqa: E402 import torch # noqa: E402 from PIL import Image # noqa: E402 from ludwig.api import LudwigModel # noqa: E402 from ludwig.backend import LocalBackend # noqa: E402 from ludwig.constants import ( # noqa: E402 AUDIO, BAG, BATCH_SIZE, BINARY, CATEGORY, CATEGORY_DISTRIBUTION, COLUMN, DATE, DECODER, ENCODER, H3, IMAGE, MODEL_ECD, NAME, NUMBER, PROC_COLUMN, SEQUENCE, SET, SPLIT, TEXT, TIMESERIES, TRAINER, VECTOR, ) from ludwig.data.dataset_synthesizer import build_synthetic_dataset, DATETIME_FORMATS # noqa: E402 from ludwig.experiment import experiment_cli # noqa: E402 from ludwig.features.feature_utils import compute_feature_hash # noqa: E402 from ludwig.globals import MODEL_FILE_NAME, PREDICTIONS_PARQUET_FILE_NAME # noqa: E402 from ludwig.schema.encoders.text_encoders import HFEncoderConfig # noqa: E402 from ludwig.schema.encoders.utils import get_encoder_classes # noqa: E402 from ludwig.trainers.trainer import Trainer # noqa: E402 from ludwig.utils import fs_utils # noqa: E402 from ludwig.utils.data_utils import read_csv, replace_file_extension, use_credentials # noqa: E402 if TYPE_CHECKING: from ludwig.data.dataset.base import Dataset from ludwig.schema.model_types.base import ModelConfig logger = logging.getLogger(__name__) # Used in sequence-related unit tests (encoders, features) as well as end-to-end integration tests. # Missing: passthrough encoder. ENCODERS = ["embed", "rnn", "parallel_cnn", "cnnrnn", "stacked_parallel_cnn", "stacked_cnn", "transformer"] TEXT_ENCODERS = ENCODERS + ["tf_idf"] HF_ENCODERS_SHORT = ["distilbert"] HF_ENCODERS = [name for name, cls in get_encoder_classes(MODEL_ECD, TEXT).items() if issubclass(cls, HFEncoderConfig)] RAY_BACKEND_CONFIG = { "type": "ray", "processor": { "parallelism": 2, }, "trainer": { "use_gpu": False, "num_workers": 1, "resources_per_worker": { "CPU": 0.1, "GPU": 0, }, }, } class LocalTestBackend(LocalBackend): @property def supports_multiprocessing(self): return False # Simulates running training on a separate node from the driver process class FakeRemoteBackend(LocalBackend): def create_trainer(self, **kwargs) -> "BaseTrainer": # noqa: F821 return FakeRemoteTrainer(**kwargs) @property def supports_multiprocessing(self): return False class FakeRemoteTrainer(Trainer): def train(self, *args, save_path=MODEL_FILE_NAME, **kwargs): with tempfile.TemporaryDirectory() as tmpdir: return super().train(*args, save_path=tmpdir, **kwargs) 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: if isinstance(value, bool): return 1 if value else 0 _value = strtobool(value) except ValueError: # More values are supported, but let's keep the message simple. raise ValueError(f"If set, {key} must be yes or no.") return _value _run_private_tests = parse_flag_from_env("RUN_PRIVATE", default=False) private_test = pytest.mark.skipif( not _run_private_tests, reason="Skipping: this test is marked private, set RUN_PRIVATE=1 in your environment to run", ) def private_param(param): """Wrap param to mark it as private, meaning it requires credentials to run. Private tests are skipped by default. Set the RUN_PRIVATE environment variable to a truth value to run them. """ return pytest.param( *param, marks=pytest.mark.skipif( not _run_private_tests, reason="Skipping: this test is marked private, set RUN_PRIVATE=1 in your environment to run", ), ) def generate_data( input_features, output_features, filename="test_csv.csv", num_examples=25, nan_percent=0.0, with_split=False, ): """Helper method to generate synthetic data based on input, output feature specs. :param num_examples: number of examples to generate :param input_features: schema :param output_features: schema :param filename: path to the file where data is stored :param nan_percent: percent of values in a feature to be NaN :param with_split: If True, then new column "split" is created, containing integer values as follows: 0 -- for training set; 1 -- for validation set; 2 -- for test set. :return: """ df = generate_data_as_dataframe(input_features, output_features, num_examples, nan_percent, with_split=with_split) df.to_csv(filename, index=False) return filename def generate_data_as_dataframe( input_features, output_features, num_examples=25, nan_percent=0.0, with_split=False, ) -> pd.DataFrame: """Helper method to generate synthetic data based on input, output feature specs. Args: input_features: schema output_features: schema num_examples: number of examples to generate nan_percent: percent of values in a feature to be NaN with_split: If True, then new column "split" is created, containing integer values as follows: 0 -- for training set; 1 -- for validation set; 2 -- for test set. Returns: A pandas DataFrame """ features = input_features + output_features df = build_synthetic_dataset(num_examples, features) data = [next(df) for _ in range(num_examples + 1)] df = pd.DataFrame(data[1:], columns=data[0]) # Add "split" column to DataFrame if with_split: num_val_examples = max(2, int(num_examples * 0.1)) num_test_examples = max(2, int(num_examples * 0.1)) num_train_examples = num_examples - num_val_examples - num_test_examples df["split"] = [0] * num_train_examples + [1] * num_val_examples + [2] * num_test_examples return df def recursive_update(dictionary, values): for k, v in values.items(): if isinstance(v, dict): dictionary[k] = recursive_update(dictionary.get(k, {}), v) else: dictionary[k] = v return dictionary def random_string(length=5): return uuid.uuid4().hex[:length].upper() def number_feature(normalization=None, **kwargs): feature = { "name": f"{NUMBER}_{random_string()}", "type": NUMBER, "preprocessing": {"normalization": normalization}, } recursive_update(feature, kwargs) feature[COLUMN] = feature[NAME] feature[PROC_COLUMN] = compute_feature_hash(feature) return feature def category_feature(output_feature=False, **kwargs): if DECODER in kwargs: output_feature = True feature = { "name": f"{CATEGORY}_{random_string()}", "type": CATEGORY, } if output_feature: feature.update( { DECODER: {"type": "classifier", "vocab_size": 10}, } ) else: feature.update( { ENCODER: {"vocab_size": 10, "embedding_size": 5}, } ) recursive_update(feature, kwargs) feature[COLUMN] = feature[NAME] feature[PROC_COLUMN] = compute_feature_hash(feature) return feature def text_feature(output_feature: bool = False, name: str = None, **kwargs): if DECODER in kwargs: output_feature = True if name is not None: feature_name = name else: feature_name = f"{TEXT}_{random_string()}" feature = { "name": feature_name, "type": TEXT, } if output_feature: feature.update( { DECODER: {"type": "generator", "vocab_size": 5, "max_len": 7}, } ) else: feature.update( { ENCODER: { "type": "parallel_cnn", "vocab_size": 5, "min_len": 7, "max_len": 7, "embedding_size": 8, "state_size": 8, }, } ) recursive_update(feature, kwargs) feature[COLUMN] = feature[NAME] feature[PROC_COLUMN] = compute_feature_hash(feature) return feature def set_feature(output_feature=False, **kwargs): if DECODER in kwargs: output_feature = True feature = { "name": f"{SET}_{random_string()}", "type": SET, } if output_feature: feature.update( { DECODER: {"type": "classifier", "vocab_size": 10, "max_len": 5}, } ) else: feature.update( { ENCODER: {"type": "embed", "vocab_size": 10, "max_len": 5, "embedding_size": 5}, } ) recursive_update(feature, kwargs) feature[COLUMN] = feature[NAME] feature[PROC_COLUMN] = compute_feature_hash(feature) return feature def sequence_feature(output_feature=False, **kwargs): if DECODER in kwargs: output_feature = True feature = { "name": f"{SEQUENCE}_{random_string()}", "type": SEQUENCE, } if output_feature: feature.update( { DECODER: { "type": "generator", "vocab_size": 10, "max_len": 7, } } ) else: feature.update( { ENCODER: { "type": "embed", "vocab_size": 10, "max_len": 7, "embedding_size": 8, "output_size": 8, "state_size": 8, "num_filters": 8, "hidden_size": 8, }, } ) recursive_update(feature, kwargs) feature[COLUMN] = feature[NAME] feature[PROC_COLUMN] = compute_feature_hash(feature) return feature def image_feature(folder, **kwargs): feature = { "name": f"{IMAGE}_{random_string()}", "type": IMAGE, "preprocessing": {"in_memory": True, "height": 12, "width": 12, "num_channels": 3}, ENCODER: { "type": "stacked_cnn", }, "destination_folder": folder, } recursive_update(feature, kwargs) feature[COLUMN] = feature[NAME] feature[PROC_COLUMN] = compute_feature_hash(feature) return feature def audio_feature(folder, **kwargs): feature = { "name": f"{AUDIO}_{random_string()}", "type": AUDIO, "preprocessing": { "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: { "type": "stacked_cnn", "should_embed": False, "conv_layers": [ {"filter_size": 400, "pool_size": 16, "num_filters": 32}, {"filter_size": 40, "pool_size": 10, "num_filters": 64}, ], "output_size": 16, }, "destination_folder": folder, } recursive_update(feature, kwargs) feature[COLUMN] = feature[NAME] feature[PROC_COLUMN] = compute_feature_hash(feature) return feature def timeseries_feature(**kwargs): feature = { "name": f"{TIMESERIES}_{random_string()}", "type": TIMESERIES, } output_feature = DECODER in kwargs if output_feature: feature.update( { DECODER: {"type": "projector"}, } ) else: feature.update( { ENCODER: {"type": "parallel_cnn", "max_len": 7}, } ) recursive_update(feature, kwargs) feature[COLUMN] = feature[NAME] feature[PROC_COLUMN] = compute_feature_hash(feature) return feature def binary_feature(**kwargs): feature = { "name": f"{BINARY}_{random_string()}", "type": BINARY, } recursive_update(feature, kwargs) feature[COLUMN] = feature[NAME] feature[PROC_COLUMN] = compute_feature_hash(feature) return feature def bag_feature(**kwargs): feature = { "name": f"{BAG}_{random_string()}", "type": BAG, ENCODER: {"type": "embed", "max_len": 5, "vocab_size": 10, "embedding_size": 5}, } recursive_update(feature, kwargs) feature[COLUMN] = feature[NAME] feature[PROC_COLUMN] = compute_feature_hash(feature) return feature def date_feature(**kwargs): feature = { "name": f"{DATE}_{random_string()}", "type": DATE, "preprocessing": { "datetime_format": random.choice(list(DATETIME_FORMATS.keys())), }, } recursive_update(feature, kwargs) feature[COLUMN] = feature[NAME] feature[PROC_COLUMN] = compute_feature_hash(feature) return feature def h3_feature(**kwargs): feature = { "name": f"{H3}_{random_string()}", "type": H3, } recursive_update(feature, kwargs) feature[COLUMN] = feature[NAME] feature[PROC_COLUMN] = compute_feature_hash(feature) return feature def vector_feature(**kwargs): feature = { "name": f"{VECTOR}_{random_string()}", "type": VECTOR, "preprocessing": { "vector_size": 5, }, } recursive_update(feature, kwargs) feature[COLUMN] = feature[NAME] feature[PROC_COLUMN] = compute_feature_hash(feature) return feature def category_distribution_feature(**kwargs): feature = { "name": f"{CATEGORY_DISTRIBUTION}_{random_string()}", "type": CATEGORY_DISTRIBUTION, "preprocessing": { "vocab": ["a", "b", "c"], }, DECODER: {"type": "classifier"}, } recursive_update(feature, kwargs) feature[COLUMN] = feature[NAME] feature[PROC_COLUMN] = compute_feature_hash(feature) return feature def run_experiment( input_features=None, output_features=None, config=None, skip_save_processed_input=True, backend=None, **kwargs ): """Helper method to avoid code repetition in running an experiment. Deletes the data saved to disk related to running an experiment. :param input_features: list of input feature dictionaries :param output_features: list of output feature dictionaries :param config: A dictionary containing the Ludwig model configuration :param skip_save_processed_input: (bool, default: `False`) if input dataset is provided it is preprocessed and cached by saving an HDF5 and JSON files to avoid running the preprocessing again. If this parameter is `False`, the HDF5 and JSON file are not saved. :param backend: (Union[Backend, str]) `Backend` or string name **kwargs you may also pass extra parameters to the experiment as keyword arguments :return: None """ if input_features is None and output_features is None and config is None: raise ValueError("Cannot run test experiment without features nor config.") if config is None: config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat", "output_size": 14}, TRAINER: {"epochs": 2, BATCH_SIZE: 128}, } with tempfile.TemporaryDirectory() as tmpdir: 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, "output_directory": tmpdir, } args.update(kwargs) return experiment_cli(**args) def generate_output_features_with_dependencies(main_feature, dependencies): """Generates multiple output features specifications with dependencies. Example usage: generate_output_features_with_dependencies('sequence_feature', ['category_feature', 'number_feature']) Args: main_feature: feature identifier, valid values 'category_feature', 'sequence_feature', 'number_feature' dependencies: list of dependencies for 'main_feature', do not li """ output_features = [ category_feature(decoder={"type": "classifier", "vocab_size": 2}, reduce_input="sum", output_feature=True), sequence_feature(decoder={"type": "generator", "vocab_size": 10, "max_len": 5}, output_feature=True), number_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 = { "category_feature": (0, output_features[0]["name"]), "sequence_feature": (1, output_features[1]["name"]), "number_feature": (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 generate_output_features_with_dependencies_complex(): """Generates multiple output features specifications with dependencies.""" tf = text_feature(decoder={"vocab_size": 4, "max_len": 5, "type": "generator"}) sf = sequence_feature(decoder={"vocab_size": 4, "max_len": 5, "type": "generator"}, dependencies=[tf["name"]]) nf = number_feature(dependencies=[tf["name"]]) vf = vector_feature(dependencies=[sf["name"], nf["name"]]) set_f = set_feature(decoder={"type": "classifier", "vocab_size": 4}, dependencies=[tf["name"], vf["name"]]) cf = category_feature( decoder={"type": "classifier", "vocab_size": 4}, dependencies=[sf["name"], nf["name"], set_f["name"]] ) # The correct order ids[tf, sf, nf, vf, set_f, cf] # shuffling it to test the robustness of the topological sort output_features = [nf, tf, set_f, vf, cf, sf] 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 get_weights(model: torch.nn.Module) -> list[torch.Tensor]: return [param.data for param in model.parameters()] def has_no_grad( val: np.ndarray | torch.Tensor | str | list, ): """Checks if two values are close to each other.""" if isinstance(val, list): return all(has_no_grad(v) for v in val) if isinstance(val, torch.Tensor): return not val.requires_grad return True def is_all_close( val1: np.ndarray | torch.Tensor | str | list, val2: np.ndarray | torch.Tensor | str | list, tolerance=1e-4, ): """Checks if two values are close to each other.""" if isinstance(val1, list): return all(is_all_close(v1, v2, tolerance) for v1, v2 in zip(val1, val2)) if isinstance(val1, str): return val1 == val2 if isinstance(val1, torch.Tensor): val1 = val1.cpu().detach().numpy() if isinstance(val2, torch.Tensor): val2 = val2.cpu().detach().numpy() return val1.shape == val2.shape and np.allclose(val1, val2, atol=tolerance) def is_all_tensors_cuda(val: np.ndarray | torch.Tensor | str | list) -> bool: if isinstance(val, list): return all(is_all_tensors_cuda(v) for v in val) if isinstance(val, torch.Tensor): return val.is_cuda return True def run_api_experiment(input_features, output_features, data_csv): """Helper method to avoid code repetition in running an experiment. :param input_features: input schema :param output_features: output schema :param data_csv: path to data :return: None """ config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat", "output_size": 14}, TRAINER: {"epochs": 2, BATCH_SIZE: 128}, } 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_FILE_NAME) loaded_model = LudwigModel.load(model_dir) # Necessary before call to get_weights() to materialize the weights loaded_model.predict(dataset=data_csv) model_weights = get_weights(model.model) loaded_weights = get_weights(loaded_model.model) for model_weight, loaded_weight in zip(model_weights, loaded_weights): assert torch.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 add_nans_to_df_in_place(df: pd.DataFrame, nan_percent: float): """Adds nans to a pandas dataframe in-place.""" if nan_percent == 0: # No-op if nan_percent is 0 return None if nan_percent < 0 or nan_percent > 1: raise ValueError("nan_percent must be between 0 and 1") num_rows = len(df) num_nans_per_col = int(round(nan_percent * num_rows)) for col in df.columns: if col == SPLIT: # do not add NaNs to the split column continue col_idx = df.columns.get_loc(col) for row_idx in random.sample(range(num_rows), num_nans_per_col): df.iloc[row_idx, col_idx] = np.nan return None def read_csv_with_nan(path, nan_percent=0.0): """Converts `nan_percent` of samples in each row of the CSV at `path` to NaNs.""" df = pd.read_csv(path) add_nans_to_df_in_place(df, nan_percent) return df def create_data_set_to_use(data_format, raw_data, nan_percent=0.0): # 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 tabulate import tabulate def to_fwf(df: pd.DataFrame, fname: str): 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": # Replace the original CSV with a CSV with NaNs dataset_to_use = raw_data read_csv_with_nan(raw_data, nan_percent=nan_percent).to_csv(dataset_to_use, index=False) elif data_format in {"df", "dict"}: dataset_to_use = read_csv_with_nan(raw_data, nan_percent=nan_percent) 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") read_csv_with_nan(raw_data, nan_percent=nan_percent).to_excel(dataset_to_use, index=False) elif data_format == "excel_xls": dataset_to_use = replace_file_extension(raw_data, "xls") read_csv_with_nan(raw_data, nan_percent=nan_percent).to_excel(dataset_to_use, index=False) elif data_format == "feather": dataset_to_use = replace_file_extension(raw_data, "feather") read_csv_with_nan(raw_data, nan_percent=nan_percent).to_feather(dataset_to_use) elif data_format == "fwf": dataset_to_use = replace_file_extension(raw_data, "fwf") read_csv_with_nan(raw_data, nan_percent=nan_percent).to_fwf(dataset_to_use) elif data_format == "html": dataset_to_use = replace_file_extension(raw_data, "html") read_csv_with_nan(raw_data, nan_percent=nan_percent).to_html(dataset_to_use, index=False) elif data_format == "json": dataset_to_use = replace_file_extension(raw_data, "json") read_csv_with_nan(raw_data, nan_percent=nan_percent).to_json(dataset_to_use, orient="records") elif data_format == "jsonl": dataset_to_use = replace_file_extension(raw_data, "jsonl") read_csv_with_nan(raw_data, nan_percent=nan_percent).to_json(dataset_to_use, orient="records", lines=True) elif data_format == "parquet": dataset_to_use = replace_file_extension(raw_data, "parquet") read_csv_with_nan(raw_data, nan_percent=nan_percent).to_parquet(dataset_to_use, index=False) elif data_format == "pickle": dataset_to_use = replace_file_extension(raw_data, "pickle") read_csv_with_nan(raw_data, nan_percent=nan_percent).to_pickle(dataset_to_use) elif data_format == "stata": dataset_to_use = replace_file_extension(raw_data, "stata") read_csv_with_nan(raw_data, nan_percent=nan_percent).to_stata(dataset_to_use) elif data_format == "tsv": dataset_to_use = replace_file_extension(raw_data, "tsv") read_csv_with_nan(raw_data, nan_percent=nan_percent).to_csv(dataset_to_use, sep="\t", index=False) elif data_format == "pandas+numpy_images": df = read_csv_with_nan(raw_data, nan_percent=nan_percent) processed_df_rows = [] for _, row in df.iterrows(): processed_df_row = {} for feature_name, raw_feature in row.items(): if "image" in feature_name and not (isinstance(raw_feature, float) and np.isnan(raw_feature)): feature = np.array(Image.open(raw_feature)) else: feature = raw_feature processed_df_row[feature_name] = feature processed_df_rows.append(processed_df_row) dataset_to_use = pd.DataFrame(processed_df_rows) else: ValueError(f"'{data_format}' is an unrecognized data format") return dataset_to_use def augment_dataset_with_none( df: pd.DataFrame, first_row_none: bool = False, last_row_none: bool = False, nan_cols: list | None = None ) -> pd.DataFrame: """Optionally sets the first and last rows of nan_cols of the given dataframe to nan. :param df: dataframe containg input features/output features :type df: pd.DataFrame :param first_row_none: indicates whether to set the first rowin the dataframe to np.nan :type first_row_none: bool :param last_row_none: indicates whether to set the last row in the dataframe to np.nan :type last_row_none: bool :param nan_cols: a list of columns in the dataframe to explicitly set the first or last rows to np.nan :type nan_cols: list """ nan_cols = nan_cols if nan_cols is not None else [] if first_row_none: for col in nan_cols: df.iloc[0, df.columns.get_loc(col)] = np.nan if last_row_none: for col in nan_cols: df.iloc[-1, df.columns.get_loc(col)] = np.nan return df def train_with_backend( backend, config, dataset=None, training_set=None, validation_set=None, test_set=None, predict=True, evaluate=True, callbacks=None, skip_save_processed_input=True, skip_save_predictions=True, required_metrics=None, ): model = LudwigModel(config, backend=backend, callbacks=callbacks) with tempfile.TemporaryDirectory() as output_directory: _, _, _ = model.train( dataset=dataset, training_set=training_set, validation_set=validation_set, test_set=test_set, skip_save_processed_input=skip_save_processed_input, skip_save_progress=True, skip_save_unprocessed_output=True, skip_save_log=True, output_directory=output_directory, ) if dataset is None: dataset = training_set if predict: preds, _ = model.predict( dataset=dataset, skip_save_predictions=skip_save_predictions, output_directory=output_directory ) assert preds is not None if not skip_save_predictions: read_preds = model.backend.df_engine.read_predictions( os.path.join(output_directory, PREDICTIONS_PARQUET_FILE_NAME) ) # call compute to ensure preds materialize correctly read_preds = read_preds.compute() assert read_preds is not None if evaluate: eval_stats, eval_preds, _ = model.evaluate( dataset=dataset, collect_overall_stats=False, collect_predictions=True ) assert eval_preds is not None assert_all_required_metrics_exist(eval_stats, required_metrics) # Test that eval_stats are approx equal when using local backend with tempfile.TemporaryDirectory() as tmpdir: model.save(tmpdir) local_model = LudwigModel.load(tmpdir, backend=LocalTestBackend()) local_eval_stats, _, _ = local_model.evaluate( dataset=dataset, collect_overall_stats=False, collect_predictions=False ) # Filter out metrics that are not being aggregated correctly for now # TODO(travis): https://github.com/ludwig-ai/ludwig/issues/1956 # Filter out next_token_perplexity since it is only relevant for LLMs def filter(stats): return { k: { metric_name: value for metric_name, value in v.items() if metric_name not in { "loss", "root_mean_squared_percentage_error", "jaccard", "token_accuracy", "next_token_perplexity", } } for k, v in stats.items() } for (feature_name_from_eval, metrics_dict_from_eval), ( feature_name_from_local, metrics_dict_from_local, ) in zip(filter(eval_stats).items(), filter(local_eval_stats).items()): for (metric_name_from_eval, metric_value_from_eval), ( metric_name_from_local, metric_value_from_local, ) in zip(metrics_dict_from_eval.items(), metrics_dict_from_local.items()): assert metric_name_from_eval == metric_name_from_local, ( f"Metric mismatch between eval and local. Metrics from eval: " f"{metrics_dict_from_eval.keys()}. Metrics from local: {metrics_dict_from_local.keys()}" ) if ( metric_value_from_eval == metric_value_from_eval and feature_name_from_eval == feature_name_from_eval ): # Check for equality if the values are non-nans. assert np.isclose( metric_value_from_eval, metric_value_from_local, rtol=1e-03, atol=1e-04 ), ( f"Metric {metric_name_from_eval} for feature {feature_name_from_eval}: " f"{metric_value_from_eval} != {metric_value_from_local}" ) return model def assert_all_required_metrics_exist( feature_to_metrics_dict: dict[str, dict[str, Any]], required_metrics: dict[str, set] | None = None ): """Checks that all `required_metrics` exist in the dictionary returned during Ludwig model evaluation. `feature_to_metrics_dict` is a dict where the feature name is a key and the value is a dictionary of metrics: { "binary_1234": { "accuracy": 0.5, "loss": 0.5, }, "numerical_1234": { "mean_squared_error": 0.5, "loss": 0.5, } } `required_metrics` is a dict where the feature name is a key and the value is a set of metric names: { "binary_1234": {"accuracy"}, "numerical_1234": {"mean_squared_error"}, } Args: feature_to_metrics_dict: dictionary of output feature to a dictionary of metrics required_metrics: optional dictionary of output feature to a set of metrics names. If None, then function returns True immediately. Returns: None. Raises an AssertionError if any required metrics are missing. """ if required_metrics is None: return for feature_name, metrics_dict in feature_to_metrics_dict.items(): if feature_name in required_metrics: required_metric_names = set(required_metrics[feature_name]) metric_names = set(metrics_dict.keys()) assert required_metric_names.issubset( metric_names ), f"required metrics {required_metric_names} not in metrics {metric_names} for feature {feature_name}" def assert_preprocessed_dataset_shape_and_dtype_for_feature( feature_name: str, preprocessed_dataset: "Dataset", config_obj: "ModelConfig", expected_dtype: np.dtype, expected_shape: tuple, ): """Asserts that the preprocessed dataset has the correct shape and dtype for a given feature type. Args: feature_name: the name of the feature to check preprocessed_dataset: the preprocessed dataset config_obj: the model config object expected_dtype: the expected dtype expected_shape: the expected shape Returns: None. Raises: AssertionError if the preprocessed dataset does not have the correct shape and dtype for the given feature type. """ if_configs = [if_config for if_config in config_obj.input_features if if_config.name == feature_name] # fail fast if given `feature_name`` is not found or is not unique if len(if_configs) != 1: raise ValueError(f"feature_name {feature_name} found {len(if_configs)} times in config_obj") if_config = if_configs[0] if_config_proc_column = if_config.proc_column for result in [ preprocessed_dataset.training_set, preprocessed_dataset.validation_set, preprocessed_dataset.test_set, ]: result_df = result.to_df() result_df_proc_col = result_df[if_config_proc_column] # Check that the proc col is of the correct dtype result_df_proc_col_dtypes = set(result_df_proc_col.map(lambda x: x.dtype)) assert all( [expected_dtype == dtype for dtype in result_df_proc_col_dtypes] ), f"proc dtype should be {expected_dtype}, got the following set of values: {result_df_proc_col_dtypes}" # Check that the proc col is of the right dimensions result_df_proc_col_shapes = set(result_df_proc_col.map(lambda x: x.shape)) assert all( expected_shape == shape for shape in result_df_proc_col_shapes ), f"proc shape should be {expected_shape}, got the following set of values: {result_df_proc_col_shapes}" @contextlib.contextmanager def remote_tmpdir(fs_protocol, bucket): if bucket is None: with tempfile.TemporaryDirectory() as tmpdir: yield f"{fs_protocol}://{tmpdir}" return prefix = f"tmp_{uuid.uuid4().hex}" tmpdir = f"{fs_protocol}://{bucket}/{prefix}" try: with use_credentials(minio_test_creds()): fs_utils.makedirs(f"{fs_protocol}://{bucket}", exist_ok=True) yield tmpdir finally: try: with use_credentials(minio_test_creds()): fs_utils.delete(tmpdir, recursive=True) except Exception as e: logger.info(f"failed to delete remote tempdir: {str(e)}") def minio_test_creds(): return { "s3": { "client_kwargs": { "endpoint_url": os.environ.get("LUDWIG_MINIO_ENDPOINT", "http://localhost:9000"), "aws_access_key_id": os.environ.get("LUDWIG_MINIO_ACCESS_KEY", "minio"), "aws_secret_access_key": os.environ.get("LUDWIG_MINIO_SECRET_KEY", "minio123"), } } } def clear_huggingface_cache(): cache_path = os.environ.get("TRANSFORMERS_CACHE") if cache_path is None: try: from huggingface_hub.constants import HF_HUB_CACHE cache_path = HF_HUB_CACHE.rstrip("/") except ImportError: cache_path = os.path.expanduser("~/.cache/huggingface") while not cache_path.endswith("huggingface") and cache_path: cache_path = "/".join(cache_path.split("/")[:-1]) du = shutil.disk_usage(cache_path) logger.info(f"Current disk usage {du} ({100 * du.free / du.total}% usage)") # only clean up cache if less than 25% of disk space is used. if du.free / du.total > 0.25: return logger.info( f"Clearing HuggingFace cache under path: `{cache_path}`. " f"Free disk space is {100 * du.free / du.total}% of total disk space." ) for root, dirs, files in os.walk(cache_path): for f in files: os.unlink(os.path.join(root, f)) for d in dirs: shutil.rmtree(os.path.join(root, d)) def run_test_suite(config, dataset, backend): with tempfile.TemporaryDirectory() as tmpdir: model = LudwigModel(config, backend=backend) _, _, output_dir = model.train(dataset=dataset, output_directory=tmpdir) model_dir = os.path.join(output_dir, MODEL_FILE_NAME) loaded_model = LudwigModel.load(model_dir, backend=backend) loaded_model.predict(dataset=dataset) return loaded_model ================================================ FILE: tests/ludwig/__init__.py ================================================ ================================================ FILE: tests/ludwig/accounting/test_used_tokens.py ================================================ import torch from ludwig.accounting.used_tokens import get_used_tokens_for_ecd, get_used_tokens_for_llm def test_get_used_tokens_for_ecd(): inputs = {"input1": torch.tensor([[1, 2], [3, 4]]), "input2": torch.tensor([5, 6])} targets = {"output": torch.tensor([7, 8, 9])} assert get_used_tokens_for_ecd(inputs, targets) == 9 def test_get_used_tokens_for_ecd_no_targets(): inputs = {"input1": torch.tensor([[1, 2], [3, 4]]), "input2": torch.tensor([5, 6])} targets = None assert get_used_tokens_for_ecd(inputs, targets) == 6 def test_get_used_tokens_for_llm(): class MockTokenizer: pad_token_id = 0 tokenizer = MockTokenizer() model_inputs = torch.tensor([1, 2, 3, 0, 0]) assert get_used_tokens_for_llm(model_inputs, tokenizer) == 3 ================================================ FILE: tests/ludwig/augmentation/test_augmentation_pipeline.py ================================================ import copy import logging import os import tempfile import pytest import torch from ludwig.api import LudwigModel from ludwig.constants import IMAGENET1K from ludwig.data.dataset_synthesizer import cli_synthesize_dataset from ludwig.error import ConfigValidationError from ludwig.features.image_feature import ImageAugmentation from ludwig.schema.features.image_feature import ImageInputFeatureConfig # define fixture for test image augmentation @pytest.fixture(scope="module") def test_image(): # return random normal batch of images of size 2x3x32x32 [batch_size, channels, height, width] return torch.randn(2, 3, 32, 32) # create training data for model training with augmentation pipeline @pytest.fixture(scope="module") def train_data_rgb(): with tempfile.TemporaryDirectory() as tmp_dir: # setup basic data description for training output_features = [ { "name": "binary_output_feature", "type": "binary", }, ] input_features = [ { "name": "my_image", "type": "image", }, ] # add parameters to generate images input_features[0].update( { "destination_folder": os.path.join(tmp_dir, "images"), "preprocessing": {"height": 350, "width": 350, "num_channels": 3}, } ) feature_list = input_features + output_features # create synthetic data data_dir = os.path.join(tmp_dir, "data") os.makedirs(data_dir, exist_ok=True) train_fp = os.path.join(tmp_dir, "train.csv") cli_synthesize_dataset(16, feature_list, train_fp) # remove unneeded data generation parameters input_features[0].pop("destination_folder") # return training data for testing yield train_fp, input_features, output_features # create training data for model training with augmentation pipeline @pytest.fixture(scope="module") def train_data_gray_scale(): with tempfile.TemporaryDirectory() as tmp_dir: # setup basic data description for training output_features = [ { "name": "binary_output_feature", "type": "binary", }, ] input_features = [ { "name": "my_image", "type": "image", }, ] # add parameters to generate images input_features[0].update( { "destination_folder": os.path.join(tmp_dir, "images"), "preprocessing": {"height": 350, "width": 350, "num_channels": 1}, } ) feature_list = input_features + output_features # create synthetic data data_dir = os.path.join(tmp_dir, "data") os.makedirs(data_dir, exist_ok=True) train_fp = os.path.join(tmp_dir, "train.csv") cli_synthesize_dataset(16, feature_list, train_fp) # remove unneeded data generation parameters input_features[0].pop("destination_folder") # return training data for testing yield train_fp, input_features, output_features # common function to run model training with augmentation pipeline def run_augmentation_training( train_data: str = "", backend: str = "local", encoder: dict = None, preprocessing: dict = None, augmentation_pipeline_ops: list[dict] = None, ): # unpack training data train_fp, input_features, output_features = train_data # add encoder and preprocessing specification to input feature input_features[0].update( { "encoder": encoder, "preprocessing": preprocessing, } ) # add augmentation pipeline to input feature if specified test_input_features = copy.deepcopy(input_features) test_input_features[0].update({"augmentation": augmentation_pipeline_ops}) config = { "input_features": test_input_features, "output_features": output_features, "trainer": { "epochs": 2, "batch_size": 8, }, "backend": { "type": backend, }, } with tempfile.TemporaryDirectory() as tmpdir: model = LudwigModel(config, logging_level=logging.INFO) model.experiment( dataset=train_fp, skip_save_processed_input=True, skip_save_model=True, output_directory=os.path.join(tmpdir, "output"), ) return model @pytest.mark.parametrize( "augmentation_pipeline_ops", [ [{"type": "random_horizontal_flip"}], [ {"type": "random_vertical_flip"}, {"type": "random_rotate", "degree": 45}, ], [ {"type": "random_horizontal_flip"}, {"type": "random_vertical_flip"}, {"type": "random_rotate", "degree": 45}, {"type": "random_brightness"}, {"type": "random_blur", "kernel_size": 9}, {"type": "random_contrast"}, ], ], ) # test image augmentation pipeline def test_image_augmentation(test_image, augmentation_pipeline_ops): # define augmentation pipeline feature = ImageInputFeatureConfig.from_dict( {"name": "foo", "type": "image", "augmentation": augmentation_pipeline_ops} ) augmentation_pipeline = ImageAugmentation(feature.augmentation) # apply augmentation pipeline to batch of test images augmentation_pipeline(test_image) AUGMENTATION_PIPELINE_OPS = [ False, True, [{"type": "random_blur"}, {"type": "random_rotate"}], ] IMAGE_ENCODER = [ {"type": "stacked_cnn"}, {"type": "alexnet", "use_pretrained": False, "model_cache_dir": os.path.join(os.getcwd(), "tv_cache")}, ] IMAGE_PREPROCESSING = [ { "standardize_image": None, "width": 300, "height": 300, }, { "standardize_image": IMAGENET1K, "width": 300, "height": 300, }, ] @pytest.mark.parametrize("augmentation_pipeline_ops", AUGMENTATION_PIPELINE_OPS) @pytest.mark.parametrize("encoder", IMAGE_ENCODER) @pytest.mark.parametrize("preprocessing", IMAGE_PREPROCESSING) def test_local_model_training_with_augmentation_pipeline( train_data_rgb, encoder, preprocessing, augmentation_pipeline_ops, ): model = run_augmentation_training( train_data=train_data_rgb, backend="local", encoder=encoder, # Ludwig encoder preprocessing=preprocessing, # Ludwig image preprocessing augmentation_pipeline_ops=augmentation_pipeline_ops, # Ludwig image augmentation ) if augmentation_pipeline_ops is not False: assert model.config_obj.input_features[0].has_augmentation() else: assert not model.config_obj.input_features[0].has_augmentation() # due to the time it takes to run the tests, run only a subset of the tests # and focus on interaction of Ludwig encoder with image preprocessing and augmentation @pytest.mark.slow @pytest.mark.distributed @pytest.mark.parametrize("augmentation_pipeline_ops", AUGMENTATION_PIPELINE_OPS) @pytest.mark.parametrize("preprocessing", IMAGE_PREPROCESSING) def test_ray_model_training_with_augmentation_pipeline( train_data_rgb, preprocessing, augmentation_pipeline_ops, ray_cluster_2cpu, ): model = run_augmentation_training( train_data=train_data_rgb, backend="ray", encoder={"type": "stacked_cnn"}, preprocessing=preprocessing, augmentation_pipeline_ops=augmentation_pipeline_ops, ) if augmentation_pipeline_ops is not False: assert model.config_obj.input_features[0].has_augmentation() else: assert not model.config_obj.input_features[0].has_augmentation() # this test gray-scale image augmentation pipeline @pytest.mark.parametrize( "augmentation_pipeline_ops", [ False, True, [ {"type": "auto_augmentation"}, {"type": "random_horizontal_flip"}, {"type": "random_vertical_flip"}, {"type": "random_rotate"}, {"type": "random_brightness"}, {"type": "random_blur"}, {"type": "random_contrast"}, ], ], ) def test_ludwig_encoder_gray_scale_image_augmentation_pipeline( train_data_gray_scale, augmentation_pipeline_ops, ): run_augmentation_training( train_data=train_data_gray_scale, backend="local", encoder={"type": "stacked_cnn", "num_filters": 1}, preprocessing={}, augmentation_pipeline_ops=augmentation_pipeline_ops, ) # this test invalid augmentation pipeline specification @pytest.mark.parametrize( "augmentation_pipeline_ops", [ None, [{"type": "invalid_string"}], ["random_horizontal_flip"], "random_horizontal_flip", [ {"type": "random_rotate", "degree": "45"}, ], ], ) def test_invalid_augmentation_parameters( train_data_rgb, augmentation_pipeline_ops, ): with pytest.raises(ConfigValidationError): run_augmentation_training( train_data=train_data_rgb, backend="local", encoder={"type": "alexnet", "model_cache_dir": os.path.join(os.getcwd(), "tv_cache")}, preprocessing={}, augmentation_pipeline_ops=augmentation_pipeline_ops, ) # tests saving and loading a model with augmentation pipeline def test_load_model_with_augmentation_pipeline( train_data_rgb, ): augmentation_pipeline_ops = [ {"type": "random_blur"}, {"type": "random_rotate"}, ] preprocessing = { "standardize_image": None, "width": 300, "height": 300, } encoder = { "type": "alexnet", "use_pretrained": False, "model_cache_dir": os.path.join(os.getcwd(), "tv_cache"), } model = run_augmentation_training( train_data=train_data_rgb, backend="local", encoder=encoder, # Ludwig encoder preprocessing=preprocessing, # Ludwig image preprocessing augmentation_pipeline_ops=augmentation_pipeline_ops, # Ludwig image augmentation ) with tempfile.TemporaryDirectory() as tmp_dir: model.save(tmp_dir) LudwigModel.load(tmp_dir) ================================================ FILE: tests/ludwig/augmentation/test_auto_augmentation.py ================================================ import pytest import torch from ludwig.constants import IMAGE from ludwig.features.image_feature import get_augmentation_op from ludwig.schema.features.augmentation.utils import get_augmentation_cls @pytest.fixture(scope="module") def test_image(): return torch.randn(5, 3, 256, 256) @pytest.mark.parametrize( "augmentation_type, augmentation_params", [ ("auto_augmentation", {"method": "trivial_augment"}), ("auto_augmentation", {"method": "auto_augment"}), ("auto_augmentation", {"method": "rand_augment"}), ], ) def test_auto_augmentation(test_image, augmentation_type, augmentation_params): aug_config = get_augmentation_cls(IMAGE, augmentation_type).from_dict(augmentation_params) augmentation_op_cls = get_augmentation_op(IMAGE, augmentation_type) augmentation_op = augmentation_op_cls(aug_config) augmented_image = augmentation_op(test_image) assert augmented_image.shape == (5, 3, 256, 256) ================================================ FILE: tests/ludwig/augmentation/test_image_augmentation.py ================================================ import pytest import torch from ludwig.constants import IMAGE from ludwig.features.image_feature import get_augmentation_op from ludwig.schema.features.augmentation.utils import get_augmentation_cls @pytest.fixture(scope="module") def test_image(): # return random normal image of size 2x3x32x32 [batch_size, channels, height, width] return torch.randn(2, 3, 32, 32) @pytest.mark.parametrize( "augmentation_type, augmentation_params", [ ("random_horizontal_flip", {}), ("random_vertical_flip", {}), ("random_rotate", {"degree": 45}), ("random_blur", {"kernel_size": 9}), ("random_blur", {"kernel_size": 15}), ("random_contrast", {"min": 0.5, "max": 1.5}), ("random_brightness", {"min": 0.5, "max": 1.5}), ], ) def test_image_augmentation(test_image, augmentation_type, augmentation_params): aug_config = get_augmentation_cls(IMAGE, augmentation_type).from_dict(augmentation_params) augmentation_op_cls = get_augmentation_op(IMAGE, augmentation_type) augmentation_op = augmentation_op_cls(aug_config) augmentation_op(test_image) ================================================ FILE: tests/ludwig/automl/__init__.py ================================================ ================================================ FILE: tests/ludwig/automl/test_base_config.py ================================================ import os from decimal import Decimal import dask import numpy as np import pandas as pd import pytest import yaml ray = pytest.importorskip("ray") # noqa # Prevent Dask from converting object-dtype columns to PyArrow strings. dask.config.set({"dataframe.convert-string": False}) from ludwig.automl.base_config import ( # noqa get_dataset_info, get_dataset_info_from_source, get_field_metadata, get_reference_configs, is_field_boolean, ) from ludwig.data.dataframe.dask import DaskEngine # noqa from ludwig.data.dataframe.pandas import PandasEngine # noqa from ludwig.schema.model_types.base import ModelConfig # noqa from ludwig.utils.automl.data_source import DataframeSource, wrap_data_source # noqa pytestmark = pytest.mark.distributed @pytest.fixture(scope="module") def dummy_df(): data = { "title": { 0: " Donald Trump Sends ...Disturbing", 1: " Drunk Bragging Trum...estigation", 2: " Sheriff David Clark...n The Eye", 3: " Trump Is So Obsesse...e (IMAGES)", 4: " Pope Francis Just C...mas Speech", }, "text": { 0: "Donald Trump just co...ty Images.", 1: "House Intelligence C...ty Images.", 2: "On Friday, it was re...ty Images.", 3: "On Christmas day, Do...ty Images.", 4: "Pope Francis used hi...ty Images.", }, "subject": {0: "News", 1: "News", 2: "News", 3: "News", 4: "News"}, "date": { 0: "December 31, 2017", 1: "December 31, 2017", 2: "December 30, 2017", 3: "December 29, 2017", 4: "December 25, 2017", }, "label": {0: "Fake", 1: "Fake", 2: "Fake", 3: "Fake", 4: "Fake"}, } return pd.DataFrame.from_dict(data) @pytest.mark.parametrize( ("df_engine",), [ pytest.param(PandasEngine(), id="pandas"), pytest.param(DaskEngine(_use_ray=False), id="dask", marks=pytest.mark.distributed), ], ) def test_is_field_boolean(df_engine, dummy_df): assert np.array_equal(dummy_df.dtypes, ["object", "object", "object", "object", "object"]) if isinstance(df_engine, DaskEngine): dummy_df = df_engine.df_lib.from_pandas(dummy_df, npartitions=1) source = wrap_data_source(dummy_df) for field in dummy_df.columns: assert not is_field_boolean(source, field) @pytest.mark.parametrize( "df_engine", [ pytest.param(PandasEngine(), id="pandas"), pytest.param(DaskEngine(_use_ray=False), id="dask", marks=pytest.mark.distributed), ], ) def test_dataset_info(df_engine, dummy_df): assert np.array_equal(dummy_df.dtypes, ["object", "object", "object", "object", "object"]) if isinstance(df_engine, DaskEngine): dummy_df = df_engine.df_lib.from_pandas(dummy_df, npartitions=1) ds_info = get_dataset_info(dummy_df) assert [f.dtype for f in ds_info.fields] == ["object", "object", "object", "object", "object"] @pytest.mark.parametrize( "col,expected_dtype", [ (["a", "b", "c", "d", "e", "a", "b", "b"], "object"), (["a", "b", "a", "b", np.nan], "object"), (["a", "b", "a", "b", None], "object"), ([True, False, True, True, ""], "object"), ([True, False, True, False, np.nan], "bool"), ], ) def test_object_and_bool_type_inference(col, expected_dtype): df = pd.DataFrame({"col1": col}) info = get_dataset_info(df) assert info.fields[0].dtype == expected_dtype def test_reference_configs(): ref_configs = get_reference_configs() for dataset in ref_configs["datasets"]: config = dataset["config"] # Ensure config is valid with the latest Ludwig schema ModelConfig.from_dict(config) def repeat(df, n): """Repeat a dataframe n times.""" return pd.concat([df] * n, ignore_index=True) def test_infer_parquet_types(tmpdir): """Test type inference works properly for a parquet file with unconventional types types.""" # Create a temporary directory to store the parquet file tmpdir = str(tmpdir) # Create a dataframe with all the types df = pd.DataFrame( { "int": [1, 2, 3], "float": [1.1, 2.2, 3.3], "string": ["a", "b", "c"], "datetime": pd.date_range("20130101", periods=3), "category": pd.Series(["a", "b", "c"], dtype="category"), "bool": [True, False, True], } ) df = repeat(df, 10) df["float"] = df["float"].apply(Decimal) df["date"] = df["datetime"].apply(str) # Write the dataframe to parquet and read it back dataset_path = os.path.join(tmpdir, "dataset.parquet") df.to_parquet(dataset_path) df = pd.read_parquet(dataset_path) # Test type inference ds = DataframeSource(df) ds_info = get_dataset_info_from_source(ds) metas = get_field_metadata(ds_info.fields, ds_info.row_count, targets=["bool"]) config = yaml.safe_load(""" input_features: - name: int type: category - name: float type: number - name: string type: category - name: datetime type: date - name: category type: category - name: date type: date output_features: - name: bool type: binary combiner: type: concat output_size: 14 trainer: epochs: 2 batch_size: 8 """) meta_dict = {meta.config.name: meta for meta in metas} for feature in config["input_features"] + config["output_features"]: meta = meta_dict[feature["name"]] assert feature["type"] == meta.config.type, f"{feature['name']}: {feature['type']} != {meta.config.type}" ================================================ FILE: tests/ludwig/automl/test_data_source.py ================================================ import tempfile import pytest from ludwig.constants import TEXT from ludwig.utils.data_utils import read_csv try: import dask.dataframe as dd from ludwig.automl import create_auto_config except ImportError: pass CSV_CONTENT = """ name,gender,lives_in_sf Jessica,f, Jim,m,FALSE """ def get_test_df(): temp = tempfile.NamedTemporaryFile(mode="w+") temp.write(CSV_CONTENT) temp.seek(0) ds = read_csv(temp.name, dtype=None) df = dd.from_pandas(ds, npartitions=1) return df @pytest.mark.distributed def test_mixed_csv_data_source(ray_cluster_2cpu): config = create_auto_config(dataset=get_test_df(), target=[], time_limit_s=3600) assert len(config["input_features"]) == 2 assert config["input_features"][0]["type"] == TEXT assert config["input_features"][1]["type"] == TEXT ================================================ FILE: tests/ludwig/automl/test_tune_config.py ================================================ import pytest try: from ludwig.automl.auto_tune_config import reduce_text_feature_max_length except ImportError: pass @pytest.mark.distributed def test_reduce_text_model_mem_99ptile(): config = {"input_features": [{"name": "description", "column": "description", "type": "text", "encoder": "bert"}]} training_set_metadata = {"description": {"max_sequence_length_99ptile": 117.0}} config_upd = { "input_features": [{"name": "description", "column": "description", "type": "text", "encoder": "bert"}], "preprocessing": {"text": {"max_sequence_length": 117}}, } reduce_text_feature_max_length(config, training_set_metadata) assert config == config_upd @pytest.mark.distributed def test_reduce_text_model_mem_128(): config = {"input_features": [{"name": "description", "column": "description", "type": "text", "encoder": "bert"}]} training_set_metadata = {"description": {"max_sequence_length_99ptile": 512.0}} config_upd = { "input_features": [{"name": "description", "column": "description", "type": "text", "encoder": "bert"}], "preprocessing": {"text": {"max_sequence_length": 128}}, } reduce_text_feature_max_length(config, training_set_metadata) assert config == config_upd @pytest.mark.distributed def test_reduce_text_model_mem_override(): config = { "input_features": [{"name": "description", "column": "description", "type": "text", "encoder": "bert"}], "preprocessing": {"text": {"max_sequence_length": 256}}, } training_set_metadata = {"description": {"max_sequence_length_99ptile": 117.0}} config_upd = { "input_features": [{"name": "description", "column": "description", "type": "text", "encoder": "bert"}], "preprocessing": {"text": {"max_sequence_length": 117}}, } reduce_text_feature_max_length(config, training_set_metadata) assert config == config_upd @pytest.mark.distributed def test_reduce_text_model_mem_respect(): config = { "input_features": [{"name": "description", "column": "description", "type": "text", "encoder": "bert"}], "preprocessing": {"text": {"max_sequence_length": 56}}, } training_set_metadata = {"description": {"max_sequence_length_99ptile": 117.0}} config_upd = { "input_features": [{"name": "description", "column": "description", "type": "text", "encoder": "bert"}], "preprocessing": {"text": {"max_sequence_length": 56}}, } reduce_text_feature_max_length(config, training_set_metadata) assert config == config_upd ================================================ FILE: tests/ludwig/automl/test_utils.py ================================================ import pytest ray = pytest.importorskip("ray") # noqa from ludwig.utils.automl.utils import get_model_type # noqa pytestmark = pytest.mark.distributed def _features(*in_types, out): return { "input_features": [{"name": f"in_{i}", "type": dtype} for i, dtype in enumerate(in_types)], "output_features": [{"name": "out_0", "type": out}], } @pytest.mark.parametrize( "config,expected", [ ({**_features("text", out="number")}, "text"), ({**_features("text", "text", out="number")}, "concat"), ({**_features("text", "text", out="number"), "combiner": {"type": "tabnet"}}, "tabnet"), ], ) def test_get_model_type(config, expected): actual = get_model_type(config) assert actual == expected ================================================ FILE: tests/ludwig/backend/test_ray.py ================================================ import copy from unittest.mock import patch import pytest # Skip these tests if Ray is not installed ray = pytest.importorskip("ray") # noqa from ray.train.torch import TorchConfig # noqa from ludwig.backend import initialize_backend # noqa from ludwig.backend.ray import get_trainer_kwargs # noqa from ludwig.constants import AUTO, EXECUTOR, MAX_CONCURRENT_TRIALS, RAY # noqa # Mark the entire module as distributed pytestmark = pytest.mark.distributed @pytest.mark.parametrize( "trainer_config,cluster_resources,num_nodes,expected_kwargs", [ # Prioritize using the GPU when available over multi-node pytest.param( {}, {"CPU": 4, "GPU": 1}, 2, dict( backend=TorchConfig(), num_workers=1, use_gpu=True, resources_per_worker={ "CPU": 0, "GPU": 1, }, ), id="ddp", marks=pytest.mark.distributed, ), ], ) def test_get_trainer_kwargs(trainer_config, cluster_resources, num_nodes, expected_kwargs): with patch("ludwig.backend.ray.ray.cluster_resources", return_value=cluster_resources): with patch("ludwig.backend.ray._num_nodes", return_value=num_nodes): trainer_config_copy = copy.deepcopy(trainer_config) actual_kwargs = get_trainer_kwargs(**trainer_config_copy) # Function should not modify the original input assert trainer_config_copy == trainer_config actual_backend = actual_kwargs.pop("backend") expected_backend = expected_kwargs.pop("backend") assert type(actual_backend) is type(expected_backend) assert actual_kwargs == expected_kwargs @pytest.mark.distributed @pytest.mark.parametrize( "hyperopt_config_old, hyperopt_config_expected", [ ( # If max_concurrent_trials is none, it should not be set in the updated config { "parameters": {"trainer.learning_rate": {"space": "choice", "values": [0.001, 0.01, 0.1]}}, "executor": {"num_samples": 4, "cpu_resources_per_trial": 1, "max_concurrent_trials": None}, }, { "parameters": {"trainer.learning_rate": {"space": "choice", "values": [0.001, 0.01, 0.1]}}, "executor": {"num_samples": 4, "cpu_resources_per_trial": 1, "max_concurrent_trials": None}, }, ), ( # If max_concurrent_trials is auto, set to cpus // cpus_per_trial { "parameters": {"trainer.learning_rate": {"space": "choice", "values": [0.001, 0.01, 0.1]}}, "executor": {"num_samples": 4, "cpu_resources_per_trial": 1, "max_concurrent_trials": "auto"}, }, { "parameters": {"trainer.learning_rate": {"space": "choice", "values": [0.001, 0.01, 0.1]}}, "executor": {"num_samples": 4, "cpu_resources_per_trial": 1, "max_concurrent_trials": 4}, }, ), ( # Even though num_samples is set to 4, this will actually result in 9 trials. # With 4 CPUs and 1 CPU/trial, max_concurrent_trials = 4 { "parameters": { "trainer.learning_rate": {"space": "grid_search", "values": [0.001, 0.01, 0.1]}, "combiner.num_fc_layers": {"space": "grid_search", "values": [1, 2, 3]}, }, "executor": {"num_samples": 4, "cpu_resources_per_trial": 1, "max_concurrent_trials": "auto"}, }, { "parameters": { "trainer.learning_rate": {"space": "grid_search", "values": [0.001, 0.01, 0.1]}, "combiner.num_fc_layers": {"space": "grid_search", "values": [1, 2, 3]}, }, "executor": {"num_samples": 4, "cpu_resources_per_trial": 1, "max_concurrent_trials": 4}, }, ), ( # Ensure user config value (1) is respected if it is passed in { "parameters": {"trainer.learning_rate": {"space": "choice", "values": [0.001, 0.01, 0.1]}}, "executor": {"num_samples": 4, "cpu_resources_per_trial": 1, "max_concurrent_trials": 1}, }, { "parameters": {"trainer.learning_rate": {"space": "choice", "values": [0.001, 0.01, 0.1]}}, "executor": {"num_samples": 4, "cpu_resources_per_trial": 1, "max_concurrent_trials": 1}, }, ), ], ids=["none", "auto", "auto_with_large_num_trials", "1"], ) def test_set_max_concurrent_trials(hyperopt_config_old, hyperopt_config_expected, ray_cluster_4cpu): backend = initialize_backend(RAY) if hyperopt_config_old[EXECUTOR].get(MAX_CONCURRENT_TRIALS) == AUTO: hyperopt_config_old[EXECUTOR][MAX_CONCURRENT_TRIALS] = backend.max_concurrent_trials(hyperopt_config_old) assert hyperopt_config_old == hyperopt_config_expected ================================================ FILE: tests/ludwig/benchmarking/example_files/invalid/benchmarking_config_1.yaml ================================================ # This benchmarking config is missing because the global experiment name is missing. process_config_file_path: tests/ludwig/benchmarking/example_files/process_config_example.py hyperopt: false export: export_artifacts: true export_base_path: s3://benchmarking.us-west-2.ludwig.com/bench/ # include the slash at the end. profiler: enable: false use_torch_profiler: false logging_interval: 0.1 experiments: - dataset_name: ames_housing experiment_name: large_learning_rate config_path: tests/regression_tests/benchmark/configs/ames_housing.yaml - dataset_name: protein config_path: tests/regression_tests/benchmark/configs/protein.yaml - dataset_name: mercedes_benz_greener experiment_name: zscore_normalization config_path: tests/regression_tests/benchmark/configs/mercedes_benz_greener.yaml ================================================ FILE: tests/ludwig/benchmarking/example_files/invalid/benchmarking_config_2.yaml ================================================ # This benchmarking config is invalid beacuse it's missing the export section. experiment_name: github_action process_config_file_path: tests/ludwig/benchmarking/example_files/process_config_example.py hyperopt: false profiler: enable: false use_torch_profiler: false logging_interval: 0.1 experiments: - dataset_name: ames_housing experiment_name: large_learning_rate config_path: tests/regression_tests/benchmark/configs/ames_housing.yaml - dataset_name: protein config_path: tests/regression_tests/benchmark/configs/protein.yaml - dataset_name: mercedes_benz_greener experiment_name: zscore_normalization config_path: tests/regression_tests/benchmark/configs/mercedes_benz_greener.yaml ================================================ FILE: tests/ludwig/benchmarking/example_files/invalid/benchmarking_config_3.yaml ================================================ # This benchmarking config is invalid because some of the dataset names aren't specified. experiment_name: github_action process_config_file_path: tests/ludwig/benchmarking/example_files/process_config_example.py hyperopt: false export: export_artifacts: true export_base_path: s3://benchmarking.us-west-2.ludwig.com/bench/ # include the slash at the end. profiler: enable: false use_torch_profiler: false logging_interval: 0.1 experiments: - experiment_name: large_learning_rate config_path: tests/regression_tests/benchmark/configs/ames_housing.yaml - config_path: tests/regression_tests/benchmark/configs/protein.yaml - dataset_name: mercedes_benz_greener experiment_name: zscore_normalization config_path: tests/regression_tests/benchmark/configs/mercedes_benz_greener.yaml ================================================ FILE: tests/ludwig/benchmarking/example_files/process_config.py ================================================ def process_config(ludwig_config: dict, experiment_dict: dict) -> dict: """Modify a Ludwig config. :param ludwig_config: a Ludwig config. :param experiment_dict: a benchmarking config experiment dictionary. returns: a modified Ludwig config. """ # Only keep input_features and output_features for the ames_housing dataset. if experiment_dict["dataset_name"] == "ames_housing": main_config_keys = list(ludwig_config.keys()) for key in main_config_keys: if key not in ["input_features", "output_features"]: del ludwig_config[key] # Set the early_stop criteria to stop training after 7 epochs of no score improvement. ludwig_config["trainer"] = {"early_stop": 7} # use sparse encoder for categorical features to mimic logreg ludwig_config["combiner"] = {"type": "concat"} for i, feature in enumerate(ludwig_config["input_features"]): if feature["type"] == "category": ludwig_config["input_features"][i]["encoder"] = "sparse" for i, feature in enumerate(ludwig_config["output_features"]): if feature["type"] == "category": ludwig_config["output_features"][i]["encoder"] = "sparse" return ludwig_config ================================================ FILE: tests/ludwig/benchmarking/example_files/valid/benchmarking_config_1.yaml ================================================ # You can specify any of the global parameters locally to any experiment. This will override the global behavior. experiment_name: github_action process_config_file_path: tests/ludwig/benchmarking/example_files/process_config_example.py export: export_artifacts: true export_base_path: s3://benchmarking.us-west-2.ludwig.com/bench/ # include the slash at the end. profiler: enable: false use_torch_profiler: false logging_interval: 0.1 experiments: - dataset_name: ames_housing experiment_name: large_learning_rate config_path: tests/regression_tests/benchmark/configs/ames_housing.yaml hyperopt: true - dataset_name: protein config_path: tests/regression_tests/benchmark/configs/protein.yaml profiler: enable: true use_torch_profiler: true logging_interval: 0.1 - dataset_name: mercedes_benz_greener experiment_name: zscore_normalization config_path: tests/regression_tests/benchmark/configs/mercedes_benz_greener.yaml ================================================ FILE: tests/ludwig/benchmarking/example_files/valid/benchmarking_config_2.yaml ================================================ # This is a minimal example of a valid benchmarking config. the hyperopt section of the benchmarking config # will default to false. The profiler section will also default to false. experiment_name: github_action export: export_artifacts: true export_base_path: s3://benchmarking.us-west-2.ludwig.com/bench/ # include the slash at the end. experiments: - dataset_name: ames_housing config_path: tests/regression_tests/benchmark/configs/ames_housing.yaml - dataset_name: protein config_path: tests/regression_tests/benchmark/configs/protein.yaml - dataset_name: mercedes_benz_greener config_path: tests/regression_tests/benchmark/configs/mercedes_benz_greener.yaml ================================================ FILE: tests/ludwig/benchmarking/example_files/valid/benchmarking_config_3.yaml ================================================ # We can skip specifying a global experiment name if it's specified for each experiment. process_config_file_path: tests/ludwig/benchmarking/example_files/process_config_example.py export: export_artifacts: true export_base_path: s3://benchmarking.us-west-2.ludwig.com/bench/ # include the slash at the end. profiler: enable: true use_torch_profiler: false logging_interval: 0.1 experiments: - dataset_name: ames_housing experiment_name: large_learning_rate config_path: tests/regression_tests/benchmark/configs/ames_housing.yaml - dataset_name: protein experiment_name: decay_rate_0.8 config_path: tests/regression_tests/benchmark/configs/protein.yaml - dataset_name: mercedes_benz_greener experiment_name: zscore_normalization config_path: tests/regression_tests/benchmark/configs/mercedes_benz_greener.yaml ================================================ FILE: tests/ludwig/benchmarking/test_benchmarking.py ================================================ import os from contextlib import nullcontext as does_not_raise import pytest from ludwig.benchmarking.utils import validate_benchmarking_config from ludwig.utils.data_utils import load_yaml def get_benchamrking_configs(validity): local_dir = "/".join(__file__.split("/")[:-1]) return [ os.path.join(local_dir, "example_files", validity, config_fp) for config_fp in os.listdir(os.path.join(local_dir, "example_files", validity)) ] @pytest.mark.parametrize("benchmarking_config_fp", get_benchamrking_configs("valid")) def test_valid_benchmarking_configs_valid(benchmarking_config_fp): benchmarking_config = load_yaml(benchmarking_config_fp) with does_not_raise(): validate_benchmarking_config(benchmarking_config) @pytest.mark.parametrize("benchmarking_config_fp", get_benchamrking_configs("invalid")) def test_invalid_benchmarking_configs_valid(benchmarking_config_fp): benchmarking_config = load_yaml(benchmarking_config_fp) with pytest.raises(ValueError): validate_benchmarking_config(benchmarking_config) ================================================ FILE: tests/ludwig/benchmarking/test_profiler.py ================================================ import os import time import numpy as np import pandas as pd import pytest import torch from packaging.version import parse as parse_version if parse_version(pd.__version__) > parse_version("1.5.3"): pytest.skip(allow_module_level=True) from ludwig.api import LudwigModel from ludwig.benchmarking.profiler import LudwigProfiler from ludwig.constants import BATCH_SIZE, TRAINER @pytest.mark.skipif( parse_version(pd.__version__) > parse_version("1.5.3"), reason="experiment_impact_tracker package is incompatible with pandas 2.0", ) def test_ludwig_profiler(tmpdir): @LudwigProfiler(tag="test_function", output_dir=tmpdir, use_torch_profiler=False, logging_interval=0.1) def func(duration): time.sleep(duration) x = torch.Tensor(2, 3) y = torch.rand(2, 3) torch.add(x, y) train_df = pd.DataFrame(np.random.normal(0, 1, size=(100, 3)), columns=["input_1", "input_2", "output_1"]) eval_df = pd.DataFrame(np.random.normal(0, 1, size=(20, 3)), columns=["input_1", "input_2", "output_1"]) config = { "input_features": [{"name": "input_1", "type": "number"}, {"name": "input_2", "type": "number"}], "output_features": [{"name": "output_1", "type": "number"}], "combiner": {"type": "concat", "output_size": 14}, TRAINER: {"epochs": 1, BATCH_SIZE: 128}, } model = LudwigModel(config=config, backend="local") with LudwigProfiler(tag="profile_1", output_dir=tmpdir, use_torch_profiler=False, logging_interval=0.1): model.train( dataset=train_df, output_directory=tmpdir, skip_save_training_description=True, skip_save_training_statistics=True, skip_save_model=True, skip_save_progress=True, skip_save_log=True, skip_save_processed_input=True, ) assert os.path.exists(os.path.join(tmpdir, "system_resource_usage", "profile_1", "run_0.json")) with LudwigProfiler(tag="profile_2", output_dir=tmpdir, use_torch_profiler=True, logging_interval=0.1): model.evaluate(dataset=eval_df) func(0.1) assert os.path.exists(os.path.join(tmpdir, "system_resource_usage", "profile_2", "run_0.json")) assert os.path.exists(os.path.join(tmpdir, "torch_ops_resource_usage", "profile_2", "run_0.json")) func(0.25) func(0.5) assert set(os.listdir(os.path.join(tmpdir, "system_resource_usage", "test_function"))) == { "run_0.json", "run_1.json", "run_2.json", } ================================================ FILE: tests/ludwig/combiners/test_combiners.py ================================================ import logging from collections import OrderedDict import numpy as np import pytest import torch from ludwig.combiners.combiners import ( ComparatorCombiner, ConcatCombiner, ProjectAggregateCombiner, SequenceCombiner, SequenceConcatCombiner, TabNetCombiner, TabTransformerCombiner, TransformerCombiner, ) from ludwig.constants import ENCODER_OUTPUT, ENCODER_OUTPUT_STATE, TYPE from ludwig.schema.combiners.comparator import ComparatorCombinerConfig from ludwig.schema.combiners.concat import ConcatCombinerConfig from ludwig.schema.combiners.project_aggregate import ProjectAggregateCombinerConfig from ludwig.schema.combiners.sequence import SequenceCombinerConfig from ludwig.schema.combiners.sequence_concat import SequenceConcatCombinerConfig from ludwig.schema.combiners.tab_transformer import TabTransformerCombinerConfig from ludwig.schema.combiners.tabnet import TabNetCombinerConfig from ludwig.schema.combiners.transformer import TransformerCombinerConfig from ludwig.schema.utils import load_config from ludwig.utils.misc_utils import set_random_seed from ludwig.utils.torch_utils import get_torch_device from tests.integration_tests.parameter_update_utils import check_module_parameters_updated logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) logging.getLogger("ludwig").setLevel(logging.INFO) DEVICE = get_torch_device() BATCH_SIZE = 16 SEQ_SIZE = 12 HIDDEN_SIZE = 24 OTHER_HIDDEN_SIZE = 32 OUTPUT_SIZE = 8 BASE_OUTPUT_SIZE = 16 NUM_FILTERS = 20 RANDOM_SEED = 1919 # emulate Input Feature class. Need to provide output_shape property to # mimic what happens during ECD.forward() processing. class PseudoInputFeature: def __init__(self, feature_name, output_shape, feature_type=None): self.name = feature_name self._output_shape = output_shape self.feature_type = feature_type def type(self): return self.feature_type @property def output_shape(self): return torch.Size(self._output_shape[1:]) # helper function to test correctness of combiner output def check_combiner_output(combiner, combiner_output, batch_size): # check for required attributes assert hasattr(combiner, "input_dtype") assert hasattr(combiner, "output_shape") # check for correct data type assert isinstance(combiner_output, dict) # required key present assert "combiner_output" in combiner_output # check for correct output shape assert combiner_output["combiner_output"].shape == (batch_size, *combiner.output_shape) # generates encoder outputs and minimal input feature objects for testing @pytest.fixture def features_to_test(feature_list: list[tuple[str, list]]) -> tuple[dict, dict]: # feature_list: list of tuples that define the output_shape and type # of input features to generate. tuple[0] is input feature type, # tuple[1] is expected encoder output shape for the input feature # make repeatable set_random_seed(RANDOM_SEED) encoder_outputs = {} input_features = {} for i in range(len(feature_list)): feature_name = f"feature_{i:02d}" encoder_outputs[feature_name] = { ENCODER_OUTPUT: torch.randn(feature_list[i][1], dtype=torch.float32, device=DEVICE) } input_features[feature_name] = PseudoInputFeature(feature_name, feature_list[i][1], feature_list[i][0]) return encoder_outputs, input_features # set up simulated encoder outputs @pytest.fixture def encoder_outputs(): # generates simulated encoder outputs dictionary: # feature_1: shape [b, h1] tensor # feature_2: shape [b, h2] tensor # feature_3: shape [b, s, h1] tensor # feature_4: shape [b, sh, h2] tensor # make repeatable set_random_seed(RANDOM_SEED) # setup synthetic encoder output for testing encoder_outputs = {} input_features = OrderedDict() shapes_list = [ [BATCH_SIZE, HIDDEN_SIZE], [BATCH_SIZE, OTHER_HIDDEN_SIZE], [BATCH_SIZE, SEQ_SIZE, HIDDEN_SIZE], [BATCH_SIZE, SEQ_SIZE, OTHER_HIDDEN_SIZE], ] feature_names = ["feature_" + str(i + 1) for i in range(len(shapes_list))] for feature_name, batch_shape in zip(feature_names, shapes_list): encoder_outputs[feature_name] = {ENCODER_OUTPUT: torch.randn(batch_shape, dtype=torch.float32, device=DEVICE)} if len(batch_shape) > 2: encoder_outputs[feature_name][ENCODER_OUTPUT_STATE] = torch.randn( [batch_shape[0], batch_shape[2]], dtype=torch.float32, device=DEVICE ) # create pseudo input feature object input_features[feature_name] = PseudoInputFeature(feature_name, batch_shape) return encoder_outputs, input_features # setup encoder outputs for ComparatorCombiner @pytest.fixture def encoder_comparator_outputs(): # generates simulated encoder outputs dictionary: # feature_1: shape [b, h1] tensor # feature_2: shape [b, h2] tensor # feature_3: shape [b, s, h1] tensor # feature_4: shape [b, sh, h2] tensor encoder_outputs = {} input_features = {} shapes_list = [ [BATCH_SIZE, HIDDEN_SIZE], [BATCH_SIZE, OTHER_HIDDEN_SIZE], [BATCH_SIZE, SEQ_SIZE, HIDDEN_SIZE], [BATCH_SIZE, SEQ_SIZE, OTHER_HIDDEN_SIZE], ] text_feature_names = ["text_feature_" + str(i + 1) for i in range(len(shapes_list))] image_feature_names = ["image_feature_" + str(i + 1) for i in range(len(shapes_list))] for i, (feature_name, batch_shape) in enumerate(zip(text_feature_names, shapes_list)): # is there a better way to do this? if i == 0 or i == 3: dot_product_shape = [batch_shape[0], BASE_OUTPUT_SIZE] encoder_outputs[feature_name] = { ENCODER_OUTPUT: torch.randn(dot_product_shape, dtype=torch.float32, device=DEVICE) } input_features[feature_name] = PseudoInputFeature(feature_name, dot_product_shape) else: encoder_outputs[feature_name] = { ENCODER_OUTPUT: torch.randn(batch_shape, dtype=torch.float32, device=DEVICE) } input_features[feature_name] = PseudoInputFeature(feature_name, batch_shape) for i, (feature_name, batch_shape) in enumerate(zip(image_feature_names, shapes_list)): if i == 0 or i == 3: dot_product_shape = [batch_shape[0], BASE_OUTPUT_SIZE] encoder_outputs[feature_name] = { ENCODER_OUTPUT: torch.randn(dot_product_shape, dtype=torch.float32, device=DEVICE) } input_features[feature_name] = PseudoInputFeature(feature_name, dot_product_shape) else: encoder_outputs[feature_name] = { ENCODER_OUTPUT: torch.randn(batch_shape, dtype=torch.float32, device=DEVICE) } input_features[feature_name] = PseudoInputFeature(feature_name, batch_shape) return encoder_outputs, input_features # test for simple concatenation combiner @pytest.mark.parametrize("norm", [None, "batch", "layer"]) @pytest.mark.parametrize("number_inputs", [None, 1]) @pytest.mark.parametrize("flatten_inputs", [True, False]) @pytest.mark.parametrize("fc_layer", [None, [{"output_size": OUTPUT_SIZE}, {"output_size": OUTPUT_SIZE}]]) def test_concat_combiner( encoder_outputs: tuple, fc_layer: list[dict] | None, flatten_inputs: bool, number_inputs: int | None, norm: str, ) -> None: # make repeatable set_random_seed(RANDOM_SEED) encoder_outputs_dict, input_features_dict = encoder_outputs # setup encoder inputs to combiner based on test case if not flatten_inputs: # clean out rank-3 encoder outputs for feature in ["feature_3", "feature_4"]: del encoder_outputs_dict[feature] del input_features_dict[feature] if number_inputs == 1: # need only one encoder output for the test del encoder_outputs_dict["feature_2"] del input_features_dict["feature_2"] elif number_inputs == 1: # require only one rank-3 encoder output for testing for feature in ["feature_1", "feature_2", "feature_3"]: del encoder_outputs_dict[feature] del input_features_dict[feature] # setup combiner to test with pseudo input features combiner = ConcatCombiner( input_features_dict, config=load_config(ConcatCombinerConfig, fc_layers=fc_layer, flatten_inputs=flatten_inputs, norm=norm), ).to(DEVICE) # confirm correctness of input_shape property assert isinstance(combiner.input_shape, dict) for k in encoder_outputs_dict: assert k in combiner.input_shape assert encoder_outputs_dict[k][ENCODER_OUTPUT].shape[1:] == combiner.input_shape[k] # combine encoder outputs combiner_output = combiner(encoder_outputs_dict) # check for correctness of combiner output check_combiner_output(combiner, combiner_output, BATCH_SIZE) if fc_layer is not None: # check for parameter updating if fully connected layer is present target = torch.randn(combiner_output["combiner_output"].shape) fpc, tpc, upc, not_updated = check_module_parameters_updated(combiner, (encoder_outputs_dict,), target) assert tpc == upc, f"Failed to update parameters. Parameters not updated: {not_updated}" # test for sequence concatenation combiner @pytest.mark.parametrize("reduce_output", [None, "sum"]) @pytest.mark.parametrize("main_sequence_feature", [None, "feature_3"]) def test_sequence_concat_combiner( encoder_outputs: tuple, main_sequence_feature: str | None, reduce_output: str | None ) -> None: # extract encoder outputs and input feature dictionaries encoder_outputs_dict, input_feature_dict = encoder_outputs # setup combiner for testing combiner = SequenceConcatCombiner( input_feature_dict, config=load_config( SequenceConcatCombinerConfig, main_sequence_feature=main_sequence_feature, reduce_output=reduce_output ), ).to(DEVICE) # confirm correctness of input_shape property assert isinstance(combiner.input_shape, dict) for k in encoder_outputs_dict: assert k in combiner.input_shape assert encoder_outputs_dict[k][ENCODER_OUTPUT].shape[1:] == combiner.input_shape[k] # calculate expected hidden size for concatenated tensors hidden_size = 0 for k in encoder_outputs_dict: hidden_size += encoder_outputs_dict[k][ENCODER_OUTPUT].shape[-1] # confirm correctness of concatenated_shape assert combiner.concatenated_shape[-1] == hidden_size # combine encoder outputs combiner_output = combiner(encoder_outputs_dict) # check for correctness of combiner output check_combiner_output(combiner, combiner_output, BATCH_SIZE) # This combiner does not contain any learnable parameters, bypassing parameter update testing # test for sequence combiner @pytest.mark.parametrize("reduce_output", [None, "sum"]) @pytest.mark.parametrize("encoder", ["rnn", "transformer"]) @pytest.mark.parametrize("main_sequence_feature", [None, "feature_3"]) def test_sequence_combiner( encoder_outputs: tuple, main_sequence_feature: str | None, encoder: str, reduce_output: str | None ) -> None: # make repeatable set_random_seed(RANDOM_SEED) encoder_outputs_dict, input_features_dict = encoder_outputs combiner = SequenceCombiner( input_features_dict, config=load_config( SequenceCombinerConfig, main_sequence_feature=main_sequence_feature, encoder={TYPE: encoder}, reduce_output=reduce_output, ), # following emulates encoder parameters passed in from config file output_size=OUTPUT_SIZE, num_fc_layers=3, ).to(DEVICE) # confirm correctness of input_shape property assert isinstance(combiner.input_shape, dict) for k in encoder_outputs_dict: assert k in combiner.input_shape assert encoder_outputs_dict[k][ENCODER_OUTPUT].shape[1:] == combiner.input_shape[k] # calculate expected hidden size for concatenated tensors hidden_size = 0 for k in encoder_outputs_dict: hidden_size += encoder_outputs_dict[k][ENCODER_OUTPUT].shape[-1] # confirm correctness of concatenated_shape assert combiner.concatenated_shape[-1] == hidden_size # combine encoder outputs combiner_output = combiner(encoder_outputs_dict) # check for correctness of combiner output check_combiner_output(combiner, combiner_output, BATCH_SIZE) # check for parameter updating target = torch.randn(combiner_output["combiner_output"].shape) fpc, tpc, upc, not_updated = check_module_parameters_updated(combiner, (encoder_outputs_dict,), target) assert tpc == upc, f"Failed to update parameters. Parameters not updated: {not_updated}" @pytest.mark.parametrize( "feature_list", # defines parameter for fixture features_to_test() [ [ # only numeric features ("binary", [BATCH_SIZE, 1]), # passthrough encoder ("number", [BATCH_SIZE, 1]), # passthrough encoder ], [ # only numeric features ("binary", [BATCH_SIZE, 1]), # passthrough encoder ("number", [BATCH_SIZE, 1]), # passthrough encoder ("number", [BATCH_SIZE, 1]), # passthrough encoder ], [ # numeric and categorical features ("binary", [BATCH_SIZE, 1]), # passthrough encoder ("number", [BATCH_SIZE, 12]), # dense encoder ("category", [BATCH_SIZE, 8]), # dense encoder ], ], ) @pytest.mark.parametrize("size", [4, 8]) @pytest.mark.parametrize("output_size", [6, 10]) def test_tabnet_combiner(features_to_test: dict, size: int, output_size: int) -> None: # make repeatable set_random_seed(RANDOM_SEED) encoder_outputs, input_features = features_to_test # setup combiner to test combiner = TabNetCombiner( input_features, config=load_config( TabNetCombinerConfig, size=size, output_size=output_size, num_steps=3, num_total_blocks=4, num_shared_blocks=2, dropout=0.1, ), ).to(DEVICE) # concatenate encoder outputs combiner_output = combiner(encoder_outputs) # required key present assert "combiner_output" in combiner_output assert "attention_masks" in combiner_output assert "aggregated_attention_masks" in combiner_output assert isinstance(combiner_output["combiner_output"], torch.Tensor) assert combiner_output["combiner_output"].shape == (BATCH_SIZE, output_size) # check for parameter updating target = torch.randn(combiner_output["combiner_output"].shape) fpc, tpc, upc, not_updated = check_module_parameters_updated(combiner, (encoder_outputs,), target) assert tpc == upc, f"Failed to update parameters. Parameters not updated: {not_updated}" @pytest.mark.parametrize("fc_layer", [None, [{"output_size": 64}, {"output_size": 32}]]) @pytest.mark.parametrize("entity_1", [["text_feature_1", "text_feature_4"]]) @pytest.mark.parametrize("entity_2", [["image_feature_1", "image_feature_2"]]) def test_comparator_combiner( encoder_comparator_outputs: tuple, fc_layer: list[dict] | None, entity_1: str, entity_2: str ) -> None: # make repeatable set_random_seed(RANDOM_SEED) encoder_comparator_outputs_dict, input_features_dict = encoder_comparator_outputs # clean out unneeded encoder outputs since we only have 2 layers del encoder_comparator_outputs_dict["text_feature_2"] del encoder_comparator_outputs_dict["image_feature_3"] del encoder_comparator_outputs_dict["text_feature_3"] del encoder_comparator_outputs_dict["image_feature_4"] # setup combiner to test set to 256 for case when none as it's the default size output_size = fc_layer[0]["output_size"] if fc_layer else 256 combiner = ComparatorCombiner( input_features_dict, config=load_config( ComparatorCombinerConfig, entity_1=entity_1, entity_2=entity_2, fc_layers=fc_layer, output_size=output_size ), ).to(DEVICE) # concatenate encoder outputs combiner_output = combiner(encoder_comparator_outputs_dict) # check for correctness of combiner output check_combiner_output(combiner, combiner_output, BATCH_SIZE) # check for parameter updating target = torch.randn(combiner_output["combiner_output"].shape) fpc, tpc, upc, not_updated = check_module_parameters_updated(combiner, (encoder_comparator_outputs_dict,), target) assert tpc == upc, f"Failed to update parameters. Parameters not updated: {not_updated}" @pytest.mark.parametrize("output_size", [8, 16]) @pytest.mark.parametrize("transformer_output_size", [4, 12]) def test_transformer_combiner(encoder_outputs: tuple, transformer_output_size: int, output_size: int) -> None: # make repeatable set_random_seed(RANDOM_SEED) encoder_outputs_dict, input_feature_dict = encoder_outputs # setup combiner to test combiner = TransformerCombiner(input_features=input_feature_dict, config=load_config(TransformerCombinerConfig)).to( DEVICE ) # confirm correctness of input_shape property assert isinstance(combiner.input_shape, dict) for k in encoder_outputs_dict: assert k in combiner.input_shape assert encoder_outputs_dict[k][ENCODER_OUTPUT].shape[1:] == combiner.input_shape[k] # calculate expected hidden size for concatenated tensors hidden_size = 0 for k in encoder_outputs_dict: hidden_size += np.prod(encoder_outputs_dict[k][ENCODER_OUTPUT].shape[1:]) # confirm correctness of effective_input_shape assert combiner.concatenated_shape[-1] == hidden_size # concatenate encoder outputs combiner_output = combiner(encoder_outputs_dict) # check for correctness of combiner output check_combiner_output(combiner, combiner_output, BATCH_SIZE) # check for parameter updating target = torch.randn(combiner_output["combiner_output"].shape) fpc, tpc, upc, not_updated = check_module_parameters_updated(combiner, (encoder_outputs_dict,), target) assert tpc == upc, f"Failed to update parameters. Parameters not updated: {not_updated}" @pytest.mark.parametrize("projection_size", [8, 16]) @pytest.mark.parametrize("output_size", [8, 16]) def test_project_aggregate_combiner(encoder_outputs: tuple, projection_size: int, output_size: int) -> None: # make repeatable set_random_seed(RANDOM_SEED) encoder_outputs_dict, input_feature_dict = encoder_outputs # setup combiner to test combiner = ProjectAggregateCombiner( input_features=input_feature_dict, config=load_config( ProjectAggregateCombinerConfig, projection_size=projection_size, output_size=output_size, ), ).to(DEVICE) # confirm correctness of input_shape property assert isinstance(combiner.input_shape, dict) for k in encoder_outputs_dict: assert k in combiner.input_shape assert encoder_outputs_dict[k][ENCODER_OUTPUT].shape[1:] == combiner.input_shape[k] # calculate expected hidden size for concatenated tensors hidden_size = 0 for k in encoder_outputs_dict: hidden_size += np.prod(encoder_outputs_dict[k][ENCODER_OUTPUT].shape[1:]) # confirm correctness of effective_input_shape assert combiner.concatenated_shape[-1] == hidden_size # concatenate encoder outputs combiner_output = combiner(encoder_outputs_dict) # check for correctness of combiner output check_combiner_output(combiner, combiner_output, BATCH_SIZE) # check for parameter updating target = torch.randn(combiner_output["combiner_output"].shape) fpc, tpc, upc, not_updated = check_module_parameters_updated(combiner, (encoder_outputs_dict,), target) assert tpc == upc, f"Failed to update parameters. Parameters not updated: {not_updated}" # Magic values for the TabTransformerCombiner test PARAMETERS_IN_SELF_ATTENTION = 4 PARAMETERS_IN_TRANSFORMER_BLOCK = 16 UNEMBEDDABLE_LAYER_NORM_PARAMETERS = 2 @pytest.mark.parametrize( "feature_list", # defines parameter for fixture features_to_test() [ [ ("binary", [BATCH_SIZE, 1]), # passthrough encoder ("number", [BATCH_SIZE, 1]), # passthrough encoder ], [ ("number", [BATCH_SIZE, 1]), ("binary", [BATCH_SIZE, 1]), ("number", [BATCH_SIZE, 1]), ], [ ("binary", [BATCH_SIZE, 1]), ("number", [BATCH_SIZE, 1]), ("binary", [BATCH_SIZE, 1]), ], ], ) @pytest.mark.parametrize( "num_layers,reduce_output,fc_layers,embed_input_feature_name", [ (1, "concat", None, None), (2, "sum", [{"output_size": 256}], 64), (1, "sum", None, "add"), ], ids=["simple", "full", "add_embed"], ) def test_tabtransformer_combiner_binary_and_number_without_category( features_to_test: tuple, embed_input_feature_name: int | str | None, fc_layers: list | None, reduce_output: str, num_layers: int, ) -> None: # make repeatable set_random_seed(RANDOM_SEED) # retrieve simulated encoder outputs and input features for the test encoder_outputs, input_features = features_to_test # setup combiner to test combiner = TabTransformerCombiner( input_features=input_features, config=load_config( TabTransformerCombinerConfig, embed_input_feature_name=embed_input_feature_name, # emulates parameters passed from combiner def num_layers=num_layers, # number of transformer layers fc_layers=fc_layers, # fully_connected layer definition reduce_output=reduce_output, # sequence reducer ), ).to(DEVICE) # concatenate encoder outputs combiner_output = combiner(encoder_outputs) check_combiner_output(combiner, combiner_output, BATCH_SIZE) # check for parameter updating target = torch.randn(combiner_output["combiner_output"].shape) fpc, tpc, upc, not_updated = check_module_parameters_updated( combiner, (encoder_outputs,), target, ) # Adjustments to the trainable parameter count (tpc) in the following assertion checks is needed # to account for the different code paths taken in the TabTransformerCombiner forward() method due to the # combination of input feature types (NUMBER, BINARY, CATEGORY) in the dataset and parameters used to # instantiate the TabTransformerCombiner object. # The entire transformer stack is by-passed because there is no categorical input features. Subtract the # number for parameters in the transformer stack to account for this situation. assert upc == ( tpc - num_layers * PARAMETERS_IN_TRANSFORMER_BLOCK - (1 if embed_input_feature_name is not None else 0) ), f"Failed to update parameters. Parameters not updated: {not_updated}" @pytest.mark.parametrize( "feature_list", # defines parameter for fixture features_to_test() [ [ ("number", [BATCH_SIZE, 1]), # passthrough encoder ("category", [BATCH_SIZE, 64]), ("binary", [BATCH_SIZE, 1]), # passthrough encoder ], [ ("binary", [BATCH_SIZE, 1]), # passthrough encoder ("category", [BATCH_SIZE, 16]), ("number", [BATCH_SIZE, 1]), # passthrough encoder ("category", [BATCH_SIZE, 48]), ("number", [BATCH_SIZE, 32]), ("binary", [BATCH_SIZE, 1]), ], ], ) @pytest.mark.parametrize( "num_layers,reduce_output,fc_layers,embed_input_feature_name", [ (1, "concat", None, None), (2, "sum", [{"output_size": 256}], 64), (1, "sum", None, "add"), ], ids=["simple", "full", "add_embed"], ) def test_tabtransformer_combiner_number_and_binary_with_category( features_to_test: tuple, embed_input_feature_name: int | str | None, fc_layers: list | None, reduce_output: str, num_layers: int, ) -> None: # make repeatable set_random_seed(RANDOM_SEED) # retrieve simulated encoder outputs and input features for the test encoder_outputs, input_features = features_to_test # setup combiner to test combiner = TabTransformerCombiner( input_features=input_features, config=load_config( TabTransformerCombinerConfig, embed_input_feature_name=embed_input_feature_name, # emulates parameters passed from combiner def num_layers=num_layers, # number of transformer layers fc_layers=fc_layers, # fully_connected layer definition reduce_output=reduce_output, # sequence reducer ), ).to(DEVICE) # concatenate encoder outputs combiner_output = combiner(encoder_outputs) check_combiner_output(combiner, combiner_output, BATCH_SIZE) # check for parameter updating target = torch.randn(combiner_output["combiner_output"].shape) fpc, tpc, upc, not_updated = check_module_parameters_updated( combiner, (encoder_outputs,), target, ) # Adjustments to the trainable parameter count (tpc) in the following assertion checks is needed # to account for the different code paths taken in the TabTransformerCombiner forward() method due to the # combination of input feature types (NUMBER, BINARY, CATEGORY) in the dataset and parameters used to # instantiate the TabTransformerCombiner object. # With F.scaled_dot_product_attention, all parameters receive gradients even with a single category feature. assert upc == tpc, f"Failed to update parameters. Parameters not updated: {not_updated}" @pytest.mark.parametrize( "feature_list", # defines parameter for fixture features_to_test() [ [ ("binary", [BATCH_SIZE, 1]), ("binary", [BATCH_SIZE, 1]), ], [ ("number", [BATCH_SIZE, 1]), ("number", [BATCH_SIZE, 1]), ], [ ("number", [BATCH_SIZE, 1]), ("binary", [BATCH_SIZE, 1]), ], ], ) @pytest.mark.parametrize( "num_layers,reduce_output,fc_layers,embed_input_feature_name", [ (1, "concat", None, None), (2, "sum", [{"output_size": 256}], 64), (1, "sum", None, "add"), ], ids=["simple", "full", "add_embed"], ) def test_tabtransformer_combiner_number_or_binary_without_category( features_to_test: tuple, embed_input_feature_name: int | str | None, fc_layers: list | None, reduce_output: str, num_layers: int, ) -> None: # make repeatable set_random_seed(RANDOM_SEED) # retrieve simulated encoder outputs and input features for the test encoder_outputs, input_features = features_to_test # setup combiner to test combiner = TabTransformerCombiner( input_features=input_features, config=load_config( TabTransformerCombinerConfig, embed_input_feature_name=embed_input_feature_name, # emulates parameters passed from combiner def num_layers=num_layers, # number of transformer layers fc_layers=fc_layers, # fully_connected layer definition reduce_output=reduce_output, # sequence reducer ), ).to(DEVICE) # concatenate encoder outputs combiner_output = combiner(encoder_outputs) check_combiner_output(combiner, combiner_output, BATCH_SIZE) # check for parameter updating target = torch.randn(combiner_output["combiner_output"].shape) fpc, tpc, upc, not_updated = check_module_parameters_updated( combiner, (encoder_outputs,), target, ) # Adjustments to the trainable parameter count (tpc) in the following assertion checks is needed # to account for the different code paths taken in the TabTransformerCombiner forward() method due to the # combination of input feature types (NUMBER, BINARY, CATEGORY) in the dataset and parameters used to # instantiate the TabTransformerCombiner object. # The entire transformer stack is by-passed because there is no categorical input features. Subtract the # number for parameters in the transformer stack to account for this situation. assert upc == ( tpc - num_layers * PARAMETERS_IN_TRANSFORMER_BLOCK - (1 if embed_input_feature_name is not None else 0) ), f"Failed to update parameters. Parameters not updated: {not_updated}" @pytest.mark.parametrize( "feature_list", # defines parameter for fixture features_to_test() [ [ ("binary", [BATCH_SIZE, 1]), ("category", [BATCH_SIZE, 16]), ("binary", [BATCH_SIZE, 1]), ("category", [BATCH_SIZE, 32]), ], [ ("number", [BATCH_SIZE, 1]), ("category", [BATCH_SIZE, 16]), ("number", [BATCH_SIZE, 1]), ("category", [BATCH_SIZE, 32]), ], [ ("number", [BATCH_SIZE, 1]), ("category", [BATCH_SIZE, 16]), ("binary", [BATCH_SIZE, 1]), ("category", [BATCH_SIZE, 32]), ], ], ) @pytest.mark.parametrize( "num_layers,reduce_output,fc_layers,embed_input_feature_name", [ (1, "concat", None, None), (2, "sum", [{"output_size": 256}], 64), (1, "sum", None, "add"), ], ids=["simple", "full", "add_embed"], ) def test_tabtransformer_combiner_number_or_binary_with_category( features_to_test: tuple, embed_input_feature_name: int | str | None, fc_layers: list | None, reduce_output: str, num_layers: int, ) -> None: # make repeatable set_random_seed(RANDOM_SEED) # retrieve simulated encoder outputs and input features for the test encoder_outputs, input_features = features_to_test # setup combiner to test combiner = TabTransformerCombiner( input_features=input_features, config=load_config( TabTransformerCombinerConfig, embed_input_feature_name=embed_input_feature_name, # emulates parameters passed from combiner def num_layers=num_layers, # number of transformer layers fc_layers=fc_layers, # fully_connected layer definition reduce_output=reduce_output, # sequence reducer ), ).to(DEVICE) # concatenate encoder outputs combiner_output = combiner(encoder_outputs) check_combiner_output(combiner, combiner_output, BATCH_SIZE) # check for parameter updating target = torch.randn(combiner_output["combiner_output"].shape) fpc, tpc, upc, not_updated = check_module_parameters_updated( combiner, (encoder_outputs,), target, ) # Adjustments to the trainable parameter count (tpc) in the following assertion checks is needed # to account for the different code paths taken in the TabTransformerCombiner forward() method due to the # combination of input feature types (NUMBER, BINARY, CATEGORY) in the dataset and parameters used to # instantiate the TabTransformerCombiner object. # This test does not explicity test for a single categorical input feature # in this situation of a one categorical input feature, the query and key parameters are not updated assert upc == tpc, f"Failed to update parameters. Parameters not updated: {not_updated}" ================================================ FILE: tests/ludwig/config_sampling/static_schema.json ================================================ [File too large to display: 12.3 MB] ================================================ FILE: tests/ludwig/config_sampling/test_config_sampling.py ================================================ import pytest from ludwig.utils.data_utils import load_json from tests.training_success.test_training_success import ( combiner_config_generator, defaults_config_generator, ecd_trainer_config_generator, ) def full_config_generator(generator_fn, *args): return len(list(generator_fn(*args))) @pytest.mark.combinatorial @pytest.mark.timeout(600) def test_config_sampling(): static_schema = load_json("tests/ludwig/config_sampling/static_schema.json") total_count = 0 total_count += full_config_generator(defaults_config_generator, "number", "preprocessing", static_schema) total_count += full_config_generator(defaults_config_generator, "number", "encoder", static_schema) total_count += full_config_generator(defaults_config_generator, "number", "decoder", static_schema) total_count += full_config_generator(defaults_config_generator, "number", "loss", static_schema) total_count += full_config_generator(defaults_config_generator, "category", "preprocessing", static_schema) total_count += full_config_generator(defaults_config_generator, "category", "encoder", static_schema) total_count += full_config_generator(defaults_config_generator, "category", "decoder", static_schema) total_count += full_config_generator(defaults_config_generator, "category", "loss", static_schema) total_count += full_config_generator(defaults_config_generator, "binary", "preprocessing", static_schema) total_count += full_config_generator(defaults_config_generator, "binary", "encoder", static_schema) total_count += full_config_generator(defaults_config_generator, "binary", "decoder", static_schema) total_count += full_config_generator(defaults_config_generator, "binary", "loss", static_schema) total_count += full_config_generator(ecd_trainer_config_generator, static_schema) total_count += full_config_generator(combiner_config_generator, "sequence_concat", static_schema) total_count += full_config_generator(combiner_config_generator, "sequence", static_schema) total_count += full_config_generator(combiner_config_generator, "comparator", static_schema) total_count += full_config_generator(combiner_config_generator, "concat", static_schema) total_count += full_config_generator(combiner_config_generator, "project_aggregate", static_schema) total_count += full_config_generator(combiner_config_generator, "tabnet", static_schema) total_count += full_config_generator(combiner_config_generator, "tabtransformer", static_schema) total_count += full_config_generator(combiner_config_generator, "transformer", static_schema) # In place to check for sudden changes in the number of combinatorially generated configs. Update ranges # accordingly if new parameters are added. assert 100 < total_count < 200 ================================================ FILE: tests/ludwig/config_validation/test_checks.py ================================================ """Tests for interdependent parameters. Note that all testing should be done with the public API, rather than individual checks. ``` ModelConfig.from_dict(config) ``` """ import contextlib from typing import Any import pytest import yaml from ludwig.constants import COMBINER, TYPE from ludwig.error import ConfigValidationError from ludwig.schema.model_types.base import ModelConfig from tests.integration_tests.utils import binary_feature, text_feature def test_passthrough_number_decoder(): config = { "defaults": {"number": {"decoder": {"fc_norm": None, "fc_output_size": 10, "type": "passthrough"}}}, "input_features": [ {"name": "MSSubClass", "type": "category"}, {"name": "MSZoning", "type": "category"}, {"name": "Street", "type": "category"}, {"name": "Neighborhood", "type": "category"}, ], "model_type": "ecd", "output_features": [{"name": "SalePrice", "type": "number", "decoder": {"type": "passthrough"}}], "trainer": {"train_steps": 1}, } with pytest.raises(ConfigValidationError): ModelConfig.from_dict(config) def test_sequence_combiner_with_embed_encoder(): config = { "combiner": { "encoder": {"dropout": 0.1641014195584432, "embedding_size": 256, "type": "embed"}, "main_sequence_feature": None, "type": "sequence", }, "input_features": [{"encoder": {"reduce_output": None, "type": "embed"}, "name": "Text", "type": "text"}], "model_type": "ecd", "output_features": [{"name": "Category", "type": "category"}], "preprocessing": {"sample_ratio": 0.05}, "trainer": {"train_steps": 1}, } with pytest.raises(ConfigValidationError): ModelConfig.from_dict(config) def test_balance_multiple_class_failure(): config = { "input_features": [ {"name": "Index", "proc_column": "Index", "type": "number"}, {"name": "random_1", "proc_column": "random_1", "type": "number"}, {"name": "random_2", "proc_column": "random_2", "type": "number"}, ], "output_features": [ {"name": "Label", "proc_column": "Label", "type": "binary"}, {"name": "Label2", "proc_column": "Label2", "type": "binary"}, ], "preprocessing": {"oversample_minority": 0.2}, } with pytest.raises(ConfigValidationError): ModelConfig.from_dict(config) def test_all_features_present_in_comparator_entities(): config = { "combiner": { "dropout": 0.20198506770751617, "entity_1": ["Age"], "entity_2": ["Sex", "Pclass"], "norm": "batch", "num_fc_layers": 1, "output_size": 256, "type": "comparator", }, "input_features": [ {"column": "Pclass", "name": "Pclass", "type": "category"}, {"column": "Sex", "name": "Sex", "type": "category"}, {"column": "Age", "name": "Age", "type": "number"}, {"column": "SibSp", "name": "SibSp", "type": "number"}, {"column": "Parch", "name": "Parch", "type": "number"}, {"column": "Fare", "name": "Fare", "type": "number"}, {"column": "Embarked", "name": "Embarked", "type": "category"}, ], "model_type": "ecd", "output_features": [{"column": "Survived", "name": "Survived", "type": "category"}], "trainer": {"train_steps": 1}, } with pytest.raises(ConfigValidationError): ModelConfig.from_dict(config) def test_balance_non_binary_failure(): config = { "input_features": [ {"name": "Index", "proc_column": "Index", "type": "number"}, {"name": "random_1", "proc_column": "random_1", "type": "number"}, {"name": "random_2", "proc_column": "random_2", "type": "number"}, ], "output_features": [{"name": "Label", "proc_column": "Label", "type": "number"}], "preprocessing": {"oversample_minority": 0.2}, } with pytest.raises(ConfigValidationError): ModelConfig.from_dict(config) def test_supported_features_config(): # ECD supports output text features. ModelConfig.from_dict( { "input_features": [binary_feature()], "output_features": [text_feature()], "model_type": "ecd", } ) @pytest.mark.parametrize( "num_fc_layers,fc_layers,expect_success", [ (None, None, True), (1, None, True), (None, [{"output_size": 256}], True), (0, [{"output_size": 256}], True), (0, None, False), ], ) def test_comparator_fc_layer_config(num_fc_layers: int | None, fc_layers: dict[str, Any] | None, expect_success: bool): config = { "input_features": [ {"name": "in1", "type": "category"}, {"name": "in2", "type": "category"}, ], "output_features": [ {"name": "out1", "type": "binary"}, ], "combiner": { "type": "comparator", "entity_1": ["in1"], "entity_2": ["in2"], }, } if num_fc_layers is not None: config["combiner"]["num_fc_layers"] = num_fc_layers if fc_layers is not None: config["combiner"]["fc_layers"] = fc_layers with pytest.raises(ConfigValidationError) if not expect_success else contextlib.nullcontext(): ModelConfig.from_dict(config) def test_dense_binary_encoder_0_layer(): config = { "defaults": {"binary": {"encoder": {"norm": "ghost", "num_layers": 0, "output_size": 128, "type": "dense"}}}, "input_features": [ {"name": "X0", "type": "category"}, {"name": "X1", "type": "category"}, {"name": "X10", "type": "binary"}, {"name": "X11", "type": "binary"}, {"name": "X14", "type": "binary", "encoder": {"num_layers": 0}}, ], "model_type": "ecd", "output_features": [{"name": "y", "type": "number"}], "trainer": {"train_steps": 1}, } with pytest.raises(ConfigValidationError): ModelConfig.from_dict(config) @pytest.mark.parametrize( "entity_1,entity_2,expected", [ (["a1"], ["b1", "b2"], True), (["a1", "a2"], ["b1", "b2", "b3"], True), ([], ["b1", "b2"], False), ([], ["a1", "b1", "b2"], False), (["a1", "b1", "b2"], [], False), (["a1", "b1"], ["b1", "b2"], False), (["a1"], ["b1"], False), ], ) def test_comparator_combiner_entities(entity_1: list[str], entity_2: list[str], expected: bool): config = { "input_features": [ {"name": "a1", "type": "category"}, {"name": "b1", "type": "category"}, {"name": "b2", "type": "category"}, ], "output_features": [ {"name": "out1", "type": "binary"}, ], "combiner": { "type": "comparator", "entity_1": entity_1, "entity_2": entity_2, }, } with pytest.raises(ConfigValidationError) if not expected else contextlib.nullcontext(): config_obj = ModelConfig.from_dict(config) assert config_obj.combiner.entity_1 == ["a1"] assert config_obj.combiner.entity_2 == ["b1", "b2"] def test_experiment_binary_fill_with_const(): """Test that the tagger decoder doesn't work with category input features.""" config = { "defaults": {"binary": {"preprocessing": {"missing_value_strategy": "fill_with_const"}}}, "input_features": [{"name": "binary_1", "type": "binary"}], "model_type": "ecd", "output_features": [{"name": "category_output_1", "type": "category"}], "trainer": {"train_steps": 1}, } with pytest.raises(ConfigValidationError): ModelConfig.from_dict(config) def test_check_concat_combiner_requirements(): config = yaml.safe_load(""" input_features: - name: description type: text encoder: type: embed reduce_output: null column: description - name: required_experience type: category column: required_experience output_features: - name: title type: category combiner: type: concat trainer: train_steps: 2 model_type: ecd """) with pytest.raises(ConfigValidationError): ModelConfig.from_dict(config) # Confirms that the choice of the combiner type is the only reason for the ConfigValidationError. config[COMBINER][TYPE] = "sequence_concat" ModelConfig.from_dict(config) def test_check_llm_input_features(): config = yaml.safe_load(""" model_type: llm base_model: facebook/opt-350m input_features: - name: sample_1 type: text - name: sample_2 type: text output_features: - name: label type: text backend: type: ray """) # do not allow more than one input feature with pytest.raises(ConfigValidationError): ModelConfig.from_dict(config) # do not allow one non-text input feature config["input_features"].pop(-1) config["input_features"][0]["type"] = "category" with pytest.raises(ConfigValidationError): ModelConfig.from_dict(config) # allow exactly one text input feature config["input_features"][0]["type"] = "text" ModelConfig.from_dict(config) def test_retrieval_config_none_type(): config = yaml.safe_load(""" model_type: llm base_model: facebook/opt-350m prompt: retrieval: type: null k: 1 task: "Classify the sample input as either negative, neutral, or positive." input_features: - name: sample type: text output_features: - name: label type: text """) with pytest.raises(ConfigValidationError): ModelConfig.from_dict(config) # will not fail config["prompt"]["retrieval"]["k"] = 0 ModelConfig.from_dict(config) def test_retrieval_config_random_type(): config = yaml.safe_load(""" model_type: llm base_model: facebook/opt-350m prompt: retrieval: type: random task: "Classify the sample input as either negative, neutral, or positive." input_features: - name: sample type: text output_features: - name: label type: text """) # should not fail because we auto-set k=1 if k=0 on __post_init__ ModelConfig.from_dict(config) def test_retrieval_config_semantic_type(): config = yaml.safe_load(""" model_type: llm base_model: facebook/opt-350m prompt: retrieval: type: semantic task: "Classify the sample input as either negative, neutral, or positive." input_features: - name: sample type: text output_features: - name: label type: text """) with pytest.raises(ConfigValidationError): ModelConfig.from_dict(config) config["prompt"]["retrieval"]["model_name"] = "some-huggingface-model" ModelConfig.from_dict(config) @pytest.mark.skip( reason="TODO(geoffrey, arnav): re-enable this when we have reconciled the config with the backend kwarg in api.py" ) def test_check_llm_quantization_backend_incompatibility(): config = yaml.safe_load(""" model_type: llm base_model: facebook/opt-350m quantization: bits: 4 input_features: - name: sample type: text output_features: - name: label type: text backend: type: ray """) with pytest.raises(ConfigValidationError): ModelConfig.from_dict(config) config["backend"]["type"] = "local" ModelConfig.from_dict(config) del config["backend"] ModelConfig.from_dict(config) del config["quantization"] config["backend"] = {"type": "ray"} ModelConfig.from_dict(config) def test_check_qlora(): config = yaml.safe_load(""" model_type: llm base_model: facebook/opt-350m quantization: bits: 4 input_features: - name: sample type: text output_features: - name: label type: text trainer: type: finetune """) with pytest.raises(ConfigValidationError): ModelConfig.from_dict(config) config["adapter"] = { "type": "adaption_prompt", } with pytest.raises(ConfigValidationError): ModelConfig.from_dict(config) config["adapter"] = { "type": "lora", } ModelConfig.from_dict(config) def test_check_prompt_requirements(): config = { "model_type": "llm", "input_features": [ text_feature(name="input1", column="col1", encoder={"type": "passthrough"}), ], "output_features": [text_feature(name="output1")], "base_model": "opt-350m", } ModelConfig.from_dict(config) config["prompt"] = {"task": "Some task"} ModelConfig.from_dict(config) config["prompt"] = {"task": "Some task", "template": "Some template not mentioning the task"} with pytest.raises(ConfigValidationError): ModelConfig.from_dict(config) config["prompt"] = {"task": "Some task", "template": "{__invalid__}"} with pytest.raises(ConfigValidationError): ModelConfig.from_dict(config) config["prompt"] = {"task": "Some task", "template": "{__task__}"} ModelConfig.from_dict(config) config["prompt"] = {"template": "{input1}"} ModelConfig.from_dict(config) # Raise an error if template has a placeholder for the output feature. config["prompt"] = {"template": "{input1}: {output1}"} with pytest.raises(ConfigValidationError): ModelConfig.from_dict(config) def test_check_sample_ratio_and_size_compatible(): config = { "input_features": [binary_feature()], "output_features": [binary_feature()], "model_type": "ecd", } ModelConfig.from_dict(config) config["preprocessing"] = {"sample_size": 10} ModelConfig.from_dict(config) config["preprocessing"]["sample_ratio"] = 1 ModelConfig.from_dict(config) config["preprocessing"]["sample_ratio"] = 0.1 with pytest.raises(ConfigValidationError): ModelConfig.from_dict(config) config["preprocessing"]["sample_size"] = 0 with pytest.raises(ConfigValidationError): ModelConfig.from_dict(config) del config["preprocessing"]["sample_size"] ModelConfig.from_dict(config) def test_check_llm_text_encoder_is_not_used_with_ecd(): config = { "input_features": [ { "name": "Question", "type": "text", "encoder": { "type": "auto_transformer", "pretrained_model_name_or_path": "meta-llama/Llama-2-7b-hf", "trainable": False, }, "preprocessing": {"cache_encoder_embeddings": True}, } ], "output_features": [{"name": "Answer", "type": "text"}], } with pytest.raises(ConfigValidationError) as excinfo: ModelConfig.from_dict(config) assert "Please use the `model_type: llm` for text-to-text models." in str(excinfo.value) ================================================ FILE: tests/ludwig/config_validation/test_validate_config_combiner.py ================================================ import pytest from ludwig.config_validation.validation import check_schema, get_schema from ludwig.constants import MODEL_ECD, TRAINER from ludwig.error import ConfigValidationError from tests.integration_tests.utils import binary_feature, category_feature, number_feature def test_combiner_schema_is_not_empty_for_ECD(): # Essentially verifies that the combiner registry is not empty at import time: assert len(get_schema(MODEL_ECD)["properties"]["combiner"]["allOf"]) > 0 @pytest.mark.parametrize("eval_batch_size", [500000, None]) def test_config_tabnet(eval_batch_size): config = { "input_features": [ category_feature(encoder={"type": "dense", "vocab_size": 2}, reduce_input="sum"), number_feature(), ], "output_features": [binary_feature()], "combiner": { "type": "tabnet", "size": 24, "output_size": 26, "sparsity": 0.000001, "bn_virtual_divider": 32, "bn_momentum": 0.4, "num_steps": 5, "relaxation_factor": 1.5, "use_keras_batch_norm": False, "bn_virtual_bs": 512, }, TRAINER: { "batch_size": 16384, "eval_batch_size": eval_batch_size, "epochs": 1000, "early_stop": 20, "learning_rate": 0.02, "optimizer": {"type": "adam"}, "learning_rate_scheduler": { "decay": "linear", "decay_steps": 20000, "decay_rate": 0.9, "staircase": True, }, "regularization_lambda": 1, "regularization_type": "l2", }, } check_schema(config) def test_config_bad_combiner(): config = { "input_features": [ category_feature(encoder={"type": "dense", "vocab_size": 2}, reduce_input="sum"), number_feature(), ], "output_features": [binary_feature()], "combiner": { "type": "tabnet", }, } # config is valid at this point check_schema(config) # combiner without type del config["combiner"]["type"] with pytest.raises(ConfigValidationError): check_schema(config) # bad combiner type config["combiner"]["type"] = "fake" with pytest.raises(ConfigValidationError): check_schema(config) # bad combiner format (list instead of dict) config["combiner"] = [{"type": "tabnet"}] with pytest.raises(ConfigValidationError): check_schema(config) # bad combiner parameter types config["combiner"] = { "type": "tabtransformer", "num_layers": 10, "dropout": False, } with pytest.raises(ConfigValidationError): check_schema(config) # bad combiner parameter range config["combiner"] = { "type": "transformer", "dropout": -1, } with pytest.raises(ConfigValidationError): check_schema(config) def test_config_bad_combiner_types_enums(): config = { "input_features": [ category_feature(encoder={"type": "dense", "vocab_size": 2}, reduce_input="sum"), number_feature(), ], "output_features": [binary_feature()], "combiner": {"type": "concat", "weights_initializer": "zeros"}, } # config is valid at this point check_schema(config) # Test weights initializer: config["combiner"]["weights_initializer"] = {"test": "fail"} with pytest.raises(ConfigValidationError): check_schema(config) config["combiner"]["weights_initializer"] = "fail" with pytest.raises(ConfigValidationError): check_schema(config) config["combiner"]["weights_initializer"] = {} with pytest.raises(ConfigValidationError): check_schema(config) config["combiner"]["weights_initializer"] = {"type": "fail"} with pytest.raises(ConfigValidationError): check_schema(config) config["combiner"]["weights_initializer"] = {"type": "normal", "stddev": 0} check_schema(config) # Test bias initializer: del config["combiner"]["weights_initializer"] config["combiner"]["bias_initializer"] = "kaiming_uniform" check_schema(config) config["combiner"]["bias_initializer"] = "fail" with pytest.raises(ConfigValidationError): check_schema(config) config["combiner"]["bias_initializer"] = {} with pytest.raises(ConfigValidationError): check_schema(config) config["combiner"]["bias_initializer"] = {"type": "fail"} with pytest.raises(ConfigValidationError): check_schema(config) config["combiner"]["bias_initializer"] = {"type": "zeros", "stddev": 0} check_schema(config) # Test norm: del config["combiner"]["bias_initializer"] config["combiner"]["norm"] = "batch" check_schema(config) config["combiner"]["norm"] = "fail" with pytest.raises(ConfigValidationError): check_schema(config) # Test activation: del config["combiner"]["norm"] config["combiner"]["activation"] = "relu" check_schema(config) config["combiner"]["activation"] = 123 with pytest.raises(ConfigValidationError): check_schema(config) # Test reduce_output: del config["combiner"]["activation"] config2 = {**config} config2["combiner"]["type"] = "tabtransformer" config2["combiner"]["reduce_output"] = "sum" check_schema(config) config2["combiner"]["reduce_output"] = "fail" with pytest.raises(ConfigValidationError): check_schema(config2) # Test reduce_output = None: config2["combiner"]["reduce_output"] = None check_schema(config2) ================================================ FILE: tests/ludwig/config_validation/test_validate_config_encoder.py ================================================ import pytest from ludwig.constants import DEFAULTS, ENCODER, INPUT_FEATURES, NAME, OUTPUT_FEATURES, SEQUENCE, TEXT, TIMESERIES, TYPE from ludwig.error import ConfigValidationError from ludwig.schema.model_config import ModelConfig from tests.integration_tests.utils import ( binary_feature, number_feature, sequence_feature, text_feature, timeseries_feature, ) @pytest.mark.parametrize("feature_type", [SEQUENCE, TEXT, TIMESERIES]) def test_default_transformer_encoder(feature_type): """Tests that a transformer hyperparameter divisibility error is correctly recognized in feature defaults. Transformers require that `hidden_size % num_heads == 0`. 9 and 18 were selected as test values because they were the values from the original error. """ config = { INPUT_FEATURES: [number_feature(), {TYPE: feature_type, NAME: f"test_{feature_type}"}], OUTPUT_FEATURES: [binary_feature()], DEFAULTS: {feature_type: {ENCODER: {TYPE: "transformer", "hidden_size": 9, "num_heads": 18}}}, } with pytest.raises(ConfigValidationError): m = ModelConfig.from_dict(config) print(m) config[DEFAULTS][feature_type][ENCODER]["hidden_size"] = 18 config[DEFAULTS][feature_type][ENCODER]["num_heads"] = 9 ModelConfig.from_dict(config) @pytest.mark.parametrize("feature_gen", [sequence_feature, text_feature, timeseries_feature]) def test_input_feature_transformer_encoder(feature_gen): """Tests that a transformer hyperparameter divisibility error is correctly recognized for a specific feature. Transformers require that `hidden_size % num_heads == 0`. 9 and 18 were selected as test values because they were the values from the original error. """ config = { INPUT_FEATURES: [ number_feature(), feature_gen(**{ENCODER: {TYPE: "transformer", "hidden_size": 9, "num_heads": 18}}), ], OUTPUT_FEATURES: [binary_feature()], } with pytest.raises(ConfigValidationError): ModelConfig.from_dict(config) config[INPUT_FEATURES][1][ENCODER]["hidden_size"] = 18 config[INPUT_FEATURES][1][ENCODER]["num_heads"] = 9 ModelConfig.from_dict(config) ================================================ FILE: tests/ludwig/config_validation/test_validate_config_features.py ================================================ import pytest from ludwig.config_validation.validation import check_schema from ludwig.error import ConfigValidationError from tests.integration_tests.utils import binary_feature, category_feature, number_feature, text_feature def test_config_input_output_features(): config = { "input_features": [ category_feature(encoder={"type": "dense"}), number_feature(encoder={"type": "passthrough"}), ], "output_features": [binary_feature(decoder={"type": "regressor"})], } check_schema(config) def test_incorrect_input_features_config(): config = { "input_features": [ category_feature(preprocessing={"normalization": "zscore"}), ], "output_features": [binary_feature()], } # TODO(ksbrar): Circle back after discussing whether additional properties should be allowed long-term. # # Not a preprocessing param for category feature # with pytest.raises(ValidationError): # check_schema(config) config = { "input_features": [ text_feature(preprocessing={"padding_symbol": 0}), ], "output_features": [binary_feature()], } # Incorrect type for padding_symbol preprocessing param with pytest.raises(ConfigValidationError): check_schema(config) config = { "input_features": [ binary_feature(), ], "output_features": [binary_feature()], } del config["input_features"][0]["type"] # No type with pytest.raises(ConfigValidationError): check_schema(config) def test_incorrect_output_features_config(): config = { "input_features": [ number_feature(), ], "output_features": [binary_feature(decoder="classifier")], } # Invalid decoder for binary output feature with pytest.raises(ConfigValidationError): check_schema(config) def test_too_few_features_config(): ifeatures = [number_feature()] ofeatures = [binary_feature()] check_schema( { "input_features": ifeatures, "output_features": ofeatures, } ) # Must have at least one input feature with pytest.raises(ConfigValidationError): check_schema( { "input_features": [], "output_features": ofeatures, } ) # Must have at least one output feature with pytest.raises(ConfigValidationError): check_schema( { "input_features": ifeatures, "output_features": [], } ) def test_multi_output_features_config(): # Multi-output is fine for ECD check_schema( { "input_features": [number_feature()], "output_features": [binary_feature(), number_feature()], "model_type": "ecd", } ) ================================================ FILE: tests/ludwig/config_validation/test_validate_config_hyperopt.py ================================================ from itertools import repeat from unittest.mock import patch import pytest # Imported to populate the registry import ludwig.schema.hyperopt.parameter # noqa: F401 import ludwig.schema.hyperopt.scheduler # noqa: F401 import ludwig.schema.hyperopt.search_algorithm # noqa: F401 from ludwig.constants import ( EXECUTOR, HYPEROPT, INPUT_FEATURES, OUTPUT_FEATURES, PARAMETERS, SCHEDULER, SEARCH_ALG, TYPE, ) from ludwig.error import ConfigValidationError from ludwig.schema.hyperopt import utils from ludwig.schema.model_types.base import ModelConfig from tests.integration_tests.utils import binary_feature, text_feature @pytest.mark.parametrize( "dependencies,raises_exception", [ ([], False), ([("ludwig", "ludwig")], False), ([("ludwig", "ludwig"), ("marshmallow", "marshmallow")], False), ([("fake_dependency", "fake_dependency")], True), ([("ludwig", "ludwig"), ("fake_dependency", "fake_dependency")], True), ], ) def test_check_scheduler_dependencies_installed(dependencies, raises_exception): config = { INPUT_FEATURES: [text_feature()], OUTPUT_FEATURES: [binary_feature()], HYPEROPT: { PARAMETERS: {"trainer.learning_rate": {"space": "choice", "categories": [0.0001, 0.001, 0.01, 0.1]}}, EXECUTOR: {SCHEDULER: {TYPE: "fifo"}}, }, } with patch("ludwig.schema.hyperopt.utils.get_scheduler_dependencies", return_value=dependencies): if raises_exception: with pytest.raises(ConfigValidationError): ModelConfig.from_dict(config) else: ModelConfig.from_dict(config) @pytest.mark.parametrize( "dependencies,raises_exception", [ ([], False), ([("ludwig", "ludwig")], False), ([("ludwig", "ludwig"), ("marshmallow", "marshmallow")], False), ([("fake_dependency", "fake_dependency")], True), ([("ludwig", "ludwig"), ("fake_dependency", "fake_dependency")], True), ], ) def test_check_search_algorithm_dependencies_installed(dependencies, raises_exception): config = { INPUT_FEATURES: [text_feature()], OUTPUT_FEATURES: [binary_feature()], HYPEROPT: { PARAMETERS: {"trainer.learning_rate": {"space": "choice", "categories": [0.0001, 0.001, 0.01, 0.1]}}, SEARCH_ALG: {TYPE: "random"}, }, } with patch("ludwig.schema.hyperopt.utils.get_search_algorithm_dependencies", return_value=dependencies): if raises_exception: with pytest.raises(ConfigValidationError): ModelConfig.from_dict(config) else: ModelConfig.from_dict(config) @pytest.mark.parametrize( "space,raises_exception", list(zip(utils.parameter_config_registry.keys(), repeat(False, len(utils.parameter_config_registry)))) + [("fake_space", True)], ) def test_parameter_type_check(space, raises_exception): """Test that the parameter type is a valid hyperparameter search space. This should only be valid until the search space schema is updated to validate spaces as config objects rather than dicts. That update is non-trivial, so to hold over until it is ready we cast the dicts to the corresponding parameter objects and validate as an aux check. The test covers every valid space and one invalid space. """ config = { INPUT_FEATURES: [text_feature()], OUTPUT_FEATURES: [binary_feature()], HYPEROPT: { SEARCH_ALG: {TYPE: "random"}, PARAMETERS: { "trainer.learning_rate": { "space": space, } }, }, } if not raises_exception: ModelConfig.from_dict(config) else: with pytest.raises(ConfigValidationError): ModelConfig.from_dict(config) @pytest.mark.parametrize( "referenced_parameter,raises_exception", [ # Passing cases ("trainer.learning_rate", False), ("in_feature.encoder.num_fc_layers", False), ("out_feature.decoder.num_fc_layers", False), # Invalid cases with various nesting of invalid names ("", True), (" ", True), ("foo.bar", True), ("trainer.bar", True), ("foo.learning_rate", True), ("in_feature.encoder.bar", True), ("in_feature.foo.num_fc_layers", True), ("out_feature.encoder.bar", True), ("out_feature.foo.num_fc_layers", True), ], ) def test_parameter_key_check(referenced_parameter, raises_exception): """Test that references to config parameters are validated correctly. Hyperopt parameters reference the config parameters they search with `.` notation to access different subsections, e.g. `trainer.learning_rate`. These are added to the config as arbitrary strings, and an invalid reference should be considered a validation error since we will otherwise search over an unused space or defer the error to train time. """ config = { INPUT_FEATURES: [text_feature(name="in_feature")], OUTPUT_FEATURES: [binary_feature(name="out_feature")], HYPEROPT: { SEARCH_ALG: {TYPE: "random"}, PARAMETERS: {referenced_parameter: {"space": "choice", "categories": [1, 2, 3, 4]}}, }, } if raises_exception: with pytest.raises(ConfigValidationError): ModelConfig.from_dict(config) else: ModelConfig.from_dict(config) @pytest.mark.parametrize( "categories,raises_exception", [ # Passing case ( [ { "combiner": {"type": "tabnet", "bn_virtual_bs": 256}, "trainer": {"learning_rate": 0.001, "batch_size": 64}, }, {"combiner": {"type": "concat"}, "trainer": {"batch_size": 256}}, ], False, ), # Errors in top level parameter names (4 cases) ( [ { "foo": {"type": "tabnet", "bn_virtual_bs": 256}, "trainer": {"learning_rate": 0.001, "batch_size": 64}, }, {"combiner": {"type": "concat"}, "trainer": {"batch_size": 256}}, ], True, ), ( [ { "combiner": {"type": "tabnet", "bn_virtual_bs": 256}, "trainer": {"learning_rate": 0.001, "batch_size": 64}, }, {"foo": {"type": "concat"}, "trainer": {"batch_size": 256}}, ], True, ), ( [ { "combiner": {"type": "tabnet", "bn_virtual_bs": 256}, "foo": {"learning_rate": 0.001, "batch_size": 64}, }, {"combiner": {"type": "concat"}, "trainer": {"batch_size": 256}}, ], True, ), ( [ { "combiner": {"type": "tabnet", "bn_virtual_bs": 256}, "trainer": {"learning_rate": 0.001, "batch_size": 64}, }, {"combiner": {"type": "concat"}, "foo": {"batch_size": 256}}, ], True, ), # Errors in nested parameters (6 cases) ( [ {"combiner": {"bar": "tabnet", "bn_virtual_bs": 256}, "trainer": {"bar": 0.001, "batch_size": 64}}, {"combiner": {"type": "concat"}, "trainer": {"batch_size": 256}}, ], True, ), ( [ {"combiner": {"type": "tabnet", "bar": 256}, "trainer": {"learning_rate": 0.001, "batch_size": 64}}, {"combiner": {"type": "concat"}, "trainer": {"batch_size": 256}}, ], False, ), ( [ {"combiner": {"type": "tabnet", "bn_virtual_bs": 256}, "trainer": {"bar": 0.001, "batch_size": 64}}, {"combiner": {"type": "concat"}, "trainer": {"batch_size": 256}}, ], True, ), ( [ {"combiner": {"type": "tabnet", "bn_virtual_bs": 256}, "trainer": {"bar": 0.001, "batch_size": 64}}, {"combiner": {"type": "concat"}, "trainer": {"batch_size": 256}}, ], True, ), ( [ {"combiner": {"type": "tabnet", "bn_virtual_bs": 256}, "trainer": {"learning_rate": 0.001, "bar": 64}}, {"combiner": {"type": "concat"}, "trainer": {"batch_size": 256}}, ], True, ), ( [ { "combiner": {"type": "tabnet", "bn_virtual_bs": 256}, "trainer": {"learning_rate": 0.001, "batch_size": 64}, }, {"combiner": {"type": "concat"}, "trainer": {"bar": 256}}, ], True, ), ], ) def test_nested_parameter_key_check(categories, raises_exception): """Test that nested parameters are validated correctly.""" config = { INPUT_FEATURES: [text_feature(name="in_feature")], OUTPUT_FEATURES: [binary_feature(name="out_feature")], HYPEROPT: {SEARCH_ALG: {TYPE: "random"}, PARAMETERS: {".": {"space": "choice", "categories": categories}}}, } if raises_exception: with pytest.raises(ConfigValidationError): ModelConfig.from_dict(config) else: ModelConfig.from_dict(config) @pytest.mark.parametrize( "config", [ { "out_feature.decoder.fc_layers": { "space": "choice", "categories": [ [{"output_size": 64}, {"output_size": 32}], [{"output_size": 64}], [{"output_size": 32}], ], } } ], ) def test_flat_parameter_edge_cases(config): config = { INPUT_FEATURES: [text_feature(name="in_feature")], OUTPUT_FEATURES: [binary_feature(name="out_feature")], HYPEROPT: {SEARCH_ALG: {TYPE: "random"}, PARAMETERS: config}, } ModelConfig.from_dict(config) ================================================ FILE: tests/ludwig/config_validation/test_validate_config_misc.py ================================================ import pytest from ludwig.config_validation.validation import check_schema, get_schema from ludwig.constants import ( ACTIVE, BACKEND, CATEGORY, COLUMN, DECODER, DEFAULTS, ENCODER, LOSS, MODEL_ECD, MODEL_LLM, NAME, PREPROCESSING, PROC_COLUMN, TRAINER, TYPE, ) from ludwig.error import ConfigValidationError from ludwig.features.feature_registries import get_output_type_registry from ludwig.schema import utils as schema_utils from ludwig.schema.combiners.utils import get_combiner_jsonschema from ludwig.schema.defaults.ecd import ECDDefaultsConfig from ludwig.schema.features.preprocessing.audio import AudioPreprocessingConfig from ludwig.schema.features.preprocessing.bag import BagPreprocessingConfig from ludwig.schema.features.preprocessing.binary import BinaryPreprocessingConfig from ludwig.schema.features.preprocessing.category import CategoryPreprocessingConfig from ludwig.schema.features.preprocessing.date import DatePreprocessingConfig from ludwig.schema.features.preprocessing.h3 import H3PreprocessingConfig from ludwig.schema.features.preprocessing.image import ImagePreprocessingConfig from ludwig.schema.features.preprocessing.number import NumberPreprocessingConfig from ludwig.schema.features.preprocessing.sequence import SequencePreprocessingConfig from ludwig.schema.features.preprocessing.set import SetPreprocessingConfig from ludwig.schema.features.preprocessing.text import TextPreprocessingConfig from ludwig.schema.features.preprocessing.timeseries import TimeseriesPreprocessingConfig from ludwig.schema.features.preprocessing.vector import VectorPreprocessingConfig from ludwig.schema.features.utils import get_input_feature_jsonschema, get_output_feature_jsonschema from ludwig.schema.llms.peft import LoraConfig from ludwig.schema.model_types.base import ModelConfig from ludwig.schema.utils import ludwig_dataclass, unload_jsonschema_from_marshmallow_class from tests.integration_tests.utils import ( audio_feature, bag_feature, binary_feature, category_feature, date_feature, ENCODERS, h3_feature, image_feature, number_feature, sequence_feature, set_feature, text_feature, timeseries_feature, vector_feature, ) def test_config_features(): all_input_features = [ audio_feature("/tmp/destination_folder", encoder={"type": "parallel_cnn"}), bag_feature(encoder={"type": "embed"}), binary_feature(encoder={"type": "passthrough"}), category_feature(encoder={"type": "dense"}), date_feature(encoder={"type": "embed"}), h3_feature(encoder={"type": "embed"}), image_feature("/tmp/destination_folder", encoder={"type": "stacked_cnn"}), number_feature(encoder={"type": "passthrough"}), sequence_feature(encoder={"type": "parallel_cnn"}), set_feature(encoder={"type": "embed"}), text_feature(encoder={"type": "parallel_cnn"}), timeseries_feature(encoder={"type": "parallel_cnn"}), vector_feature(encoder={"type": "dense"}), ] all_output_features = [ binary_feature(decoder={"type": "regressor"}), category_feature(decoder={"type": "classifier"}), number_feature(decoder={"type": "regressor"}), sequence_feature(decoder={"type": "generator"}), set_feature(decoder={"type": "classifier"}), text_feature(decoder={"type": "generator"}), vector_feature(decoder={"type": "projector"}), ] # validate config with all features config = { "input_features": all_input_features, "output_features": all_output_features, } check_schema(config) # test various invalid output features input_only_features = [ feature for feature in all_input_features if feature["type"] not in get_output_type_registry().keys() ] for input_feature in input_only_features: config = { "input_features": all_input_features, "output_features": all_output_features + [input_feature], } with pytest.raises(ConfigValidationError): check_schema(config) def test_config_encoders(): for encoder in ENCODERS: config = { "input_features": [ sequence_feature(encoder={"type": encoder, "reduce_output": "sum"}), image_feature("/tmp/destination_folder"), ], "output_features": [category_feature(decoder={"type": "classifier", "vocab_size": 2}, reduce_input="sum")], "combiner": {"type": "concat", "output_size": 14}, } check_schema(config) def test_config_with_backend(): config = { "input_features": [ category_feature(encoder={"type": "dense", "vocab_size": 2}, reduce_input="sum"), number_feature(), ], "output_features": [binary_feature()], "combiner": { "type": "tabnet", "size": 24, "output_size": 26, "sparsity": 0.000001, "bn_virtual_divider": 32, "bn_momentum": 0.4, "num_steps": 5, "relaxation_factor": 1.5, "bn_virtual_bs": 512, }, TRAINER: { "batch_size": 16384, "eval_batch_size": 500000, "epochs": 1000, "early_stop": 20, "learning_rate": 0.02, "optimizer": {"type": "adam"}, "learning_rate_scheduler": { "decay": "linear", "decay_steps": 20000, "decay_rate": 0.9, "staircase": True, }, "regularization_lambda": 1, "regularization_type": "l2", }, BACKEND: {"type": "ray", "trainer": {"num_workers": 2}}, } check_schema(config) def test_config_bad_feature_type(): config = { "input_features": [{"name": "foo", "type": "fake"}], "output_features": [category_feature(encoder={"vocab_size": 2}, reduce_input="sum")], "combiner": {"type": "concat", "output_size": 14}, } with pytest.raises(ConfigValidationError): check_schema(config) def test_config_bad_encoder_name(): config = { "input_features": [sequence_feature(encoder={"type": "fake", "reduce_output": "sum"})], "output_features": [category_feature(decoder={"type": "classifier", "vocab_size": 2}, reduce_input="sum")], "combiner": {"type": "concat", "output_size": 14}, } with pytest.raises(ConfigValidationError): check_schema(config) def test_config_fill_values(): vector_fill_values = ["1.0 0.0 1.04 10.49", "1 2 3 4 5" "0" "1.0" ""] binary_fill_values = ["yes", "No", "1", "TRUE", 1] for vector_fill_value, binary_fill_value in zip(vector_fill_values, binary_fill_values): config = { "input_features": [ vector_feature(preprocessing={"fill_value": vector_fill_value}), ], "output_features": [binary_feature(preprocessing={"fill_value": binary_fill_value})], } check_schema(config) bad_vector_fill_values = ["one two three", "1,2,3", 0] bad_binary_fill_values = ["one", 2, "maybe"] for vector_fill_value, binary_fill_value in zip(bad_vector_fill_values, bad_binary_fill_values): config = { "input_features": [ vector_feature(preprocessing={"fill_value": vector_fill_value}), ], "output_features": [binary_feature(preprocessing={"fill_value": binary_fill_value})], } with pytest.raises(ConfigValidationError): check_schema(config) def test_validate_with_preprocessing_defaults(): config = { "input_features": [ audio_feature( "/tmp/destination_folder", preprocessing=AudioPreprocessingConfig().to_dict(), encoder={"type": "parallel_cnn"}, ), bag_feature(preprocessing=BagPreprocessingConfig().to_dict(), encoder={"type": "embed"}), binary_feature(preprocessing=BinaryPreprocessingConfig().to_dict(), encoder={"type": "passthrough"}), category_feature(preprocessing=CategoryPreprocessingConfig().to_dict(), encoder={"type": "dense"}), date_feature(preprocessing=DatePreprocessingConfig().to_dict(), encoder={"type": "embed"}), h3_feature(preprocessing=H3PreprocessingConfig().to_dict(), encoder={"type": "embed"}), image_feature( "/tmp/destination_folder", preprocessing=ImagePreprocessingConfig().to_dict(), encoder={"type": "stacked_cnn"}, ), number_feature(preprocessing=NumberPreprocessingConfig().to_dict(), encoder={"type": "passthrough"}), sequence_feature(preprocessing=SequencePreprocessingConfig().to_dict(), encoder={"type": "parallel_cnn"}), set_feature(preprocessing=SetPreprocessingConfig().to_dict(), encoder={"type": "embed"}), text_feature(preprocessing=TextPreprocessingConfig().to_dict(), encoder={"type": "parallel_cnn"}), timeseries_feature( preprocessing=TimeseriesPreprocessingConfig().to_dict(), encoder={"type": "parallel_cnn"} ), vector_feature(preprocessing=VectorPreprocessingConfig().to_dict(), encoder={"type": "dense"}), ], "output_features": [{"name": "target", "type": "category"}], TRAINER: { "learning_rate_scheduler": { "decay": "linear", }, "learning_rate": 0.001, "validation_field": "target", "validation_metric": "accuracy", }, } check_schema(config) def test_ecd_defaults_schema(): schema = ECDDefaultsConfig() assert schema.binary.decoder.type == "regressor" assert schema.binary.encoder.type == "passthrough" assert schema.category.encoder.dropout == 0.0 assert ENCODER in schema.category.to_dict() assert PREPROCESSING in schema.category.to_dict() assert DECODER in schema.category.to_dict() assert LOSS in schema.category.to_dict() def test_validate_defaults_schema(): config = { "input_features": [ category_feature(), number_feature(), ], "output_features": [category_feature(output_feature=True)], "defaults": { "category": { "preprocessing": { "missing_value_strategy": "drop_row", }, "encoder": { "type": "sparse", }, "decoder": { "type": "classifier", "norm_params": None, "dropout": 0.0, "use_bias": True, }, "loss": { "type": "softmax_cross_entropy", "confidence_penalty": 0, }, }, "number": { "preprocessing": { "missing_value_strategy": "fill_with_const", "fill_value": 0, }, "loss": {"type": "mean_absolute_error"}, }, }, } check_schema(config) config[DEFAULTS][CATEGORY][NAME] = "TEST" with pytest.raises(ConfigValidationError): check_schema(config) def test_validate_no_trainer_type(): config = { "model_type": "ecd", "input_features": [ category_feature(), number_feature(), ], "output_features": [category_feature(output_feature=True)], "trainer": {"learning_rate": "auto", "batch_size": "auto"}, } # Ensure validation succeeds with ECD trainer params and ECD model type check_schema(config) def test_schema_no_duplicates(): schema = get_schema() popped_fields = [NAME, TYPE, COLUMN, PROC_COLUMN, ACTIVE] for field in popped_fields: assert field not in schema["properties"]["input_features"]["items"]["allOf"][0]["then"]["properties"] assert field not in schema["properties"]["output_features"]["items"]["allOf"][0]["then"]["properties"] assert field not in schema["properties"]["combiner"]["allOf"][0]["then"]["properties"] assert field not in schema["properties"]["trainer"]["properties"]["optimizer"]["allOf"][0]["then"]["properties"] assert ( field not in schema["properties"]["input_features"]["items"]["allOf"][0]["then"]["properties"]["encoder"][ "allOf" ][0]["then"]["properties"] ) assert ( field not in schema["properties"]["output_features"]["items"]["allOf"][0]["then"]["properties"]["decoder"][ "allOf" ][0]["then"]["properties"] ) @pytest.mark.parametrize("model_type", [MODEL_ECD, MODEL_LLM]) def test_ludwig_schema_serialization(model_type): import json schema = get_schema(model_type) try: json.dumps(schema) except TypeError as e: raise TypeError( f"Ludwig schema of type `{model_type}` cannot be represented by valid JSON. See further details: {e}" ) def test_encoder_descriptions(): """This test tests that each encoder in the enum for each feature type has a description.""" schema = get_input_feature_jsonschema(MODEL_ECD) for feature_schema in schema["allOf"]: type_data = feature_schema["then"]["properties"]["encoder"]["properties"]["type"] assert len(set(type_data["enumDescriptions"].keys())) > 0 assert set(type_data["enumDescriptions"].keys()).issubset(set(type_data["enum"])) def test_combiner_descriptions(): """This test tests that each combiner in the enum for available combiners has a description.""" combiner_json_schema = get_combiner_jsonschema() type_data = combiner_json_schema["properties"]["type"] assert len(set(type_data["enumDescriptions"].keys())) > 0 assert set(type_data["enumDescriptions"].keys()).issubset(set(type_data["enum"])) def test_decoder_descriptions(): """This test tests that each decoder in the enum for each feature type has a description.""" schema = get_output_feature_jsonschema(MODEL_ECD) for feature_schema in schema["allOf"]: type_data = feature_schema["then"]["properties"]["decoder"]["properties"]["type"] assert len(type_data["enumDescriptions"].keys()) > 0 assert set(type_data["enumDescriptions"].keys()).issubset(set(type_data["enum"])) def test_deprecation_warning_raised_for_unknown_parameters(): config = { "input_features": [ category_feature(encoder={"type": "dense", "vocab_size": 2}, reduce_input="sum"), number_feature(), ], "output_features": [binary_feature()], "combiner": { "type": "tabnet", "unknown_parameter_combiner": False, }, TRAINER: { "epochs": 1000, }, } with pytest.warns(DeprecationWarning, match="not a valid parameter"): ModelConfig.from_dict(config) @pytest.mark.parametrize( "encoder_config,expected_adapter", [ ({"type": "bert", "trainable": True}, None), ({"type": "bert", "trainable": True, "adapter": None}, None), ({"type": "bert", "trainable": True, "adapter": {"type": "lora"}}, LoraConfig()), ( { "type": "bert", "trainable": True, "adapter": {"type": "lora", "r": 16, "alpha": 32, "dropout": 0.1, "bias_type": "all"}, }, LoraConfig(r=16, alpha=32, dropout=0.1, bias_type="all"), ), ], ) def test_text_encoder_adapter(encoder_config, expected_adapter): config = { "input_features": [text_feature(encoder=encoder_config)], "output_features": [category_feature(decoder={"type": "classifier", "vocab_size": 2}, reduce_input="sum")], } config_obj = ModelConfig.from_dict(config) assert config_obj.input_features[0].encoder.adapter == expected_adapter def test_default_param_metadata(): @ludwig_dataclass class TestClass(schema_utils.BaseMarshmallowConfig): test_schema_entry: str = schema_utils.StringOptions( options=["test"], default="test", description="", ) test_class = unload_jsonschema_from_marshmallow_class(TestClass) assert test_class["properties"]["test_schema_entry"]["parameter_metadata"] is not None ================================================ FILE: tests/ludwig/config_validation/test_validate_config_preprocessing.py ================================================ import pytest from ludwig.config_validation.preprocessing import check_global_max_sequence_length_fits_prompt_template from ludwig.config_validation.validation import check_schema from tests.integration_tests.utils import binary_feature, category_feature def test_config_preprocessing(): input_features = [category_feature(), category_feature()] output_features = [binary_feature()] config = { "input_features": input_features, "output_features": output_features, "preprocessing": { "split": { "type": "random", "probabilities": [0.6, 0.2, 0.2], }, "oversample_minority": 0.4, }, } check_schema(config) # TODO(ksbrar): Circle back after discussing whether additional properties should be allowed long-term. # config["preprocessing"]["fake_parameter"] = True # with pytest.raises(Exception): # ModelConfig(config) def test_check_global_max_sequence_length_fits_prompt_template(): check_global_max_sequence_length_fits_prompt_template( {"input_feature": {"prompt_template_num_tokens": 10}}, {"global_max_sequence_length": 10} ) check_global_max_sequence_length_fits_prompt_template( {"input_feature": {"prompt_template_num_tokens": 100}}, {"global_max_sequence_length": 1000} ) check_global_max_sequence_length_fits_prompt_template( {"input_feature": {"prompt_template_num_tokens": 100}}, {"global_max_sequence_length": None} ) with pytest.raises(ValueError): # Prompt template token length cannot be larger than the global max sequence length. check_global_max_sequence_length_fits_prompt_template( {"input_feature": {"prompt_template_num_tokens": 10}}, {"global_max_sequence_length": 5} ) with pytest.raises(ValueError): # Any input feature's prompt template token length can trigger the global max sequence length. check_global_max_sequence_length_fits_prompt_template( {"input_feature": {"prompt_template_num_tokens": 5}, "input_feature_2": {"prompt_template_num_tokens": 20}}, {"global_max_sequence_length": 10}, ) ================================================ FILE: tests/ludwig/config_validation/test_validate_config_trainer.py ================================================ import pytest from ludwig.config_validation.validation import check_schema from ludwig.constants import TRAINER from ludwig.error import ConfigValidationError from ludwig.schema.optimizers import optimizer_registry from ludwig.schema.trainer import ECDTrainerConfig from tests.integration_tests.utils import binary_feature, category_feature, number_feature # Note: simple tests for now, but once we add dependent fields we can add tests for more complex relationships in this # file. Currently verifies that the nested fields work, as the others are covered by basic marshmallow validation: def test_config_trainer_empty_null_and_default(): config = { "input_features": [ category_feature(encoder={"type": "dense", "vocab_size": 2}, reduce_input="sum"), number_feature(), ], "output_features": [binary_feature()], "combiner": { "type": "tabnet", }, TRAINER: {}, } check_schema(config) config[TRAINER] = None with pytest.raises(ConfigValidationError): check_schema(config) config[TRAINER] = ECDTrainerConfig.Schema().dump({}) check_schema(config) def test_config_trainer_bad_optimizer(): config = { "input_features": [ category_feature(encoder={"type": "dense", "vocab_size": 2}, reduce_input="sum"), number_feature(), ], "output_features": [binary_feature()], "combiner": { "type": "tabnet", }, TRAINER: {}, } check_schema(config) # Test manually set-to-null optimizer vs unspecified: config[TRAINER]["optimizer"] = None with pytest.raises(ConfigValidationError): check_schema(config) assert ECDTrainerConfig.Schema().load({}).optimizer is not None # Test all types in optimizer_registry supported: for key in optimizer_registry.keys(): config[TRAINER]["optimizer"] = {"type": key} check_schema(config) # Test invalid optimizer type: config[TRAINER]["optimizer"] = {"type": 0} with pytest.raises(ConfigValidationError): check_schema(config) config[TRAINER]["optimizer"] = {"type": "invalid"} with pytest.raises(ConfigValidationError): check_schema(config) def test_optimizer_property_validation(): config = { "input_features": [ category_feature(encoder={"type": "dense", "vocab_size": 2}, reduce_input="sum"), number_feature(), ], "output_features": [binary_feature()], "combiner": { "type": "tabnet", }, TRAINER: {}, } check_schema(config) # Test that an optimizer's property types are enforced: config[TRAINER]["optimizer"] = {"type": "rmsprop"} check_schema(config) config[TRAINER]["optimizer"]["momentum"] = "invalid" with pytest.raises(ConfigValidationError): check_schema(config) # Test extra keys are excluded and defaults are loaded appropriately: config[TRAINER]["optimizer"]["momentum"] = 10 config[TRAINER]["optimizer"]["extra_key"] = "invalid" check_schema(config) assert not hasattr(ECDTrainerConfig.Schema().load(config[TRAINER]).optimizer, "extra_key") # Test bad parameter range: config[TRAINER]["optimizer"] = {"type": "rmsprop", "eps": -1} with pytest.raises(ConfigValidationError): check_schema(config) # Test config validation for tuple types: config[TRAINER]["optimizer"] = {"type": "adam", "betas": (0.1, 0.1)} check_schema(config) def test_clipper_property_validation(): config = { "input_features": [ category_feature(encoder={"type": "dense", "vocab_size": 2}, reduce_input="sum"), number_feature(), ], "output_features": [binary_feature()], "combiner": { "type": "tabnet", }, TRAINER: {}, } check_schema(config) # Test null/empty clipper: config[TRAINER]["gradient_clipping"] = None check_schema(config) config[TRAINER]["gradient_clipping"] = {} check_schema(config) assert ( ECDTrainerConfig.Schema().load(config[TRAINER]).gradient_clipping == ECDTrainerConfig.Schema().load({}).gradient_clipping ) # Test invalid clipper type: config[TRAINER]["gradient_clipping"] = 0 with pytest.raises(ConfigValidationError): check_schema(config) config[TRAINER]["gradient_clipping"] = "invalid" with pytest.raises(ConfigValidationError): check_schema(config) # Test that an optimizer's property types are enforced: config[TRAINER]["gradient_clipping"] = {"clipglobalnorm": None} check_schema(config) config[TRAINER]["gradient_clipping"] = {"clipglobalnorm": 1} check_schema(config) config[TRAINER]["gradient_clipping"] = {"clipglobalnorm": "invalid"} with pytest.raises(ConfigValidationError): check_schema(config) # Test extra keys are excluded and defaults are loaded appropriately: config[TRAINER]["gradient_clipping"] = {"clipnorm": 1} config[TRAINER]["gradient_clipping"]["extra_key"] = "invalid" assert not hasattr(ECDTrainerConfig.Schema().load(config[TRAINER]).gradient_clipping, "extra_key") ================================================ FILE: tests/ludwig/contrib/test_contrib.py ================================================ import argparse from collections.abc import Sequence import pytest from ludwig.contrib import add_contrib_callback_args from ludwig.contribs.aim import AimCallback from ludwig.contribs.comet import CometCallback from ludwig.contribs.mlflow import MlflowCallback from ludwig.contribs.wandb import WandbCallback @pytest.mark.parametrize( "sys_argv,expected", [ ([], []), (["--mlflow"], [MlflowCallback]), (["--aim"], [AimCallback]), (["--comet"], [CometCallback]), (["--wandb"], [WandbCallback]), ], ) def test_add_contrib_callback_args(sys_argv: Sequence[str], expected: list[type]): parser = argparse.ArgumentParser() add_contrib_callback_args(parser) args = parser.parse_args(sys_argv) callbacks = args.callbacks or [] assert len(callbacks) == len(expected) for callback, expected_cls in zip(callbacks, expected): assert isinstance(callback, expected_cls) ================================================ FILE: tests/ludwig/data/dataframe/test_dask.py ================================================ import pandas as pd import pytest from ludwig.api import LudwigModel from tests.integration_tests.utils import generate_data_as_dataframe @pytest.mark.distributed def test_from_ray_dataset_empty(tmpdir, ray_cluster_2cpu): import dask.dataframe as dd # Verifies that when the dataset is an empty MapBatches(BatchInferModel), we mitigate Ray's native to_dask() # IndexError. config = { "input_features": [ {"name": "cat1", "type": "category", "vocab_size": 2}, {"name": "num1", "type": "number"}, ], "output_features": [ {"name": "bin1", "type": "binary"}, ], "trainer": {"epochs": 1}, } train_input_df = generate_data_as_dataframe(config["input_features"], config["output_features"]) model = LudwigModel(config, backend="ray") model.train( train_input_df, output_directory=tmpdir, skip_save_model=True, skip_save_progress=True, skip_save_processed_output=True, skip_save_processed_input=True, ) predict_input_df = dd.from_pandas(pd.DataFrame([], columns=["cat1", "num1", "bin1"]), npartitions=1) model.predict(predict_input_df) ================================================ FILE: tests/ludwig/data/test_cache_util.py ================================================ import copy import uuid from unittest import mock import pytest from ludwig.constants import INPUT_FEATURES, OUTPUT_FEATURES from ludwig.data.cache.util import calculate_checksum from ludwig.schema.model_types.base import ModelConfig from ludwig.types import FeatureConfigDict, ModelConfigDict from ludwig.utils.misc_utils import merge_dict def _gen_config(input_features: list[FeatureConfigDict]) -> ModelConfigDict: return {INPUT_FEATURES: input_features, OUTPUT_FEATURES: [{"name": "out1", "type": "binary"}]} @pytest.mark.parametrize( "input_features,diff,expected", [ ( [ { "name": "in1", "type": "text", "encoder": {"type": "parallel_cnn"}, } ], [ { "encoder": {"type": "stacked_cnn"}, } ], True, ), ( [ { "name": "in1", "type": "text", "preprocessing": {"cache_encoder_embeddings": True}, "encoder": {"type": "bert"}, } ], [ { "encoder": {"type": "distilbert"}, } ], False, ), ], ) def test_calculate_checksum(input_features: list[FeatureConfigDict], diff: list[FeatureConfigDict], expected: bool): config = _gen_config(input_features) diff_features = [merge_dict(f, df) for f, df in zip(input_features, diff)] diff_config = _gen_config(diff_features) mock_dataset = mock.Mock() mock_dataset.checksum = uuid.uuid4().hex assert ( calculate_checksum(mock_dataset, ModelConfig.from_dict(config).to_dict()) == calculate_checksum(mock_dataset, ModelConfig.from_dict(diff_config).to_dict()) ) == expected def test_proc_col_checksum_consistency(): """Tests that proc_col is equal if checksum are equal.""" config_dict1 = { "input_features": [{"name": "txt1", "type": "text", "encoder": {"type": "bert"}}], "output_features": [{"name": "bin1", "type": "binary"}], } config1 = ModelConfig.from_dict(config_dict1) config_dict2 = copy.deepcopy(config_dict1) config_dict2["input_features"][0]["preprocessing"] = { "tokenizer": "bert", } config2 = ModelConfig.from_dict(config_dict2) mock_dataset = mock.Mock() mock_dataset.checksum = uuid.uuid4().hex assert calculate_checksum(mock_dataset, config1.to_dict()) == calculate_checksum(mock_dataset, config2.to_dict()) for if1, if2 in zip(config1.input_features, config2.input_features): assert if1.name == if2.name assert if1.proc_column == if2.proc_column for of1, of2 in zip(config1.output_features, config2.output_features): assert of1.name == of2.name assert of1.proc_column == of2.proc_column def test_proc_col_checksum_consistency_same_preprocessing_different_types(): """Tests that proc_col is different if preprocessing and names are the same but types are different.""" config = { "input_features": [ # Same name, different types, same preprocessing {"name": "num1", "type": "number", "preprocessing": {"missing_value_strategy": "fill_with_mode"}}, {"name": "num2", "type": "category", "preprocessing": {"missing_value_strategy": "fill_with_mode"}}, ], "output_features": [ {"name": "num3", "type": "number", "preprocessing": {"missing_value_strategy": "fill_with_mode"}} ], } config = ModelConfig.from_dict(config) assert config.input_features[0].proc_column != config.input_features[1].proc_column @pytest.mark.distributed def test_checksum_determinism(ray_cluster_2cpu): """Tests that checksums are deterministic across different processes (no unordered hash maps).""" import ray # Generate a lot of features so the probability of a reordering of feature sets is very high. config = { INPUT_FEATURES: [{"name": f"in{i}", "type": "number"} for i in range(100)], OUTPUT_FEATURES: [{"name": "out1", "type": "binary"}], } config = ModelConfig.from_dict(config) mock_dataset = mock.Mock() mock_dataset.checksum = uuid.uuid4().hex @ray.remote(max_calls=1) def calculate_checksum_remote(dataset, config): return calculate_checksum(dataset, config) # Run each checksum calculation as a remote function so it gets its own Python interpreter, as # the hash function in Python is deterministic within a process, but not between different processes. # See: https://docs.python.org/3/reference/datamodel.html#object.__hash__ checksum1 = ray.get(calculate_checksum_remote.remote(mock_dataset, config.to_dict())) checksum2 = ray.get(calculate_checksum_remote.remote(mock_dataset, config.to_dict())) assert checksum1 == checksum2 ================================================ FILE: tests/ludwig/data/test_dataset_synthesizer.py ================================================ from ludwig.data import dataset_synthesizer def test_build_synthetic_dataset(tmpdir): features = [ {"name": "text", "type": "text"}, {"name": "category", "type": "category"}, {"name": "number", "type": "number"}, {"name": "binary", "type": "binary"}, {"name": "set", "type": "set"}, {"name": "bag", "type": "bag"}, {"name": "sequence", "type": "sequence"}, {"name": "timeseries", "type": "timeseries"}, {"name": "date", "type": "date"}, {"name": "h3", "type": "h3"}, {"name": "vector", "type": "vector"}, {"name": "audio", "type": "audio"}, {"name": "image", "type": "image"}, ] assert len(list(dataset_synthesizer.build_synthetic_dataset(100, features, tmpdir))) == 101 # Extra for the header. ================================================ FILE: tests/ludwig/data/test_negative_sampling.py ================================================ import pandas as pd from ludwig.data.negative_sampling import negative_sample def test_negative_sample(): df = pd.DataFrame( { "user_id": [1, 1, 2, 2, 3], "item_id": ["a", "b", "b", "c", "a"], "label": [1, 1, 1, 1, 1], } ) df_with_samples = negative_sample(df, "user_id", "item_id", "label") assert 9 <= len(df_with_samples) <= 10 assert df_with_samples["label"].sum() == 5 # Check data types assert df_with_samples["user_id"].dtype == "int64" assert df_with_samples["item_id"].dtype == "object" # Check that the negative samples are unique user-item pairs assert len(df_with_samples) == len(df_with_samples.drop_duplicates(["user_id", "item_id"])) ================================================ FILE: tests/ludwig/data/test_postprocessing.py ================================================ import torch from ludwig.data.postprocessing import convert_dict_to_df def test_convert_dict_to_df(): d = { "binary_C82EB": { "predictions": torch.tensor([True, True, True, False]), "probabilities": torch.tensor([[0.4777, 0.5223], [0.4482, 0.5518], [0.4380, 0.5620], [0.5059, 0.4941]]), }, "category_1491D": { "predictions": ["NkNUG", "NkNUG", "NkNUG", "NkNUG"], "probabilities": torch.tensor( [ [0.1058, 0.4366, 0.1939, 0.2637], [0.0816, 0.4807, 0.1978, 0.2399], [0.0907, 0.4957, 0.1829, 0.2308], [0.0728, 0.5015, 0.1900, 0.2357], ] ), }, "num_7B25F": {"predictions": torch.tensor([2.0436, 2.1158, 2.1222, 2.1964])}, } df = convert_dict_to_df(d) assert df.shape == (4, 5) # Check that all elements in nested lists are stored in each row assert all(len(row) == 2 for row in df["binary_C82EB_probabilities"]) assert all(len(row) == 4 for row in df["category_1491D_probabilities"]) ================================================ FILE: tests/ludwig/data/test_preprocessing.py ================================================ from ludwig.data.preprocessing import is_input_feature from tests.integration_tests.utils import text_feature def test_is_input_feature(): # Adds encoder when output_feature=False assert is_input_feature(text_feature(output_feature=False)) is True # Adds decoder when output_feature=True assert is_input_feature(text_feature(output_feature=True)) is False ================================================ FILE: tests/ludwig/data/test_ray_data.py ================================================ import os import shutil from unittest import mock import pandas as pd import pytest # Skip these tests if Ray is not installed ray = pytest.importorskip("ray") # noqa dask = pytest.importorskip("dask") # noqa from ludwig.data.dataset.ray import RayDatasetBatcher, read_remote_parquet # noqa # Mark the entire module as distributed pytestmark = pytest.mark.distributed def test_async_reader_error(): """Test that RayDatasetBatcher handles a dataset that produces no batches. When the dataset's iter_batches raises an error in the producer thread, the batcher should end up with last_batch=True (no data to consume). """ mock_dataset = mock.Mock() # map_batches returns a mock whose iter_batches yields nothing (empty iteration) mock_mapped = mock.Mock() mock_mapped.iter_batches.return_value = iter([]) mock_dataset.map_batches.return_value = mock_mapped features = { "num1": {"name": "num1", "type": "number"}, "bin1": {"name": "bin1", "type": "binary"}, } training_set_metadata = { "num1": {}, "bin1": {}, } batcher = RayDatasetBatcher( dataset=mock_dataset, features=features, training_set_metadata=training_set_metadata, batch_size=64, samples_per_epoch=100, ) # With no data to read, the batcher should immediately signal last batch assert batcher.last_batch() @pytest.fixture(scope="module") def parquet_file(ray_cluster_2cpu) -> str: """Write a multi-file parquet dataset to the cwd. Returns: The path to the parquet dataset. """ # The data needs to be written to a multi-file parquet format, otherwise the issue doesn't repro. To do this, we # partitition a test dataframe with dask and then write to file. df = pd.DataFrame({"col1": list(range(1000)), "col2": list(range(1000))}) df = dask.dataframe.from_pandas(df, chunksize=100) # Typically we would write test data to a temporary directory, but the issue this was set up to test only happens # when using relative filepaths. cwd = os.getcwd() filepath = os.path.join(cwd, "data.training.parquet") df.to_parquet(filepath, engine="pyarrow") yield filepath # Clean up the data shutil.rmtree(filepath) @pytest.fixture(scope="module", params=["absolute", "relative"]) def parquet_filepath(parquet_file: str, request: "pytest.FixtureRequest") -> str: """Convert a filepath in the CWD to either an absolute or relative path. Args: parquet_file: Absolute path to a parquet file in the CWD request: pytest request fixture with the fixture parameters Returns: Either the absolute or relative path of the parquet file. """ filepath_type = request.param return parquet_file if filepath_type == "absolute" else os.path.basename(parquet_file) def test_read_remote_parquet(parquet_filepath: str): """Test for the fix to https://github.com/ludwig-ai/ludwig/issues/3440. Parquet file reads will fail with `pyarrow.lib.ArrowInvalid` under the following conditions: 1) The Parquet data is in multi-file format 2) A relative filepath is passed to the read function 3) A filesystem object is passed to the read function The issue can be resolved by either: 1) Passing an absolute filepath 2) Not passing a filesystem object """ read_remote_parquet(parquet_filepath) ================================================ FILE: tests/ludwig/data/test_split.py ================================================ from datetime import datetime, timedelta from itertools import combinations from random import randrange from unittest.mock import Mock import numpy as np import pandas as pd import pytest from ludwig.data.dataframe.pandas import PandasEngine from ludwig.data.split import get_splitter try: from ludwig.data.dataframe.dask import DaskEngine except ImportError: DaskEngine = Mock def test_make_divisions_ensure_minimum_rows(): from ludwig.data.split import _make_divisions_ensure_minimum_rows # Constraints are satisfied, the function should make no change to divisions. divisions = _make_divisions_ensure_minimum_rows((70, 80), 100, min_val_rows=3, min_test_rows=3) assert divisions[0] == 70 assert divisions[1] == 80 # Constraints are satisfied, the function should make no change to divisions. divisions = _make_divisions_ensure_minimum_rows((20, 22), 25, min_val_rows=0, min_test_rows=0) assert divisions[0] == 20 assert divisions[1] == 22 # The number of rows in validation set is too small. divisions = _make_divisions_ensure_minimum_rows((17, 19), 25, min_val_rows=3, min_test_rows=3) assert divisions[0] == 16 assert divisions[1] == 19 # The number of rows in validation and test sets are both too small. divisions = _make_divisions_ensure_minimum_rows((20, 22), 25, min_val_rows=3, min_test_rows=3) assert divisions[0] == 19 assert divisions[1] == 22 @pytest.mark.parametrize( ("df_engine",), [ pytest.param(PandasEngine(), id="pandas"), pytest.param(DaskEngine(_use_ray=False), id="dask", marks=pytest.mark.distributed), ], ) def test_random_split(df_engine, ray_cluster_2cpu): nrows = 100 npartitions = 10 df = pd.DataFrame(np.random.randint(0, 100, size=(nrows, 3)), columns=["A", "B", "C"]) if isinstance(df_engine, DaskEngine): df = df_engine.df_lib.from_pandas(df, npartitions=npartitions) probs = (0.7, 0.1, 0.2) split_params = { "type": "random", "probabilities": probs, } splitter = get_splitter(**split_params) backend = Mock() backend.df_engine = df_engine splits = splitter.split(df, backend, random_seed=42) assert len(splits) == 3 for split, p in zip(splits, probs): if isinstance(df_engine, DaskEngine): # Dask splitting is not exact, so apply soft constraint here assert np.isclose(len(split), int(nrows * p), atol=5) else: assert len(split) == int(nrows * p) # Test determinism def compute(dfs): return [df.compute() if isinstance(backend.df_engine, DaskEngine) else df for df in dfs] splits = compute(splits) splits2 = compute(splitter.split(df, backend, random_seed=7)) for s1, s2 in zip(splits, splits2): assert not s1.equals(s2) splits3 = compute(splitter.split(df, backend, random_seed=42)) for s1, s3 in zip(splits, splits3): assert s1.equals(s3) @pytest.mark.parametrize( ("df_engine",), [ pytest.param(PandasEngine(), id="pandas"), pytest.param(DaskEngine(_use_ray=False), id="dask", marks=pytest.mark.distributed), ], ) def test_random_split_zero_probability_for_test_produces_no_zombie(df_engine, ray_cluster_2cpu): nrows = 102 npartitions = 10 df = pd.DataFrame(np.random.randint(0, 100, size=(nrows, 3)), columns=["A", "B", "C"]) if isinstance(df_engine, DaskEngine): df = df_engine.df_lib.from_pandas(df, npartitions=npartitions) probs = (0.7, 0.3, 0.0) split_params = { "type": "random", "probabilities": probs, } splitter = get_splitter(**split_params) backend = Mock() backend.df_engine = df_engine splits = splitter.split(df, backend, random_seed=42) assert len(splits[-1]) == 0 @pytest.mark.parametrize( ("df_engine",), [ pytest.param(PandasEngine(), id="pandas"), pytest.param(DaskEngine(_use_ray=False), id="dask", marks=pytest.mark.distributed), ], ) def test_fixed_split(df_engine, ray_cluster_2cpu): nrows = 100 npartitions = 10 thresholds = [60, 80, 100] df = pd.DataFrame(np.random.randint(0, 100, size=(nrows, 3)), columns=["A", "B", "C"]) def get_split(v): if v < thresholds[0]: return 0 if thresholds[0] <= v < thresholds[1]: return 1 return 2 df["split_col"] = df["C"].map(get_split).astype(np.int8) if isinstance(df_engine, DaskEngine): df = df_engine.df_lib.from_pandas(df, npartitions=npartitions) split_params = { "type": "fixed", "column": "split_col", } splitter = get_splitter(**split_params) backend = Mock() backend.df_engine = df_engine splits = splitter.split(df, backend) assert len(splits) == 3 last_t = 0 for split, t in zip(splits, thresholds): if isinstance(df_engine, DaskEngine): split = split.compute() assert np.all(split["C"] < t) assert np.all(split["C"] >= last_t) last_t = t @pytest.mark.parametrize( ("df_engine", "nrows", "atol"), [ pytest.param(PandasEngine(), 100, 1, id="pandas"), # Splitting with a distributed engine becomes more accurate with more rows. pytest.param(DaskEngine(_use_ray=False), 10000, 10, id="dask", marks=pytest.mark.distributed), ], ) @pytest.mark.parametrize( "class_probs", [ pytest.param(np.array([0.33, 0.33, 0.34]), id="balanced"), pytest.param(np.array([0.6, 0.2, 0.2]), id="imbalanced"), ], ) def test_stratify_split(df_engine, nrows, atol, class_probs, ray_cluster_2cpu): npartitions = 10 thresholds = np.cumsum((class_probs * nrows).astype(int)) df = pd.DataFrame(np.random.randint(0, 100, size=(nrows, 3)), columns=["A", "B", "C"]) def get_category(v): if v < thresholds[0]: return 0 if thresholds[0] <= v < thresholds[1]: return 1 return 2 df["category"] = df.index.map(get_category).astype(np.int8) if isinstance(df_engine, DaskEngine): df = df_engine.df_lib.from_pandas(df, npartitions=npartitions) probs = (0.7, 0.1, 0.2) split_params = { "type": "stratify", "column": "category", "probabilities": probs, } splitter = get_splitter(**split_params) backend = Mock() backend.df_engine = df_engine splits = splitter.split(df, backend, random_seed=42) assert len(splits) == 3 ratios = class_probs * nrows for split, p in zip(splits, probs): if isinstance(df_engine, DaskEngine): split = split.compute() for idx, r in enumerate(ratios): actual = np.sum(split["category"] == idx) expected = int(r * p) assert np.isclose(actual, expected, atol=atol) # Test determinism splits2 = splitter.split(df, backend, random_seed=7) for s1, s2 in zip(splits, splits2): if isinstance(df_engine, DaskEngine): s1 = s1.compute() s2 = s2.compute() assert not s1.equals(s2) splits3 = splitter.split(df, backend, random_seed=42) for s1, s3 in zip(splits, splits3): if isinstance(df_engine, DaskEngine): s1 = s1.compute() s3 = s3.compute() assert s1.equals(s3) @pytest.mark.parametrize( ("df_engine", "atol"), [ pytest.param(PandasEngine(), 1, id="pandas"), pytest.param(DaskEngine(_use_ray=False), 10, id="dask", marks=pytest.mark.distributed), ], ) def test_single_occurrence_stratified_split(df_engine, atol, ray_cluster_2cpu): nrows = 1000 df = pd.DataFrame(np.random.randint(0, 100, size=(nrows, 2)), columns=["A", "B"]) # create 4 classes, where two of them each occurs once in the dataframe. df["category"] = (nrows // 2 - 1) * [0, 1] + [2, 3] if isinstance(df_engine, DaskEngine): df = df_engine.df_lib.from_pandas(df, npartitions=10) probs = (0.7, 0.1, 0.2) split_params = { "type": "stratify", "column": "category", "probabilities": probs, } splitter = get_splitter(**split_params) backend = Mock() backend.df_engine = df_engine splits = splitter.split(df, backend, random_seed=42) assert len(splits) == 3 ratios = np.array([0.499, 0.499, 0.001, 0.001]) * nrows for split, p in zip(splits, probs): if isinstance(df_engine, DaskEngine): split = split.compute() for idx, r in enumerate(ratios): actual = np.sum(split["category"] == idx) expected = int(r * p) assert np.isclose(actual, expected, atol=atol) @pytest.mark.parametrize( ("df_engine",), [ pytest.param(PandasEngine(), id="pandas"), pytest.param(DaskEngine(_use_ray=False), id="dask", marks=pytest.mark.distributed), ], ) @pytest.mark.parametrize("format", ["str", "datetime"]) def test_datetime_split(format, df_engine, ray_cluster_2cpu): nrows = 100 npartitions = 10 df = pd.DataFrame(np.random.randint(0, 100, size=(nrows, 3)), columns=["A", "B", "C"]) def random_date(*args, **kwargs): start = datetime.strptime("1/1/1990 1:30 PM", "%m/%d/%Y %I:%M %p") end = datetime.strptime("1/1/2022 4:50 AM", "%m/%d/%Y %I:%M %p") delta = end - start int_delta = (delta.days * 24 * 60 * 60) + delta.seconds random_second = randrange(int_delta) t = start + timedelta(seconds=random_second) return str(t) if format == "str" else t df["date_col"] = df["C"].map(random_date) if isinstance(df_engine, DaskEngine): df = df_engine.df_lib.from_pandas(df, npartitions=npartitions) probs = (0.7, 0.1, 0.2) split_params = { "type": "datetime", "column": "date_col", "probabilities": probs, } splitter = get_splitter(**split_params) backend = Mock() backend.df_engine = df_engine splits = splitter.split(df, backend) assert len(splits) == 3 min_datestr = "1990-01-01 00:00:00" for split, p in zip(splits, probs): if isinstance(df_engine, DaskEngine): # Dask splitting is not exact, so apply soft constraint here split = split.compute() assert len(split) >= 1 # Dask splitting is not exact, so we can potentially apply soft constraint. However, this can also be flaky: # https://github.com/ludwig-ai/ludwig/actions/runs/4590907163/jobs/8106746310?pr=3315. # assert np.isclose(len(split), int(nrows * p), atol=15) else: assert len(split) == int(nrows * p) assert np.all(split["date_col"] > min_datestr) min_datestr = split["date_col"].max() @pytest.mark.parametrize( ("df_engine",), [ pytest.param(PandasEngine(), id="pandas"), pytest.param(DaskEngine(_use_ray=False), id="dask", marks=pytest.mark.distributed), ], ) def test_hash_split(df_engine, ray_cluster_2cpu): nrows = 100 npartitions = 10 df = pd.DataFrame(np.random.randint(0, 100, size=(nrows, 3)), columns=["A", "B", "C"]) df["id"] = np.arange(0, 100) if isinstance(df_engine, DaskEngine): df = df_engine.df_lib.from_pandas(df, npartitions=npartitions) probabilities = [0.8, 0.1, 0.1] split_params = {"type": "hash", "column": "id", "probabilities": probabilities} splitter = get_splitter(**split_params) backend = Mock() backend.df_engine = df_engine splits = splitter.split(df, backend) assert len(splits) == 3 if isinstance(df_engine, DaskEngine): splits = [split.compute() for split in splits] # IDs should not overlap between splits assert all([set(split1["id"]).isdisjoint(set(split2["id"])) for split1, split2 in combinations(splits, 2)]) for split, p in zip(splits, probabilities): # Should be approximately the same size as the desired proportion assert nrows * p - 5 <= len(split["id"]) <= nrows * p + 5 # Need to ensure deterministic splitting even as we append data df2 = pd.DataFrame(np.random.randint(0, 100, size=(nrows, 3)), columns=["A", "B", "C"]) df2["id"] = np.arange(100, 200) nrows *= 2 df = df_engine.df_lib.concat([df, df2]) splits2 = splitter.split(df, backend) assert len(splits2) == 3 if isinstance(df_engine, DaskEngine): splits2 = [split.compute() for split in splits2] # IDs should not overlap between splits assert all([set(split1["id"]).isdisjoint(set(split2["id"])) for split1, split2 in combinations(splits2, 2)]) for split1, split2, p in zip(splits, splits2, probabilities): ids1 = set(split1["id"].values.tolist()) ids2 = set(split2["id"].values.tolist()) assert nrows * p - 10 <= len(ids2) <= nrows * p + 10 # All elements from the first round of splitting are in the same split, even after appending # more rows assert ids1.issubset(ids2) ================================================ FILE: tests/ludwig/datasets/__init__.py ================================================ # Copyright (c) 2023 Predibase, Inc., 2020 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. # ============================================================================== ================================================ FILE: tests/ludwig/datasets/download_all_datasets.py ================================================ #! /usr/bin/env python # # Lists and downloads all datasets, including Kaggle datasets, into ./download_datasets # You must have valid kaggle credentials in your environment, a few GB of disk space, and good internet bandwidth. # Also, for each dataset associated with a Kaggle competition you'll need to sign in to Kaggle and accept the terms of # the competition. # from ludwig import datasets def download_all_datasets(): """Downloads all datasets to ./downloaded_datasets.""" dataset_names = datasets.list_datasets() print("Datasets: ") for name in dataset_names: print(f" {name}") print("Downloading all datasets") # Download All Datasets for dataset_name in dataset_names: print(f"Downloading {dataset_name}") datasets.download_dataset(dataset_name, "./downloaded_datasets") if __name__ == "__main__": download_all_datasets() ================================================ FILE: tests/ludwig/datasets/mnist/test_mnist_workflow.py ================================================ import gzip import os import shutil from unittest import mock import ludwig.datasets from ludwig.datasets.dataset_config import DatasetConfig from ludwig.datasets.loaders.dataset_loader import DatasetState def test_download_mnist_dataset(tmpdir): train_image_archive_filename = os.path.join(tmpdir, "train-images-idx3-ubyte") train_image_handle = open(train_image_archive_filename, "w+b") train_image_handle.write(b"This binary string will be written as training mage data") train_image_handle.close() with open(train_image_archive_filename, "rb") as f_in: with gzip.open(train_image_archive_filename + ".gz", "wb") as f_out: shutil.copyfileobj(f_in, f_out) train_labels_archive_filename = os.path.join(tmpdir, "train-labels-idx1-ubyte") train_labels_handle = open(train_labels_archive_filename, "w") train_labels_handle.write("0") train_labels_handle.close() with open(train_labels_archive_filename, "rb") as f_in: with gzip.open(train_labels_archive_filename + ".gz", "wb") as f_out: shutil.copyfileobj(f_in, f_out) test_image_archive_filename = os.path.join(tmpdir, "t10k-images-idx3-ubyte") test_image_handle = open(test_image_archive_filename, "w+b") test_image_handle.write(b"This binary string will be written as test mage data") test_image_handle.close() with open(test_image_archive_filename, "rb") as f_in: with gzip.open(test_image_archive_filename + ".gz", "wb") as f_out: shutil.copyfileobj(f_in, f_out) test_labels_archive_filename = os.path.join(tmpdir, "t10k-labels-idx1-ubyte") test_labels_handle = open(test_labels_archive_filename, "w") test_labels_handle.write("0") test_labels_handle.close() with open(test_labels_archive_filename, "rb") as f_in: with gzip.open(test_labels_archive_filename + ".gz", "wb") as f_out: shutil.copyfileobj(f_in, f_out) download_urls = [ "file://" + train_image_archive_filename + ".gz", "file://" + train_labels_archive_filename + ".gz", "file://" + test_image_archive_filename + ".gz", "file://" + test_labels_archive_filename + ".gz", ] config = DatasetConfig( version=1.0, name="mnist", download_urls=download_urls, ) ludwig.datasets._get_dataset_configs.cache_clear() with mock.patch("ludwig.datasets._load_dataset_config", return_value=config): dataset = ludwig.datasets.get_dataset("mnist", cache_dir=tmpdir) assert not dataset.state == DatasetState.DOWNLOADED assert not dataset.state == DatasetState.TRANSFORMED dataset.download() assert dataset.state == DatasetState.DOWNLOADED ludwig.datasets._get_dataset_configs.cache_clear() ================================================ FILE: tests/ludwig/datasets/model_configs/train_all_model_configs.py ================================================ #! /usr/bin/env python # # Trains a ludwig model for every dataset which has a default_model_config. # You must have valid kaggle credentials in your environment, a few GB of disk space, and good internet bandwidth. # Also, for each dataset associated with a Kaggle competition you'll need to sign in to Kaggle and accept the terms of # the competition. # import multiprocessing import time from dataclasses import dataclass import pandas as pd from ludwig import datasets, visualize from ludwig.api import LudwigModel from ludwig.globals import LUDWIG_VERSION from ludwig.utils.misc_utils import get_commit_hash @dataclass class TrainingResults: """Results of a training run for a dataset.""" ludwig_version: str ludwig_commit: str | None dataset_version: str dataset_name: str has_config: bool output_directory: str | None = None splits: str | None = None metric: str | None = None performance: float | None = None load_time: float | None = None train_time: float | None = None eval_time: float | None = None def _train_dataset_process(dataset_name, results_queue): """Runs each train job in a new process.""" load_start_time = time.time() dataset = datasets.get_dataset(dataset_name) config = dataset.default_model_config df = dataset.load() load_end_time = time.time() if "split" not in df: df["split"] = 0 available_splits = sorted(df.split.unique()) results = TrainingResults( LUDWIG_VERSION, get_commit_hash(), dataset.version, dataset.name, config is not None, splits=" ".join([str(s) for s in available_splits]), load_time=load_end_time - load_start_time, ) if config: dataset.export(".") print(f"Training {dataset_name}") # Train model on config train_start_time = time.time() model = LudwigModel(config) train_stats, _, output_directory = model.train(dataset=df, model_name=dataset_name) # If dataset has a test split with labels, evaluate on test set. If not, evaluate on training set. evaluate_start_time = time.time() eval_stats, _, _ = model.evaluate( df, split=2 if 2 in available_splits else 0, collect_predictions=False, collect_overall_stats=True, ) evaluate_end_time = time.time() # Visualize learning curve visualize.learning_curves([train_stats], model_names=[dataset_name], output_directory=output_directory) results.output_directory = output_directory # Get metric for first output feature first_of_name = config["output_features"][0]["name"] stats = eval_stats[first_of_name] if "accuracy" in stats: results.metric = "accuracy" results.performance = stats["accuracy"] elif "root_mean_squared_error" in stats: results.metric = "root_mean_squared_error" results.performance = stats["root_mean_squared_error"] elif "mean_squared_error" in stats: results.metric = "mean_squared_error" results.performance = stats["mean_squared_error"] elif "mean_absolute_error" in stats: results.metric = "mean_absolute_error" results.performance = stats["mean_absolute_error"] elif "loss" in stats: results.metric = "loss" results.performance = stats["loss"] results.train_time = evaluate_start_time - train_start_time results.eval_time = evaluate_end_time - evaluate_start_time print(f"Trained {dataset_name} in {evaluate_end_time - load_start_time:.2f} seconds") results_queue.put(results) def train_all_datasets(): # Maps dataset name to current running process. max_processes = 4 running_processes = {} accumulated_results = [] # As each process completes it pushes its results onto the results_queue. results_queue = multiprocessing.Queue() for dataset_name in datasets.list_datasets(): if len(running_processes) >= max_processes: # Block until a subprocess completes next_results = results_queue.get() accumulated_results.append(next_results) process = running_processes[next_results.dataset_name] process.join() del running_processes[next_results.dataset_name] process = multiprocessing.Process(target=_train_dataset_process, args=[dataset_name, results_queue]) running_processes[dataset_name] = process process.start() while len(running_processes) > 0: if len(running_processes) < 4: remaining_datasets = ", ".join(sorted(running_processes.keys())) print(f"Finishing up, waiting for {len(running_processes)} to complete ({remaining_datasets})") else: print(f"Finishing up, waiting for {len(running_processes)} to complete") # Block until a subprocess completes, clear it out, next_results = results_queue.get() accumulated_results.append(next_results) process = running_processes[next_results.dataset_name] process.join() del running_processes[next_results.dataset_name] results_df = pd.DataFrame(accumulated_results) with pd.option_context( "display.max_rows", None, "display.max_columns", None, "display.precision", 3, "display.width", 120 ): results_to_display = results_df[results_df["has_config"]].copy() results_to_display = results_to_display.drop( columns=["dataset_version", "output_directory", "ludwig_version", "ludwig_commit", "has_config"] ) print(results_to_display) results_df.to_csv("train_all_model_configs_results.csv", index=False) if __name__ == "__main__": train_all_datasets() ================================================ FILE: tests/ludwig/datasets/test_dataset_configs.py ================================================ import ludwig.datasets from ludwig.datasets.dataset_config import DatasetConfig from ludwig.datasets.loaders.dataset_loader import DatasetLoader from tests.integration_tests.utils import private_test @private_test def test_get_config_and_load(tmpdir): yosemite_config = ludwig.datasets._get_dataset_config("yosemite") assert isinstance(yosemite_config, DatasetConfig) yosemite_dataset = ludwig.datasets.get_dataset("yosemite", cache_dir=tmpdir) assert isinstance(yosemite_dataset, DatasetLoader) df = yosemite_dataset.load() assert df is not None assert len(df) == 18721 # Expected number of rows in Yosemite temperatures dataset. # DISABLED: Flaky for tests, probably due to the dataset size. # # Test loading dataset without 'split' and 'Unnamed: 0' columns in config. # twitter_bots_config = ludwig.datasets._get_dataset_config("twitter_bots") # assert isinstance(twitter_bots_config, DatasetConfig) # twitter_bots_dataset = ludwig.datasets.get_dataset("twitter_bots", cache_dir=tmpdir) # assert isinstance(twitter_bots_dataset, DatasetLoader) # df = twitter_bots_dataset.load() # assert df is not None # assert len(df.columns) == 22 # Expected number of columns in Twitter bots dataset including split column. def test_get_config_kaggle(tmpdir): twitter_bots_config = ludwig.datasets._get_dataset_config("twitter_bots") assert isinstance(twitter_bots_config, DatasetConfig) twitter_bots_dataset = ludwig.datasets.get_dataset("twitter_bots", cache_dir=tmpdir) # Twitter bots dataset is large, so we won't load it in this unit test. assert isinstance(twitter_bots_dataset, DatasetLoader) assert twitter_bots_dataset.is_kaggle_dataset ================================================ FILE: tests/ludwig/datasets/test_dataset_links.py ================================================ #! /usr/bin/env python # # Checks all dataset download links (just those with URLs, not including kaggle datasets).""" # import logging from concurrent.futures import as_completed, ThreadPoolExecutor import pytest import requests import ludwig logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @pytest.mark.slow def test_links(): # Iterate through all datasets, ensure links are valid and reachable. all_datasets = ludwig.datasets.list_datasets() tasks = {} with ThreadPoolExecutor(max_workers=10) as executor: for dataset_name in all_datasets: config = ludwig.datasets._get_dataset_config(dataset_name) download_urls = [config.download_urls] if isinstance(config.download_urls, str) else config.download_urls for url in download_urls: future = executor.submit(_check_url, dataset_name, url) tasks[future] = (dataset_name, url) failures = [] for future in as_completed(tasks): dataset_name, url = tasks[future] error = future.result() if error: failures.append(error) assert not failures, "Failed URLs:\n" + "\n".join(failures) def _check_url(dataset_name, url): logger.info(f"Checking {dataset_name}: {url}") try: response = requests.head(url, timeout=30) if not response.ok: return f"Failed to download {dataset_name} from {url} (status {response.status_code})" except requests.RequestException as e: return f"Failed to download {dataset_name} from {url} ({e})" return None ================================================ FILE: tests/ludwig/datasets/test_datasets.py ================================================ import importlib import importlib.util import io import os import uuid from unittest import mock import pandas as pd import pytest import ludwig.datasets from ludwig.api import LudwigModel from ludwig.datasets.dataset_config import DatasetConfig from ludwig.datasets.loaders.dataset_loader import DatasetState from tests.integration_tests.utils import private_test SUPPORTED_UNCOMPRESSED_FILETYPES = ["json", "jsonl", "tsv", "csv"] def test_load_csv_dataset(tmpdir): input_df = pd.DataFrame( {"name": ["Raphael", "Donatello"], "mask": ["red", "purple"], "weapon": ["sai", "bo staff"], "split": [0, 1]} ) extracted_filename = "input.csv" compression_opts = dict(method="zip", archive_name=extracted_filename) archive_filename = os.path.join(tmpdir, "archive.zip") input_df.to_csv(archive_filename, index=False, compression=compression_opts) config = DatasetConfig( version=1.0, name="fake_csv_dataset", download_urls=["file://" + archive_filename], ) ludwig.datasets._get_dataset_configs.cache_clear() with mock.patch("ludwig.datasets._load_dataset_config", return_value=config): dataset = ludwig.datasets.get_dataset("fake_csv_dataset", cache_dir=tmpdir) assert not dataset.state == DatasetState.DOWNLOADED assert not dataset.state == DatasetState.TRANSFORMED output_df = dataset.load() pd.testing.assert_frame_equal(input_df, output_df) assert dataset.state == DatasetState.TRANSFORMED ludwig.datasets._get_dataset_configs.cache_clear() @pytest.mark.parametrize("f_type", SUPPORTED_UNCOMPRESSED_FILETYPES) def test_multifile_join_dataset(tmpdir, f_type): if f_type != "jsonl": train_df = pd.DataFrame( {"name": ["Raphael", "Donatello"], "mask": ["red", "purple"], "weapon": ["sai", "bo staff"]} ) test_df = pd.DataFrame({"name": ["Jack", "Bob"], "mask": ["green", "yellow"], "weapon": ["knife", "gun"]}) val_df = pd.DataFrame({"name": ["Tom"], "mask": ["pink"], "weapon": ["stick"]}) else: train_df = pd.DataFrame([{"name": "joe"}, {"mask": "green"}, {"weapon": "stick"}]) test_df = pd.DataFrame([{"name": "janice"}, {"mask": "black"}, {"weapon": "gun"}]) val_df = pd.DataFrame([{"name": "sara"}, {"mask": "pink"}, {"weapon": "gun"}]) # filetypes = ['json', 'tsv', 'jsonl'] train_filename = "train." + f_type test_filename = "test." + f_type val_filename = "val." + f_type train_filepath = os.path.join(tmpdir, train_filename) test_filepath = os.path.join(tmpdir, test_filename) val_filepath = os.path.join(tmpdir, val_filename) if f_type == "json": train_df.to_json(train_filepath) test_df.to_json(test_filepath) val_df.to_json(val_filepath) elif f_type == "jsonl": train_df.to_json(train_filepath, orient="records", lines=True) test_df.to_json(test_filepath, orient="records", lines=True) val_df.to_json(val_filepath, orient="records", lines=True) elif f_type == "tsv": train_df.to_csv(train_filepath, sep="\t") test_df.to_csv(test_filepath, sep="\t") val_df.to_csv(val_filepath, sep="\t") else: train_df.to_csv(train_filepath) test_df.to_csv(test_filepath) val_df.to_csv(val_filepath) config = DatasetConfig( version=1.0, name="fake_multifile_dataset", download_urls=["file://" + train_filepath, "file://" + test_filepath, "file://" + val_filepath], train_filenames=train_filename, validation_filenames=val_filename, test_filenames=test_filename, ) ludwig.datasets._get_dataset_configs.cache_clear() with mock.patch("ludwig.datasets._load_dataset_config", return_value=config): dataset = ludwig.datasets.get_dataset("fake_multifile_dataset", cache_dir=tmpdir) assert not dataset.state == DatasetState.DOWNLOADED assert not dataset.state == DatasetState.TRANSFORMED output_df = dataset.load() assert output_df.shape[0] == train_df.shape[0] + test_df.shape[0] + val_df.shape[0] assert dataset.state == DatasetState.TRANSFORMED ludwig.datasets._get_dataset_configs.cache_clear() @pytest.mark.parametrize( "include_competitions,include_data_modalities", [(True, True), (True, False), (False, True), (False, False)] ) def test_get_datasets_info(include_competitions, include_data_modalities): dataset_output_features = ludwig.datasets.get_datasets_output_features( include_competitions=include_competitions, include_data_modalities=include_data_modalities ) assert len(dataset_output_features) > 1 assert isinstance(dataset_output_features, dict) assert dataset_output_features["twitter_bots"].get("name", None) assert dataset_output_features["twitter_bots"].get("output_features", None) assert isinstance(dataset_output_features["twitter_bots"]["output_features"], list) assert dataset_output_features["twitter_bots"]["output_features"][0].get("name", None) assert dataset_output_features["twitter_bots"]["output_features"][0].get("type", None) if include_competitions: assert dataset_output_features["titanic"].get("name", None) else: assert dataset_output_features.get("titanic", None) is None if include_data_modalities: data_modalities = dataset_output_features["twitter_bots"].get("data_modalities", None) assert data_modalities assert len(data_modalities) >= 1 else: assert dataset_output_features["twitter_bots"].get("data_modalities", None) is None dataset_output_features = ludwig.datasets.get_datasets_output_features(dataset="twitter_bots") assert len(dataset_output_features["output_features"]) == 1 assert dataset_output_features["name"] == "twitter_bots" def test_get_dataset_buffer(): buffer = ludwig.datasets.get_buffer("iris") assert isinstance(buffer, io.BytesIO) def test_train_dataset_uri(tmpdir): input_df = pd.DataFrame( { "input": ["a", "b", "a", "b", "a", "b", "c", "c", "a", "b"], "output": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], "split": [0, 0, 0, 0, 0, 0, 0, 1, 2, 2], } ) extracted_filename = "input.csv" compression_opts = dict(method="zip", archive_name=extracted_filename) archive_filename = os.path.join(tmpdir, "archive.zip") input_df.to_csv(archive_filename, index=False, compression=compression_opts) dataset_name = f"fake_csv_dataset_{uuid.uuid4().hex}" config = DatasetConfig( version=1.0, name=dataset_name, download_urls=["file://" + archive_filename], ) model_config = { "input_features": [{"name": "input", "type": "category"}], "output_features": [{"name": "output", "type": "number"}], "preprocessing": {"split": {"type": "fixed"}}, "combiner": {"type": "concat", "fc_size": 14}, "trainer": {"batch_size": 8, "epochs": 1}, } ludwig.datasets._get_dataset_configs.cache_clear() with mock.patch("ludwig.datasets._load_dataset_config", return_value=config): with mock.patch("ludwig.datasets.loaders.dataset_loader.get_default_cache_location", return_value=str(tmpdir)): model = LudwigModel(model_config, backend="local") results = model.train(dataset=f"ludwig://{dataset_name}") proc_result = results.preprocessed_data train_df1 = proc_result.training_set.to_df() val_df1 = proc_result.validation_set.to_df() test_df1 = proc_result.test_set.to_df() assert len(train_df1) == 7 assert len(val_df1) == 1 assert len(test_df1) == 2 results = model.train( training_set=f"ludwig://{dataset_name}", validation_set=f"ludwig://{dataset_name}", test_set=f"ludwig://{dataset_name}", ) proc_result_split = results.preprocessed_data train_df2 = proc_result_split.training_set.to_df() val_df2 = proc_result_split.validation_set.to_df() test_df2 = proc_result_split.test_set.to_df() assert len(train_df2) == 7 assert len(val_df2) == 1 assert len(test_df2) == 2 sort_col = train_df1.columns[-1] def sort_df(df): return df.sort_values(by=[sort_col]).reset_index(drop=True) assert sort_df(train_df1).equals(sort_df(train_df2)) assert sort_df(val_df1).equals(sort_df(val_df2)) assert sort_df(test_df1).equals(sort_df(test_df2)) ludwig.datasets._get_dataset_configs.cache_clear() @private_test @pytest.mark.parametrize("dataset_name, size", [("code_alpaca", 20000), ("consumer_complaints", 38000)]) def test_ad_hoc_dataset_download(tmpdir, dataset_name, size): dataset_config = ludwig.datasets._get_dataset_config(dataset_name) assert isinstance(dataset_config, DatasetConfig) ludwig_dataset = ludwig.datasets.get_dataset(dataset_name, cache_dir=tmpdir) df = ludwig_dataset.load() assert df is not None assert len(df) >= size @pytest.mark.skipif(not importlib.util.find_spec("datasets"), reason="huggingface datasets not installed") @pytest.mark.xfail(reason="HuggingFace datasets library no longer supports loading datasets via scripts") def test_hf_dataset_loading(): import datasets loader = ludwig.datasets.get_dataset("hugging_face") data = loader.load("JeremyAlain/123_test", "data_0") hf_data = datasets.load_dataset(path="JeremyAlain/123_test", name="data_0") assert len(data) == hf_data["train"].num_rows train, val, test = loader.load("neil-code/dialogsum-test", None, split=True) hf_data = datasets.load_dataset(path="neil-code/dialogsum-test") assert len(train) == hf_data["train"].num_rows assert len(val) == hf_data["validation"].num_rows assert len(test) == hf_data["test"].num_rows ================================================ FILE: tests/ludwig/datasets/test_model_configs.py ================================================ import ludwig.datasets def test_default_model_config(tmpdir): titanic_configs = ludwig.datasets.model_configs_for_dataset("titanic") assert len(titanic_configs) > 0 titanic = ludwig.datasets.get_dataset("titanic", cache_dir=tmpdir) assert titanic.default_model_config is not None assert titanic.default_model_config == titanic_configs["default"] def test_best_model_config(tmpdir): higgs_configs = ludwig.datasets.model_configs_for_dataset("higgs") assert len(higgs_configs) > 0 higgs = ludwig.datasets.get_dataset("higgs", cache_dir=tmpdir) assert higgs.default_model_config is not None assert higgs.best_model_config is not None assert higgs.default_model_config == higgs_configs["default"] assert higgs.best_model_config == higgs_configs["best"] def test_dataset_has_no_model_configs(tmpdir): bbc_news_configs = ludwig.datasets.model_configs_for_dataset("bbcnews") assert len(bbc_news_configs) == 0 bbcnews = ludwig.datasets.get_dataset("bbcnews", cache_dir=tmpdir) assert bbcnews.default_model_config is None ================================================ FILE: tests/ludwig/datasets/titanic/test_titanic_workflow.py ================================================ import os import zipfile from shutil import copy from unittest import mock import pandas as pd import ludwig.datasets from ludwig.datasets.dataset_config import DatasetConfig from ludwig.datasets.loaders.dataset_loader import DatasetState def test_download_titanic_dataset(tmpdir): titanic_train_df = pd.DataFrame( { "passenger_id": [1216, 699, 234], "pclass": [3, 3, 4], "name": ["sai bo", "bo staff", "tae kwan nic"], "sex": ["female", "male", "male"], "age": [38, 28, 18], "sibsp": [0, 1, 0], "parch": [1, 1, 2], "ticket": [335432, 315089, 322472], "fare": [7.7333, 8.6625, 9.8765], "cabin": [1, 2, 4], "embarked": ["C", "Q", "S"], "boat": [0, 0, 0], "body": [0, 1, 0], "home.dest": ["Croatia", "Italy", "Sweden"], "survived": [0, 1, 0], } ) titanic_test_df = pd.DataFrame( { "passenger_id": [1216, 699, 234], "pclass": [3, 3, 4], "name": ["mo bo", "bo bo bo", "Rafael Nadal"], "sex": ["female", "male", "male"], "age": [28, 18, 30], "sibsp": [0, 1, 0], "parch": [1, 1, 2], "ticket": [335412, 215089, 922472], "fare": [17.7333, 18.6625, 19.8765], "cabin": [2, 2, 1], "embarked": ["Q", "Q", "C"], "boat": [0, 0, 0], "body": [0, 1, 0], "home.dest": ["Sweden", "Slovenia", "Italy"], "survived": [0, 1, 0], } ) train_fname = os.path.join(tmpdir, "train.csv") titanic_train_df.to_csv(train_fname, index=False) test_fname = os.path.join(tmpdir, "test.csv") titanic_test_df.to_csv(test_fname, index=False) archive_filename = os.path.join(tmpdir, "titanic.zip") with zipfile.ZipFile(archive_filename, "w") as z: z.write(train_fname, "train.csv") z.write(test_fname, "test.csv") config = DatasetConfig( version=1.0, name="titanic", kaggle_competition="titanic", archive_filenames="titanic.zip", # Normally we would verify the zip file, but in this test the zip file is created every time and contains the # creation dates of the csv files so its digest will be different every time the test is run. sha256={ "test.csv": "348c49a95fe099fcc3b9142c82fb6becb87edc0f4d2c69c485e0dce4af8625e0", "train.csv": "483556c465414fd78deb02b25f39a0de844b0728c1ef0505df0e5b3e40fec995", }, train_filenames="train.csv", test_filenames="test.csv", ) def download_files(competition_name, path): assert competition_name == "titanic" copy(archive_filename, path) ludwig.datasets._get_dataset_configs.cache_clear() with mock.patch("ludwig.datasets._load_dataset_config", return_value=config): with mock.patch("ludwig.datasets.kaggle.create_kaggle_client") as mock_kaggle_cls: mock_kaggle_api = mock.MagicMock() mock_kaggle_api.competition_download_files = download_files mock_kaggle_cls.return_value = mock_kaggle_api dataset = ludwig.datasets.get_dataset("titanic", cache_dir=tmpdir) assert not dataset.state == DatasetState.DOWNLOADED dataset.download() assert dataset.state == DatasetState.DOWNLOADED mock_kaggle_api.authenticate.assert_called_once() assert not dataset.state == DatasetState.TRANSFORMED dataset.extract() # Normally we would verify before extracting, but in this test the zip file is created on each run and # changes between test runs. Instead we verify the extracted .csv files. dataset.verify() dataset.transform() assert dataset.state == DatasetState.TRANSFORMED output_train_df, output_test_df, output_val_df = dataset.load(split=True) assert len(output_train_df) == len(titanic_train_df) assert len(output_test_df) == len(titanic_test_df) assert len(output_val_df) == 0 ludwig.datasets._get_dataset_configs.cache_clear() ================================================ FILE: tests/ludwig/decoders/test_image_decoder.py ================================================ import pytest import torch from ludwig.constants import ENCODER_OUTPUT, ENCODER_OUTPUT_STATE, HIDDEN, LOGITS from ludwig.decoders.image_decoders import UNetDecoder from ludwig.encoders.image.base import UNetEncoder from ludwig.utils.misc_utils import set_random_seed from tests.integration_tests.parameter_update_utils import check_module_parameters_updated RANDOM_SEED = 1919 @pytest.mark.parametrize("height,width,num_channels,num_classes", [(224, 224, 1, 2), (224, 224, 3, 8)]) @pytest.mark.parametrize("batch_size", [4, 1]) def test_unet_decoder(height, width, num_channels, num_classes, batch_size): # make repeatable set_random_seed(RANDOM_SEED) unet_encoder = UNetEncoder(height=height, width=width, num_channels=num_channels) inputs = torch.rand(batch_size, num_channels, height, width) encoder_outputs = unet_encoder(inputs) assert encoder_outputs[ENCODER_OUTPUT].shape[1:] == unet_encoder.output_shape assert len(encoder_outputs[ENCODER_OUTPUT_STATE]) == 4 hidden = torch.reshape(encoder_outputs[ENCODER_OUTPUT], [batch_size, -1]) unet_decoder = UNetDecoder(hidden.size(dim=1), height, width, 1, num_classes) combiner_outputs = { HIDDEN: hidden, ENCODER_OUTPUT_STATE: encoder_outputs[ENCODER_OUTPUT_STATE].copy(), # create a copy } output = unet_decoder(combiner_outputs, target=None) assert list(output[LOGITS].size()) == [batch_size, num_classes, height, width] # check for parameter updating target = torch.randn(output[LOGITS].shape) combiner_outputs[ENCODER_OUTPUT_STATE] = encoder_outputs[ENCODER_OUTPUT_STATE] # restore state fpc, tpc, upc, not_updated = check_module_parameters_updated(unet_decoder, (combiner_outputs, None), target) assert upc == tpc, f"Failed to update parameters. Parameters not updated: {not_updated}" ================================================ FILE: tests/ludwig/decoders/test_llm_decoders.py ================================================ import pytest import torch from ludwig.constants import BACKEND, BASE_MODEL, GENERATION, INPUT_FEATURES, MODEL_TYPE, OUTPUT_FEATURES from ludwig.decoders.llm_decoders import TextExtractorDecoder from ludwig.schema.model_config import ModelConfig from tests.integration_tests.utils import text_feature TEST_MODEL_NAME = "hf-internal-testing/tiny-random-GPTJForCausalLM" def test_text_extractor_decoder(): max_new_tokens = 4 input_features = [ { "name": "Question", "type": "text", "encoder": {"type": "passthrough"}, } ] output_features = [text_feature(output_feature=True, name="Answer", decoder={"type": "text_extractor"})] config = { MODEL_TYPE: "llm", BASE_MODEL: TEST_MODEL_NAME, GENERATION: { "temperature": 0.1, "top_p": 0.75, "top_k": 40, "num_beams": 4, "max_new_tokens": max_new_tokens, }, INPUT_FEATURES: input_features, OUTPUT_FEATURES: output_features, BACKEND: "local", } config = ModelConfig.from_dict(config) decoder_config = config.output_features[0].decoder decoder = TextExtractorDecoder(32, decoder_config) inputs = [ torch.tensor([1, 1, 1, 2, 2, 2, 2]), # baseline torch.tensor([1, 1, 1, 2]), # too short; test padding torch.tensor([1, 1, 1, 1, 2, 2, 2]), # test different input length ] input_lengths = [3, 3, 4] # tests happy path outputs = decoder.forward(inputs, input_lengths, max_new_tokens) assert outputs["predictions"].shape == (3, max_new_tokens) # Create a Boolean mask for elements equal to 0 or 2 (padding or output) mask = (outputs["predictions"] == 0) | (outputs["predictions"] == 2) assert mask.all() # test overly long generation fails without updated max_new_tokens inputs.append(torch.tensor([1, 1, 1, 2, 2, 2, 2, 2])) # too long; test downstream failure) input_lengths.append(3) with pytest.raises(ValueError): outputs = decoder.forward(inputs, input_lengths, max_new_tokens) # test overly long generation succeeds with new max_new_tokens new_max_new_tokens = 5 outputs = decoder.forward(inputs, input_lengths, new_max_new_tokens) assert outputs["predictions"].shape == (4, new_max_new_tokens) mask = (outputs["predictions"] == 0) | (outputs["predictions"] == 2) assert mask.all() ================================================ FILE: tests/ludwig/decoders/test_sequence_decoder.py ================================================ import pytest import torch from ludwig.constants import HIDDEN, LOGITS from ludwig.decoders.sequence_decoders import ( LSTMDecoder, RNNDecoder, SequenceGeneratorDecoder, SequenceLSTMDecoder, SequenceRNNDecoder, ) from ludwig.utils.misc_utils import set_random_seed from tests.integration_tests.parameter_update_utils import check_module_parameters_updated RANDOM_SEED = 1919 @pytest.mark.parametrize("cell_type", ["rnn", "gru"]) @pytest.mark.parametrize("num_layers", [1, 2]) @pytest.mark.parametrize("batch_size", [20, 1]) def test_rnn_decoder(cell_type, num_layers, batch_size): hidden_size = 256 vocab_size = 50 input = torch.randint(vocab_size, size=(batch_size,)) initial_hidden = torch.zeros(num_layers, batch_size, hidden_size) rnn_decoder = RNNDecoder(hidden_size, vocab_size, cell_type, num_layers=num_layers) output = rnn_decoder(input, initial_hidden) assert len(output) == 2 assert list(output[0].size()) == [batch_size, 1, vocab_size] assert list(output[1].size()) == [num_layers, batch_size, hidden_size] @pytest.mark.parametrize("num_layers", [1, 2]) @pytest.mark.parametrize("batch_size", [20, 1]) def test_lstm_decoder(num_layers, batch_size): hidden_size = 256 vocab_size = 50 input = torch.randint(vocab_size, size=(batch_size,)) initial_hidden = torch.zeros(num_layers, batch_size, hidden_size) initial_cell_state = torch.zeros(num_layers, batch_size, hidden_size) lstm_decoder = LSTMDecoder(hidden_size, vocab_size, num_layers=num_layers) output = lstm_decoder(input, initial_hidden, initial_cell_state) assert len(output) == 3 assert list(output[0].size()) == [batch_size, 1, vocab_size] assert list(output[1].size()) == [num_layers, batch_size, hidden_size] assert list(output[2].size()) == [num_layers, batch_size, hidden_size] @pytest.mark.parametrize("cell_type", ["rnn", "gru"]) @pytest.mark.parametrize("num_layers", [1, 2]) @pytest.mark.parametrize("batch_size", [20, 1]) def test_sequence_rnn_decoder(cell_type, num_layers, batch_size): hidden_size = 256 vocab_size = 50 max_sequence_length = 10 # make repeatable set_random_seed(RANDOM_SEED) combiner_outputs = {HIDDEN: torch.rand([batch_size, hidden_size])} sequence_rnn_decoder = SequenceRNNDecoder( hidden_size, vocab_size, max_sequence_length, cell_type, num_layers=num_layers ) output = sequence_rnn_decoder(combiner_outputs, target=None) assert list(output.size()) == [batch_size, max_sequence_length, vocab_size] # check for parameter updating target = torch.randn(output.shape) fpc, tpc, upc, not_updated = check_module_parameters_updated(sequence_rnn_decoder, (combiner_outputs, None), target) assert upc == tpc, f"Failed to update parameters. Parameters not updated: {not_updated}" @pytest.mark.parametrize("num_layers", [1, 2]) @pytest.mark.parametrize("batch_size", [20, 1]) def test_sequence_lstm_decoder(num_layers, batch_size): hidden_size = 256 vocab_size = 50 max_sequence_length = 10 # make repeatable set_random_seed(RANDOM_SEED) combiner_outputs = {HIDDEN: torch.rand([batch_size, hidden_size])} sequence_lstm_decoder = SequenceLSTMDecoder(hidden_size, vocab_size, max_sequence_length, num_layers=num_layers) output = sequence_lstm_decoder(combiner_outputs, target=None) assert list(output.size()) == [batch_size, max_sequence_length, vocab_size] # check for parameter updating target = torch.randn(output.shape) fpc, tpc, upc, not_updated = check_module_parameters_updated( sequence_lstm_decoder, (combiner_outputs, None), target ) assert upc == tpc, f"Failed to update parameters. Parameters not updated: {not_updated}" @pytest.mark.parametrize("cell_type", ["rnn", "gru", "lstm"]) @pytest.mark.parametrize("num_layers", [1, 2]) @pytest.mark.parametrize("batch_size", [20, 1]) def test_sequence_generator_decoder(cell_type, num_layers, batch_size): hidden_size = 256 vocab_size = 50 max_sequence_length = 10 # make repeatable set_random_seed(RANDOM_SEED) combiner_outputs = {HIDDEN: torch.rand([batch_size, hidden_size])} sequence_rnn_decoder = SequenceGeneratorDecoder( input_size=hidden_size, vocab_size=vocab_size, max_sequence_length=max_sequence_length, cell_type=cell_type, num_layers=num_layers, ) output = sequence_rnn_decoder(combiner_outputs, target=None) assert list(output[LOGITS].size()) == [batch_size, max_sequence_length, vocab_size] # check for parameter updating target = torch.randn(output[LOGITS].shape) fpc, tpc, upc, not_updated = check_module_parameters_updated(sequence_rnn_decoder, (combiner_outputs, None), target) assert upc == tpc, f"Failed to update parameters. Parameters not updated: {not_updated}" ================================================ FILE: tests/ludwig/decoders/test_sequence_decoder_utils.py ================================================ import pytest import torch from ludwig.constants import ENCODER_OUTPUT_STATE, HIDDEN from ludwig.decoders import sequence_decoder_utils from ludwig.modules.reduction_modules import SequenceReducer @pytest.mark.parametrize("num_layers", [1, 2]) def test_get_rnn_init_state_uses_hidden(num_layers): batch_size = 16 sequence_length = 32 state_size = 64 combiner_outputs = {} combiner_outputs[HIDDEN] = torch.rand([batch_size, sequence_length, state_size]) # With sequence reduction. result = sequence_decoder_utils.get_rnn_init_state(combiner_outputs, SequenceReducer(reduce_mode="sum"), num_layers) assert list(result.size()) == [num_layers, batch_size, state_size] # Without sequence reduction. with pytest.raises(ValueError): sequence_decoder_utils.get_rnn_init_state(combiner_outputs, SequenceReducer(reduce_mode="none"), num_layers) @pytest.mark.parametrize("num_layers", [1, 2]) def test_get_rnn_init_state_prefers_encoder_output_state(num_layers): batch_size = 16 state_size = 64 combiner_outputs = {} combiner_outputs[HIDDEN] = torch.rand([batch_size, state_size]) combiner_outputs[ENCODER_OUTPUT_STATE] = torch.rand([batch_size, state_size * 2]) result = sequence_decoder_utils.get_rnn_init_state(combiner_outputs, SequenceReducer(reduce_mode="sum"), num_layers) assert list(result.size()) == [num_layers, batch_size, state_size * 2] @pytest.mark.parametrize("num_layers", [1, 2]) def test_get_lstm_init_state_uses_hidden(num_layers): batch_size = 16 sequence_length = 32 state_size = 64 combiner_outputs = {} combiner_outputs[HIDDEN] = torch.rand([batch_size, sequence_length, state_size]) # With sequence reduction. decoder_hidden_state, decoder_cell_state = sequence_decoder_utils.get_lstm_init_state( combiner_outputs, SequenceReducer(reduce_mode="sum"), num_layers ) assert list(decoder_hidden_state.size()) == [num_layers, batch_size, state_size] assert list(decoder_cell_state.size()) == [num_layers, batch_size, state_size] # Without sequence reduction. with pytest.raises(ValueError): sequence_decoder_utils.get_lstm_init_state(combiner_outputs, SequenceReducer(reduce_mode="none"), num_layers) @pytest.mark.parametrize("num_layers", [1, 2]) def test_get_lstm_init_state_prefers_encoder_output_state(num_layers): batch_size = 16 state_size = 64 combiner_outputs = {} combiner_outputs[HIDDEN] = torch.rand([batch_size, state_size]) combiner_outputs[ENCODER_OUTPUT_STATE] = torch.rand([batch_size, state_size * 2]) decoder_hidden_state, decoder_cell_state = sequence_decoder_utils.get_lstm_init_state( combiner_outputs, SequenceReducer(reduce_mode="sum"), num_layers ) assert list(decoder_hidden_state.size()) == [num_layers, batch_size, state_size * 2] assert list(decoder_cell_state.size()) == [num_layers, batch_size, state_size * 2] ================================================ FILE: tests/ludwig/decoders/test_sequence_tagger.py ================================================ import pytest import torch from ludwig.constants import HIDDEN, LOGITS from ludwig.decoders.sequence_tagger import SequenceTaggerDecoder from ludwig.utils.misc_utils import set_random_seed from tests.integration_tests.parameter_update_utils import check_module_parameters_updated RANDOM_SEED = 1919 @pytest.mark.parametrize("use_attention", [True, False]) @pytest.mark.parametrize("use_bias", [True, False]) def test_sequence_tagger(use_attention, use_bias): # make repeatable set_random_seed(RANDOM_SEED) batch_size = 20 combiner_output_state_size = 100 vocab_size = 150 max_sequence_length = 30 decoder_inputs = {HIDDEN: torch.rand(batch_size, max_sequence_length, combiner_output_state_size)} tagger_decoder = SequenceTaggerDecoder( combiner_output_state_size, vocab_size, max_sequence_length, use_attention=use_attention, use_bias=use_bias ) outputs = tagger_decoder(decoder_inputs) assert outputs[LOGITS].size()[1:] == tagger_decoder.output_shape # check for parameter updating target = torch.randn(outputs[LOGITS].shape) fpc, tpc, upc, not_updated = check_module_parameters_updated(tagger_decoder, (decoder_inputs,), target) assert upc == tpc, f"Failed to update parameters. Parameters not updated: {not_updated}" ================================================ FILE: tests/ludwig/encoders/__init__.py ================================================ ================================================ FILE: tests/ludwig/encoders/test_bag_encoders.py ================================================ import pytest import torch from ludwig.constants import ENCODER_OUTPUT from ludwig.encoders.bag_encoders import BagEmbedWeightedEncoder from ludwig.utils.torch_utils import get_torch_device from tests.integration_tests.parameter_update_utils import check_module_parameters_updated RANDOM_SEED = 1919 DEVICE = get_torch_device() @pytest.mark.parametrize("dropout", [0, 0.9]) @pytest.mark.parametrize("num_fc_layers", [0, 2]) @pytest.mark.parametrize("vocab", [["a", "b", "c", "d", "e", "f", "g", "h"]]) @pytest.mark.parametrize("embedding_size", [10]) @pytest.mark.parametrize("representation", ["dense", "sparse"]) def test_set_encoder(vocab: list[str], embedding_size: int, representation: str, num_fc_layers: int, dropout: float): # make repeatable torch.manual_seed(RANDOM_SEED) bag_encoder = BagEmbedWeightedEncoder( vocab=vocab, representation=representation, embedding_size=embedding_size, num_fc_layers=num_fc_layers, dropout=dropout, ).to(DEVICE) inputs = torch.randint(0, 9, size=(2, len(vocab))).to(DEVICE) outputs = bag_encoder(inputs)[ENCODER_OUTPUT] assert outputs.shape[1:] == bag_encoder.output_shape # check for parameter updating target = torch.randn(outputs.shape) fpc, tpc, upc, not_updated = check_module_parameters_updated(bag_encoder, (inputs,), target) if dropout == 0: assert upc == tpc, f"Not all parameters updated. Parameters not updated: {not_updated}.\nModule: {bag_encoder}" else: # given random seed and configuration, non-zero dropout can take various values assert (upc == tpc) or ( upc == 0 ), f"Not all parameterss updated. Parameters not updated: {not_updated}.\nModule: {bag_encoder}" ================================================ FILE: tests/ludwig/encoders/test_category_encoders.py ================================================ import pytest import torch from ludwig.constants import ENCODER_OUTPUT from ludwig.encoders.category_encoders import CategoricalEmbedEncoder, CategoricalSparseEncoder from ludwig.utils.torch_utils import get_torch_device from tests.integration_tests.parameter_update_utils import check_module_parameters_updated RANDOM_SEED = 1919 DEVICE = get_torch_device() @pytest.mark.parametrize("trainable", [True, False]) @pytest.mark.parametrize("vocab", [["red", "orange", "yellow", "green", "blue", "violet"], ["a", "b", "c"]]) @pytest.mark.parametrize("embedding_size", [4, 6, 10]) def test_categorical_dense_encoder(vocab: list[str], embedding_size: int, trainable: bool): # make repeatable torch.manual_seed(RANDOM_SEED) dense_encoder = CategoricalEmbedEncoder( vocab=vocab, embedding_size=embedding_size, embeddings_trainable=trainable, ).to(DEVICE) inputs = torch.randint(len(vocab), (10,)).to(DEVICE) # Chooses 10 items from vocab with replacement. inputs = torch.unsqueeze(inputs, 1) outputs = dense_encoder(inputs)[ENCODER_OUTPUT] # In dense mode, the embedding size should be less than or equal to vocab size. assert outputs.shape[-1] == min(embedding_size, len(vocab)) # Ensures output shape matches encoder expected output shape. assert outputs.shape[1:] == dense_encoder.output_shape # check for parameter updating target = torch.randn(outputs.shape) fpc, tpc, upc, not_updated = check_module_parameters_updated(dense_encoder, (inputs,), target) if trainable: assert fpc == 0, "Embedding layer should be trainable, but found to be frozen." else: assert fpc == 1, "Embedding layer should be frozen, but found to be trainable." assert upc == tpc, f"Not all parameters updated. Parameters not updated: {not_updated}.\nModule: {dense_encoder}" @pytest.mark.parametrize("trainable", [True, False]) @pytest.mark.parametrize("vocab", [["red", "orange", "yellow", "green", "blue", "violet"], ["a", "b", "c"]]) def test_categorical_sparse_encoder(vocab: list[str], trainable: bool): # make repeatable torch.manual_seed(RANDOM_SEED) sparse_encoder = CategoricalSparseEncoder(vocab=vocab, embeddings_trainable=trainable).to(DEVICE) inputs = torch.randint(len(vocab), (10,)).to(DEVICE) # Chooses 10 items from vocab with replacement. inputs = torch.unsqueeze(inputs, 1) outputs = sparse_encoder(inputs)[ENCODER_OUTPUT] # In sparse mode, embedding_size will always be equal to vocab size. assert outputs.shape[-1] == len(vocab) # Ensures output shape matches encoder expected output shape. assert outputs.shape[1:] == sparse_encoder.output_shape # check for parameter updating target = torch.randn(outputs.shape) fpc, tpc, upc, not_updated = check_module_parameters_updated(sparse_encoder, (inputs,), target) if trainable: assert fpc == 0, "Embedding layer should be trainable, but found to be frozen." else: assert fpc == 1, "Embedding layer should be frozen, but found to be trainable." assert upc == tpc, f"Not all parameters updated. Parameters not updated: {not_updated}.\nModule: {sparse_encoder}" ================================================ FILE: tests/ludwig/encoders/test_date_encoders.py ================================================ import logging import torch from ludwig.constants import ENCODER_OUTPUT from ludwig.encoders.date_encoders import DateEmbed, DateWave from ludwig.utils.misc_utils import set_random_seed from ludwig.utils.torch_utils import get_torch_device from tests.integration_tests.parameter_update_utils import check_module_parameters_updated RANDOM_SEED = 1919 DEVICE = get_torch_device() logger = logging.getLogger(__name__) def test_date_embed(): # make repeatable set_random_seed(RANDOM_SEED) # setup encoder to test date_embed = DateEmbed().to(DEVICE) inputs = torch.tensor( [[2022, 6, 25, 5, 176, 9, 30, 59, 34259], [2022, 6, 25, 5, 176, 9, 30, 59, 34259]], dtype=torch.int32 ).to(DEVICE) outputs = date_embed(inputs) assert outputs[ENCODER_OUTPUT].size()[1:] == date_embed.output_shape # check for parameter updating target = torch.randn(outputs[ENCODER_OUTPUT].shape) fpc, tpc, upc, not_updated = check_module_parameters_updated(date_embed, (inputs,), target) assert tpc == upc, f"Failed to update parameters. Parameters not updated: {not_updated}" def test_date_wave(): # make repeatable set_random_seed(RANDOM_SEED) # setup encoder to test date_embed = DateWave().to(DEVICE) inputs = torch.tensor( [[2022, 6, 25, 5, 176, 9, 30, 59, 34259], [2022, 6, 25, 5, 176, 9, 30, 59, 34259]], dtype=torch.int32 ).to(DEVICE) outputs = date_embed(inputs) assert outputs[ENCODER_OUTPUT].size()[1:] == date_embed.output_shape # check for parameter updating target = torch.randn(outputs[ENCODER_OUTPUT].shape) fpc, tpc, upc, not_updated = check_module_parameters_updated(date_embed, (inputs,), target) assert tpc == upc, f"Failed to update parameters. Parameters not updated: {not_updated}" ================================================ FILE: tests/ludwig/encoders/test_generic_encoders.py ================================================ import pytest import torch from ludwig.constants import ENCODER_OUTPUT from ludwig.encoders.generic_encoders import DenseEncoder, PassthroughEncoder @pytest.mark.parametrize("input_size", [1, 2, 10]) @pytest.mark.parametrize("categorical", [True, False]) def test_generic_passthrough_encoder(input_size: int, categorical: bool): passthrough_encoder = PassthroughEncoder(input_size) # Passthrough encoder allows categorical input feature (int), dense encoder's input must be float. if categorical: inputs = torch.randint(10, (10, input_size)) else: inputs = torch.rand((10, input_size)) outputs = passthrough_encoder(inputs) # Ensures output shape matches encoder expected output shape. assert outputs[ENCODER_OUTPUT].shape[1:] == passthrough_encoder.output_shape @pytest.mark.parametrize("input_size", [1, 2, 10]) @pytest.mark.parametrize("num_layers", [1, 3, 6]) @pytest.mark.parametrize("output_size", [1, 2, 10, 256]) def test_generic_dense_encoder(input_size: int, num_layers: int, output_size: int): dense_encoder = DenseEncoder(input_size, num_layers=num_layers, output_size=output_size) inputs = torch.rand((10, input_size)) outputs = dense_encoder(inputs) # Ensures output shape matches encoder expected output shape. assert outputs[ENCODER_OUTPUT].shape[1:] == dense_encoder.output_shape ================================================ FILE: tests/ludwig/encoders/test_h3_encoders.py ================================================ import logging import torch from ludwig.constants import ENCODER_OUTPUT from ludwig.encoders import h3_encoders from ludwig.utils.misc_utils import set_random_seed from ludwig.utils.torch_utils import get_torch_device from tests.integration_tests.parameter_update_utils import check_module_parameters_updated RANDOM_SEED = 1919 DEVICE = get_torch_device() logger = logging.getLogger(__name__) def test_h3_embed(): # make repeatable set_random_seed(RANDOM_SEED) # setup encoder to test embed = h3_encoders.H3Embed().to(DEVICE) inputs = torch.tensor( [ [2, 0, 14, 102, 7, 0, 3, 5, 0, 5, 5, 0, 5, 7, 7, 7, 7, 7, 7], [2, 0, 14, 102, 7, 0, 3, 5, 0, 5, 5, 0, 5, 7, 7, 7, 7, 7, 7], ], dtype=torch.int32, ).to(DEVICE) outputs = embed(inputs) assert outputs[ENCODER_OUTPUT].size()[1:] == embed.output_shape # check for parameter updating target = torch.randn(outputs[ENCODER_OUTPUT].shape) fpc, tpc, upc, not_updated = check_module_parameters_updated(embed, (inputs,), target) assert tpc == upc, f"Failed to update parameters. Parameters not updated: {not_updated}" def test_h3_weighted_sum(): # make repeatable set_random_seed(RANDOM_SEED) # setup encoder to test embed = h3_encoders.H3WeightedSum().to(DEVICE) inputs = torch.tensor( [ [2, 0, 14, 102, 7, 0, 3, 5, 0, 5, 5, 0, 5, 7, 7, 7, 7, 7, 7], [2, 0, 14, 102, 7, 0, 3, 5, 0, 5, 5, 0, 5, 7, 7, 7, 7, 7, 7], ], dtype=torch.int32, ).to(DEVICE) outputs = embed(inputs) assert outputs[ENCODER_OUTPUT].size()[1:] == embed.output_shape # check for parameter updating target = torch.randn(outputs[ENCODER_OUTPUT].shape) fpc, tpc, upc, not_updated = check_module_parameters_updated(embed, (inputs,), target) assert tpc == upc, f"Failed to update parameters. Parameters not updated: {not_updated}" def test_h3_rnn_embed(): # make repeatable set_random_seed(RANDOM_SEED) # setup encoder to test embed = h3_encoders.H3RNN().to(DEVICE) inputs = torch.tensor( [ [2, 0, 14, 102, 7, 0, 3, 5, 0, 5, 5, 0, 5, 7, 7, 7, 7, 7, 7], [2, 0, 14, 102, 7, 0, 3, 5, 0, 5, 5, 0, 5, 7, 7, 7, 7, 7, 7], ], dtype=torch.int32, ).to(DEVICE) outputs = embed(inputs) assert outputs[ENCODER_OUTPUT].size()[1:] == embed.output_shape # check for parameter updating target = torch.randn(outputs[ENCODER_OUTPUT].shape) fpc, tpc, upc, not_updated = check_module_parameters_updated(embed, (inputs,), target) assert tpc == upc, f"Failed to update parameters. Parameters not updated: {not_updated}" ================================================ FILE: tests/ludwig/encoders/test_image_encoders.py ================================================ import pytest import torch from ludwig.constants import ENCODER_OUTPUT from ludwig.encoders.image.base import MLPMixerEncoder, ResNetEncoder, Stacked2DCNN, UNetEncoder, ViTEncoder from ludwig.encoders.image.torchvision import ( TVAlexNetEncoder, TVConvNeXtEncoder, TVDenseNetEncoder, TVEfficientNetEncoder, TVGoogLeNetEncoder, TVInceptionV3Encoder, TVMaxVitEncoder, TVMNASNetEncoder, TVMobileNetV2Encoder, TVMobileNetV3Encoder, TVRegNetEncoder, TVResNetEncoder, TVResNeXtEncoder, TVShuffleNetV2Encoder, TVSqueezeNetEncoder, TVSwinTransformerEncoder, TVVGGEncoder, TVViTEncoder, TVWideResNetEncoder, ) from ludwig.utils.image_utils import torchvision_model_registry from ludwig.utils.misc_utils import set_random_seed from tests.integration_tests.parameter_update_utils import check_module_parameters_updated RANDOM_SEED = 1919 @pytest.mark.parametrize("height,width,num_conv_layers,num_channels", [(224, 224, 5, 3)]) def test_stacked2d_cnn(height: int, width: int, num_conv_layers: int, num_channels: int): # make repeatable set_random_seed(RANDOM_SEED) stacked_2d_cnn = Stacked2DCNN( height=height, width=width, num_conv_layers=num_conv_layers, num_channels=num_channels ) inputs = torch.rand(2, num_channels, height, width) outputs = stacked_2d_cnn(inputs) assert outputs[ENCODER_OUTPUT].shape[1:] == stacked_2d_cnn.output_shape # check for parameter updating target = torch.randn(outputs[ENCODER_OUTPUT].shape) fpc, tpc, upc, not_updated = check_module_parameters_updated(stacked_2d_cnn, (inputs,), target) assert tpc == upc, f"Not all expected parameters updated. Parameters not updated {not_updated}." @pytest.mark.parametrize("height,width,num_channels", [(224, 224, 1), (224, 224, 3)]) def test_resnet_encoder(height: int, width: int, num_channels: int): # make repeatable set_random_seed(RANDOM_SEED) resnet = ResNetEncoder(height=height, width=width, num_channels=num_channels) inputs = torch.rand(2, num_channels, height, width) outputs = resnet(inputs) assert outputs[ENCODER_OUTPUT].shape[1:] == resnet.output_shape # check for parameter updating target = torch.randn(outputs[ENCODER_OUTPUT].shape) fpc, tpc, upc, not_updated = check_module_parameters_updated(resnet, (inputs,), target) assert tpc == upc, f"Not all expected parameters updated. Parameters not updated {not_updated}." @pytest.mark.parametrize("height,width,num_channels", [(224, 224, 3)]) def test_mlp_mixer_encoder(height: int, width: int, num_channels: int): # make repeatable set_random_seed(RANDOM_SEED) mlp_mixer = MLPMixerEncoder(height=height, width=width, num_channels=num_channels) inputs = torch.rand(2, num_channels, height, width) outputs = mlp_mixer(inputs) assert outputs[ENCODER_OUTPUT].shape[1:] == mlp_mixer.output_shape # check for parameter updating target = torch.randn(outputs[ENCODER_OUTPUT].shape) fpc, tpc, upc, not_updated = check_module_parameters_updated(mlp_mixer, (inputs,), target) assert tpc == upc, f"Not all expected parameters updated. Parameters not updated {not_updated}." @pytest.mark.parametrize("image_size,num_channels", [(224, 3)]) @pytest.mark.parametrize("use_pretrained", [True, False]) def test_vit_encoder(image_size: int, num_channels: int, use_pretrained: bool): # make repeatable set_random_seed(RANDOM_SEED) vit = ViTEncoder( height=image_size, width=image_size, num_channels=num_channels, use_pretrained=use_pretrained, ) inputs = torch.rand(2, num_channels, image_size, image_size) outputs = vit(inputs) assert outputs[ENCODER_OUTPUT].shape[1:] == vit.output_shape # check for parameter updating target = torch.randn(outputs[ENCODER_OUTPUT].shape) fpc, tpc, upc, not_updated = check_module_parameters_updated(vit, (inputs,), target) assert tpc == upc, f"Not all expected parameters updated. Parameters not updated {not_updated}." @pytest.mark.parametrize("height,width,num_channels", [(224, 224, 1), (224, 224, 3)]) def test_unet_encoder(height: int, width: int, num_channels: int): # make repeatable set_random_seed(RANDOM_SEED) unet_encoder = UNetEncoder(height=height, width=width, num_channels=num_channels) inputs = torch.rand(2, num_channels, height, width) outputs = unet_encoder(inputs) assert outputs[ENCODER_OUTPUT].shape[1:] == unet_encoder.output_shape # check for parameter updating target = torch.randn(outputs[ENCODER_OUTPUT].shape) fpc, tpc, upc, not_updated = check_module_parameters_updated(unet_encoder, (inputs,), target) assert tpc == upc, f"Not all expected parameters updated. Parameters not updated {not_updated}." @pytest.mark.parametrize( "trainable,saved_weights_in_checkpoint,use_pretrained", [(True, True, False), (False, False, False)], ids=["trainable", "frozen"], ) @pytest.mark.parametrize("model_variant", [v.variant_id for v in torchvision_model_registry["alexnet"].values()]) def test_tv_alexnet_encoder( model_variant: int, use_pretrained: bool, saved_weights_in_checkpoint: bool, trainable: bool, ): # make repeatable set_random_seed(RANDOM_SEED) pretrained_model = TVAlexNetEncoder( model_variant=model_variant, use_pretrained=use_pretrained, saved_weights_in_checkpoint=saved_weights_in_checkpoint, trainable=trainable, ) inputs = torch.rand(2, *pretrained_model.input_shape) outputs = pretrained_model(inputs) assert outputs[ENCODER_OUTPUT].shape[1:] == pretrained_model.output_shape @pytest.mark.parametrize( "trainable,saved_weights_in_checkpoint,use_pretrained", [(True, True, False), (False, False, False)], ids=["trainable", "frozen"], ) @pytest.mark.parametrize("model_variant", [next(iter(torchvision_model_registry["convnext"].values())).variant_id]) def test_tv_convnext_encoder( model_variant: int, use_pretrained: bool, saved_weights_in_checkpoint: bool, trainable: bool, ): # make repeatable set_random_seed(RANDOM_SEED) pretrained_model = TVConvNeXtEncoder( model_variant=model_variant, use_pretrained=use_pretrained, saved_weights_in_checkpoint=saved_weights_in_checkpoint, trainable=trainable, ) inputs = torch.rand(2, *pretrained_model.input_shape) outputs = pretrained_model(inputs) assert outputs[ENCODER_OUTPUT].shape[1:] == pretrained_model.output_shape @pytest.mark.parametrize( "trainable,saved_weights_in_checkpoint,use_pretrained", [(True, True, False), (False, False, False)], ids=["trainable", "frozen"], ) @pytest.mark.parametrize("model_variant", [next(iter(torchvision_model_registry["densenet"].values())).variant_id]) def test_tv_densenet_encoder( model_variant: int, use_pretrained: bool, saved_weights_in_checkpoint: bool, trainable: bool, ): # make repeatable set_random_seed(RANDOM_SEED) pretrained_model = TVDenseNetEncoder( model_variant=model_variant, use_pretrained=use_pretrained, saved_weights_in_checkpoint=saved_weights_in_checkpoint, trainable=trainable, ) inputs = torch.rand(2, *pretrained_model.input_shape) outputs = pretrained_model(inputs) assert outputs[ENCODER_OUTPUT].shape[1:] == pretrained_model.output_shape # test only model variants that do not require large amount of memory @pytest.mark.parametrize( "trainable,saved_weights_in_checkpoint,use_pretrained", [(True, True, False), (False, False, False)], ids=["trainable", "frozen"], ) @pytest.mark.parametrize("model_variant", [next(iter(torchvision_model_registry["efficientnet"].values())).variant_id]) def test_tv_efficientnet_encoder( model_variant: int, use_pretrained: bool, saved_weights_in_checkpoint: bool, trainable: bool, ): # make repeatable set_random_seed(RANDOM_SEED) pretrained_model = TVEfficientNetEncoder( model_variant=model_variant, use_pretrained=use_pretrained, saved_weights_in_checkpoint=saved_weights_in_checkpoint, trainable=trainable, ) inputs = torch.rand(2, *pretrained_model.input_shape) outputs = pretrained_model(inputs) assert outputs[ENCODER_OUTPUT].shape[1:] == pretrained_model.output_shape @pytest.mark.parametrize( "trainable,saved_weights_in_checkpoint,use_pretrained", [(True, True, False), (False, False, False)], ids=["trainable", "frozen"], ) @pytest.mark.parametrize("model_variant", [v.variant_id for v in torchvision_model_registry["googlenet"].values()]) def test_tv_googlenet_encoder( model_variant: int, use_pretrained: bool, saved_weights_in_checkpoint: bool, trainable: bool, ): # make repeatable set_random_seed(RANDOM_SEED) pretrained_model = TVGoogLeNetEncoder( model_variant=model_variant, use_pretrained=use_pretrained, saved_weights_in_checkpoint=saved_weights_in_checkpoint, trainable=trainable, ) inputs = torch.rand(2, *pretrained_model.input_shape) outputs = pretrained_model(inputs) assert outputs[ENCODER_OUTPUT].shape[1:] == pretrained_model.output_shape @pytest.mark.parametrize( "trainable,saved_weights_in_checkpoint,use_pretrained", [(True, True, False), (False, False, False)], ids=["trainable", "frozen"], ) @pytest.mark.parametrize("model_variant", [v.variant_id for v in torchvision_model_registry["inceptionv3"].values()]) def test_tv_inceptionv3_encoder( model_variant: int, use_pretrained: bool, saved_weights_in_checkpoint: bool, trainable: bool, ): # make repeatable set_random_seed(RANDOM_SEED) pretrained_model = TVInceptionV3Encoder( model_variant=model_variant, use_pretrained=use_pretrained, saved_weights_in_checkpoint=saved_weights_in_checkpoint, trainable=trainable, ) inputs = torch.rand(2, *pretrained_model.input_shape) outputs = pretrained_model(inputs) assert outputs[ENCODER_OUTPUT].shape[1:] == pretrained_model.output_shape @pytest.mark.parametrize( "trainable,saved_weights_in_checkpoint,use_pretrained", [(True, True, False), (False, False, False)], ids=["trainable", "frozen"], ) @pytest.mark.parametrize("model_variant", [next(iter(torchvision_model_registry["maxvit"].values())).variant_id]) def test_tv_maxvit_encoder( model_variant: int, use_pretrained: bool, saved_weights_in_checkpoint: bool, trainable: bool, ): # make repeatable set_random_seed(RANDOM_SEED) pretrained_model = TVMaxVitEncoder( model_variant=model_variant, use_pretrained=use_pretrained, saved_weights_in_checkpoint=saved_weights_in_checkpoint, trainable=trainable, ) inputs = torch.rand(2, *pretrained_model.input_shape) outputs = pretrained_model(inputs) assert outputs[ENCODER_OUTPUT].shape[1:] == pretrained_model.output_shape @pytest.mark.parametrize( "trainable,saved_weights_in_checkpoint,use_pretrained", [(True, True, False), (False, False, False)], ids=["trainable", "frozen"], ) @pytest.mark.parametrize("model_variant", [next(iter(torchvision_model_registry["mnasnet"].values())).variant_id]) def test_tv_mnasnet_encoder( model_variant: int, use_pretrained: bool, saved_weights_in_checkpoint: bool, trainable: bool, ): # make repeatable set_random_seed(RANDOM_SEED) pretrained_model = TVMNASNetEncoder( model_variant=model_variant, use_pretrained=use_pretrained, saved_weights_in_checkpoint=saved_weights_in_checkpoint, trainable=trainable, ) inputs = torch.rand(2, *pretrained_model.input_shape) outputs = pretrained_model(inputs) assert outputs[ENCODER_OUTPUT].shape[1:] == pretrained_model.output_shape @pytest.mark.parametrize( "trainable,saved_weights_in_checkpoint,use_pretrained", [(True, True, False), (False, False, False)], ids=["trainable", "frozen"], ) @pytest.mark.parametrize("model_variant", [v.variant_id for v in torchvision_model_registry["mobilenetv2"].values()]) def test_tv_mobilenetv2_encoder( model_variant: int, use_pretrained: bool, saved_weights_in_checkpoint: bool, trainable: bool, ): # make repeatable set_random_seed(RANDOM_SEED) pretrained_model = TVMobileNetV2Encoder( model_variant=model_variant, use_pretrained=use_pretrained, saved_weights_in_checkpoint=saved_weights_in_checkpoint, trainable=trainable, ) inputs = torch.rand(2, *pretrained_model.input_shape) outputs = pretrained_model(inputs) assert outputs[ENCODER_OUTPUT].shape[1:] == pretrained_model.output_shape @pytest.mark.parametrize( "trainable,saved_weights_in_checkpoint,use_pretrained", [(True, True, False), (False, False, False)], ids=["trainable", "frozen"], ) @pytest.mark.parametrize("model_variant", [next(iter(torchvision_model_registry["mobilenetv3"].values())).variant_id]) def test_tv_mobilenetv3_encoder( model_variant: int, use_pretrained: bool, saved_weights_in_checkpoint: bool, trainable: bool, ): # make repeatable set_random_seed(RANDOM_SEED) pretrained_model = TVMobileNetV3Encoder( model_variant=model_variant, use_pretrained=use_pretrained, saved_weights_in_checkpoint=saved_weights_in_checkpoint, trainable=trainable, ) inputs = torch.rand(2, *pretrained_model.input_shape) outputs = pretrained_model(inputs) assert outputs[ENCODER_OUTPUT].shape[1:] == pretrained_model.output_shape # test only model variants that do not require large amount of memory @pytest.mark.parametrize( "trainable,saved_weights_in_checkpoint,use_pretrained", [(True, True, False), (False, False, False)], ids=["trainable", "frozen"], ) @pytest.mark.parametrize("model_variant", [next(iter(torchvision_model_registry["regnet"].values())).variant_id]) def test_tv_regnet_encoder( model_variant: int, use_pretrained: bool, saved_weights_in_checkpoint: bool, trainable: bool, ): # make repeatable set_random_seed(RANDOM_SEED) pretrained_model = TVRegNetEncoder( model_variant=model_variant, use_pretrained=use_pretrained, saved_weights_in_checkpoint=saved_weights_in_checkpoint, trainable=trainable, ) inputs = torch.rand(2, *pretrained_model.input_shape) outputs = pretrained_model(inputs) assert outputs[ENCODER_OUTPUT].shape[1:] == pretrained_model.output_shape @pytest.mark.parametrize( "trainable,saved_weights_in_checkpoint,use_pretrained", [(True, True, False), (False, False, False)], ids=["trainable", "frozen"], ) @pytest.mark.parametrize("model_variant", [next(iter(torchvision_model_registry["resnet"].values())).variant_id]) def test_tv_resnet_torch_encoder( model_variant: int, use_pretrained: bool, saved_weights_in_checkpoint: bool, trainable: bool, ): # make repeatable set_random_seed(RANDOM_SEED) pretrained_model = TVResNetEncoder( model_variant=model_variant, use_pretrained=use_pretrained, saved_weights_in_checkpoint=saved_weights_in_checkpoint, trainable=trainable, ) inputs = torch.rand(2, *pretrained_model.input_shape) outputs = pretrained_model(inputs) assert outputs[ENCODER_OUTPUT].shape[1:] == pretrained_model.output_shape @pytest.mark.parametrize( "trainable,saved_weights_in_checkpoint,use_pretrained", [(True, True, False), (False, False, False)], ids=["trainable", "frozen"], ) @pytest.mark.parametrize("model_variant", [next(iter(torchvision_model_registry["resnext"].values())).variant_id]) def test_tv_resnext_encoder( model_variant: int, use_pretrained: bool, saved_weights_in_checkpoint: bool, trainable: bool, ): # make repeatable set_random_seed(RANDOM_SEED) pretrained_model = TVResNeXtEncoder( model_variant=model_variant, use_pretrained=use_pretrained, saved_weights_in_checkpoint=saved_weights_in_checkpoint, trainable=trainable, ) inputs = torch.rand(2, *pretrained_model.input_shape) outputs = pretrained_model(inputs) assert outputs[ENCODER_OUTPUT].shape[1:] == pretrained_model.output_shape @pytest.mark.parametrize( "trainable,saved_weights_in_checkpoint,use_pretrained", [(True, True, False), (False, False, False)], ids=["trainable", "frozen"], ) @pytest.mark.parametrize("model_variant", [next(iter(torchvision_model_registry["shufflenet_v2"].values())).variant_id]) def test_tv_shufflenet_v2_encoder( model_variant: str, use_pretrained: bool, saved_weights_in_checkpoint: bool, trainable: bool, ): # make repeatable set_random_seed(RANDOM_SEED) pretrained_model = TVShuffleNetV2Encoder( model_variant=model_variant, use_pretrained=use_pretrained, saved_weights_in_checkpoint=saved_weights_in_checkpoint, trainable=trainable, ) inputs = torch.rand(2, *pretrained_model.input_shape) outputs = pretrained_model(inputs) assert outputs[ENCODER_OUTPUT].shape[1:] == pretrained_model.output_shape @pytest.mark.parametrize( "trainable,saved_weights_in_checkpoint,use_pretrained", [(True, True, False), (False, False, False)], ids=["trainable", "frozen"], ) @pytest.mark.parametrize("model_variant", [v.variant_id for v in torchvision_model_registry["squeezenet"].values()]) def test_tv_squeezenet_encoder( model_variant: str, use_pretrained: bool, saved_weights_in_checkpoint: bool, trainable: bool, ): # make repeatable set_random_seed(RANDOM_SEED) pretrained_model = TVSqueezeNetEncoder( model_variant=model_variant, use_pretrained=use_pretrained, saved_weights_in_checkpoint=saved_weights_in_checkpoint, trainable=trainable, ) inputs = torch.rand(2, *pretrained_model.input_shape) outputs = pretrained_model(inputs) assert outputs[ENCODER_OUTPUT].shape[1:] == pretrained_model.output_shape @pytest.mark.parametrize( "trainable,saved_weights_in_checkpoint,use_pretrained", [(True, True, False), (False, False, False)], ids=["trainable", "frozen"], ) @pytest.mark.parametrize( "model_variant", [next(iter(torchvision_model_registry["swin_transformer"].values())).variant_id] ) def test_tv_swin_transformer_encoder( model_variant: str, use_pretrained: bool, saved_weights_in_checkpoint: bool, trainable: bool, ): # make repeatable set_random_seed(RANDOM_SEED) pretrained_model = TVSwinTransformerEncoder( model_variant=model_variant, use_pretrained=use_pretrained, saved_weights_in_checkpoint=saved_weights_in_checkpoint, trainable=trainable, ) inputs = torch.rand(2, *pretrained_model.input_shape) outputs = pretrained_model(inputs) assert outputs[ENCODER_OUTPUT].shape[1:] == pretrained_model.output_shape @pytest.mark.parametrize( "trainable,saved_weights_in_checkpoint,use_pretrained", [(True, True, False), (False, False, False)], ids=["trainable", "frozen"], ) @pytest.mark.parametrize("model_variant", [next(iter(torchvision_model_registry["vgg"].values())).variant_id]) def test_tv_vgg_encoder( model_variant: int | str, use_pretrained: bool, saved_weights_in_checkpoint: bool, trainable: bool, ): # make repeatable set_random_seed(RANDOM_SEED) pretrained_model = TVVGGEncoder( model_variant=model_variant, use_pretrained=use_pretrained, saved_weights_in_checkpoint=saved_weights_in_checkpoint, trainable=trainable, ) inputs = torch.rand(2, *pretrained_model.input_shape) outputs = pretrained_model(inputs) assert outputs[ENCODER_OUTPUT].shape[1:] == pretrained_model.output_shape # test only VIT model variants that do not require large amount of memory @pytest.mark.parametrize( "trainable,saved_weights_in_checkpoint,use_pretrained", [(True, True, False), (False, False, False)], ids=["trainable", "frozen"], ) @pytest.mark.parametrize("model_variant", [next(iter(torchvision_model_registry["vit"].values())).variant_id]) def test_tv_vit_encoder( model_variant: str, use_pretrained: bool, saved_weights_in_checkpoint: bool, trainable: bool, ): # make repeatable set_random_seed(RANDOM_SEED) pretrained_model = TVViTEncoder( model_variant=model_variant, use_pretrained=use_pretrained, saved_weights_in_checkpoint=saved_weights_in_checkpoint, trainable=trainable, ) inputs = torch.rand(2, *pretrained_model.input_shape) outputs = pretrained_model(inputs) assert outputs[ENCODER_OUTPUT].shape[1:] == pretrained_model.output_shape @pytest.mark.parametrize( "trainable,saved_weights_in_checkpoint,use_pretrained", [(True, True, False), (False, False, False)], ids=["trainable", "frozen"], ) @pytest.mark.parametrize("model_variant", [next(iter(torchvision_model_registry["wide_resnet"].values())).variant_id]) def test_tv_wide_resnet_encoder( model_variant: str, use_pretrained: bool, saved_weights_in_checkpoint: bool, trainable: bool, ): # make repeatable set_random_seed(RANDOM_SEED) pretrained_model = TVWideResNetEncoder( model_variant=model_variant, use_pretrained=use_pretrained, saved_weights_in_checkpoint=saved_weights_in_checkpoint, trainable=trainable, ) inputs = torch.rand(2, *pretrained_model.input_shape) outputs = pretrained_model(inputs) assert outputs[ENCODER_OUTPUT].shape[1:] == pretrained_model.output_shape ================================================ FILE: tests/ludwig/encoders/test_llm_encoders.py ================================================ import copy import pytest import torch import torch.nn as nn from transformers import AutoConfig, PreTrainedModel from ludwig.encoders.text_encoders import LLMEncoder from ludwig.schema.encoders.text_encoders import LLMEncoderConfig from ludwig.schema.llms.peft import AdaloraConfig, BaseAdapterConfig, IA3Config, LoraConfig from ludwig.utils.llm_utils import get_context_len # Mapping of adapter types to test against and their respective config objects. ADAPTER_CONFIG_MAP = { "adalora": AdaloraConfig, "ia3": IA3Config, "lora": LoraConfig, } @pytest.fixture() def encoder_config() -> LLMEncoderConfig: """Create a baseline LLMEncoderConfig. Returns: A baseline LLMEncoderConfig with a small model, no adapter, and no quantization """ return LLMEncoderConfig( type="llm", max_sequence_length=256, base_model="HuggingFaceH4/tiny-random-LlamaForCausalLM", adapter=None, quantization=None, ) @pytest.fixture() def model_config(encoder_config): return AutoConfig.from_pretrained(encoder_config.base_model) class WrapperModule(nn.Module): def __init__(self, encoder: LLMEncoder): super().__init__() self.encoder = encoder class TestLLMEncoder: def create_encoder_config_with_adapter( self, encoder_config: LLMEncoderConfig, adapter: str, **kwargs ) -> BaseAdapterConfig: """Create a config for the requested adapter. Args: adapter: name of the adapter Returns: A config object for the requested adapter. If any keyword args are passed, they will be used to initialize the config. """ new_config = copy.deepcopy(encoder_config) new_config.adapter = ADAPTER_CONFIG_MAP[adapter](**kwargs) return new_config def adapter_param_name_prefix(self, adapter: str) -> str: """Get the PEFT paramter name prefix for a given adapter type. Args: adapter: A valid config value for `adapter.type` Returns: The PEFT-applied prefix for the adapter's parameter names. Raises: KeyError: raised when the provided adapter name is not valid for LLMEncoder. """ return LLMEncoder.ADAPTER_PARAM_NAME_PREFIX[adapter] def test_init(self, encoder_config: LLMEncoderConfig, model_config): # Test initializing without an adapter encoder = LLMEncoder(encoder_config=encoder_config) assert encoder.model_name == encoder_config.base_model assert isinstance(encoder.model, PreTrainedModel) # Check adapter was not initialized for k in ADAPTER_CONFIG_MAP.keys(): prefix = self.adapter_param_name_prefix(k) assert all(map(lambda k: prefix not in k, encoder.state_dict().keys())) assert encoder.input_shape == torch.Size([encoder_config.max_sequence_length]) assert encoder.output_shape == torch.Size([encoder_config.max_sequence_length, model_config.hidden_size]) # The final layer must not be trainable because it is not used last_module = list(encoder.model.modules())[-1] assert all(not p.requires_grad for p in last_module.parameters()) # Test that max sequence length falls back to the context length when too large context_len = get_context_len(model_config) cl_config = copy.deepcopy(encoder_config) cl_config.max_sequence_length = context_len + 1 encoder = LLMEncoder(encoder_config=cl_config) assert encoder.model_name == encoder_config.base_model assert isinstance(encoder.model, PreTrainedModel) # Check adapter was not initialized for k in ADAPTER_CONFIG_MAP.keys(): prefix = self.adapter_param_name_prefix(k) assert all(map(lambda k: prefix not in k, encoder.state_dict().keys())) assert encoder.input_shape == torch.Size([context_len]) assert encoder.output_shape == torch.Size([context_len, model_config.hidden_size]) # The final layer must not be trainable because it is not used last_module = list(encoder.model.modules())[-1] assert all(not p.requires_grad for p in last_module.parameters()) @pytest.mark.parametrize("adapter", list(ADAPTER_CONFIG_MAP.keys())) def test_init_with_adapter(self, encoder_config: LLMEncoderConfig, adapter: str, model_config): from peft import PeftModel encoder_config_with_adapter = self.create_encoder_config_with_adapter(encoder_config, adapter) encoder = LLMEncoder(encoder_config=encoder_config_with_adapter) prefix = self.adapter_param_name_prefix(adapter) # The adapter should not be initialized until `prepare_for_training` is called assert not isinstance(encoder.model, PeftModel) assert not any(map(lambda k: prefix in k, encoder.state_dict().keys())) assert encoder.model_name == encoder_config.base_model assert encoder.input_shape == torch.Size([encoder_config.max_sequence_length]) assert encoder.output_shape == torch.Size([encoder_config.max_sequence_length, model_config.hidden_size]) # The final layer must not be trainable because it is not used last_module = list(encoder.model.modules())[-1] assert all(not p.requires_grad for p in last_module.parameters()) @pytest.mark.parametrize("adapter", list(ADAPTER_CONFIG_MAP.keys())) def test_prepare_for_training(self, encoder_config: LLMEncoderConfig, adapter: str): from peft import PeftModel encoder_config_with_adapter = self.create_encoder_config_with_adapter(encoder_config, adapter) encoder = LLMEncoder(encoder_config=encoder_config_with_adapter) prefix = self.adapter_param_name_prefix(adapter) # The adapter should not be initialized until `prepare_for_training` is called assert not isinstance(encoder.model, PeftModel) assert not any(map(lambda k: prefix in k, encoder.state_dict().keys())) # Initialize the adapter encoder.prepare_for_training() # At this point, the adapter should be initialized and the state dict should contain adapter parameters assert isinstance(encoder.model, PeftModel) assert any(map(lambda k: prefix in k, encoder.state_dict().keys())) def test_save_to_state_dict(self, encoder_config: LLMEncoderConfig, tmpdir): # With no adapter, the state dict should only contain the model parameters encoder = LLMEncoder(encoder_config=encoder_config) # Check adapter was not initialized for k in ADAPTER_CONFIG_MAP.keys(): prefix = self.adapter_param_name_prefix(k) assert all(map(lambda k: prefix not in k, encoder.state_dict().keys())) @pytest.mark.parametrize("adapter", list(ADAPTER_CONFIG_MAP.keys())) def test_save_to_state_dict_adapter(self, encoder_config: LLMEncoderConfig, adapter: str, tmpdir): # With an adapter, the state dict should only contain adapter parameters encoder_config_with_adapter = self.create_encoder_config_with_adapter(encoder_config, adapter) encoder = LLMEncoder(encoder_config=encoder_config_with_adapter) prefix = self.adapter_param_name_prefix(adapter) # Initialize the adapters encoder.prepare_for_training() assert all(map(lambda k: prefix in k, encoder.state_dict().keys())) @pytest.mark.parametrize("wrap", [False, True], ids=["no_wrapper", "with_wrapper"]) def test_load_from_state_dict(self, encoder_config: LLMEncoderConfig, wrap: bool): def weights_init(m): """Reinitialize the weights of a torch module.""" if hasattr(m, "weight") and m.weight.ndim > 1: torch.nn.init.xavier_uniform_(m.weight.data) # Create two encoders from the same config encoder1 = LLMEncoder(encoder_config=encoder_config) encoder2 = LLMEncoder(encoder_config=encoder_config) if wrap: encoder1 = WrapperModule(encoder1) encoder2 = WrapperModule(encoder2) # Reinitialize the weights of one encoder so the two are not identical encoder2.apply(weights_init) # Ensure that the weights are different encoder1_sd = encoder1.state_dict() encoder2_sd = encoder2.state_dict() assert any(map(lambda k: not torch.equal(encoder1_sd[k], encoder2_sd[k]), encoder1_sd.keys())) # Load the weights of encoder1 back into encoder2 and ensure the weights are equal encoder2.load_state_dict(encoder1_sd) encoder2_sd = encoder2.state_dict() assert all(map(lambda k: torch.equal(encoder1_sd[k], encoder2_sd[k]), encoder1_sd.keys())) @pytest.mark.parametrize("wrap", [False, True], ids=["no_wrapper", "with_wrapper"]) @pytest.mark.parametrize("adapter", list(ADAPTER_CONFIG_MAP.keys())) def test_load_from_state_dict_adapter(self, encoder_config: LLMEncoderConfig, adapter: str, wrap: bool): def weights_init(m): """Reinitialize the weights of a torch module.""" if hasattr(m, "weight") and m.weight.ndim > 1: torch.nn.init.xavier_uniform_(m.weight.data) prefix = self.adapter_param_name_prefix(adapter) # Update the config with an adapter encoder_config_with_adapter = self.create_encoder_config_with_adapter(encoder_config, adapter) # Create two encoders from the same config encoder1 = LLMEncoder(encoder_config=encoder_config_with_adapter) encoder2 = LLMEncoder(encoder_config=encoder_config_with_adapter) # Initialize the adapters encoder1.prepare_for_training() encoder2.prepare_for_training() if wrap: encoder1 = WrapperModule(encoder1) encoder2 = WrapperModule(encoder2) encoder2.apply(weights_init) encoder1_sd = encoder1.state_dict() encoder2_sd = encoder2.state_dict() adapter_keys = [k for k in encoder1_sd.keys() if prefix in k and "weight" in k] model_keys = [k for k in encoder1_sd.keys() if prefix not in k] # The LoRA weights should no longer be equal assert all(map(lambda k: not torch.equal(encoder1_sd[k], encoder2_sd[k]), adapter_keys)) # The remaining weights should also no longer be equal assert all(map(lambda k: not torch.equal(encoder1_sd[k], encoder2_sd[k]), model_keys)) # Load the weights of encoder1 back into encoder2 encoder2.load_state_dict(encoder1_sd) encoder2_sd = encoder2.state_dict() # The LoRA weights should now be equal again assert all(map(lambda k: torch.equal(encoder1_sd[k], encoder2_sd[k]), adapter_keys)) # The remaining weights should still be unequal assert all(map(lambda k: not torch.equal(encoder1_sd[k], encoder2_sd[k]), model_keys)) ================================================ FILE: tests/ludwig/encoders/test_sequence_encoders.py ================================================ import pytest import torch from ludwig.constants import ENCODER_OUTPUT from ludwig.encoders.sequence_encoders import ( SequenceEmbedEncoder, SequencePassthroughEncoder, StackedRNN, StackedTransformer, ) from ludwig.utils.torch_utils import get_torch_device from tests.integration_tests.parameter_update_utils import check_module_parameters_updated DEVICE = get_torch_device() RANDOM_SEED = 1919 @pytest.mark.parametrize("reduce_output", ["mean", "last", "concat", None]) def test_sequence_passthrough_encoder(reduce_output: str): batch_size = 10 sequence_length = 32 sequence_passthrough_encoder = SequencePassthroughEncoder( reduce_output=reduce_output, max_sequence_length=sequence_length, encoding_size=8 ).to(DEVICE) inputs = torch.rand(batch_size, sequence_length, 8).to(DEVICE) outputs = sequence_passthrough_encoder(inputs) # SequencePassthroughEncoder does not implement output_shape, expect output to match input shape after reduce. assert outputs[ENCODER_OUTPUT].shape[1:] == sequence_passthrough_encoder.reduce_sequence.output_shape @pytest.mark.parametrize( "encoder_type", [SequenceEmbedEncoder, StackedRNN, StackedTransformer], ) @pytest.mark.parametrize("reduce_output", ["mean", "last", "concat", None]) @pytest.mark.parametrize("vocab_size", [2, 1024]) # Uses vocabularies smaller than (and larger than) embedding size. def test_sequence_encoders(encoder_type: type, reduce_output: str, vocab_size: int): # make repeatable torch.manual_seed(RANDOM_SEED) batch_size = 10 sequence_length = 32 sequence_encoder = encoder_type( vocab=list(range(1, vocab_size + 1)), max_sequence_length=sequence_length, reduce_output=reduce_output ).to(DEVICE) inputs = torch.randint(2, (batch_size, sequence_length)).to(DEVICE) outputs = sequence_encoder(inputs) assert outputs[ENCODER_OUTPUT].shape[1:] == sequence_encoder.output_shape # check for parameter updating target = torch.randn(outputs[ENCODER_OUTPUT].shape) fpc, tpc, upc, not_updated = check_module_parameters_updated(sequence_encoder, (inputs,), target) assert ( upc == tpc ), f"Not all parameters updated. Parameters not updated: {not_updated}.\nModule: {sequence_encoder}" ================================================ FILE: tests/ludwig/encoders/test_set_encoders.py ================================================ import pytest import torch from ludwig.constants import ENCODER_OUTPUT from ludwig.encoders.set_encoders import SetSparseEncoder from ludwig.utils.misc_utils import set_random_seed from ludwig.utils.torch_utils import get_torch_device from tests.integration_tests.parameter_update_utils import check_module_parameters_updated RANDOM_SEED = 1919 DEVICE = get_torch_device() @pytest.mark.parametrize("num_fc_layers", [0, 2]) @pytest.mark.parametrize("vocab", [["a", "b", "c", "d", "e", "f", "g", "h"]]) @pytest.mark.parametrize("embedding_size", [10]) @pytest.mark.parametrize("representation", ["sparse"]) def test_set_encoder( vocab: list[str], embedding_size: int, representation: str, num_fc_layers: int, ): # make repeatable set_random_seed(RANDOM_SEED) # setup encoder to test set_encoder = SetSparseEncoder( vocab=vocab, representation=representation, embedding_size=embedding_size, num_fc_layers=num_fc_layers, ).to(DEVICE) inputs = torch.randint(0, 2, size=(2, len(vocab))).bool().to(DEVICE) outputs = set_encoder(inputs)[ENCODER_OUTPUT] assert outputs.shape[1:] == set_encoder.output_shape # check for parameter updating target = torch.randn(outputs.shape) fpc, tpc, upc, not_updated = check_module_parameters_updated(set_encoder, (inputs,), target) assert tpc == upc, f"Failed to update parameters. Parameters not updated: {not_updated}" ================================================ FILE: tests/ludwig/encoders/test_text_encoders.py ================================================ import json import os from unittest import mock import pytest import torch import ludwig.schema.encoders.utils as schema_encoders_utils from ludwig.api import LudwigModel from ludwig.constants import ENCODER, ENCODER_OUTPUT, MODEL_ECD, NAME, TEXT, TRAINER from ludwig.encoders import text_encoders from ludwig.error import ConfigValidationError from ludwig.globals import MODEL_FILE_NAME, MODEL_HYPERPARAMETERS_FILE_NAME from ludwig.schema.model_config import ModelConfig from ludwig.utils.data_utils import load_json from ludwig.utils.torch_utils import get_torch_device from tests.integration_tests.parameter_update_utils import check_module_parameters_updated from tests.integration_tests.utils import ( category_feature, clear_huggingface_cache, generate_data, HF_ENCODERS, HF_ENCODERS_SHORT, LocalTestBackend, text_feature, ) DEVICE = get_torch_device() RANDOM_SEED = 1919 def _load_pretrained_hf_model_no_weights( modelClass: type, pretrained_model_name_or_path: str | os.PathLike | None, **pretrained_kwargs, ): """Loads a HF model architecture without loading the weights.""" from transformers import AutoConfig, AutoModel config = AutoConfig.from_pretrained(pretrained_model_name_or_path) return AutoModel.from_config(config), False def get_mismatched_config_params(ludwig_results_dir, ludwig_model): saved_config_dict = load_json(os.path.join(ludwig_results_dir, MODEL_FILE_NAME, MODEL_HYPERPARAMETERS_FILE_NAME)) saved_config_obj = ModelConfig.from_dict(saved_config_dict) mismatches = [] for input_feature_config in saved_config_obj.input_features.to_list(): feature_name = input_feature_config[NAME] encoder_config_from_file = input_feature_config[ENCODER] encoder_config_from_model = ludwig_model.model.input_features.get(feature_name).encoder_obj.config.to_dict() for k, v in encoder_config_from_model.items(): # Skip saved_weights_in_checkpoint because this value is not yet set when the global config # is modified with the final encoder config. if k == "saved_weights_in_checkpoint": continue if encoder_config_from_file[k] != v: mismatch = { "feature_name": feature_name, "param_name": k, "val_from_file": encoder_config_from_file[k], "val_from_model": v, } mismatches.append(mismatch) return mismatches # Use a curated subset of HF encoders that are compatible with transformers 5.x. # Full encoder-schema coverage is tested by test_encoder_names_constant_synced_with_schema. _HF_ENCODERS_E2E = ["albert", "bert", "distilbert", "electra", "roberta", "auto_transformer"] @pytest.mark.parametrize("encoder_name", _HF_ENCODERS_E2E) def test_hf_ludwig_model_e2e(tmpdir, csv_filename, encoder_name): """Tests HuggingFace encoders end-to-end. This test validates the following: 1. Encoder config defaults are compatible with Ludwig experiments. 2. Ludwig correctly updates the encoder config with the parameters introduced by the HF encoder. 3. Ludwig correctly loads checkpoints containing HF encoder weights. """ input_features = [ text_feature( encoder={ "vocab_size": 30, "min_len": 1, "type": encoder_name, } ) ] output_features = [category_feature(decoder={"vocab_size": 2})] rel_path = generate_data(input_features, output_features, csv_filename) if encoder_name == "auto_transformer": # need to explciitly set the pretrained model name for auto_transformer input_features[0][ENCODER][ "pretrained_model_name_or_path" ] = "hf-internal-testing/tiny-bert-for-token-classification" config = { "input_features": input_features, "output_features": output_features, TRAINER: {"train_steps": 1}, } model = LudwigModel(config=config, backend=LocalTestBackend()) with mock.patch( "ludwig.encoders.text_encoders.load_pretrained_hf_model_with_hub_fallback", side_effect=_load_pretrained_hf_model_no_weights, ): # Validates that the defaults associated with the encoder are compatible with Ludwig training. _, _, _, results_dir = model.experiment(dataset=rel_path, output_directory=tmpdir) # Validate that the saved config reflects the parameters introduced by the HF encoder. # This ensures that the config updates after initializing the encoder. mismatched_config_params = get_mismatched_config_params(results_dir, model) if len(mismatched_config_params) > 0: raise AssertionError( f"Config parameters mismatched with encoder parameters: " f"{json.dumps(mismatched_config_params, indent=4)}" ) # Validate the model can be loaded. # This ensures that the config reflects the internal architecture of the encoder. LudwigModel.load(os.path.join(results_dir, MODEL_FILE_NAME)) clear_huggingface_cache() @pytest.mark.parametrize("reduce_output", [None, "last", "sum", "mean", "max", "concat"]) @pytest.mark.parametrize("encoder_name", HF_ENCODERS_SHORT) def test_hf_ludwig_model_reduce_options(tmpdir, csv_filename, encoder_name, reduce_output): input_features = [ text_feature( preprocessing={ "max_sequence_length": 10, }, encoder={ "vocab_size": 30, "min_len": 1, "type": encoder_name, "reduce_output": reduce_output, }, ) ] output_features = [category_feature(decoder={"vocab_size": 2})] rel_path = generate_data(input_features, output_features, csv_filename) if encoder_name == "auto_transformer": # need to explciitly set the pretrained model name for auto_transformer input_features[0][ENCODER][ "pretrained_model_name_or_path" ] = "hf-internal-testing/tiny-bert-for-token-classification" config = { "input_features": input_features, "output_features": output_features, TRAINER: {"train_steps": 1}, } try: ModelConfig.from_dict(config) except ConfigValidationError as e: pytest.skip(e.message) model = LudwigModel( config=config, backend=LocalTestBackend(), ) # Validates that the defaults associated with the encoder are compatible with Ludwig training. with mock.patch( "ludwig.encoders.text_encoders.load_pretrained_hf_model_with_hub_fallback", side_effect=_load_pretrained_hf_model_no_weights, ): model.train( dataset=rel_path, output_directory=tmpdir, skip_save_training_description=True, skip_save_training_statistics=True, skip_save_model=True, skip_save_progress=True, skip_save_log=True, skip_save_processed_input=True, ) clear_huggingface_cache() @pytest.mark.parametrize( "pretrained_model_name_or_path", [ "hf-internal-testing/tiny-random-OPTModel", "hf-internal-testing/tiny-random-BertModel", "hf-internal-testing/tiny-random-DistilBertModel", ], ) def test_hf_ludwig_model_auto_transformers(tmpdir, csv_filename, pretrained_model_name_or_path): """Tests different AutoModel types to ensure our wrapper handles them correctly. This is needed because different PretrainedModel implemetnations have different input / output signatures. """ input_features = [ text_feature( preprocessing={ "max_sequence_length": 10, }, encoder={ "vocab_size": 30, "min_len": 1, "type": "auto_transformer", "pretrained_model_name_or_path": pretrained_model_name_or_path, }, ) ] output_features = [category_feature(decoder={"vocab_size": 2})] rel_path = generate_data(input_features, output_features, csv_filename) config = { "input_features": input_features, "output_features": output_features, TRAINER: {"train_steps": 1}, } model = LudwigModel(config=config, backend=LocalTestBackend()) # Validates that the defaults associated with the encoder are compatible with Ludwig training. with mock.patch( "ludwig.encoders.text_encoders.load_pretrained_hf_model_with_hub_fallback", side_effect=_load_pretrained_hf_model_no_weights, ): model.train(dataset=rel_path, output_directory=tmpdir) @pytest.mark.parametrize("trainable", [True, False]) def test_distilbert_param_updates(trainable: bool): max_sequence_length = 20 distil_bert_encoder = text_encoders.DistilBERTEncoder( use_pretrained=False, max_sequence_length=max_sequence_length, trainable=trainable, ).to(DEVICE) # send a random input through the model with its initial weights inputs = torch.rand((2, max_sequence_length)).type(distil_bert_encoder.input_dtype).to(DEVICE) outputs = distil_bert_encoder(inputs) # perform a backward pass to update the model params target = torch.randn(outputs[ENCODER_OUTPUT].shape).to(DEVICE) check_module_parameters_updated(distil_bert_encoder, (inputs,), target) # send the same input through the model again. should be different if trainable, else the same outputs2 = distil_bert_encoder(inputs) encoder_output1 = outputs[ENCODER_OUTPUT] encoder_output2 = outputs2[ENCODER_OUTPUT] if trainable: # Outputs should be different if the model was updated assert not torch.equal(encoder_output1, encoder_output2) else: # Outputs should be the same if the model wasn't updated assert torch.equal(encoder_output1, encoder_output2) @pytest.mark.parametrize("encoder_name", HF_ENCODERS) def test_encoder_names_constant_synced_with_schema(encoder_name): """Ensures that each value in the HF_ENCODERS constant is represented by an equivalent schema object.""" schema_encoders_utils.get_encoder_cls(MODEL_ECD, TEXT, encoder_name) @pytest.mark.parametrize("vocab_size", [20]) def test_tfidf_encoder(vocab_size: int): # make repeatable torch.manual_seed(RANDOM_SEED) batch_size = 10 sequence_length = 32 vocab = [str(i) for i in range(1, vocab_size + 1)] str2idf = {s: 1 for s in vocab} text_encoder = text_encoders.TfIdfEncoder( max_sequence_length=sequence_length, str2idf=str2idf, vocab=vocab, vocab_size=vocab_size, ).to(DEVICE) assert len(text_encoder.output_shape) == 1 assert text_encoder.output_shape[0] == vocab_size assert len(list(text_encoder.parameters())) == 0 inputs = torch.randint(2, (batch_size, sequence_length)).to(DEVICE) outputs = text_encoder(inputs) assert outputs[ENCODER_OUTPUT].shape[1:] == text_encoder.output_shape def test_hf_auto_transformer_use_pretrained(): """This test ensures that use_pretrained is always True when using the auto_transformer text encoder even if a user explicitly sets it to False.""" config = { "input_features": [ text_feature( encoder={ "type": "auto_transformer", "use_pretrained": False, "pretrained_model_name_or_path": "hf-internal-testing/tiny-random-bloom", }, ) ], "output_features": [category_feature(decoder={"vocab_size": 2})], } model = LudwigModel(config=config, backend=LocalTestBackend()) assert model.config_obj.input_features[0].encoder.use_pretrained ================================================ FILE: tests/ludwig/encoders/test_timm_encoder.py ================================================ import pytest import torch timm = pytest.importorskip("timm", reason="timm not installed") from ludwig.encoders.image.timm import ( # noqa: E402 TimmCAFormerEncoder, TimmConvFormerEncoder, TimmEncoder, TimmPoolFormerEncoder, ) @pytest.mark.parametrize( "encoder_cls,model_name", [ (TimmEncoder, "resnetv2_50"), (TimmCAFormerEncoder, "caformer_s18"), (TimmConvFormerEncoder, "convformer_s18"), (TimmPoolFormerEncoder, "poolformerv2_s12"), ], ids=["timm_resnet", "caformer", "convformer", "poolformer"], ) def test_timm_encoder_forward(encoder_cls, model_name): encoder = encoder_cls(model_name=model_name, use_pretrained=False, trainable=True) # Get the expected input shape from the encoder input_shape = encoder.input_shape # (C, H, W) batch = torch.randn(2, *input_shape) output = encoder(batch) assert "encoder_output" in output out_tensor = output["encoder_output"] assert out_tensor.shape[0] == 2 assert out_tensor.shape[1:] == encoder.output_shape @pytest.mark.parametrize("trainable", [True, False]) def test_timm_encoder_trainable(trainable): encoder = TimmCAFormerEncoder(model_name="caformer_s18", use_pretrained=False, trainable=trainable) for p in encoder.model.parameters(): assert p.requires_grad == trainable def test_timm_encoder_output_shape_property(): encoder = TimmEncoder(model_name="caformer_s18", use_pretrained=False) assert len(encoder.output_shape) == 1 assert encoder.output_shape[0] > 0 ================================================ FILE: tests/ludwig/evaluation/test_evaluation.py ================================================ import os import pandas as pd import yaml from ludwig.api import LudwigModel def test_eval_steps_determinism(): # Force CPU to avoid CUBLAS errors with tiny random LLM models on GPU. old_val = os.environ.get("CUDA_VISIBLE_DEVICES") os.environ["CUDA_VISIBLE_DEVICES"] = "" try: _run_eval_steps_determinism() finally: if old_val is None: os.environ.pop("CUDA_VISIBLE_DEVICES", None) else: os.environ["CUDA_VISIBLE_DEVICES"] = old_val def _run_eval_steps_determinism(): df = pd.DataFrame( { "in": "a b c d e f g h i j k l m n o p q r s t".split(" "), "out": [i for i in range(20)], "split": ([0] * 10) + ([2] * 10), } ) config = yaml.safe_load(""" model_type: llm base_model: hf-internal-testing/tiny-random-GPT2LMHeadModel input_features: - name: in type: text output_features: - name: out type: text prompt: template: >- {in} generation: temperature: null do_sample: False max_new_tokens: 64 preprocessing: split: type: fixed column: split trainer: type: finetune epochs: 1 batch_size: 1 eval_batch_size: 2 learning_rate: 0.00001 gradient_clipping: clipglobalnorm: 1.0 backend: type: local """) model = LudwigModel(config=config) model.train(df) results1 = model.evaluate(df) model.config_obj.trainer.eval_steps = 4 results2 = model.evaluate(df) results3 = model.evaluate(df) for k in results1[0]["out"]: # The core assertion: repeated evaluations with the same eval_steps # setting must produce identical results (determinism). assert ( results2[0]["out"][k] == results3[0]["out"][k] ), f"Metric '{k}' differs between repeated evaluations: {results2[0]['out'][k]} vs {results3[0]['out'][k]}" ================================================ FILE: tests/ludwig/explain/test_captum.py ================================================ import torch from ludwig.explain.captum import get_token_attributions def test_get_token_attributions(): feature_name = "text_8D824" input_ids = torch.tensor([[1, 5, 6, 4, 4, 4, 6, 0, 2], [1, 4, 5, 6, 4, 4, 6, 5, 0]], dtype=torch.int8) model = type("Model", (), {})() model.training_set_metadata = { feature_name: { "idx2str": [ "", "", "", "", "oypszb", "yscnrkzw", "llcgslcvzr", ] } } token_attributions = torch.tensor( [ [-0.1289, -0.3222, -0.4931, -0.2914, -0.2891, -0.2871, -0.4118, -0.4647, 0.0000], # zero norm should not lead to division by zero [0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000], ], dtype=torch.float64, ) toks_and_attrs = get_token_attributions(model, feature_name, input_ids, token_attributions) # assert equality up to 4 decimal places assert [[(ta[0], round(ta[1], 4)) for ta in tas] for tas in toks_and_attrs] == [ [ # normalized attributions ("", -0.1289), ("yscnrkzw", -0.3222), ("llcgslcvzr", -0.4931), ("oypszb", -0.2914), ("oypszb", -0.2891), ("oypszb", -0.2871), ("llcgslcvzr", -0.4118), ("", -0.4647), ("", 0.0), ], [ # zero norm should retain original zero attributions ("", 0.0), ("oypszb", 0.0), ("yscnrkzw", 0.0), ("llcgslcvzr", 0.0), ("oypszb", 0.0), ("oypszb", 0.0), ("llcgslcvzr", 0.0), ("yscnrkzw", 0.0), ("", 0.0), ], ] ================================================ FILE: tests/ludwig/explain/test_util.py ================================================ import logging import os import pandas as pd import torch from ludwig.api import LudwigModel from ludwig.constants import NAME from ludwig.explain.util import get_absolute_module_key_from_submodule, replace_layer_with_copy from tests.integration_tests.utils import binary_feature, generate_data, LocalTestBackend, text_feature def test_get_absolute_module_key_from_submodule(): class ParentModule(torch.nn.Module): def __init__(self): super().__init__() self.child_module_1 = ChildModule() self.child_module_2 = ChildModule() class ChildModule(torch.nn.Module): def __init__(self): super().__init__() self.linear = torch.nn.Linear(10, 10) # the expected module names are those that are relative to the parent module, i.e. "child_module_1.linear.weight" parent_module = ParentModule() expected_module_names = set() for parent_param_name, _ in parent_module.named_parameters(): expected_module_names.add(parent_param_name) # incorrect module names are those that are relative to the child module, not the parent module, # i.e. "linear.weight" and "linear.bias" incorrect_param_names = set() for child_param_name, _ in parent_module.child_module_1.named_parameters(): incorrect_param_names.add(child_param_name) module_names_child_1 = set(get_absolute_module_key_from_submodule(parent_module, parent_module.child_module_1)) module_names_child_2 = set(get_absolute_module_key_from_submodule(parent_module, parent_module.child_module_2)) # check that the module names are not equivalent to the incorrect module names assert set.isdisjoint(module_names_child_1, incorrect_param_names) assert set.isdisjoint(module_names_child_2, incorrect_param_names) # check that the module names are disjoint from one another because they are relative to the parent module assert set.isdisjoint(module_names_child_1, module_names_child_2) # check that the union of the two sets is equal to the expected module names assert set.union(module_names_child_1, module_names_child_2) == expected_module_names def test_replace_layer_with_copy(tmpdir): text_feature_1 = text_feature() text_feature_2 = text_feature(tied=text_feature_1["name"]) input_features = [text_feature_1, text_feature_2] output_features = [binary_feature()] csv_filename = os.path.join(tmpdir, "training.csv") generate_data(input_features, output_features, csv_filename, num_examples=200) df = pd.read_csv(csv_filename) config = { "input_features": input_features, "output_features": output_features, "trainer": { "epochs": 1, }, } model = LudwigModel(config, logging_level=logging.WARNING, backend=LocalTestBackend()) model.train(df) input_feature_module = model.model.input_features.get(text_feature_2[NAME]) target_layer = input_feature_module.encoder_obj.get_embedding_layer() data_ptrs_before = {} for param_name, param in input_feature_module.named_parameters(): data_ptrs_before[param_name] = param.data_ptr() keys_to_copy = get_absolute_module_key_from_submodule(input_feature_module, target_layer) replace_layer_with_copy(input_feature_module, target_layer) data_ptrs_after = {} for param_name, param in input_feature_module.named_parameters(): data_ptrs_after[param_name] = param.data_ptr() # Check that the data pointers are different for the copied keys and that they are the same for the rest. for param_name, _ in input_feature_module.named_parameters(): if param_name in keys_to_copy: assert ( data_ptrs_before[param_name] != data_ptrs_after[param_name] ), f"Data pointers should be different for copied key {param_name}" else: assert ( data_ptrs_before[param_name] == data_ptrs_after[param_name] ), f"Data pointers should be the same for non-copied key {param_name}" ================================================ FILE: tests/ludwig/features/__init__.py ================================================ ================================================ FILE: tests/ludwig/features/test_audio_feature.py ================================================ import os from random import choice from string import ascii_lowercase, ascii_uppercase, digits import pandas as pd import pytest import torch from ludwig.backend import LOCAL_BACKEND from ludwig.constants import BFILL, ENCODER_OUTPUT, PROC_COLUMN from ludwig.features.audio_feature import AudioFeatureMixin, AudioInputFeature from ludwig.schema.features.audio_feature import AudioInputFeatureConfig from ludwig.schema.utils import load_config_with_kwargs from ludwig.utils.torch_utils import get_torch_device from tests.integration_tests.utils import audio_feature, category_feature, generate_data BATCH_SIZE = 2 SEQ_SIZE = 20 AUDIO_W_SIZE = 16 CHARS = ascii_uppercase + ascii_lowercase + digits VOCAB = ["".join(choice(CHARS) for _ in range(2)) for _ in range(256)] DEVICE = get_torch_device() @pytest.mark.parametrize("encoder", ["rnn", "stacked_cnn", "parallel_cnn", "stacked_parallel_cnn", "rnn", "cnnrnn"]) def test_audio_input_feature(encoder: str) -> None: audio_config = { "name": "audio_feature", "type": "audio", "preprocessing": { "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": { "type": encoder, "should_embed": False, "vocab": VOCAB, "max_sequence_length": SEQ_SIZE, "embedding_size": AUDIO_W_SIZE, }, } audio_config, _ = load_config_with_kwargs(AudioInputFeatureConfig, audio_config) audio_input_feature = AudioInputFeature(audio_config).to(DEVICE) audio_tensor = torch.randn([BATCH_SIZE, SEQ_SIZE, AUDIO_W_SIZE], dtype=torch.float32).to(DEVICE) encoder_output = audio_input_feature(audio_tensor) assert encoder_output[ENCODER_OUTPUT].shape[1:] == audio_input_feature.output_shape @pytest.mark.parametrize("feature_type", ["raw", "stft", "stft_phase", "group_delay", "fbank"]) def test_add_feature_data(feature_type, tmpdir): preprocessing_params = { "audio_file_length_limit_in_s": 3.0, "missing_value_strategy": BFILL, "in_memory": True, "padding_value": 0, "norm": "per_file", "type": feature_type, "window_length_in_s": 0.04, "window_shift_in_s": 0.02, "num_fft_points": None, "window_type": "hamming", "num_filter_bands": 80, } audio_dest_folder = os.path.join(tmpdir, "generated_audio") audio_feature_config = audio_feature(audio_dest_folder, preprocessing=preprocessing_params) data_df_path = generate_data( [audio_feature_config], [category_feature(vocab_size=5, reduce_input="sum")], os.path.join(tmpdir, "data.csv"), num_examples=10, ) data_df = pd.read_csv(data_df_path) metadata = { audio_feature_config["name"]: AudioFeatureMixin.get_feature_meta( {}, data_df[audio_feature_config["name"]], preprocessing_params, LOCAL_BACKEND, True ) } proc_df = {} AudioFeatureMixin.add_feature_data( feature_config=audio_feature_config, input_df=data_df, proc_df=proc_df, metadata=metadata, preprocessing_parameters=preprocessing_params, backend=LOCAL_BACKEND, skip_save_processed_input=False, ) assert len(proc_df[audio_feature_config[PROC_COLUMN]]) == 10 ================================================ FILE: tests/ludwig/features/test_bag_feature.py ================================================ from random import choice from string import ascii_lowercase, ascii_uppercase, digits import pytest import torch from ludwig.constants import ENCODER, ENCODER_OUTPUT from ludwig.features.bag_feature import BagInputFeature from ludwig.schema.features.bag_feature import BagInputFeatureConfig from ludwig.schema.utils import load_config_with_kwargs from ludwig.utils.torch_utils import get_torch_device BATCH_SIZE = 2 SEQ_SIZE = 20 BAG_W_SIZE = 256 EMBEDDING_SIZE = 5 CHARS = ascii_uppercase + ascii_lowercase + digits VOCAB = ["".join(choice(CHARS) for _ in range(2)) for _ in range(256)] DEVICE = get_torch_device() @pytest.fixture(scope="module") def bag_config(): return { "name": "bag_feature", "type": "bag", "encoder": { "max_len": 5, "vocab_size": 10, "embedding_size": EMBEDDING_SIZE, "vocab": VOCAB, }, } @pytest.mark.parametrize("encoder", ["embed"]) def test_bag_input_feature(bag_config: dict, encoder: str) -> None: bag_config[ENCODER].update({"type": encoder}) bag_config, _ = load_config_with_kwargs(BagInputFeatureConfig, bag_config) bag_input_feature = BagInputFeature(bag_config).to(DEVICE) bag_tensor = torch.randn([BATCH_SIZE, SEQ_SIZE, BAG_W_SIZE], dtype=torch.float32).to(DEVICE) encoder_output = bag_input_feature(bag_tensor) assert encoder_output[ENCODER_OUTPUT].shape[1:][1:] == bag_input_feature.output_shape ================================================ FILE: tests/ludwig/features/test_binary_feature.py ================================================ import pytest import torch from ludwig.constants import ENCODER, ENCODER_OUTPUT from ludwig.features.binary_feature import BinaryInputFeature, BinaryOutputFeature from ludwig.schema.features.binary_feature import BinaryInputFeatureConfig, BinaryOutputFeatureConfig from ludwig.schema.utils import load_config_with_kwargs from ludwig.utils.torch_utils import get_torch_device BATCH_SIZE = 2 BINARY_W_SIZE = 1 DEVICE = get_torch_device() @pytest.fixture(scope="module") def binary_config(): return { "name": "binary_feature", "type": "binary", } @pytest.mark.parametrize("encoder", ["passthrough", "dense"]) def test_binary_input_feature(binary_config: dict, encoder: str): binary_config.update({ENCODER: {"type": encoder}}) binary_config, _ = load_config_with_kwargs(BinaryInputFeatureConfig, binary_config) binary_input_feature = BinaryInputFeature(binary_config).to(DEVICE) binary_tensor = binary_input_feature.create_sample_input(batch_size=BATCH_SIZE).to(DEVICE) assert binary_tensor.shape == torch.Size([BATCH_SIZE]) assert binary_tensor.dtype == torch.bool encoder_output = binary_input_feature(binary_tensor) assert encoder_output[ENCODER_OUTPUT].shape[1:] == binary_input_feature.output_shape def test_binary_output_feature(): binary_output_config = { "name": "binary_feature", "type": "binary", "input_size": BINARY_W_SIZE, "decoder": { "type": "regressor", "input_size": 1, }, "loss": { "type": "binary_weighted_cross_entropy", "positive_class_weight": 1, "robust_lambda": 0, "confidence_penalty": 0, }, } binary_output_config, _ = load_config_with_kwargs(BinaryOutputFeatureConfig, binary_output_config) binary_output_feature = BinaryOutputFeature(binary_output_config, {}).to(DEVICE) combiner_outputs = dict() combiner_outputs["combiner_output"] = torch.randn([BATCH_SIZE, BINARY_W_SIZE], dtype=torch.float32).to(DEVICE) binary_output = binary_output_feature(combiner_outputs, {}) assert "last_hidden" in binary_output assert "logits" in binary_output assert binary_output["logits"].size() == torch.Size([BATCH_SIZE]) def test_binary_output_feature_without_positive_class_weight(): binary_output_config = { "name": "binary_feature", "type": "binary", "input_size": BINARY_W_SIZE, "decoder": { "type": "regressor", "input_size": 1, }, "loss": { "type": "binary_weighted_cross_entropy", "positive_class_weight": None, "robust_lambda": 0, "confidence_penalty": 0, }, } binary_output_config, _ = load_config_with_kwargs(BinaryOutputFeatureConfig, binary_output_config) binary_output_feature = BinaryOutputFeature(binary_output_config, {}).to(DEVICE) combiner_outputs = {} combiner_outputs["combiner_output"] = torch.randn([BATCH_SIZE, BINARY_W_SIZE], dtype=torch.float32).to(DEVICE) binary_output = binary_output_feature(combiner_outputs, {}) assert "last_hidden" in binary_output assert "logits" in binary_output assert binary_output["logits"].size() == torch.Size([BATCH_SIZE]) ================================================ FILE: tests/ludwig/features/test_category_feature.py ================================================ from copy import deepcopy import pytest import torch from ludwig.constants import ENCODER, ENCODER_OUTPUT, TYPE from ludwig.features.category_feature import CategoryInputFeature from ludwig.schema.features.category_feature import ECDCategoryInputFeatureConfig from ludwig.schema.utils import load_config_with_kwargs from ludwig.utils.misc_utils import merge_dict from ludwig.utils.torch_utils import get_torch_device BATCH_SIZE = 2 DEVICE = get_torch_device() @pytest.fixture(scope="module") def category_config(): return { "name": "category_column_name", "type": "category", "tied": None, "encoder": { "embedding_size": 256, "embeddings_on_cpu": False, "pretrained_embeddings": None, "embeddings_trainable": True, "dropout": 0.0, "vocab": ["a", "b", "c"], "embedding_initializer": None, }, } @pytest.mark.parametrize("encoder", ["dense", "sparse"]) def test_category_input_feature( category_config: dict, encoder: str, ) -> None: # setup image input feature definition category_def = deepcopy(category_config) category_def[ENCODER][TYPE] = encoder # pickup any other missing parameters defaults = ECDCategoryInputFeatureConfig(name="foo").to_dict() category_def = merge_dict(defaults, category_def) # ensure no exceptions raised during build category_config, _ = load_config_with_kwargs(ECDCategoryInputFeatureConfig, category_def) input_feature_obj = CategoryInputFeature(category_config).to(DEVICE) # check one forward pass through input feature input_tensor = torch.randint(0, 3, size=(BATCH_SIZE,), dtype=torch.int32).to(DEVICE) encoder_output = input_feature_obj(input_tensor) assert encoder_output[ENCODER_OUTPUT].shape == (BATCH_SIZE, *input_feature_obj.output_shape) ================================================ FILE: tests/ludwig/features/test_date_feature.py ================================================ from copy import deepcopy from datetime import date, datetime, timezone from typing import Any import pytest import torch from dateutil.parser import parse from ludwig.constants import ENCODER_OUTPUT, FILL_WITH_CONST, MISSING_VALUE_STRATEGY from ludwig.features import date_feature from ludwig.features.date_feature import DateInputFeature from ludwig.schema.features.date_feature import DateInputFeatureConfig from ludwig.schema.utils import load_config_with_kwargs from ludwig.types import FeatureConfigDict from ludwig.utils.date_utils import create_vector_from_datetime_obj from ludwig.utils.misc_utils import merge_dict from ludwig.utils.torch_utils import get_torch_device BATCH_SIZE = 2 DATE_W_SIZE = 9 DEVICE = get_torch_device() @pytest.fixture(scope="module") def date_config(): return {"name": "date_column_name", "type": "date"} def test_date_input_feature(date_config: FeatureConfigDict): # setup image input feature definition feature_def = deepcopy(date_config) # pickup any other missing parameters defaults = DateInputFeatureConfig(name="foo").to_dict() set_def = merge_dict(defaults, feature_def) # ensure no exceptions raised during build feature_config, _ = load_config_with_kwargs(DateInputFeatureConfig, set_def) input_feature_obj = DateInputFeature(feature_config).to(DEVICE) # check one forward pass through input feature input_tensor = input_feature_obj.create_sample_input(batch_size=BATCH_SIZE).to(DEVICE) assert input_tensor.shape == torch.Size((BATCH_SIZE, DATE_W_SIZE)) assert input_tensor.dtype == torch.int32 encoder_output = input_feature_obj(input_tensor) assert encoder_output[ENCODER_OUTPUT].shape == (BATCH_SIZE, *input_feature_obj.output_shape) @pytest.mark.parametrize( "date_str,datetime_format,expected_list", [ ("2012-02-26T13:51:50.417-07:00", None, [2012, 2, 26, 6, 57, 13, 51, 50, 49910]), ("2022-06-25 09:30:59", None, [2022, 6, 25, 5, 176, 9, 30, 59, 34259]), ("2022-06-25", None, [2022, 6, 25, 5, 176, 0, 0, 0, 0]), ], ) def test_date_to_list(date_str, datetime_format, expected_list): preprocessing_parameters = None assert ( date_feature.DateInputFeature.date_to_list(date_str, datetime_format, preprocessing_parameters) == expected_list ) @pytest.fixture(scope="module") def reference_date_list() -> list[int]: return create_vector_from_datetime_obj( datetime.fromtimestamp(1691600953.443032, tz=timezone.utc).replace(tzinfo=None) ) @pytest.fixture(scope="module") def fill_value() -> str: return "1970-01-01 00:00:00" @pytest.fixture(scope="module") def fill_value_list(fill_value: str) -> list[int]: return create_vector_from_datetime_obj(parse(fill_value)) @pytest.mark.parametrize( "timestamp,datetime_format,expected_list", [ pytest.param(1691600953.443032, None, "reference_date_list", id="float-s"), pytest.param(1691600953443.032, None, "reference_date_list", id="float-ms"), pytest.param(1691600953, None, "reference_date_list", id="int-s"), pytest.param(1691600953443, None, "reference_date_list", id="int-ms"), pytest.param(1691600953.443032, "%d/%m/%y %H:%M:%S.%f", "reference_date_list", id="float-s-fmt"), pytest.param(1691600953443.032, "%d/%m/%y %H:%M:%S.%f", "reference_date_list", id="float-ms-fmt"), pytest.param(1691600953, "%d/%m/%y %H:%M:%S.%f", "reference_date_list", id="int-s-fmt"), pytest.param(1691600953443, "%d/%m/%y %H:%M:%S.%f", "reference_date_list", id="int-ms-fmt"), pytest.param("1691600953.443032", None, "reference_date_list", id="string[float]-s"), pytest.param("1691600953443.0032", None, "reference_date_list", id="string[float]-ms"), pytest.param("1691600953", None, "reference_date_list", id="string[int]-s"), pytest.param("1691600953443", None, "reference_date_list", id="string[int]-ms"), pytest.param("1691600953.443032", "%d/%m/%y %H:%M:%S.%f", "reference_date_list", id="string[float]-s-fmt"), pytest.param("1691600953443.0032", "%d/%m/%y %H:%M:%S.%f", "reference_date_list", id="string[float]-ms-fmt"), pytest.param("1691600953", "%d/%m/%y %H:%M:%S.%f", "reference_date_list", id="string[int]-s-fmt"), pytest.param("1691600953443", "%d/%m/%y %H:%M:%S.%f", "reference_date_list", id="string[int]-ms-fmt"), pytest.param("foo", None, "fill_value_list", id="string error"), pytest.param([1691600953.443032], None, "fill_value_list", id="list error"), pytest.param(None, None, "fill_value_list", id="NoneType error"), ], ) def test_date_to_list_numeric(timestamp: Any, datetime_format: str, expected_list: list[int], fill_value: str, request): """Test that numeric datetime formats are converted correctly. Currently, we support int, float, and string representations of POSIX timestamps in seconds and milliseconds. Valid timestamps should be converted to datetime lists by `luwdig.utils.date_utils.create_vector_from_datetime_object`. If a string format is provided, it should be ignored. Args: timestamp: Input to be converted to a date vector datetime_format: Optional format string, should be ignored under the hood with these timestamps. expected_list: The expected output of `DateFeatureMixin.date_to_list` fill_value: Date to be used as fallback request: pytest request fixture """ expected_result = request.getfixturevalue(expected_list) # The default fill value is `datetime.now`, for testing we override this to be a constant. preprocessing_parameters = {MISSING_VALUE_STRATEGY: FILL_WITH_CONST, "fill_value": fill_value} # No exception should ever be raised from `date_to_list` due to a parsing error. The expected behavior is to fall # back to the fill value. dt = date_feature.DateInputFeature.date_to_list(timestamp, datetime_format, preprocessing_parameters) assert dt == expected_result def test_date_to_list__DatetimeObjectFromParsedJSON(): preprocessing_parameters = None datetime_obj = datetime.fromisoformat("2022-06-25") assert date_feature.DateInputFeature.date_to_list(datetime_obj, None, preprocessing_parameters) == [ 2022, 6, 25, 5, 176, 0, 0, 0, 0, ] def test_date_to_list__UsesFillValueOnInvalidDate(): preprocessing_parameters = {"fill_value": "2013-02-26"} invalid_date_str = "2012abc-02" datetime_format = None assert date_feature.DateInputFeature.date_to_list(invalid_date_str, datetime_format, preprocessing_parameters) == [ 2013, 2, 26, 1, 57, 0, 0, 0, 0, ] @pytest.fixture(scope="module") def date_obj(): return date.fromisoformat("2022-06-25") @pytest.fixture(scope="module") def date_obj_vec(): return create_vector_from_datetime_obj(datetime.fromisoformat("2022-06-25")) def test_date_object_to_list(date_obj, date_obj_vec, fill_value): """Test support for datetime.date object conversion. Args: date_obj: Date object to convert into a vector date_obj_vector: Expected vector version of `date_obj` """ computed_date_vec = date_feature.DateInputFeature.date_to_list( date_obj, None, preprocessing_parameters={MISSING_VALUE_STRATEGY: FILL_WITH_CONST, "fill_value": fill_value} ) assert computed_date_vec == date_obj_vec ================================================ FILE: tests/ludwig/features/test_feature_utils.py ================================================ import numpy as np import pytest import torch from ludwig.features import feature_utils def test_ludwig_feature_dict(): feature_dict = feature_utils.LudwigFeatureDict() to_module = torch.nn.Module() type_module = torch.nn.Module() feature_dict.set("to", to_module) feature_dict.set("type", type_module) assert iter(feature_dict) is not None assert next(feature_dict) is not None assert len(feature_dict) == 2 assert feature_dict.keys() == ["to", "type"] assert feature_dict.items() == [("to", to_module), ("type", type_module)] assert feature_dict.get("to"), to_module feature_dict.update({"to_empty": torch.nn.Module()}) assert len(feature_dict) == 3 assert [key for key in feature_dict] == ["to", "type", "to_empty"] def test_ludwig_feature_dict_with_periods(): feature_dict = feature_utils.LudwigFeatureDict() to_module = torch.nn.Module() feature_dict.set("to.", to_module) assert feature_dict.keys() == ["to."] assert feature_dict.items() == [("to.", to_module)] assert feature_dict.get("to.") == to_module @pytest.mark.parametrize("sequence_type", [list, tuple, np.array]) def test_compute_token_probabilities(sequence_type): inputs = sequence_type( [ [0.1, 0.2, 0.7], [0.3, 0.4, 0.3], [0.6, 0.3, 0.2], ] ) token_probabilities = feature_utils.compute_token_probabilities(inputs) assert np.allclose(token_probabilities, [0.7, 0.4, 0.6]) def test_compute_sequence_probability(): inputs = np.array([0.7, 0.4, 0.6]) sequence_probability = feature_utils.compute_sequence_probability( inputs, max_sequence_length=2, return_log_prob=False ) assert np.allclose(sequence_probability, [0.28]) # 0.7 * 0.4 ================================================ FILE: tests/ludwig/features/test_h3_feature.py ================================================ from ludwig.features import h3_feature def test_h3_to_list(): assert h3_feature.H3FeatureMixin.h3_to_list(0) == [0, 0, 0, 0, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7] assert h3_feature.H3FeatureMixin.h3_to_list(576495936675512319) == [ 1, 0, 0, 0, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, ] assert h3_feature.H3FeatureMixin.h3_to_list(102576495936675512319) == [ 1, 7, 8, 71, 2, 7, 1, 2, 2, 6, 1, 6, 7, 7, 7, 7, 7, 7, 7, ] assert h3_feature.H3FeatureMixin.h3_to_list(50102576495936675512319) == [ 2, 0, 14, 102, 7, 0, 3, 5, 0, 5, 5, 0, 5, 7, 7, 7, 7, 7, 7, ] ================================================ FILE: tests/ludwig/features/test_image_feature.py ================================================ from copy import deepcopy import pytest import torch from ludwig.constants import ( BFILL, CROP_OR_PAD, ENCODER, ENCODER_OUTPUT, ENCODER_OUTPUT_STATE, INTERPOLATE, LOGITS, TYPE, ) from ludwig.features.image_feature import _ImagePreprocessing, ImageInputFeature, ImageOutputFeature from ludwig.schema.features.image_feature import ImageInputFeatureConfig, ImageOutputFeatureConfig from ludwig.schema.utils import load_config_with_kwargs from ludwig.utils.misc_utils import merge_dict from ludwig.utils.torch_utils import get_torch_device from tests.integration_tests.utils import image_feature BATCH_SIZE = 2 DEVICE = get_torch_device() @pytest.fixture(scope="module") def image_config(): return { "name": "image_column_name", "type": "image", "tied": None, "encoder": { "type": "stacked_cnn", "conv_layers": None, "num_conv_layers": None, "filter_size": 3, "num_filters": 256, "strides": (1, 1), "padding": "valid", "dilation_rate": (1, 1), "conv_use_bias": True, "conv_weights_initializer": "xavier_uniform", "conv_bias_initializer": "zeros", "conv_norm": None, "conv_norm_params": None, "conv_activation": "relu", "conv_dropout": 0, "pool_function": "max", "pool_size": (2, 2), "pool_strides": None, "fc_layers": None, "num_fc_layers": 1, "output_size": 16, "fc_use_bias": True, "fc_weights_initializer": "xavier_uniform", "fc_bias_initializer": "zeros", "fc_norm": None, "fc_norm_params": None, "fc_activation": "relu", "fc_dropout": 0, }, "preprocessing": { "height": 28, "width": 28, "num_channels": 1, "scaling": "pixel_normalization", }, } @pytest.mark.parametrize( "encoder, height, width, num_channels", [ ("stacked_cnn", 28, 28, 3), ("stacked_cnn", 28, 28, 1), ("mlp_mixer", 32, 32, 3), ], ) def test_image_input_feature(image_config: dict, encoder: str, height: int, width: int, num_channels: int) -> None: # setup image input feature definition image_def = deepcopy(image_config) image_def[ENCODER][TYPE] = encoder image_def[ENCODER]["height"] = height image_def[ENCODER]["width"] = width image_def[ENCODER]["num_channels"] = num_channels # pickup any other missing parameters defaults = ImageInputFeatureConfig(name="foo").to_dict() set_def = merge_dict(defaults, image_def) # ensure no exceptions raised during build image_config, _ = load_config_with_kwargs(ImageInputFeatureConfig, set_def) input_feature_obj = ImageInputFeature(image_config).to(DEVICE) # check one forward pass through input feature input_tensor = torch.rand(size=(BATCH_SIZE, num_channels, height, width), dtype=torch.float32).to(DEVICE) encoder_output = input_feature_obj(input_tensor) assert encoder_output[ENCODER_OUTPUT].shape == (BATCH_SIZE, *input_feature_obj.output_shape) # todo: remove code # # test for parameter updates # before = [(x[0], x[1].clone()) for x in input_feature_obj.named_parameters()] # loss_function = torch.nn.MSELoss() # optimizer = torch.optim.SGD(input_feature_obj.parameters(), lr=0.1) # target_tensor = torch.ones(encoder_output['encoder_output'].shape, dtype=torch.float32) # # # do parameter update # loss = loss_function(encoder_output['encoder_output'], target_tensor) # loss.backward() # optimizer.step() # # after = [(x[0], x[1].clone()) for x in input_feature_obj.named_parameters()] # # # check for parameter update # for b, a in zip(before, after): # if not (b[1] != a[1]).any(): # raise RuntimeError( # f'no parameter update for {a[0]}' # ) @pytest.mark.parametrize( "encoder, decoder, height, width, num_channels, num_classes", [ ("unet", "unet", 128, 128, 3, 2), ("unet", "unet", 32, 32, 3, 7), ], ) def test_image_output_feature( encoder: str, decoder: str, height: int, width: int, num_channels: int, num_classes: int, ) -> None: # setup image input feature definition input_feature_def = image_feature( folder=".", encoder={ "type": encoder, "height": height, "width": width, "num_channels": num_channels, }, ) # create image input feature object feature_cls = ImageInputFeature schema_cls = ImageInputFeatureConfig input_config = schema_cls.from_dict(input_feature_def) input_feature_obj = feature_cls(input_config).to(DEVICE) # check one forward pass through input feature input_tensor = torch.rand(size=(BATCH_SIZE, num_channels, height, width), dtype=torch.float32).to(DEVICE) encoder_output = input_feature_obj(input_tensor) assert encoder_output[ENCODER_OUTPUT].shape == (BATCH_SIZE, *input_feature_obj.output_shape) if encoder == "unet": assert len(encoder_output[ENCODER_OUTPUT_STATE]) == 4 hidden = torch.reshape(encoder_output[ENCODER_OUTPUT], [BATCH_SIZE, -1]) # setup image output feature definition output_feature_def = image_feature( folder=".", decoder={ "type": decoder, "height": height, "width": width, "num_channels": num_channels, "num_classes": num_classes, }, input_size=hidden.size(dim=1), ) # create image output feature object feature_cls = ImageOutputFeature schema_cls = ImageOutputFeatureConfig output_config = schema_cls.from_dict(output_feature_def) output_feature_obj = feature_cls(output_config, {}).to(DEVICE) combiner_outputs = { "combiner_output": hidden, ENCODER_OUTPUT_STATE: encoder_output[ENCODER_OUTPUT_STATE], } image_output = output_feature_obj(combiner_outputs, {}) assert LOGITS in image_output assert image_output[LOGITS].size() == torch.Size([BATCH_SIZE, num_classes, height, width]) def test_image_preproc_module_bad_num_channels(): metadata = { "preprocessing": { "missing_value_strategy": BFILL, "in_memory": True, "resize_method": "interpolate", "scaling": "pixel_normalization", "num_processes": 1, "infer_image_num_channels": True, "infer_image_dimensions": True, "infer_image_max_height": 256, "infer_image_max_width": 256, "infer_image_sample_size": 100, "height": 12, "width": 12, "num_channels": 2, "num_classes": 0, "channel_class_map": [], }, "reshape": (2, 12, 12), } module = _ImagePreprocessing(metadata) with pytest.raises(ValueError): module(torch.rand(2, 3, 10, 10)) @pytest.mark.parametrize("resize_method", [INTERPOLATE, CROP_OR_PAD]) @pytest.mark.parametrize(["num_channels", "num_channels_expected"], [(1, 3), (3, 1)]) def test_image_preproc_module_list_of_tensors(resize_method, num_channels, num_channels_expected): metadata = { "preprocessing": { "missing_value_strategy": BFILL, "in_memory": True, "resize_method": resize_method, "scaling": "pixel_normalization", "num_processes": 1, "infer_image_num_channels": True, "infer_image_dimensions": True, "infer_image_max_height": 256, "infer_image_max_width": 256, "infer_image_sample_size": 100, "height": 12, "width": 12, "num_channels": num_channels_expected, "num_classes": 0, "channel_class_map": [], }, "reshape": (num_channels_expected, 12, 12), } module = _ImagePreprocessing(metadata) res = module([torch.rand(num_channels, 25, 25), torch.rand(num_channels, 10, 10)]) assert res.shape == torch.Size((2, num_channels_expected, 12, 12)) @pytest.mark.parametrize("resize_method", [INTERPOLATE, CROP_OR_PAD]) @pytest.mark.parametrize(["num_channels", "num_channels_expected"], [(1, 3), (3, 1)]) def test_image_preproc_module_tensor(resize_method, num_channels, num_channels_expected): metadata = { "preprocessing": { "missing_value_strategy": BFILL, "in_memory": True, "resize_method": resize_method, "scaling": "pixel_normalization", "num_processes": 1, "infer_image_num_channels": True, "infer_image_dimensions": True, "infer_image_max_height": 256, "infer_image_max_width": 256, "infer_image_sample_size": 100, "height": 12, "width": 12, "num_channels": num_channels_expected, "num_classes": 0, "channel_class_map": [], }, "reshape": (num_channels_expected, 12, 12), } module = _ImagePreprocessing(metadata) res = module(torch.rand(2, num_channels, 10, 10)) assert res.shape == torch.Size((2, num_channels_expected, 12, 12)) @pytest.mark.parametrize(["height", "width"], [(224, 224), (32, 32)]) def test_image_preproc_module_class_map(height, width): metadata = { "preprocessing": { "num_processes": 1, "resize_method": CROP_OR_PAD, "infer_image_num_channels": True, "infer_image_dimensions": True, "infer_image_max_height": height, "infer_image_max_width": width, "infer_image_sample_size": 100, "infer_image_num_classes": True, "height": height, "width": width, "num_channels": 3, "num_classes": 8, "channel_class_map": [ [40, 40, 40], [40, 40, 41], [40, 41, 40], [40, 41, 41], [41, 40, 40], [41, 40, 41], [41, 41, 40], [41, 41, 41], ], }, } module = _ImagePreprocessing(metadata) res = module(torch.randint(40, 42, (2, 3, height, width))) assert res.shape == torch.Size((2, height, width)) assert torch.all(res.ge(0)) and torch.all(res.le(7)) ================================================ FILE: tests/ludwig/features/test_number_feature.py ================================================ from copy import deepcopy import numpy as np import pytest import torch from ludwig.constants import ENCODER_OUTPUT from ludwig.features.number_feature import _OutlierReplacer, NumberInputFeature from ludwig.schema.features.number_feature import ECDNumberInputFeatureConfig from ludwig.schema.utils import load_config_with_kwargs from ludwig.utils.misc_utils import merge_dict from ludwig.utils.torch_utils import get_torch_device BATCH_SIZE = 2 DEVICE = get_torch_device() @pytest.fixture(scope="module") def number_config(): return {"name": "number_column_name", "type": "number"} def test_number_input_feature( number_config: dict, ) -> None: # setup image input feature definition number_def = deepcopy(number_config) # pickup any other missing parameters defaults = ECDNumberInputFeatureConfig(name="foo").to_dict() set_def = merge_dict(defaults, number_def) # ensure no exceptions raised during build number_config, _ = load_config_with_kwargs(ECDNumberInputFeatureConfig, set_def) input_feature_obj = NumberInputFeature(number_config).to(DEVICE) # check one forward pass through input feature input_tensor = input_feature_obj.create_sample_input(batch_size=BATCH_SIZE) assert input_tensor.shape == torch.Size([BATCH_SIZE]) assert input_tensor.dtype == torch.float32 encoder_output = input_feature_obj(input_tensor) assert encoder_output[ENCODER_OUTPUT].shape == (BATCH_SIZE, *input_feature_obj.output_shape) def test_outlier_replacer(): replacer = _OutlierReplacer( {"mean": 50, "std": 30, "preprocessing": {"outlier_threshold": 2.0, "computed_outlier_fill_value": 42}} ) t = torch.from_numpy(np.array([10, 20, 1000, -500, 80], dtype=np.float32)) t_out_expected = torch.from_numpy(np.array([10, 20, 42, 42, 80], dtype=np.float32)) t_out = replacer(t) assert torch.equal(t_out, t_out_expected) ================================================ FILE: tests/ludwig/features/test_sequence_features.py ================================================ import numpy as np import pytest import torch try: import torchtext except ImportError: torchtext = None from ludwig.constants import ENCODER_OUTPUT, LAST_HIDDEN, LOGITS, SEQUENCE, TEXT, TYPE from ludwig.features.sequence_feature import _SequencePreprocessing, SequenceInputFeature, SequenceOutputFeature from ludwig.features.text_feature import TextInputFeature, TextOutputFeature from ludwig.schema.features.sequence_feature import SequenceInputFeatureConfig, SequenceOutputFeatureConfig from ludwig.schema.features.text_feature import ECDTextInputFeatureConfig, ECDTextOutputFeatureConfig from ludwig.utils.torch_utils import get_torch_device from tests.integration_tests.utils import ENCODERS, sequence_feature DEVICE = get_torch_device() BATCH_SIZE = 8 SEQ_SIZE = 6 VOCAB_SIZE = 64 @pytest.fixture(scope="module") def input_sequence() -> tuple[torch.Tensor, list]: # generates a realistic looking synthetic sequence tensor, i.e. # each sequence will have non-zero tokens at the beginning with # trailing zero tokens, including a max length token with a single # zero token at the end. Example: # [ # [3, 5, 6, 0, 0, 0], # [10, 11, 12, 13, 14, 0], # max length sequence # [32, 0, 0, 0, 0, 0] # minimum length sequence # ] input_tensor = torch.zeros([BATCH_SIZE, SEQ_SIZE], dtype=torch.int32).to(DEVICE) sequence_lengths = np.random.randint(1, SEQ_SIZE, size=BATCH_SIZE) for i in range(input_tensor.shape[0]): input_tensor[i, : sequence_lengths[i]] = torch.tensor( np.random.randint(2, VOCAB_SIZE, size=sequence_lengths[i]) ) # emulate idx2str structure idx2str = ["", ""] + [str(i) for i in range(2, VOCAB_SIZE)] return input_tensor, idx2str @pytest.mark.parametrize("encoder", ENCODERS) @pytest.mark.parametrize("sequence_type", [SEQUENCE, TEXT]) def test_sequence_input_feature(input_sequence: tuple, encoder: str, sequence_type: str): # test assumes "sequence data" has been tokenized and converted to # numeric representation. Focus of this test is primarily on # integration with encoder with correctly sized encoder tensor and # required properties are present input_sequence, idx2str = input_sequence # setup input sequence feature definition # use sequence_feature() to generate baseline # sequence definition and then augment with # pre-processing metadata parameters input_feature_def = sequence_feature( encoder={ "type": encoder, "max_len": SEQ_SIZE, # augment with emulated pre-processing metadata "max_sequence_length": SEQ_SIZE, "vocab": idx2str, } ) input_feature_def[TYPE] = sequence_type # create sequence input feature object feature_cls = SequenceInputFeature if sequence_type == SEQUENCE else TextInputFeature schema_cls = SequenceInputFeatureConfig if sequence_type == SEQUENCE else ECDTextInputFeatureConfig sequence_config = schema_cls.from_dict(input_feature_def) input_feature_obj = feature_cls(sequence_config).to(DEVICE) # confirm dtype property assert input_feature_obj.input_dtype == torch.int32 # confirm input_shape property assert input_feature_obj.input_shape == (SEQ_SIZE,) # confirm output_shape property default output shape # from sequence_feature() function encoder_output = input_feature_obj(input_sequence) assert encoder_output[ENCODER_OUTPUT].shape == (BATCH_SIZE, *input_feature_obj.output_shape) @pytest.mark.parametrize("sequence_type", [SEQUENCE, TEXT]) def test_sequence_output_feature(sequence_type: str): output_feature_def = sequence_feature( decoder={ "type": "generator", "max_len": SEQ_SIZE, "max_sequence_length": SEQ_SIZE, "vocab_size": VOCAB_SIZE, }, input_size=VOCAB_SIZE, ) output_feature_def[TYPE] = sequence_type feature_cls = SequenceOutputFeature if sequence_type == SEQUENCE else TextOutputFeature schema_cls = SequenceOutputFeatureConfig if sequence_type == SEQUENCE else ECDTextOutputFeatureConfig sequence_config = schema_cls.from_dict(output_feature_def) output_feature_obj = feature_cls(sequence_config, {}).to(DEVICE) combiner_outputs = { "combiner_output": torch.randn([BATCH_SIZE, SEQ_SIZE, VOCAB_SIZE], dtype=torch.float32).to(DEVICE) } text_output = output_feature_obj(combiner_outputs, {}) assert LAST_HIDDEN in text_output assert LOGITS in text_output assert text_output[LOGITS].size() == torch.Size([BATCH_SIZE, SEQ_SIZE, VOCAB_SIZE]) def test_sequence_preproc_module_bad_tokenizer(): metadata = { "preprocessing": { "lowercase": True, "tokenizer": "dutch_lemmatize", "unknown_symbol": "", "padding_symbol": "", "computed_fill_value": "", }, "max_sequence_length": SEQ_SIZE, "str2idx": {"": 0, "": 1, "": 2, "": 3, "▁hell": 4, "o": 5, "▁world": 6}, } with pytest.raises(ValueError): _SequencePreprocessing(metadata) def test_sequence_preproc_module_space_tokenizer(): metadata = { "preprocessing": { "lowercase": True, "tokenizer": "space", "unknown_symbol": "", "padding_symbol": "", "computed_fill_value": "", }, "max_sequence_length": SEQ_SIZE, "str2idx": { "": 0, "": 1, "": 2, "": 3, "hello": 4, "world": 5, "paleontology": 6, }, } module = _SequencePreprocessing(metadata) res = module([" paleontology", "unknown", "hello world hello", "hello world hello world "]) assert torch.allclose( res, torch.tensor([[1, 6, 0, 2, 2, 2], [1, 3, 0, 2, 2, 2], [1, 4, 5, 4, 0, 2], [1, 4, 5, 4, 5, 0]]) ) def test_text_preproc_module_space_punct_tokenizer(): metadata = { "preprocessing": { "lowercase": True, "tokenizer": "space_punct", "unknown_symbol": "", "padding_symbol": "", "computed_fill_value": "", }, "max_sequence_length": SEQ_SIZE, "str2idx": { "": 0, "": 1, "": 2, "": 3, "this": 4, "sentence": 5, "has": 6, "punctuation": 7, ",": 8, ".": 9, }, } module = _SequencePreprocessing(metadata) res = module(["punctuation", ",,,,", "this... this... punctuation", "unknown"]) assert torch.allclose( res, torch.tensor([[1, 7, 0, 2, 2, 2], [1, 8, 8, 8, 8, 0], [1, 4, 9, 9, 9, 4], [1, 3, 0, 2, 2, 2]]) ) @pytest.mark.skipif( torchtext is None or torch.torch_version.TorchVersion(torchtext.__version__) < (0, 12, 0), reason="requires torchtext 0.12.0 or higher", ) def test_sequence_preproc_module_sentencepiece_tokenizer(): metadata = { "preprocessing": { "lowercase": True, "tokenizer": "sentencepiece", "unknown_symbol": "", "padding_symbol": "", "computed_fill_value": "", }, "max_sequence_length": SEQ_SIZE, "str2idx": { "": 0, "": 1, "": 2, "": 3, "▁hell": 4, "o": 5, "▁world": 6, "▁pale": 7, "ont": 8, "ology": 9, }, } module = _SequencePreprocessing(metadata) res = module(["paleontology", "unknown", "hello world hello", "hello world hello world"]) assert torch.allclose( res, torch.tensor([[1, 7, 8, 9, 0, 2], [1, 3, 3, 3, 0, 2], [1, 4, 5, 6, 4, 5], [1, 4, 5, 6, 4, 5]]) ) @pytest.mark.skipif( torchtext is None or torch.torch_version.TorchVersion(torchtext.__version__) < (0, 12, 0), reason="requires torchtext 0.12.0 or higher", ) def test_sequence_preproc_module_clip_tokenizer(): metadata = { "preprocessing": { "lowercase": True, "tokenizer": "clip", "unknown_symbol": "", "padding_symbol": "", "computed_fill_value": "", }, "max_sequence_length": SEQ_SIZE, "str2idx": { "": 0, "": 1, "": 2, "": 3, "hello": 4, "world": 5, "pale": 7, "ontology": 8, }, } module = _SequencePreprocessing(metadata) res = module(["paleontology", "unknown", "hello world hello", "hello world hello world"]) assert torch.allclose( res, torch.tensor([[1, 7, 8, 0, 2, 2], [1, 3, 0, 2, 2, 2], [1, 4, 5, 4, 0, 2], [1, 4, 5, 4, 5, 0]]) ) @pytest.mark.skipif( torchtext is None or torch.torch_version.TorchVersion(torchtext.__version__) < (0, 12, 0), reason="requires torchtext 0.12.0 or higher", ) def test_sequence_preproc_module_gpt2bpe_tokenizer(): metadata = { "preprocessing": { "lowercase": True, "tokenizer": "gpt2bpe", "unknown_symbol": "", "padding_symbol": "", "computed_fill_value": "", }, "max_sequence_length": SEQ_SIZE, "str2idx": { "": 0, "": 1, "": 2, "": 3, "hello": 4, "Ġworld": 5, "Ġhello": 7, "p": 8, "ale": 9, "ont": 10, "ology": 11, }, } module = _SequencePreprocessing(metadata) res = module(["paleontology", "unknown", "hello world hello", "hello world hello world"]) assert torch.allclose( res, torch.tensor([[1, 8, 9, 10, 11, 0], [1, 3, 0, 2, 2, 2], [1, 4, 5, 7, 0, 2], [1, 4, 5, 7, 5, 0]]) ) @pytest.mark.skipif( torchtext is None or torch.torch_version.TorchVersion(torchtext.__version__) < (0, 13, 0), reason="requires torchtext 0.13.0 or higher", ) def test_sequence_preproc_module_bert_tokenizer(): metadata = { "preprocessing": { "lowercase": True, "tokenizer": "bert", "unknown_symbol": "", "padding_symbol": "", "computed_fill_value": "", }, "max_sequence_length": SEQ_SIZE, "str2idx": { "": 0, "": 1, "": 2, "": 3, "hello": 4, "world": 5, "pale": 7, "##ont": 8, "##ology": 9, }, } module = _SequencePreprocessing(metadata) res = module(["paleontology", "unknown", "hello world hello", "hello world hello world"]) assert torch.allclose( res, torch.tensor([[1, 7, 8, 9, 0, 2], [1, 3, 0, 2, 2, 2], [1, 4, 5, 4, 0, 2], [1, 4, 5, 4, 5, 0]]) ) ================================================ FILE: tests/ludwig/features/test_set_feature.py ================================================ from copy import deepcopy import pytest import torch from ludwig.constants import ENCODER, ENCODER_OUTPUT from ludwig.features.set_feature import SetInputFeature from ludwig.schema.features.set_feature import SetInputFeatureConfig from ludwig.schema.utils import load_config_with_kwargs from ludwig.utils.misc_utils import merge_dict from ludwig.utils.torch_utils import get_torch_device BATCH_SIZE = 2 DEVICE = get_torch_device() @pytest.fixture(scope="module") def set_config(): return { "name": "set_column_name", "type": "set", "tied": None, "encoder": { "type": "embed", "vocab": ["a", "b", "c"], "representation": "dense", "embedding_size": 50, "embeddings_trainable": True, "pretrained_embeddings": None, "embeddings_on_cpu": False, "fc_layers": None, "num_fc_layers": 0, "use_bias": True, "weights_initializer": "uniform", "bias_initializer": "zeros", "norm": None, "norm_params": None, "activation": "relu", "dropout": 0.0, "reduce_output": "sum", }, } def test_set_input_feature(set_config: dict) -> None: # setup image input feature definition set_def = deepcopy(set_config) # pickup any other missing parameters defaults = SetInputFeatureConfig(name="foo").to_dict() set_def = merge_dict(defaults, set_def) # ensure no exceptions raised during build set_config, _ = load_config_with_kwargs(SetInputFeatureConfig, set_def) input_feature_obj = SetInputFeature(set_config).to(DEVICE) # check one forward pass through input feature input_tensor = torch.randint(0, 2, size=(BATCH_SIZE, len(set_def[ENCODER]["vocab"])), dtype=torch.int64).to(DEVICE) encoder_output = input_feature_obj(input_tensor) assert encoder_output[ENCODER_OUTPUT].shape == (BATCH_SIZE, *input_feature_obj.output_shape) ================================================ FILE: tests/ludwig/features/test_text_feature.py ================================================ import pandas as pd import pytest import torch from transformers import AutoTokenizer from ludwig.backend import LocalBackend from ludwig.constants import IGNORE_INDEX_TOKEN_ID, LOGITS, PREDICTIONS, PROBABILITIES from ludwig.features import text_feature TEST_MODEL_NAME = "hf-internal-testing/tiny-random-OPTForCausalLM" def test_backwards_compatibility(): # Tests that legacy level-based metadata keys are supported. metadata = { "SibSp": { "char_idx2str": ["", "", "", "", "0", "1", "2", "4", "3", "8", "5"], "char_max_sequence_length": 3, "char_pad_idx": 2, "char_pad_symbol": "", "char_str2freq": { "0": 608, "1": 209, "2": 28, "3": 16, "4": 18, "5": 5, "8": 7, "": 0, "": 0, "": 0, "": 0, }, "char_str2idx": { "0": 4, "1": 5, "2": 6, "3": 8, "4": 7, "5": 10, "8": 9, "": 0, "": 2, "": 1, "": 3, }, "char_unk_symbol": "", "char_vocab_size": 11, "preprocessing": { "char_most_common": 70, "char_sequence_length_limit": 1024, "char_tokenizer": "characters", "char_vocab_file": None, "computed_fill_value": "", "fill_value": "", "lowercase": True, "missing_value_strategy": "fill_with_const", "padding": "right", "padding_symbol": "", "pretrained_model_name_or_path": None, "unknown_symbol": "", "word_most_common": 20000, "word_sequence_length_limit": 256, "word_tokenizer": "space_punct", "word_vocab_file": None, }, "word_idx2str": ["", "", "", "", "0", "1", "2", "4", "3", "8", "5"], "word_max_sequence_length": 3, "word_pad_idx": 2, "word_pad_symbol": "", "word_str2freq": { "0": 608, "1": 209, "2": 28, "3": 16, "4": 18, "5": 5, "8": 7, "": 0, "": 0, "": 0, "": 0, }, "word_str2idx": { "0": 4, "1": 5, "2": 6, "3": 8, "4": 7, "5": 10, "8": 9, "": 0, "": 2, "": 1, "": 3, }, "word_unk_symbol": "", "word_vocab_size": 11, } } column = pd.core.series.Series(["hello world", "hello world"]) feature_data = text_feature.TextInputFeature.feature_data( column, metadata["SibSp"], metadata["SibSp"]["preprocessing"], LocalBackend() ) assert list(feature_data[0]) == [1, 3, 3] assert list(feature_data[1]) == [1, 3, 3] @pytest.mark.parametrize("vocab_size", [8]) @pytest.mark.parametrize( "targets", [ ([78, 79, 504, 76, 397, 84, 0], [" first she 18 yearman our"]), ([IGNORE_INDEX_TOKEN_ID, IGNORE_INDEX_TOKEN_ID, IGNORE_INDEX_TOKEN_ID, 76, 397, 84, 0], [" yearman our"]), ], ) @pytest.mark.parametrize("predictions", [[78, 79, 504, 76, 397, 84, 0]]) def test_get_decoded_targets_and_predictions(vocab_size, targets, predictions): tokenizer = AutoTokenizer.from_pretrained(TEST_MODEL_NAME) # Scenario 1: Prediction and target tensors have the same length, so nothing should change targets, decoded_texts_gt = targets targets = torch.tensor([targets]) predictions = { PREDICTIONS: torch.tensor([predictions], dtype=torch.int64), PROBABILITIES: torch.randn(1, 7, vocab_size).to(torch.float32), LOGITS: torch.randn(1, 7, vocab_size).to(torch.float32), } ( decoded_targets, decoded_predictions, ) = text_feature.get_decoded_targets_and_predictions(targets, predictions, tokenizer) assert isinstance(decoded_targets, list) assert isinstance(decoded_predictions, list) assert all(isinstance(x, str) for x in decoded_targets) assert all(isinstance(x, str) for x in decoded_predictions) assert decoded_targets == decoded_predictions == decoded_texts_gt ================================================ FILE: tests/ludwig/features/test_timeseries_feature.py ================================================ import pytest import torch from ludwig.constants import ENCODER, ENCODER_OUTPUT, TYPE from ludwig.features.timeseries_feature import TimeseriesInputFeature from ludwig.schema.features.timeseries_feature import TimeseriesInputFeatureConfig from ludwig.schema.utils import load_config_with_kwargs from ludwig.utils.torch_utils import get_torch_device SEQ_SIZE = 2 TIMESERIES_W_SIZE = 1 MAX_LEN = 7 EMBEDDING_SIZE = 5 DEVICE = get_torch_device() @pytest.fixture(scope="module") def timeseries_config(): return { "name": "timeseries_12", "type": "timeseries", "encoder": { "max_len": MAX_LEN, "embedding_size": EMBEDDING_SIZE, "max_sequence_length": SEQ_SIZE, "output_size": 8, "state_size": 8, "num_filters": 8, "hidden_size": 8, }, } @pytest.mark.parametrize("encoder", ["rnn", "stacked_cnn", "parallel_cnn"]) def test_timeseries_input_feature(timeseries_config: dict, encoder: str) -> None: timeseries_config[ENCODER][TYPE] = encoder timeseries_config, _ = load_config_with_kwargs(TimeseriesInputFeatureConfig, timeseries_config) timeseries_input_feature = TimeseriesInputFeature(timeseries_config).to(DEVICE) timeseries_tensor = torch.randn([SEQ_SIZE, TIMESERIES_W_SIZE], dtype=torch.float32).to(DEVICE) encoder_output = timeseries_input_feature(timeseries_tensor) assert encoder_output[ENCODER_OUTPUT].shape[1:] == timeseries_input_feature.output_shape ================================================ FILE: tests/ludwig/hyperopt/test_hyperopt.py ================================================ import pytest from ludwig.constants import INPUT_FEATURES, NAME, OUTPUT_FEATURES, TYPE from ludwig.hyperopt.utils import log_warning_if_all_grid_type_parameters, substitute_parameters from ludwig.schema.model_config import ModelConfig BASE_CONFIG = { INPUT_FEATURES: [{NAME: "title", TYPE: "text"}], OUTPUT_FEATURES: [{NAME: "summary", TYPE: "text"}], } def _get_config(): return { "input_features": [{"name": "Date received", "type": "category"}], "output_features": [{"name": "Product", "type": "category"}], "hyperopt": { "goal": "minimize", "metric": "loss", "executor": { "type": "ray", "scheduler": { "type": "async_hyperband", "max_t": 3600, "time_attr": "time_total_s", "grace_period": 72, "reduction_factor": 5, }, "num_samples": 10, "time_budget_s": 3600, "cpu_resources_per_trial": 1, }, "parameters": {"trainer.learning_rate": {"space": "choice", "categories": [0.005, 0.01, 0.02, 0.025]}}, "search_alg": {"type": "variant_generator"}, "output_feature": "Product", }, } @pytest.mark.parametrize( "parameters, expected", [ ( { "combiner.type": "tabnet", "combiner.fc_layers": [{"output_size": 64}, {"output_size": 32}], "trainer.learning_rate": 0.1, "trainer.batch_size": 256, }, { **BASE_CONFIG, "combiner": {"type": "tabnet", "fc_layers": [{"output_size": 64}, {"output_size": 32}]}, "trainer": {"learning_rate": 0.1, "batch_size": 256}, }, ), ( { "title.encoder.type": "bert", "summary.decoder.reduce_input": "sum", "trainer.learning_rate": 0.1, "trainer.batch_size": 256, }, { INPUT_FEATURES: [{NAME: "title", TYPE: "text", "encoder": {"type": "bert"}}], OUTPUT_FEATURES: [{NAME: "summary", TYPE: "text", "decoder": {"reduce_input": "sum"}}], "trainer": {"learning_rate": 0.1, "batch_size": 256}, }, ), ( { ".": { "combiner": {"type": "concat", "num_fc_layers": 2}, "trainer": {"learning_rate_scaling": "linear"}, }, "trainer.learning_rate": 0.1, }, { **BASE_CONFIG, "combiner": {"type": "concat", "num_fc_layers": 2}, "trainer": {"learning_rate_scaling": "linear", "learning_rate": 0.1}, }, ), ( { ".": { "combiner": {"type": "concat", "num_fc_layers": 2}, "trainer": {"learning_rate_scaling": "linear"}, }, "trainer": { "learning_rate": 0.1, "batch_size": 256, }, }, { **BASE_CONFIG, "combiner": {"type": "concat", "num_fc_layers": 2}, "trainer": {"learning_rate_scaling": "linear", "learning_rate": 0.1, "batch_size": 256}, }, ), ], ids=["flat", "features", "nested", "multi-nested"], ) def test_substitute_parameters(parameters, expected): actual_config = substitute_parameters(BASE_CONFIG, parameters) assert actual_config == expected def test_grid_search_more_than_one_sample(): """Test logs a user warning indicating that duplicate trials will be created because all of the parameters in the search space are of type grid_search and the number of samples is greater than 1.""" with pytest.warns(RuntimeWarning): log_warning_if_all_grid_type_parameters( { "parameters": { "trainer.learning_rate": {"space": "grid_search", "values": [0.001, 0.005, 0.1]}, "defaults.text.encoder.type": {"space": "grid_search", "values": ["parallel_cnn", "stacked_cnn"]}, }, "executor": {"num_samples": 2}, } ) @pytest.mark.parametrize( "parameters, expected_num_samples", [ ( { "trainer.learning_rate": {"space": "grid_search", "values": [0.001, 0.005, 0.1]}, "defaults.category.encoder.type": {"space": "grid_search", "values": ["dense", "sparse"]}, }, 1, ), ( { "trainer.learning_rate": { "space": "loguniform", "lower": 0.0001, "upper": 0.01, }, "defaults.category.encoder.type": {"space": "grid_search", "values": ["dense", "sparse"]}, }, 1, ), ( { "trainer.learning_rate": { "space": "loguniform", "lower": 0.0001, "upper": 0.01, }, }, 10, ), ], ids=["all_grid_search", "mixed", "no_grid_search"], ) def test_default_num_samples(parameters, expected_num_samples): """This test ensures that the default number of samples is set correctly when the user does not specify the number of samples in the hyperopt config.""" config = _get_config() # Override to set num_samples to None so we can test inference logic config["hyperopt"]["executor"]["num_samples"] = None config["hyperopt"]["parameters"] = parameters processed_config = ModelConfig.from_dict(config).to_dict() assert processed_config["hyperopt"]["executor"]["num_samples"] == expected_num_samples ================================================ FILE: tests/ludwig/model_export/test_onnx_exporter.py ================================================ import unittest from unittest.mock import MagicMock, patch import pytest onnx = pytest.importorskip("onnx") from ludwig.api import LudwigModel # noqa: E402 from ludwig.model_export.base_model_exporter import LudwigTorchWrapper # noqa: E402 from ludwig.model_export.onnx_exporter import OnnxExporter # noqa: E402 class TestOnnxExporter(unittest.TestCase): @patch.object(LudwigModel, "load") @patch.object(LudwigTorchWrapper, "eval") @patch("torch.onnx") def test_onnx_export( self, mock_onnx, mock_ludwig_torch_wrapper_eval, mock_ludwig_model_load, ): sample_model_path = MagicMock() sample_export_path = MagicMock() sample_output_model_name = MagicMock() mock_ludwig_model_load.return_value = MagicMock() mock_onnx.export.return_value = MagicMock() onnx_exporter = OnnxExporter() onnx_exporter.export(sample_model_path, sample_export_path, sample_output_model_name) mock_ludwig_torch_wrapper_eval.assert_called_once() mock_ludwig_model_load.assert_called_once() ================================================ FILE: tests/ludwig/models/__init__.py ================================================ # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== ================================================ FILE: tests/ludwig/models/test_trainable_image_layers.py ================================================ import logging import os import pytest import torch from torchvision.models import resnet18, ResNet18_Weights from ludwig.api import LudwigModel from ludwig.data.dataset_synthesizer import cli_synthesize_dataset # pytest fixture to do one time setup of required data @pytest.fixture(scope="module") def setup_data(tmp_path_factory): # setup location for training data data_dir = tmp_path_factory.mktemp("data", numbered=False) train_fp = os.path.join(data_dir, "train.csv") # setup local cache to torchvision model to avoid multiple downloads tv_cache = tmp_path_factory.mktemp("tv_cache", numbered=False) # describe synthetic data to create feature_list = [ {"name": "binary_output_feature", "type": "binary"}, { "name": "image", "type": "image", "destination_folder": os.path.join(data_dir, "images"), "preprocessing": {"height": 600, "width": 600, "num_channels": 3}, }, ] # create synthetic data cli_synthesize_dataset(10, feature_list, train_fp) return train_fp, str(tv_cache) @pytest.mark.parametrize("trainable", [True, False]) def test_trainable_torchvision_layers(setup_data, trainable): # retrieve data setup from fixture train_fp, tv_cache = setup_data config = { "input_features": [ { "name": "image", "type": "image", "encoder": { "type": "resnet", "model_variant": 18, "model_cache_dir": tv_cache, "trainable": trainable, }, }, ], "output_features": [ { "name": "binary_output_feature", "type": "binary", } ], "trainer": { "epochs": 2, }, } model = LudwigModel(config, logging_level=logging.INFO) _, _, output_dir = model.train(dataset=train_fp, skip_save_processed_input=True) # instantiate native torchvision to get original parameter values os.environ["TORCH_HOME"] = tv_cache tv_model = resnet18(weights=ResNet18_Weights.DEFAULT) # replace last layer to match image encoder setup tv_model.fc = torch.nn.Identity() # compare Ludwig image encoder parameter against original native torchvision weights # if trainable is True, parameters should differ, otherwise all parameters should be unchanged if trainable: for p1, p2 in zip( model.model.input_features.get("image").encoder_obj.model.parameters(), tv_model.parameters() ): assert not torch.all(p1.cpu() == p2.cpu()) else: for p1, p2 in zip( model.model.input_features.get("image").encoder_obj.model.parameters(), tv_model.parameters() ): assert torch.all(p1.cpu() == p2.cpu()) ================================================ FILE: tests/ludwig/models/test_training_determinism.py ================================================ import logging import os import numpy as np import pytest from ludwig.api import LudwigModel from ludwig.constants import BATCH_SIZE, EVAL_BATCH_SIZE, TRAINER from ludwig.utils.numerical_test_utils import assert_all_finite def _assert_stats_close(stats1, stats2, rtol=1e-2, atol=1e-2): """Assert that two nested stats structures are approximately equal. CUDA floating-point operations may introduce non-deterministic differences across runs, so we use approximate comparison instead of exact equality. """ if isinstance(stats1, dict): assert set(stats1.keys()) == set(stats2.keys()) for k in stats1: _assert_stats_close(stats1[k], stats2[k], rtol=rtol, atol=atol) elif isinstance(stats1, (list, tuple)): assert len(stats1) == len(stats2) for v1, v2 in zip(stats1, stats2): _assert_stats_close(v1, v2, rtol=rtol, atol=atol) elif isinstance(stats1, (int, float, np.integer, np.floating)): np.testing.assert_allclose(float(stats1), float(stats2), rtol=rtol, atol=atol) elif hasattr(stats1, "__dict__"): _assert_stats_close(vars(stats1), vars(stats2), rtol=rtol, atol=atol) else: assert stats1 == stats2 from tests.integration_tests.utils import ( # noqa: E402 audio_feature, bag_feature, binary_feature, category_feature, date_feature, generate_data, h3_feature, image_feature, number_feature, sequence_feature, set_feature, text_feature, timeseries_feature, vector_feature, ) @pytest.mark.distributed @pytest.mark.skip(reason="https://github.com/ludwig-ai/ludwig/issues/2686") def test_training_determinism_ray_backend(csv_filename, tmpdir, ray_cluster_4cpu): experiment_output_1, experiment_output_2 = train_twice("ray", csv_filename, tmpdir) eval_stats_1, train_stats_1, _, _ = experiment_output_1 eval_stats_2, train_stats_2, _, _ = experiment_output_2 assert_all_finite(eval_stats_1) assert_all_finite(eval_stats_2) assert_all_finite(train_stats_1) assert_all_finite(train_stats_2) np.testing.assert_equal(eval_stats_1, eval_stats_2) np.testing.assert_equal(train_stats_1, train_stats_2) def test_training_determinism_local_backend(csv_filename, tmpdir): experiment_output_1, experiment_output_2 = train_twice("local", csv_filename, tmpdir) eval_stats_1, train_stats_1, _, _ = experiment_output_1 eval_stats_2, train_stats_2, _, _ = experiment_output_2 assert_all_finite(eval_stats_1) assert_all_finite(eval_stats_2) assert_all_finite(train_stats_1) assert_all_finite(train_stats_2) _assert_stats_close(eval_stats_1, eval_stats_2) _assert_stats_close(train_stats_1, train_stats_2) def train_twice(backend, csv_filename, tmpdir): image_dest_folder = os.path.join(tmpdir, "generated_images") audio_dest_folder = os.path.join(tmpdir, "generated_audio") # Configure features to be tested: input_features = [ binary_feature(), number_feature(), category_feature(encoder={"vocab_size": 10}), sequence_feature(encoder={"vocab_size": 3}), text_feature(encoder={"vocab_size": 3}), vector_feature(), timeseries_feature(), date_feature(), h3_feature(), set_feature(encoder={"vocab_size": 3}), bag_feature(encoder={"vocab_size": 3}), image_feature(image_dest_folder), audio_feature(audio_dest_folder), ] output_features = [ binary_feature(), number_feature(), category_feature(decoder={"vocab_size": 10}), ] # NOTE: It's important that we set batch size and eval batch size explicitly to bypass all batch size tuning, which # is non-deterministic, even with fixed random seeds. config = { "input_features": input_features, "output_features": output_features, TRAINER: {"epochs": 2, BATCH_SIZE: 128, EVAL_BATCH_SIZE: 2}, } # Generate training data training_data_csv_path = generate_data(input_features, output_features, csv_filename, num_examples=100) ludwig_model_1 = LudwigModel(config, logging_level=logging.ERROR, backend=backend) ludwig_model_2 = LudwigModel(config, logging_level=logging.ERROR, backend=backend) experiment_output_1 = ludwig_model_1.experiment( dataset=training_data_csv_path, skip_save_training_description=True, skip_save_training_statistics=True, skip_save_model=True, skip_save_progress=True, skip_save_log=True, skip_save_processed_input=True, ) experiment_output_2 = ludwig_model_2.experiment( dataset=training_data_csv_path, skip_save_training_description=True, skip_save_training_statistics=True, skip_save_model=True, skip_save_progress=True, skip_save_log=True, skip_save_processed_input=True, ) return experiment_output_1, experiment_output_2 ================================================ FILE: tests/ludwig/models/test_training_success.py ================================================ from contextlib import nullcontext as no_error_raised from ludwig.api import LudwigModel from ludwig.constants import BINARY, TRAINER from tests.integration_tests.utils import binary_feature, category_feature, generate_data def generate_data_and_train(config, csv_filename): # Generate training data training_data_csv_path = generate_data(config["input_features"], config["output_features"], csv_filename) # Train Ludwig (Pythonic) model: ludwig_model = LudwigModel(config) with no_error_raised(): ludwig_model.experiment( dataset=training_data_csv_path, skip_save_training_description=True, skip_save_training_statistics=True, skip_save_model=True, skip_save_progress=True, skip_save_log=True, skip_save_processed_input=True, ) def test_category_passthrough_encoder(csv_filename): input_features = [category_feature(), category_feature()] output_features = [category_feature(output_feature=True)] config = { "input_features": input_features, "output_features": output_features, TRAINER: {"train_steps": 1}, "defaults": {"category": {"encoder": {"type": "passthrough"}}}, } generate_data_and_train(config, csv_filename) def test_binary_encoders(csv_filename): config = { "input_features": [ {"name": "binary1", "type": BINARY, "encoder": {"type": "passthrough"}}, {"name": "binary2", "type": BINARY, "encoder": {"type": "dense"}}, ], "output_features": [binary_feature(output_feature=True)], TRAINER: {"train_steps": 1}, } generate_data_and_train(config, csv_filename) ================================================ FILE: tests/ludwig/modules/__init__.py ================================================ # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== ================================================ FILE: tests/ludwig/modules/test_attention.py ================================================ import pytest import torch from ludwig.modules.attention_modules import ( FeedForwardAttentionReducer, MultiHeadSelfAttention, TransformerBlock, TransformerStack, ) from ludwig.utils.misc_utils import set_random_seed from tests.integration_tests.parameter_update_utils import check_module_parameters_updated RANDOM_SEED = 1919 @pytest.mark.parametrize("input_hidden_size", [128, 256]) @pytest.mark.parametrize("input_seq_size", [10]) @pytest.mark.parametrize("input_batch_size", [16]) def test_feed_forward_attention_reducer(input_batch_size: int, input_seq_size: int, input_hidden_size: int): # make repeatable set_random_seed(RANDOM_SEED) # Generate synthetic data current_inputs = torch.normal(0, 1, size=[input_batch_size, input_seq_size, input_hidden_size], dtype=torch.float32) # instantiate feed forward attention reducer feed_forward_attention_reducer = FeedForwardAttentionReducer(input_hidden_size) result = feed_forward_attention_reducer(current_inputs) # ensure returned tensor is the correct shape assert list(result.shape) == [input_batch_size, input_hidden_size] # check for parameter updating if fully connected layer is present target = torch.randn(result.shape) fpc, tpc, upc, not_updated = check_module_parameters_updated( feed_forward_attention_reducer, (current_inputs,), target, ) assert upc == tpc, f"Some parameters not updated. These parameters not updated: {not_updated}" @pytest.mark.parametrize("input_hidden_size", [128, 256]) @pytest.mark.parametrize("input_seq_size", [1, 10]) @pytest.mark.parametrize("input_batch_size", [16]) def test_multihead_self_attention(input_batch_size: int, input_seq_size: int, input_hidden_size: int): # make repeatable set_random_seed(RANDOM_SEED) # Generate synthetic data current_inputs = torch.normal(0, 1, size=[input_batch_size, input_seq_size, input_hidden_size], dtype=torch.float32) # instantiate feed forward attention reducer multihead_self_attention = MultiHeadSelfAttention(input_hidden_size, input_hidden_size) result = multihead_self_attention(current_inputs) # ensure returned tensor is the correct shape assert list(result.shape) == [input_batch_size, input_seq_size, input_hidden_size] # check for parameter updating if fully connected layer is present target = torch.randn(result.shape) fpc, tpc, upc, not_updated = check_module_parameters_updated( multihead_self_attention, (current_inputs,), target, ) # With F.scaled_dot_product_attention, all parameters receive gradients even with a single-token sequence. assert upc == tpc, f"Some parameters not updated. These parameters not updated: {not_updated}" # heads must be a divisor of input_hidden_size @pytest.mark.parametrize( "input_batch_size,input_seq_size,input_hidden_size,output_size,heads", [ (16, 10, 128, 64, 8), (16, 20, 256, 128, 16), (32, 10, 256, 256, 8), ], ids=["small", "medium", "large"], ) def test_transformer_block( input_batch_size: int, input_seq_size: int, input_hidden_size: int, output_size: int, heads: int, ): # make repeatable set_random_seed(RANDOM_SEED) # Generate synthetic data current_inputs = torch.normal(0, 1, size=[input_batch_size, input_seq_size, input_hidden_size], dtype=torch.float32) # instantiate feed forward attention reducer transformer_block = TransformerBlock(input_hidden_size, input_seq_size, input_hidden_size, heads, output_size) result = transformer_block(current_inputs) # ensure returned tensor is the correct shape assert list(result.shape) == [input_batch_size, input_seq_size, input_hidden_size] # check for parameter updating if fully connected layer is present target = torch.randn(result.shape) fpc, tpc, upc, not_updated = check_module_parameters_updated( transformer_block, (current_inputs,), target, ) assert upc == tpc, f"Some parameters not updated. These parameters not updated: {not_updated}" @pytest.mark.parametrize( "input_batch_size,input_seq_size,input_hidden_size,output_size,heads,num_layers", [ (16, 10, 128, 64, 8, 1), (16, 20, 256, 128, 16, 1), (32, 10, 256, 256, 8, 4), ], ids=["single_layer_small", "single_layer_medium", "multi_layer"], ) def test_transformer_stack( input_batch_size: int, input_seq_size: int, input_hidden_size: int, output_size: int, heads: int, num_layers: int, ): # make repeatable set_random_seed(RANDOM_SEED) # Generate synthetic data current_inputs = torch.normal(0, 1, size=[input_batch_size, input_seq_size, input_hidden_size], dtype=torch.float32) # instantiate feed forward attention reducer transformer_stack = TransformerStack( input_hidden_size, input_seq_size, input_hidden_size, heads, output_size, num_layers, ) result = transformer_stack(current_inputs) # ensure returned tensor is the correct shape assert list(result.shape) == [input_batch_size, input_seq_size, input_hidden_size] # check for parameter updating if fully connected layer is present target = torch.randn(result.shape) fpc, tpc, upc, not_updated = check_module_parameters_updated( transformer_stack, (current_inputs,), target, ) assert upc == tpc, f"Some parameters not updated. These parameters not updated: {not_updated}" ================================================ FILE: tests/ludwig/modules/test_convolutional_modules.py ================================================ from collections.abc import Callable import pytest import torch from ludwig.modules.convolutional_modules import ( Conv1DLayer, Conv1DStack, Conv2DLayer, Conv2DLayerFixedPadding, Conv2DStack, ParallelConv1D, ParallelConv1DStack, ResNet, ResNetBlock, ResNetBlockLayer, ResNetBottleneckBlock, ) from ludwig.utils.image_utils import get_img_output_shape from tests.integration_tests.parameter_update_utils import check_module_parameters_updated BATCH_SIZE = 2 SEQ_SIZE = 17 HIDDEN_SIZE = 8 NUM_FILTERS = 4 RANDOM_SEED = 1919 ### # Helper function to compute expected output shape # for Conv1D related layers ### def expected_seq_size( seq_size: int, # input max sequence length padding: str, # conv1d padding: 'same' or 'valid' kernel_size: int, # conv1d kernel size stride: int, # conv1d stride dilation: int, # conv1d dilation rate pool_size: None | int, # pooling layer kernel size pool_padding: str, # pooling layer padding: 'same' or 'valid' pool_stride: int, # pooling layer stride ) -> int: # output shape for the convolutional layer output_seq_size = get_img_output_shape( img_height=0, # img_height set to zero for 1D structure img_width=seq_size, # img_width equates to max sequence length kernel_size=kernel_size, stride=stride, padding=padding, dilation=dilation, ) if pool_size is not None: # pooling layer present, adjust expected output shape for pooling layer output_seq_size = get_img_output_shape( img_height=0, # img_height set to zero for 1D structure img_width=output_seq_size[1], # img_width equates to max sequence length kernel_size=pool_size, stride=pool_stride, padding=pool_padding, dilation=1, # pooling layer only support unit dilation ) return output_seq_size[1] ### # 1D Convolutional Tests ### @pytest.mark.parametrize("pool_function", ["max", "mean"]) @pytest.mark.parametrize( "pool_size, pool_padding, pool_stride", [(None, None, None), (3, "same", 1), (5, "same", 1), (3, "valid", 2), (5, "valid", 2)], ) @pytest.mark.parametrize("dilation", [1, 2]) @pytest.mark.parametrize("strides, padding", [(1, "same"), (1, "valid"), (2, "valid")]) @pytest.mark.parametrize("kernel_size", [3, 5]) def test_conv1d_layer( kernel_size: int, strides: int, padding: str, dilation: int, pool_size: None | int, pool_padding: str, pool_stride: int, pool_function: str, ) -> None: # make test repeatable torch.manual_seed(RANDOM_SEED) # setup synthetic tensor for test input = torch.randn([BATCH_SIZE, SEQ_SIZE, HIDDEN_SIZE], dtype=torch.float32) conv1_layer = Conv1DLayer( in_channels=HIDDEN_SIZE, out_channels=NUM_FILTERS, max_sequence_length=SEQ_SIZE, kernel_size=kernel_size, strides=strides, padding=padding, dilation=dilation, pool_function=pool_function, pool_size=pool_size, pool_strides=pool_stride, pool_padding=pool_padding, ) out_tensor = conv1_layer(input) # check for correct output class assert isinstance(out_tensor, torch.Tensor) # check for correct output shape output_seq_size = expected_seq_size( seq_size=SEQ_SIZE, padding=padding, kernel_size=kernel_size, stride=strides, dilation=dilation, pool_size=pool_size, pool_padding=pool_padding, pool_stride=pool_stride, ) assert out_tensor.size() == (BATCH_SIZE, output_seq_size, NUM_FILTERS) @pytest.mark.parametrize("dropout", [0, 0.5]) @pytest.mark.parametrize( "layers, num_layers", [ (None, None), # setup up default number of layers with default values (None, 4), # setup of 4 layers with default values ([{"num_filters": NUM_FILTERS - 2}, {"num_filters": NUM_FILTERS + 2}], None), # 2 custom layers ], ) def test_conv1d_stack(layers: None | list, num_layers: None | int, dropout: float) -> None: # make test repeatable torch.manual_seed(RANDOM_SEED) # setup synthetic input tensor for test input = torch.randn([BATCH_SIZE, SEQ_SIZE, HIDDEN_SIZE], dtype=torch.float32) conv1_stack = Conv1DStack( in_channels=HIDDEN_SIZE, out_channels=NUM_FILTERS, max_sequence_length=SEQ_SIZE, layers=layers, num_layers=num_layers, default_num_filters=NUM_FILTERS, default_dropout=dropout, ) # check for correct stack formation if layers is None: assert len(conv1_stack.stack) == 6 if num_layers is None else num_layers else: # custom layer specification assert len(conv1_stack.stack) == len(layers) assert conv1_stack.stack[0].out_channels == NUM_FILTERS - 2 assert conv1_stack.stack[1].out_channels == NUM_FILTERS + 2 # generate output tensor out_tensor = conv1_stack(input) # check for correct output class assert isinstance(out_tensor, torch.Tensor) assert out_tensor.size()[1:] == conv1_stack.output_shape[:] # check for correct output shape last_module = conv1_stack.stack[-1] output_seq_size = expected_seq_size( seq_size=last_module.input_shape[0], padding=last_module.padding, kernel_size=last_module.kernel_size, stride=last_module.stride, dilation=last_module.dilation, pool_size=last_module.pool_size, pool_padding=last_module.pool_padding, pool_stride=last_module.pool_strides, ) if layers is None: # default stack setup assert out_tensor.size() == (BATCH_SIZE, output_seq_size, NUM_FILTERS) else: # custom stack setup assert out_tensor.size() == (BATCH_SIZE, output_seq_size, NUM_FILTERS + 2) # check for parameter updates target = torch.randn(conv1_stack.output_shape) _, tpc, upc, not_updated = check_module_parameters_updated(conv1_stack, (input,), target) if dropout == 0: # all trainable parameters should be updated assert tpc == upc, ( f"All parameter not updated. Parameters not updated: {not_updated}" f"\nModule structure:\n{conv1_stack}" ) else: # with specified config and random seed, non-zero dropout update parameter count could take different values assert (tpc == upc) or (upc == 1), ( f"All parameter not updated. Parameters not updated: {not_updated}" f"\nModule structure:\n{conv1_stack}" ) @pytest.mark.parametrize( "layers", [ None, # setup up default number of layers with default values [{"filter_size": 3}, {"filter_size": 4}], # custom parallel layers ], ) def test_parallel_conv1d(layers: None | list) -> None: input = torch.randn([BATCH_SIZE, SEQ_SIZE, HIDDEN_SIZE], dtype=torch.float32) parallel_conv1d = ParallelConv1D( in_channels=HIDDEN_SIZE, out_channels=NUM_FILTERS, max_sequence_length=SEQ_SIZE, layers=layers, default_num_filters=NUM_FILTERS, ) # check for correct stack formation if layers is None: assert len(parallel_conv1d.parallel_layers) == 4 else: # custom layer specification assert len(parallel_conv1d.parallel_layers) == len(layers) assert parallel_conv1d.parallel_layers[0].kernel_size == 3 assert parallel_conv1d.parallel_layers[1].kernel_size == 4 # generate output tensor out_tensor = parallel_conv1d(input) # check for correct output class assert isinstance(out_tensor, torch.Tensor) # check for correct output shape parallel_module = parallel_conv1d.parallel_layers[0] output_seq_size = expected_seq_size( seq_size=parallel_module.input_shape[0], padding=parallel_module.padding, kernel_size=parallel_module.kernel_size, stride=parallel_module.stride, dilation=parallel_module.dilation, pool_size=parallel_module.pool_size, pool_padding=parallel_module.pool_padding, pool_stride=parallel_module.pool_strides, ) assert out_tensor.size() == (BATCH_SIZE, output_seq_size, len(parallel_conv1d.parallel_layers) * NUM_FILTERS) TEST_FILTER_SIZE0 = 7 TEST_FILTER_SIZE1 = 5 @pytest.mark.parametrize("dropout", [0, 0.99]) @pytest.mark.parametrize( "stacked_layers", [ None, # setup up default number of layers with default values # custom stacked parallel layers [ [ # parallel_conv1d_stack.stack[0] {"filter_size": 3}, {"filter_size": 5}, {"filter_size": TEST_FILTER_SIZE0}, ], [ # parallel_conv1d_stack.stack[1] {"filter_size": 2}, {"filter_size": 3}, {"filter_size": 4}, {"filter_size": TEST_FILTER_SIZE1}, ], ], ], ) def test_parallel_conv1d_stack(stacked_layers: None | list, dropout: float) -> None: # make repeatable torch.manual_seed(RANDOM_SEED) # setup synthetic input tensor for test input = torch.randn([BATCH_SIZE, SEQ_SIZE, HIDDEN_SIZE], dtype=torch.float32) parallel_conv1d_stack = ParallelConv1DStack( in_channels=HIDDEN_SIZE, out_channels=NUM_FILTERS, max_sequence_length=SEQ_SIZE, stacked_layers=stacked_layers, default_num_filters=NUM_FILTERS, default_dropout=dropout, ) # check for correct stack formation if stacked_layers is None: assert len(parallel_conv1d_stack.stack) == 3 for i in range(len(parallel_conv1d_stack.stack)): assert len(parallel_conv1d_stack.stack[i].parallel_layers) == 4 else: # spot check custom layer specification assert len(parallel_conv1d_stack.stack) == len(stacked_layers) assert len(parallel_conv1d_stack.stack[0].parallel_layers) == 3 assert parallel_conv1d_stack.stack[0].parallel_layers[2].kernel_size == TEST_FILTER_SIZE0 assert len(parallel_conv1d_stack.stack[1].parallel_layers) == 4 assert parallel_conv1d_stack.stack[1].parallel_layers[3].kernel_size == TEST_FILTER_SIZE1 # generate output tensor out_tensor = parallel_conv1d_stack(input) # check for correct output class assert isinstance(out_tensor, torch.Tensor) # check output shape assert out_tensor.size() == (BATCH_SIZE, *parallel_conv1d_stack.output_shape) # check for parameter updates target = torch.randn(parallel_conv1d_stack.output_shape) _, tpc, upc, not_updated = check_module_parameters_updated(parallel_conv1d_stack, (input,), target) if dropout == 0: # all trainable parameters should be updated assert tpc == upc, ( f"All parameter not updated. Parameters not updated: {not_updated}" f"\nModule structure:\n{parallel_conv1d_stack}" ) else: # With high dropout (0.99), most gradients are zeroed out. The exact number of updated # parameters depends on the random seed and PyTorch version. assert upc > 0, ( f"No parameters updated with dropout={dropout}. Parameters not updated: {not_updated}" f"\nModule structure:\n{parallel_conv1d_stack}" ) ### # 2D Convolutional Tests ### @pytest.mark.parametrize( ("img_height,img_width,in_channels,out_channels,pool_kernel_size," "pool_stride,pool_padding,pool_dilation"), [(224, 224, 3, 16, 2, 2, 0, 1)], ) @pytest.mark.parametrize("stride,padding", [(1, "valid"), (1, "same"), (2, "valid")]) @pytest.mark.parametrize("kernel_size", [1, 3, 5]) @pytest.mark.parametrize("dilation", [1, 2]) @pytest.mark.parametrize("norm", ["batch", "layer"]) def test_conv2d_layer( img_height: int, img_width: int, in_channels: int, out_channels: int, kernel_size: int, stride: int, padding: int | tuple[int] | str, dilation: int | tuple[int], norm: str, pool_kernel_size: int | tuple[int], pool_stride: int, pool_padding: int | tuple[int] | str, pool_dilation: int | tuple[int], ) -> None: conv2d_layer = Conv2DLayer( img_height=img_height, img_width=img_width, in_channels=in_channels, out_channels=out_channels, kernel_size=kernel_size, stride=stride, padding=padding, dilation=dilation, norm=norm, pool_kernel_size=pool_kernel_size, pool_stride=pool_stride, pool_padding=pool_padding, pool_dilation=pool_dilation, ) input_tensor = torch.rand(2, in_channels, img_height, img_width) output_tensor = conv2d_layer(input_tensor) assert output_tensor.shape[1:] == conv2d_layer.output_shape @pytest.mark.parametrize("img_height,img_width", [(224, 224)]) @pytest.mark.parametrize( "layers,num_layers,first_in_channels", [ (None, None, 3), (None, 5, 3), ([{"out_channels": 8}], None, 3), ([{"out_channels": 8, "in_channels": 3}], None, None), ], ) def test_conv2d_stack( img_height: int, img_width: int, layers: list[dict] | None, num_layers: int | None, first_in_channels: int | None, ) -> None: conv2d_stack = Conv2DStack( img_height=img_height, img_width=img_width, layers=layers, num_layers=num_layers, first_in_channels=first_in_channels, ) input_tensor = torch.rand(2, 3, img_height, img_width) output_tensor = conv2d_stack(input_tensor) assert output_tensor.shape[1:] == conv2d_stack.output_shape @pytest.mark.parametrize("img_height,img_width,in_channels", [(224, 224, 8)]) @pytest.mark.parametrize("stride", [1, 3]) @pytest.mark.parametrize("groups", [1, 8]) def test_conv2d_layer_fixed_padding( img_height: int, img_width: int, in_channels: int, stride: int, groups: int ) -> None: conv2d_fixed_padding = Conv2DLayerFixedPadding( img_height=img_height, img_width=img_width, in_channels=in_channels, stride=stride, groups=groups ) input_tensor = torch.rand(2, in_channels, img_height, img_width) output_tensor = conv2d_fixed_padding(input_tensor) assert output_tensor.shape[1:] == conv2d_fixed_padding.output_shape @pytest.mark.parametrize("img_height,img_width,first_in_channels,out_channels", [(224, 224, 64, 64)]) @pytest.mark.parametrize( "projection_shortcut", [None, Conv2DLayerFixedPadding(img_height=224, img_width=224, in_channels=64, out_channels=64)], ) def test_resnet_block( img_height: int, img_width: int, first_in_channels: int, out_channels: int, projection_shortcut: Callable ) -> None: resnet_block = ResNetBlock( img_height=img_height, img_width=img_width, first_in_channels=first_in_channels, out_channels=out_channels, projection_shortcut=projection_shortcut, ) input_tensor = torch.rand(2, first_in_channels, img_height, img_width) output_tensor = resnet_block(input_tensor) assert output_tensor.shape[1:] == resnet_block.output_shape @pytest.mark.parametrize("img_height,img_width,first_in_channels,out_channels", [(224, 224, 64, 64)]) @pytest.mark.parametrize( "projection_shortcut", [None, Conv2DLayerFixedPadding(img_height=224, img_width=224, in_channels=64, out_channels=256)], ) def test_resnet_bottleneck_block( img_height: int, img_width: int, first_in_channels: int, out_channels: int, projection_shortcut: Callable ) -> None: resnet_block = ResNetBottleneckBlock( img_height=img_height, img_width=img_width, first_in_channels=first_in_channels, out_channels=out_channels, projection_shortcut=projection_shortcut, ) input_tensor = torch.rand(2, first_in_channels, img_height, img_width) output_tensor = resnet_block(input_tensor) assert output_tensor.shape[1:] == resnet_block.output_shape @pytest.mark.parametrize("img_height,img_width,first_in_channels,out_channels,num_blocks", [(224, 224, 3, 32, 3)]) @pytest.mark.parametrize("is_bottleneck, block_fn", [(True, ResNetBottleneckBlock), (False, ResNetBlock)]) def test_resnet_block_layer( img_height: int, img_width: int, first_in_channels: int, out_channels: int, is_bottleneck: bool, block_fn: ResNetBlock | ResNetBottleneckBlock, num_blocks: int, ): resnet_block_layer = ResNetBlockLayer( img_height=img_height, img_width=img_width, first_in_channels=first_in_channels, out_channels=out_channels, is_bottleneck=is_bottleneck, block_fn=block_fn, num_blocks=num_blocks, ) input_tensor = torch.rand(2, first_in_channels, img_height, img_width) output_tensor = resnet_block_layer(input_tensor) assert output_tensor.shape[1:] == resnet_block_layer.output_shape @pytest.mark.parametrize("img_height,img_width,first_in_channels,out_channels", [(224, 224, 3, 64)]) @pytest.mark.parametrize("resnet_size", [18, 34, 50]) def test_resnet( img_height: int, img_width: int, first_in_channels: int, out_channels: int, resnet_size: int, ): # make repeatable torch.manual_seed(RANDOM_SEED) resnet = ResNet( img_height=img_height, img_width=img_width, first_in_channels=first_in_channels, out_channels=out_channels, resnet_size=resnet_size, ) input_tensor = torch.rand(2, first_in_channels, img_height, img_width) output_tensor = resnet(input_tensor) assert output_tensor.shape[1:] == resnet.output_shape # check for parameter updates target = torch.randn(output_tensor.shape) fpc, tpc, upc, not_updated = check_module_parameters_updated(resnet, (input_tensor,), target) # all trainable parameters should be updated assert tpc == upc, ( f"All parameter not updated. Parameters not updated: {not_updated}" f"\nModule structure:\n{resnet}" ) ================================================ FILE: tests/ludwig/modules/test_embedding_modules.py ================================================ import pytest import torch from ludwig.modules.embedding_modules import Embed, EmbedSequence, EmbedSet, EmbedWeighted, TokenAndPositionEmbedding from ludwig.utils.torch_utils import get_torch_device DEVICE = get_torch_device() @pytest.mark.parametrize("vocab", [["a", "b", "c"]]) @pytest.mark.parametrize("embedding_size", [2]) @pytest.mark.parametrize("representation", ["dense", "sparse"]) def test_embed( vocab: list[str], embedding_size: int, representation: str, ): embed = Embed( vocab=vocab, embedding_size=embedding_size, representation=representation, ).to(DEVICE) inputs = torch.randint(0, 2, size=(2, 1)).bool().to(DEVICE) outputs = embed(inputs) assert outputs.shape[1:] == embed.output_shape @pytest.mark.parametrize("vocab", [["a", "b", "c", "d"]]) @pytest.mark.parametrize("embedding_size", [3]) @pytest.mark.parametrize("representation", ["dense", "sparse"]) def test_embed_set( vocab: list[str], embedding_size: int, representation: str, ): embed = EmbedSet( vocab=vocab, embedding_size=embedding_size, representation=representation, ).to(DEVICE) inputs = torch.randint(0, 2, size=(2, len(vocab))).bool().to(DEVICE) outputs = embed(inputs) assert outputs.shape[1:] == embed.output_shape @pytest.mark.parametrize("vocab", [["a", "b", "c", "d", "e", "f", "g", "h"]]) @pytest.mark.parametrize("embedding_size", [5, 10]) @pytest.mark.parametrize("representation", ["dense", "sparse"]) def test_embed_weighted( vocab: list[str], embedding_size: int, representation: str, ): embed_weighted = EmbedWeighted(vocab=vocab, embedding_size=embedding_size, representation=representation).to(DEVICE) inputs = torch.randint(0, 2, size=(2, len(vocab))).bool().to(DEVICE) outputs = embed_weighted(inputs) assert outputs.shape[1:] == embed_weighted.output_shape @pytest.mark.parametrize("vocab", [["a", "b", "c"]]) @pytest.mark.parametrize("embedding_size", [2]) @pytest.mark.parametrize("representation", ["dense", "sparse"]) def test_embed_sequence( vocab: list[str], embedding_size: int, representation: str, ): embed = EmbedSequence( vocab=vocab, embedding_size=embedding_size, max_sequence_length=10, representation=representation, ).to(DEVICE) inputs = torch.randint(0, 2, size=(2, 10)).to(DEVICE) outputs = embed(inputs) assert outputs.shape[1:] == embed.output_shape @pytest.mark.parametrize("vocab", [["a", "b", "c"]]) @pytest.mark.parametrize("embedding_size", [10]) @pytest.mark.parametrize("representation", ["dense", "sparse"]) def test_token_and_position_embedding( vocab: list[str], embedding_size: int, representation: str, ): embed = TokenAndPositionEmbedding( vocab=vocab, embedding_size=embedding_size, max_sequence_length=10, representation=representation, ).to(DEVICE) inputs = torch.randint(0, 2, size=(2, 10)).to(DEVICE) outputs = embed(inputs) assert outputs.shape[1:] == embed.output_shape ================================================ FILE: tests/ludwig/modules/test_encoder.py ================================================ # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import random import numpy as np import pytest import torch from ludwig.constants import ENCODER_OUTPUT from ludwig.data.dataset_synthesizer import build_vocab from ludwig.encoders.base import Encoder from ludwig.encoders.image.base import MLPMixerEncoder, Stacked2DCNN from ludwig.encoders.sequence_encoders import ( ParallelCNN, SequenceEmbedEncoder, StackedCNN, StackedCNNRNN, StackedParallelCNN, StackedRNN, ) from ludwig.utils.torch_utils import get_torch_device from tests.integration_tests.parameter_update_utils import check_module_parameters_updated DROPOUT = 0.5 DEVICE = get_torch_device() RANDOM_SEED = 1919 def create_encoder(encoder_type, **encoder_kwargs): encoder = encoder_type(**encoder_kwargs) return encoder def _generate_image(image_size): return np.random.randint(0, 1, image_size).astype(np.float32) def generate_images(image_size, num_images): return np.array([_generate_image(image_size) for _ in range(num_images)]) def _generate_sentence(vocab_size, max_len): sentence = np.zeros(max_len, dtype=np.int32) random_length = random.randint(1, max_len) sentence[:random_length] = [random.randint(0, vocab_size - 1) for _ in range(random_length)] return sentence def generate_random_sentences(num_sentences=10, max_len=10, vocab_size=10): # Generate some random text vocab = build_vocab(vocab_size) text = np.array([_generate_sentence(vocab_size, max_len) for _ in range(num_sentences)]) return text, vocab def encoder_test( encoder, input_data, output_dtype, output_shape, output_data=None, ): """Helper method to test different kinds of encoders. :param encoder: encoder object :param input_data: data to encode :param output_dtype: expected data type of the output (optional) :param output_shape: expected shape of the encoder output (optional) :param output_data: expected output data (optional) :return: returns the encoder object for the caller to run extra checks """ encoder = encoder.to(DEVICE) # Run the encoder input_data = torch.from_numpy(input_data).to(DEVICE) hidden = encoder(input_data)[ENCODER_OUTPUT] # Check output shape and type assert hidden.dtype == output_dtype assert list(hidden.shape) == output_shape if output_data is not None: # todo the hidden output is actually a tensor. May need modification assert np.allclose(hidden, output_data) def test_image_encoders_stacked_2dcnn(): # make repeatable np.random.seed(RANDOM_SEED) torch.manual_seed(RANDOM_SEED) # Test the resnet encoder for images encoder_kwargs = {"num_conv_layers": 2, "num_filters": 16, "output_size": 28, "dropout": DROPOUT} image_size = (3, 10, 10) encoder = create_encoder( Stacked2DCNN, num_channels=image_size[0], height=image_size[1], width=image_size[2], **encoder_kwargs ) assert encoder is not None assert encoder.conv_stack_2d is not None assert list(encoder.conv_stack_2d.output_shape) == [32, 1, 1] assert len(encoder.fc_stack.layers) == 1 assert encoder.conv_stack_2d.layers[0]["pool_kernel_size"] == 2 assert encoder.conv_stack_2d.layers[0]["stride"] == 1 assert encoder.conv_stack_2d.layers[0]["pool_stride"] == 2 assert encoder.conv_stack_2d.layers[0]["norm"] is None assert encoder.conv_stack_2d.layers[0]["activation"] == "relu" assert encoder.conv_stack_2d.layers[0]["dropout"] == 0 output_shape = [1, 28] input_image = generate_images(image_size, 1) encoder_test( encoder=encoder, input_data=input_image, output_dtype=torch.float32, output_shape=output_shape, output_data=None ) output_shape = [5, 28] input_images = generate_images(image_size, 5) encoder_test( encoder=encoder, input_data=input_images, output_dtype=torch.float32, output_shape=output_shape, output_data=None, ) # test for parameter updates # generate tensors for parameter update test target = torch.rand(output_shape) image_tensor = torch.rand(input_image.shape) # check for parameter updates fpc, tpc, upc, not_updated = check_module_parameters_updated(encoder, (image_tensor,), target) assert upc == tpc, ( f"Not all trainable parameters updated. Parameters not updated: {not_updated}." f" Module structure\n{encoder}" ) def test_image_encoders_mlpmixer(): # make repeatable np.random.seed(RANDOM_SEED) torch.manual_seed(RANDOM_SEED) # Test the resnet encoder for images encoder_kwargs = { "patch_size": 5, "embed_size": 8, "token_size": 32, "channel_dim": 16, "num_layers": 2, "dropout": DROPOUT, } image_size = (3, 10, 10) output_shape = [1, 8] input_image = generate_images(image_size, 1) encoder = create_encoder( MLPMixerEncoder, num_channels=image_size[0], height=image_size[1], width=image_size[2], **encoder_kwargs ) encoder_test( encoder=encoder, input_data=input_image, output_dtype=torch.float32, output_shape=output_shape, output_data=None ) output_shape = [5, 8] input_images = generate_images(image_size, 5) encoder_test( encoder=encoder, input_data=input_images, output_dtype=torch.float32, output_shape=output_shape, output_data=None, ) assert encoder is not None assert encoder.mlp_mixer.__class__.__name__ == "MLPMixer" assert len(encoder.mlp_mixer.mixer_blocks) == 2 assert list(encoder.mlp_mixer.mixer_blocks[0].mlp1.output_shape) == [4] assert encoder.mlp_mixer.patch_conv.__class__.__name__ == "Conv2d" assert encoder.mlp_mixer.patch_conv.kernel_size == (5, 5) # test for parameter updates # generate tensors for parameter update test target = torch.rand(output_shape) image_tensor = torch.rand(input_image.shape) # check for parameter updates fpc, tpc, upc, not_updated = check_module_parameters_updated(encoder, (image_tensor,), target) assert upc == tpc, ( f"Not all trainable parameters updated. Parameters not updated: {not_updated}." f" Module structure\n{encoder}" ) def test_sequence_encoder_embed(): num_sentences = 4 embedding_size = 5 max_len = 6 # make repeatable np.random.seed(RANDOM_SEED) torch.manual_seed(RANDOM_SEED) # Generate data text, vocab = generate_random_sentences( num_sentences=num_sentences, max_len=max_len, ) encoder_kwargs = {"embedding_size": embedding_size, "vocab": vocab} # Different values for reduce_output and the corresponding expected size reduce_outputs = ["sum", None, "concat"] output_shapes = [ [num_sentences, embedding_size], [num_sentences, max_len, embedding_size], [num_sentences, max_len * embedding_size], ] for reduce_output, output_shape in zip(reduce_outputs, output_shapes): for trainable in [True, False]: encoder_kwargs["reduce_output"] = reduce_output encoder_kwargs["embeddings_trainable"] = trainable encoder_kwargs["dropout"] = DROPOUT encoder = create_encoder(SequenceEmbedEncoder, max_sequence_length=max_len, **encoder_kwargs) encoder_test( encoder=encoder, input_data=text, output_dtype=torch.float32, output_shape=output_shape, output_data=None, ) assert encoder.embed_sequence.dropout is not None # test for parameter updates # generate tensors for parameter update test target = torch.rand(output_shape) # check for parameter updates fpc, tpc, upc, not_updated = check_module_parameters_updated( encoder, (torch.tensor(text, dtype=torch.int32),), target ) assert upc == tpc, ( f"Not all trainable parameters updated. Parameters not updated: {not_updated}." f" Module structure\n{encoder}" ) @pytest.mark.parametrize("encoder_type", [ParallelCNN, StackedCNN, StackedParallelCNN, StackedRNN, StackedCNNRNN]) @pytest.mark.parametrize("trainable", [True, False]) @pytest.mark.parametrize("reduce_output", ["sum", "max"]) def test_sequence_encoders(encoder_type: Encoder, trainable: bool, reduce_output: str): num_sentences = 4 embedding_size = 5 max_len = 7 output_size = 3 # make repeatable np.random.seed(RANDOM_SEED) torch.manual_seed(RANDOM_SEED) # Generate data text, vocab = generate_random_sentences( num_sentences=num_sentences, max_len=max_len, ) encoder_kwargs = { "embedding_size": embedding_size, "vocab": vocab, "output_size": output_size, "num_fc_layers": 1, "filter_size": 3, "num_filters": 8, "state_size": output_size, } # todo figure out the output size for parallel 1d conv output_shape = [num_sentences, output_size] encoder_kwargs["embeddings_trainable"] = trainable encoder_kwargs["dropout"] = DROPOUT encoder_kwargs["recurrent_dropout"] = DROPOUT encoder_kwargs["fc_dropout"] = DROPOUT encoder_kwargs["reduce_output"] = reduce_output encoder = create_encoder(encoder_type, max_sequence_length=max_len, **encoder_kwargs) encoder_test( encoder=encoder, input_data=text, output_dtype=torch.float32, output_shape=output_shape, output_data=None ) assert isinstance(encoder, encoder_type) # test for parameter updates # generate tensors for parameter update test target = torch.rand(output_shape) # check for parameter updates fpc, tpc, upc, not_updated = check_module_parameters_updated( encoder, (torch.tensor(text, dtype=torch.int32),), target ) if trainable: assert fpc == 0, "Embedding layer expected to be trainable but found to be frozen" else: assert fpc == 1, "Embedding layer expected to be frozen, but found to be trainable." # With dropout=0.5 and small sequences (4 sentences, max_len=7), many parameters # may legitimately not receive gradients in a single step. ParallelCNN with max # reduction is especially susceptible since max selects sparse gradients, and dropout # can zero out entire channels. if trainable: # At least some trainable parameters should update assert upc > 0 or encoder_type == ParallelCNN, ( f"No trainable parameters updated. Parameters not updated: {not_updated}." f" Module structure\n{encoder}" ) ================================================ FILE: tests/ludwig/modules/test_fully_connected_modules.py ================================================ import pytest import torch from ludwig.modules.fully_connected_modules import FCLayer, FCStack from ludwig.utils.misc_utils import set_random_seed from ludwig.utils.torch_utils import get_torch_device BATCH_SIZE = 2 DEVICE = get_torch_device() RANDOM_SEED = 1919 @pytest.mark.parametrize("input_size", [2, 3]) @pytest.mark.parametrize("output_size", [3, 4]) @pytest.mark.parametrize("activation", ["relu", "sigmoid", "tanh"]) @pytest.mark.parametrize("dropout", [0.0, 0.6]) @pytest.mark.parametrize("batch_size", [1, 2]) @pytest.mark.parametrize("norm", [None, "layer", "batch", "ghost"]) def test_fc_layer( input_size: int, output_size: int, activation: str, dropout: float, batch_size: int, norm: str | None, ): set_random_seed(RANDOM_SEED) # make repeatable fc_layer = FCLayer( input_size=input_size, output_size=output_size, activation=activation, dropout=dropout, norm=norm ).to(DEVICE) input_tensor = torch.randn(batch_size, input_size, device=DEVICE) output_tensor = fc_layer(input_tensor) assert output_tensor.shape[1:] == fc_layer.output_shape @pytest.mark.parametrize( "first_layer_input_size,layers,num_layers", [ (2, None, 3), (2, [{"output_size": 4}, {"output_size": 8}], None), (2, [{"input_size": 2, "output_size": 4}, {"output_size": 8}], None), ], ) def test_fc_stack( first_layer_input_size: int | None, layers: list | None, num_layers: int | None, ): set_random_seed(RANDOM_SEED) fc_stack = FCStack(first_layer_input_size=first_layer_input_size, layers=layers, num_layers=num_layers).to(DEVICE) input_tensor = torch.randn(BATCH_SIZE, first_layer_input_size, device=DEVICE) output_tensor = fc_stack(input_tensor) assert output_tensor.shape[1:] == fc_stack.output_shape def test_fc_stack_input_size_mismatch_fails(): first_layer_input_size = 10 layers = [{"input_size": 2, "output_size": 4}, {"output_size": 8}] fc_stack = FCStack( first_layer_input_size=first_layer_input_size, layers=layers, ).to(DEVICE) input_tensor = torch.randn(BATCH_SIZE, first_layer_input_size, device=DEVICE) with pytest.raises(RuntimeError): fc_stack(input_tensor) def test_fc_stack_no_layers_behaves_like_passthrough(): first_layer_input_size = 10 layers = None num_layers = 0 output_size = 15 fc_stack = FCStack( first_layer_input_size=first_layer_input_size, layers=layers, num_layers=num_layers, default_output_size=output_size, ).to(DEVICE) input_tensor = torch.randn(BATCH_SIZE, first_layer_input_size, device=DEVICE) output_tensor = fc_stack(input_tensor) assert list(output_tensor.shape[1:]) == [first_layer_input_size] assert output_tensor.shape[1:] == fc_stack.output_shape assert torch.allclose(input_tensor, output_tensor) ================================================ FILE: tests/ludwig/modules/test_initializer_modules.py ================================================ import torch import torch.nn as nn from ludwig.modules.initializer_modules import get_initializer from ludwig.utils.torch_utils import get_torch_device DEVICE = "cuda:0" if get_torch_device() == "cuda" else "cpu" def test_get_initializer(): """Currently only checks for when the parameters are default case.""" tensor_size = (2, 3) # Test for when the parameters are default torch.random.manual_seed(0) initialized_tensor = get_initializer("xavier_uniform")(*tensor_size, device=DEVICE) # Check that the tensor using the expected initialization and the same seed is identical default_initializer = nn.init.xavier_uniform_ torch.random.manual_seed(0) default_tensor = default_initializer(torch.empty(*tensor_size, device=DEVICE)) assert torch.equal(initialized_tensor, default_tensor) ================================================ FILE: tests/ludwig/modules/test_loss_modules.py ================================================ import contextlib import pytest import torch from pydantic import ValidationError from ludwig.features.category_feature import CategoryOutputFeature from ludwig.features.set_feature import SetOutputFeature from ludwig.features.text_feature import TextOutputFeature from ludwig.modules import loss_modules from ludwig.schema.features.loss.loss import ( BWCEWLossConfig, CORNLossConfig, HuberLossConfig, MAELossConfig, MAPELossConfig, MSELossConfig, RMSELossConfig, RMSPELossConfig, SigmoidCrossEntropyLossConfig, SoftmaxCrossEntropyLossConfig, ) from ludwig.schema.model_config import ModelConfig from tests.integration_tests.utils import category_feature, set_feature, text_feature def from_float(v: float) -> torch.Tensor: return torch.tensor(v).float() @pytest.mark.parametrize("preds", [torch.arange(6).reshape(3, 2).float()]) @pytest.mark.parametrize("target", [torch.arange(6, 12).reshape(3, 2).float()]) @pytest.mark.parametrize("output", [torch.tensor(36).float()]) def test_mse_loss(preds: torch.Tensor, target: torch.Tensor, output: torch.Tensor): loss = loss_modules.MSELoss(MSELossConfig()) assert loss(preds, target) == output @pytest.mark.parametrize("preds", [torch.arange(6).reshape(3, 2).float()]) @pytest.mark.parametrize("target", [torch.arange(6, 12).reshape(3, 2).float()]) @pytest.mark.parametrize("output", [torch.tensor(6).float()]) def test_mae_loss(preds: torch.Tensor, target: torch.Tensor, output: torch.Tensor): loss = loss_modules.MAELoss(MAELossConfig()) assert loss(preds, target) == output @pytest.mark.parametrize("preds", [torch.arange(6).reshape(3, 2).float()]) @pytest.mark.parametrize("target", [torch.arange(6, 12).reshape(3, 2).float()]) @pytest.mark.parametrize("output", [torch.tensor(0.7365440726280212)]) def test_mape_loss(preds: torch.Tensor, target: torch.Tensor, output: torch.Tensor): loss = loss_modules.MAPELoss(MAPELossConfig()) assert loss(preds, target) == output @pytest.mark.parametrize("preds", [torch.arange(6).reshape(3, 2).float()]) @pytest.mark.parametrize("target", [torch.arange(6, 12).reshape(3, 2).float()]) @pytest.mark.parametrize("output", [torch.tensor(6).float()]) def test_rmse_loss(preds: torch.Tensor, target: torch.Tensor, output: torch.Tensor): loss = loss_modules.RMSELoss(RMSELossConfig()) assert loss(preds, target) == output @pytest.mark.parametrize("preds", [torch.arange(6).reshape(3, 2).float()]) @pytest.mark.parametrize("target", [torch.arange(6, 12).reshape(3, 2).float()]) @pytest.mark.parametrize("output", [torch.tensor(0.7527).float()]) def test_rmspe_loss(preds: torch.Tensor, target: torch.Tensor, output: torch.Tensor): loss = loss_modules.RMSPELoss(RMSPELossConfig()) assert torch.isclose(loss(preds, target), output, rtol=0.0001) @pytest.mark.parametrize("preds", [torch.tensor([[0.1, 0.2]]).float()]) @pytest.mark.parametrize("target", [torch.tensor([[0.0, 0.2]]).float()]) @pytest.mark.parametrize("output", [torch.tensor(707.1068).float()]) def test_rmspe_loss_zero_targets(preds: torch.Tensor, target: torch.Tensor, output: torch.Tensor): loss = loss_modules.RMSPELoss(RMSPELossConfig()) assert torch.isclose(loss(preds, target), output, rtol=0.0001) @pytest.mark.parametrize( "confidence_penalty,positive_class_weight,robust_lambda,output", [ (0.0, None, 0, from_float(-21.4655)), (2.0, None, 0, from_float(-21.1263)), (0.0, 2.0, 0, from_float(-20.1222)), (0.0, None, 2, from_float(22.4655)), (2, 2, 2, from_float(21.4614)), ], ) @pytest.mark.parametrize("preds", [torch.arange(6).reshape(3, 2).float()]) @pytest.mark.parametrize("target", [torch.arange(6, 12).reshape(3, 2).float()]) def test_bwcew_loss( preds: torch.Tensor, target: torch.Tensor, confidence_penalty: float, positive_class_weight: float | None, robust_lambda: int, output: torch.Tensor, ): loss = loss_modules.BWCEWLoss( BWCEWLossConfig( positive_class_weight=positive_class_weight, robust_lambda=robust_lambda, confidence_penalty=confidence_penalty, ) ) assert torch.isclose(loss(preds, target), output) @pytest.mark.parametrize("preds", [torch.tensor([[0.5, 0.5], [0.2, 0.8], [0.6, 0.4]])]) @pytest.mark.parametrize("target", [torch.tensor([1, 1, 0])]) @pytest.mark.parametrize("output", [torch.tensor(0.5763)]) def test_softmax_cross_entropy_loss(preds: torch.Tensor, target: torch.Tensor, output: torch.Tensor): loss = loss_modules.SoftmaxCrossEntropyLoss(SoftmaxCrossEntropyLossConfig()) assert torch.isclose(loss(preds, target), output, rtol=0.0001) @pytest.mark.parametrize("preds", [torch.arange(6).reshape(3, 2).float()]) @pytest.mark.parametrize("target", [torch.arange(6, 12).reshape(3, 2).float()]) @pytest.mark.parametrize("output", [torch.tensor(-21.4655).float()]) def test_sigmoid_cross_entropy_loss(preds: torch.Tensor, target: torch.Tensor, output: torch.Tensor): loss = loss_modules.SigmoidCrossEntropyLoss(SigmoidCrossEntropyLossConfig()) assert torch.isclose(loss(preds, target), output) @pytest.mark.parametrize( "delta,output", [ (1.0, from_float(5.5000)), (0.5, from_float(2.8750)), (2.0, from_float(10.0)), (0.0, ValidationError), ], ) @pytest.mark.parametrize("preds", [torch.arange(6).reshape(3, 2).float()]) @pytest.mark.parametrize("target", [torch.arange(6, 12).reshape(3, 2).float()]) def test_huber_loss(preds: torch.Tensor, target: torch.Tensor, delta: float, output: torch.Tensor | type[Exception]): with pytest.raises(output) if not isinstance(output, torch.Tensor) else contextlib.nullcontext(): loss = loss_modules.HuberLoss(HuberLossConfig.from_dict({"delta": delta})) value = loss(preds, target) assert value == output @pytest.mark.parametrize("preds", [torch.tensor([[0.25, 0.2, 0.55], [0.2, 0.35, 0.45], [0.8, 0.1, 0.1]])]) @pytest.mark.parametrize("target", [torch.tensor([2, 1, 0])]) @pytest.mark.parametrize("output", [torch.tensor(0.7653)]) def test_corn_loss(preds: torch.Tensor, target: torch.Tensor, output: torch.Tensor): loss = loss_modules.CORNLoss(CORNLossConfig()) assert torch.isclose(loss(preds, target), output, rtol=0.0001) def test_dict_class_weights_category(): input_features = [text_feature()] output_features = [category_feature(decoder={"vocab_size": 3})] config = { "input_features": input_features, "output_features": output_features, } # Set class weights as dictionary on config class_weights_dict = {"token_1": 0.1, "token_2": 0.2, "token_3": 0.3} config["output_features"][0]["loss"] = {"type": "softmax_cross_entropy", "class_weights": class_weights_dict} # Mock feature metadata feature_metadata = { "idx2str": ["token_1", "token_2", "token_3"], "str2idx": {"token_1": 0, "token_2": 1, "token_3": 2}, "str2freq": {"token_1": 300, "token_2": 200, "token_3": 100}, "vocab_size": 3, "preprocessing": { "missing_value_strategy": "drop_row", "fill_value": "", "computed_fill_value": "", "lowercase": False, "most_common": 10000, "cache_encoder_embeddings": False, }, } model_config = ModelConfig.from_dict(config) CategoryOutputFeature.update_config_with_metadata( feature_config=model_config.output_features[0], feature_metadata=feature_metadata, ) assert model_config.output_features[0].loss.class_weights == [0.1, 0.2, 0.3] def test_dict_class_weights_text(): input_features = [text_feature()] output_features = [text_feature(decoder={"vocab_size": 3, "max_sequence_length": 10})] config = { "input_features": input_features, "output_features": output_features, } # Set class weights as dictionary on config class_weights_dict = { "": 0, "": 0, "": 0, "": 0, "token_1": 0.5, "token_2": 0.4, "token_3": 0.1, } config["output_features"][0]["loss"] = { "type": "sequence_softmax_cross_entropy", "class_weights": class_weights_dict, } # Mock feature metadata feature_metadata = { "idx2str": ["", "", "", "", "token_1", "token_2", "token_3"], "str2idx": {"": 0, "": 1, "": 2, "": 3, "token_1": 4, "token_2": 5, "token_3": 6}, "str2freq": {"": 0, "": 0, "": 0, "": 0, "token_1": 300, "token_2": 200, "token_3": 100}, "str2idf": None, "vocab_size": 7, "max_sequence_length": 9, "max_sequence_length_99ptile": 9.0, "pad_idx": 2, "padding_symbol": "", "unknown_symbol": "", "index_name": None, "preprocessing": { "prompt": { "retrieval": {"type": None, "index_name": None, "model_name": None, "k": 0}, "task": None, "template": None, }, "pretrained_model_name_or_path": None, "tokenizer": "space_punct", "vocab_file": None, "sequence_length": None, "max_sequence_length": 256, "most_common": 20000, "padding_symbol": "", "unknown_symbol": "", "padding": "right", "lowercase": True, "missing_value_strategy": "drop_row", "fill_value": "", "computed_fill_value": "", "ngram_size": 2, "cache_encoder_embeddings": False, "compute_idf": False, }, } model_config = ModelConfig.from_dict(config) TextOutputFeature.update_config_with_metadata( feature_config=model_config.output_features[0], feature_metadata=feature_metadata, ) assert model_config.output_features[0].loss.class_weights == [0, 0, 0, 0, 0.5, 0.4, 0.1] def test_dict_class_weights_set(): input_features = [category_feature()] output_features = [set_feature()] config = { "input_features": input_features, "output_features": output_features, } # Set class weights as dictionary on config class_weights_dict = {"token_1": 0.1, "token_2": 0.2, "token_3": 0.3, "": 0} config["output_features"][0]["loss"] = {"type": "sigmoid_cross_entropy", "class_weights": class_weights_dict} # Mock feature metadata feature_metadata = { "idx2str": ["token_1", "token_2", "token_3", ""], "str2idx": {"token_1": 0, "token_2": 1, "token_3": 2, "": 3}, "str2freq": {"token_1": 300, "token_2": 200, "token_3": 100, "": 0}, "vocab_size": 4, "max_set_size": 3, "preprocessing": { "tokenizer": "space", "missing_value_strategy": "drop_row", "fill_value": "", "computed_fill_value": "", "lowercase": False, "most_common": 10000, }, } model_config = ModelConfig.from_dict(config) SetOutputFeature.update_config_with_metadata( feature_config=model_config.output_features[0], feature_metadata=feature_metadata, ) assert model_config.output_features[0].loss.class_weights == [0.1, 0.2, 0.3, 0] ================================================ FILE: tests/ludwig/modules/test_lr_scheduler.py ================================================ import math import sys import numpy as np from torch.optim import SGD from ludwig.features.number_feature import NumberInputFeature, NumberOutputFeature from ludwig.modules.lr_scheduler import LRScheduler from ludwig.schema.encoders.base import DenseEncoderConfig from ludwig.schema.features.number_feature import ECDNumberOutputFeatureConfig, NumberInputFeatureConfig from ludwig.schema.lr_scheduler import LRSchedulerConfig from ludwig.utils.metric_utils import TrainerMetric from ludwig.utils.trainer_utils import get_new_progress_tracker def test_lr_scheduler_warmup_decay(): total_steps = 10000 steps_per_checkpoint = 1000 base_lr = 1.0 warmup_fraction = 0.1 module = NumberInputFeature(NumberInputFeatureConfig(name="num1", encoder=DenseEncoderConfig())) const_optimizer = SGD(module.parameters(), lr=base_lr) const_config = LRSchedulerConfig(warmup_evaluations=0) const_scheduler = LRScheduler( config=const_config, optimizer=const_optimizer, steps_per_checkpoint=steps_per_checkpoint, total_steps=total_steps, ) linear_optimizer = SGD(module.parameters(), lr=base_lr) linear_config = LRSchedulerConfig(warmup_fraction=warmup_fraction, decay="linear") linear_scheduler = LRScheduler( config=linear_config, optimizer=linear_optimizer, steps_per_checkpoint=steps_per_checkpoint, total_steps=total_steps, ) exp_optimizer = SGD(module.parameters(), lr=base_lr) exp_config = LRSchedulerConfig(warmup_fraction=warmup_fraction, decay="exponential") exp_scheduler = LRScheduler( config=exp_config, optimizer=exp_optimizer, steps_per_checkpoint=steps_per_checkpoint, total_steps=total_steps ) cosine_optimizer = SGD(module.parameters(), lr=base_lr) cosine_config = LRSchedulerConfig(warmup_fraction=warmup_fraction, decay="cosine", t_0=steps_per_checkpoint) cosine_scheduler = LRScheduler( config=cosine_config, optimizer=cosine_optimizer, steps_per_checkpoint=steps_per_checkpoint, total_steps=total_steps, ) warmup_steps = total_steps * warmup_fraction for i in range(total_steps): # Offset by 1 step = i + 1 const_scheduler.step() const_lr = const_optimizer.param_groups[0]["lr"] assert const_lr == base_lr, f"step: {step}" linear_scheduler.step() linear_lr = linear_optimizer.param_groups[0]["lr"] exp_scheduler.step() exp_lr = exp_optimizer.param_groups[0]["lr"] cosine_scheduler.step() cosine_lr = cosine_optimizer.param_groups[0]["lr"] if step < warmup_steps: assert linear_lr == exp_lr, f"step: {step}" assert linear_lr == cosine_lr, f"step: {step}" assert linear_lr < base_lr, f"step: {step}" elif step == warmup_steps: assert linear_lr == base_lr, f"step: {step}" assert cosine_lr == base_lr, f"step: {step}" assert exp_lr < base_lr, f"step: {step}" else: assert linear_lr < base_lr, f"step: {step}" assert exp_lr < base_lr, f"step: {step}" assert cosine_lr <= base_lr, f"step: {step}" assert linear_lr < exp_lr assert exp_lr < cosine_lr assert cosine_lr == base_lr def test_lr_scheduler_reduce_on_plateau(): total_eval_steps = 100 base_lr = 1.0 reduce_limit = 3 module = NumberInputFeature(NumberInputFeatureConfig(name="num1", encoder=DenseEncoderConfig())) output1 = NumberOutputFeature(ECDNumberOutputFeatureConfig(name="output1", input_size=10), output_features={}) optimizer = SGD(module.parameters(), lr=base_lr) config = LRSchedulerConfig( warmup_evaluations=0, decay=None, reduce_on_plateau=reduce_limit, reduce_on_plateau_patience=10, reduce_on_plateau_rate=0.1, ) scheduler = LRScheduler(config=config, optimizer=optimizer, steps_per_checkpoint=0, total_steps=0) progress_tracker = get_new_progress_tracker( batch_size=64, best_eval_metric_value=sys.float_info.max, best_increase_batch_size_eval_metric=sys.float_info.max, learning_rate=base_lr, output_features={"output1": output1}, ) num_reductions = 0 last_lr = optimizer.param_groups[0]["lr"] steps_to_plateau = 5 loss = 10.0 for epoch in range(total_eval_steps): for i in range(100): # Simulate batch-wise steps. If we make a mistake, then this will reset # the learning rate. scheduler.step() steps_to_plateau -= 1 if steps_to_plateau > 0: loss -= 0.1 progress_tracker.train_metrics["output1"]["loss"].append( TrainerMetric(epoch=epoch, step=epoch * 100, value=loss) ) scheduler.eval_step(progress_tracker, "output1") lr = optimizer.param_groups[0]["lr"] if lr < last_lr: # Reset steps to plateau steps_to_plateau = 5 num_reductions += 1 last_lr = lr assert num_reductions == reduce_limit # 3 reductions that multiply by 0.1 each time assert np.isclose(lr, 0.001) def test_lr_scheduler_cosine_decay_fixed_period(): total_steps = 10000 steps_per_checkpoint = 1000 base_lr = 1.0 module = NumberInputFeature(NumberInputFeatureConfig(name="num1", encoder=DenseEncoderConfig())) optimizer = SGD(module.parameters(), lr=base_lr) config = LRSchedulerConfig(decay="cosine", t_0=steps_per_checkpoint, decay_rate=0, reduce_on_plateau=0) scheduler = LRScheduler(config=config, optimizer=optimizer, steps_per_checkpoint=0, total_steps=0) curr_lr = base_lr prev_lr = base_lr num_restarts = 0 for step in range(total_steps + 1): # Cosine annealing formula expected_lr = base_lr * 0.5 * (1 + math.cos(math.pi * (step % steps_per_checkpoint) / steps_per_checkpoint)) assert np.isclose(curr_lr, expected_lr), f"step: {step}" if prev_lr < curr_lr: # Since Cosine decay is periodic, we should see the learning rate # decrease and then increase again. num_restarts += 1 prev_lr = curr_lr scheduler.step() curr_lr = optimizer.param_groups[0]["lr"] assert num_restarts == 10, f"num_restarts: {num_restarts}" def test_lr_scheduler_cosine_decay_increasing_period(): total_steps = 20000 steps_per_checkpoint = 1000 base_lr = 1.0 module = NumberInputFeature(NumberInputFeatureConfig(name="num1", encoder=DenseEncoderConfig())) optimizer = SGD(module.parameters(), lr=base_lr) config = LRSchedulerConfig( decay="cosine", t_0=steps_per_checkpoint, t_mult=2, decay_rate=0, reduce_on_plateau=0, ) scheduler = LRScheduler( config=config, optimizer=optimizer, steps_per_checkpoint=steps_per_checkpoint, total_steps=total_steps ) curr_lr = base_lr prev_lr = base_lr num_restarts = 0 for _ in range(total_steps + 1): if prev_lr < curr_lr: # Since Cosine decay is periodic, we should see the learning rate # decrease and then increase again. num_restarts += 1 prev_lr = curr_lr scheduler.step() curr_lr = optimizer.param_groups[0]["lr"] # 1000, 3000, 6000, 12000, 24000 (but we stop at 20000) assert num_restarts == 4, f"num_restarts: {num_restarts}" def test_lr_scheduler_save_load(): steps_per_checkpoint = 10 total_steps = 100 base_lr = 1.0 reduce_limit = 3 module = NumberInputFeature(NumberInputFeatureConfig(name="num1", encoder=DenseEncoderConfig())) output1 = NumberOutputFeature(ECDNumberOutputFeatureConfig(name="output1", input_size=10), output_features={}) optimizer = SGD(module.parameters(), lr=base_lr) config = LRSchedulerConfig(warmup_fraction=0.2, reduce_on_plateau=reduce_limit) scheduler = LRScheduler( config=config, optimizer=optimizer, steps_per_checkpoint=steps_per_checkpoint, total_steps=total_steps ) progress_tracker = get_new_progress_tracker( batch_size=64, best_eval_metric_value=sys.float_info.max, best_increase_batch_size_eval_metric=sys.float_info.max, learning_rate=base_lr, output_features={"output1": output1}, ) for _ in range(10): scheduler.step() progress_tracker.train_metrics["output1"]["loss"].append(TrainerMetric(epoch=0, step=10, value=1.0)) scheduler.eval_step(progress_tracker, "output1") optimizer_state = optimizer.state_dict() scheduler_state = scheduler.state_dict() optimizer2 = SGD(module.parameters(), lr=base_lr) scheduler2 = LRScheduler( config=config, optimizer=optimizer2, steps_per_checkpoint=steps_per_checkpoint, total_steps=total_steps ) # Important: state needs to be loaded after init of optimizer and scheduler, otherwise # it can override loaded state optimizer2.load_state_dict(optimizer_state) scheduler2.load_state_dict(scheduler_state) lr = optimizer.param_groups[0]["lr"] assert lr == optimizer2.param_groups[0]["lr"] assert scheduler.state_dict() == scheduler2.state_dict() for _ in range(10): scheduler.step() scheduler2.step() progress_tracker.train_metrics["output1"]["loss"].append(TrainerMetric(epoch=1, step=20, value=0.8)) scheduler.eval_step(progress_tracker, "output1") scheduler2.eval_step(progress_tracker, "output1") assert lr != optimizer.param_groups[0]["lr"] assert optimizer.param_groups[0]["lr"] == optimizer2.param_groups[0]["lr"] assert scheduler.state_dict() == scheduler2.state_dict() ================================================ FILE: tests/ludwig/modules/test_metric_modules.py ================================================ import pytest import torch from ludwig.distributed import init_dist_strategy from ludwig.modules import metric_modules from ludwig.schema.features.loss.loss import ( BWCEWLossConfig, SigmoidCrossEntropyLossConfig, SoftmaxCrossEntropyLossConfig, ) # Required for local testing. init_dist_strategy("local") @pytest.mark.parametrize("preds", [torch.arange(6).reshape(3, 2).float()]) @pytest.mark.parametrize("target", [torch.arange(6, 12).reshape(3, 2).float()]) @pytest.mark.parametrize("output", [torch.tensor(6).float()]) def test_rmse_metric(preds: torch.Tensor, target: torch.Tensor, output: torch.Tensor): metric = metric_modules.RMSEMetric() with metric.sync_context(): metric.update(preds, target) assert output == metric.compute() @pytest.mark.parametrize("preds", [torch.tensor([0.2, 0.3, 0.8, 0.1])]) @pytest.mark.parametrize("target", [torch.tensor([0, 0, 1, 1])]) @pytest.mark.parametrize("output", [torch.tensor(0.5)]) def test_roc_auc_metric(preds: torch.Tensor, target: torch.Tensor, output: torch.Tensor): metric = metric_modules.BinaryAUROCMetric(task="binary") with metric.sync_context(): metric.update(preds, target) assert output == metric.compute() @pytest.mark.parametrize("preds", [torch.tensor([0.2, 0.3, 0.8, 0.1, 0.8])]) @pytest.mark.parametrize("target", [torch.tensor([0, 0, 1, 1, 0])]) @pytest.mark.parametrize("output", [torch.tensor(0.6667).float()]) def test_specificity_metric(preds: torch.Tensor, target: torch.Tensor, output: torch.Tensor): metric = metric_modules.SpecificityMetric() with metric.sync_context(): metric.update(preds, target) assert torch.isclose(output, metric.compute(), rtol=0.0001) @pytest.mark.parametrize("preds", [torch.arange(6).reshape(3, 2).float()]) @pytest.mark.parametrize("target", [torch.arange(6, 12).reshape(3, 2).float()]) @pytest.mark.parametrize("output", [torch.tensor(0.7527).float()]) def test_rmspe_metric(preds: torch.Tensor, target: torch.Tensor, output: torch.Tensor): metric = metric_modules.RMSPEMetric() with metric.sync_context(): metric.update(preds, target) assert torch.isclose(output, metric.compute(), rtol=0.0001) @pytest.mark.parametrize( "preds,target,num_outputs,output", [ (torch.arange(3), torch.arange(3, 6), 1, torch.tensor(-12.5)), (torch.arange(6).reshape(3, 2), torch.arange(6, 12).reshape(3, 2), 2, torch.tensor(-12.5)), ], ) def test_r2_score(preds: torch.Tensor, target: torch.Tensor, num_outputs: int, output: torch.Tensor): metric = metric_modules.R2Score(num_outputs=num_outputs) with metric.sync_context(): metric.update(preds, target) assert metric.compute() == output def test_r2_score_single_sample(): metric = metric_modules.R2Score(num_outputs=1) with metric.sync_context(): metric.update(preds=torch.tensor([0.8]), target=torch.arange(1)) assert torch.isnan(metric.compute()) @pytest.mark.parametrize("preds", [torch.arange(6).reshape(3, 2).float()]) @pytest.mark.parametrize("target", [torch.arange(6, 12).reshape(3, 2).float()]) @pytest.mark.parametrize("output", [torch.tensor(-21.4655).float()]) def test_bwcewl_metric(preds: torch.Tensor, target: torch.Tensor, output: torch.Tensor): metric = metric_modules.BWCEWLMetric(BWCEWLossConfig()) with metric.sync_context(): metric.update(preds, target) assert torch.isclose(output, metric.compute(), rtol=0.0001) @pytest.mark.parametrize("preds", [torch.tensor([[0.5, 0.5], [0.2, 0.8], [0.6, 0.4]])]) @pytest.mark.parametrize("target", [torch.tensor([1, 1, 0])]) @pytest.mark.parametrize("output", [torch.tensor(0.5763)]) def test_softmax_cross_entropy_metric(preds: torch.Tensor, target: torch.Tensor, output: torch.Tensor): metric = metric_modules.SoftmaxCrossEntropyMetric(SoftmaxCrossEntropyLossConfig()) with metric.sync_context(): metric.update(preds, target) assert torch.isclose(output, metric.compute(), rtol=0.0001) @pytest.mark.parametrize("preds", [torch.arange(6).reshape(3, 2).float()]) @pytest.mark.parametrize("target", [torch.arange(6, 12).reshape(3, 2).float()]) @pytest.mark.parametrize("output", [torch.tensor(-21.4655).float()]) def test_sigmoid_cross_entropy_metric(preds: torch.Tensor, target: torch.Tensor, output: torch.Tensor): metric = metric_modules.SigmoidCrossEntropyMetric(SigmoidCrossEntropyLossConfig()) with metric.sync_context(): metric.update(preds, target) assert torch.isclose(output, metric.compute(), rtol=0.0001) @pytest.mark.parametrize( "preds,target,output", [ ( torch.tensor([[0, 1], [3, 2], [4, 5]]), torch.tensor([[0, 1], [1, 2], [4, 5]]), torch.tensor(0.8), ), ( torch.tensor([[0, 1, 2], [1, 3, 4], [3, 4, 5]]), torch.tensor([[0, 1, 2], [1, 1, 4], [3, 4, 5]]), torch.tensor(0.8750), ), ( torch.tensor([[1, 5, 1, 5, 1, 5, 12, 12, 12], [10, 1, 5, 1, 5, 12, 12, 12, 12]]), torch.tensor([[1, 9, 5, 7, 5, 9, 13, 6, 0], [1, 9, 7, 13, 4, 7, 7, 7, 0]]), torch.tensor(0.05555555), ), ], ) def test_token_accuracy_metric(preds: torch.Tensor, target: torch.Tensor, output: torch.Tensor): metric = metric_modules.TokenAccuracyMetric() with metric.sync_context(): metric.update(preds, target) assert torch.allclose(metric.compute(), output) def test_sequence_accuracy_metric(): target = torch.tensor( [ [1, 6, 5, 4, 0], [1, 4, 5, 4, 0], [1, 4, 5, 4, 0], [1, 4, 5, 4, 0], [1, 4, 5, 4, 0], [1, 4, 5, 4, 0], [1, 4, 5, 4, 0], [1, 4, 5, 4, 0], [1, 4, 5, 4, 0], [1, 6, 5, 4, 0], [1, 6, 5, 4, 0], [1, 6, 5, 4, 0], [1, 4, 5, 4, 0], [1, 4, 5, 4, 0], [1, 6, 5, 4, 0], [1, 4, 5, 4, 0], [1, 6, 5, 4, 0], [1, 4, 5, 4, 0], [1, 4, 5, 4, 0], [1, 4, 5, 4, 0], [1, 4, 5, 4, 0], [1, 4, 5, 4, 0], [1, 4, 5, 4, 0], [1, 4, 5, 4, 0], [1, 4, 5, 4, 0], [1, 4, 5, 4, 0], [1, 4, 5, 4, 0], [1, 4, 5, 4, 0], [1, 4, 5, 4, 0], [1, 4, 5, 4, 0], [1, 4, 5, 4, 0], [1, 4, 5, 4, 0], ] ) preds = torch.tensor( [ [1, 6, 5, 4, 0], [1, 4, 5, 4, 0], [1, 4, 5, 4, 0], [1, 4, 5, 4, 0], [1, 4, 5, 4, 0], [1, 4, 5, 4, 0], [1, 4, 5, 4, 0], [1, 4, 5, 4, 0], [1, 6, 5, 4, 0], [1, 6, 5, 4, 0], [1, 4, 5, 4, 0], [1, 6, 5, 4, 0], [1, 6, 5, 4, 0], [1, 4, 5, 4, 0], [1, 6, 5, 4, 0], [1, 4, 5, 4, 0], [1, 4, 5, 4, 0], [1, 4, 5, 4, 0], [1, 4, 5, 4, 0], [1, 4, 5, 4, 0], [1, 6, 5, 4, 0], [1, 4, 5, 4, 0], [1, 4, 5, 4, 0], [1, 4, 5, 4, 0], [1, 4, 5, 4, 0], [1, 4, 5, 4, 0], [1, 4, 5, 4, 0], [1, 4, 5, 4, 0], [1, 4, 5, 4, 0], [1, 4, 5, 4, 0], [1, 4, 5, 4, 0], [1, 4, 5, 4, 0], ] ) metric = metric_modules.SequenceAccuracyMetric() with metric.sync_context(): metric.update(preds, target) assert torch.isclose(metric.compute(), torch.tensor(0.8438), rtol=0.0001) @pytest.mark.parametrize("preds", [torch.arange(6)]) @pytest.mark.parametrize("target", [torch.tensor([0, 1, 2, 1, 4, 5]).float()]) @pytest.mark.parametrize("output", [torch.tensor(0.7500).float()]) @pytest.mark.parametrize("one_hot", [False, True]) def test_category_accuracy(preds: torch.Tensor, target: torch.Tensor, output: torch.Tensor, one_hot: bool): if one_hot: target = torch.nn.functional.one_hot(target.long(), num_classes=6).float() metric = metric_modules.CategoryAccuracy(num_classes=6) with metric.sync_context(): metric.update(preds, target) assert torch.isclose(output, metric.compute(), rtol=0.0001) @pytest.mark.parametrize("preds", [torch.arange(6)]) @pytest.mark.parametrize("target", [torch.tensor([0, 1, 2, 1, 4, 5]).float()]) @pytest.mark.parametrize("output", [torch.tensor(0.8333).float()]) @pytest.mark.parametrize("one_hot", [False, True]) def test_category_accuracy_micro(preds: torch.Tensor, target: torch.Tensor, output: torch.Tensor, one_hot: bool): if one_hot: target = torch.nn.functional.one_hot(target.long(), num_classes=6).float() metric = metric_modules.CategoryAccuracyMicro(num_classes=6) with metric.sync_context(): metric.update(preds, target) assert torch.isclose(output, metric.compute(), rtol=0.0001) @pytest.mark.parametrize( "preds,target,output,k", [ ( torch.tensor([[0.1, 0.9, 0], [0.3, 0.1, 0.6], [0.2, 0.5, 0.3]]), torch.tensor([0, 1, 2]), torch.tensor(0.6667).float(), 2, ) ], ) def test_hits_at_k_metric(preds: torch.Tensor, target: torch.Tensor, output: torch.Tensor, k: int): metric = metric_modules.HitsAtKMetric(num_classes=3, top_k=k) with metric.sync_context(): metric.update(preds, target) assert torch.isclose(output, metric.compute(), rtol=0.0001) @pytest.mark.parametrize("preds", [torch.arange(6).reshape(3, 2).float()]) @pytest.mark.parametrize("target", [torch.arange(6, 12).reshape(3, 2).float()]) @pytest.mark.parametrize("output", [torch.tensor(6).float()]) def test_mae_metric(preds: torch.Tensor, target: torch.Tensor, output: torch.Tensor): metric = metric_modules.MAEMetric() with metric.sync_context(): metric.update(preds, target) assert output == metric.compute() @pytest.mark.parametrize("preds", [torch.arange(6).reshape(3, 2).float()]) @pytest.mark.parametrize("target", [torch.arange(6, 12).reshape(3, 2).float()]) @pytest.mark.parametrize("output", [torch.tensor(36).float()]) def test_mse_metric(preds: torch.Tensor, target: torch.Tensor, output: torch.Tensor): metric = metric_modules.MSEMetric() with metric.sync_context(): metric.update(preds, target) assert output == metric.compute() @pytest.mark.parametrize("preds", [torch.arange(6).reshape(3, 2).float()]) @pytest.mark.parametrize("target", [torch.arange(6, 12).reshape(3, 2).float()]) @pytest.mark.parametrize("output", [torch.tensor(0.7365440726280212)]) def test_mape_metric(preds: torch.Tensor, target: torch.Tensor, output: torch.Tensor): metric = metric_modules.MAPEMetric() with metric.sync_context(): metric.update(preds, target) assert output.item() == metric.compute().item() @pytest.mark.parametrize("preds", [torch.tensor([[0, 1], [1, 1]])]) @pytest.mark.parametrize("target", [torch.tensor([[1, 0], [1, 1]])]) @pytest.mark.parametrize("output", [torch.tensor(0.5)]) def test_jaccard_metric(preds: torch.Tensor, target: torch.Tensor, output: torch.Tensor): metric = metric_modules.JaccardMetric() with metric.sync_context(): metric.update(preds, target) assert output == metric.compute() def test_char_error_rate(): metric = metric_modules.CharErrorRateMetric() with metric.sync_context(): metric.update( ["this is the prediction", "there is an other sample"], ["this is the reference", "there is another one"] ) assert torch.isclose(torch.tensor(0.3415), metric.compute(), rtol=0.5) ================================================ FILE: tests/ludwig/modules/test_mlp_mixer_modules.py ================================================ import pytest from ludwig.modules.mlp_mixer_modules import MixerBlock, MLP, MLPMixer from .test_utils import assert_output_shapes @pytest.mark.parametrize("in_features,hidden_size,out_features", [(3, 8, 8), (8, 64, 32)]) def test_mlp(in_features: int, hidden_size: int, out_features: int): assert_output_shapes(module=MLP(in_features, hidden_size, out_features), input_shape=(in_features,)) @pytest.mark.parametrize("embed_size,n_patches,token_dim,channel_dim", [(512, 49, 2048, 256)]) def test_mixer_block( embed_size: int, n_patches: int, token_dim: int, channel_dim: int, ): assert_output_shapes( module=MixerBlock(embed_size, n_patches, token_dim, channel_dim), input_shape=(n_patches, embed_size) ) @pytest.mark.parametrize("img_height,img_width,in_channels", [(224, 224, 3)]) def test_mlp_mixer(img_height: int, img_width: int, in_channels: int): assert_output_shapes(module=MLPMixer(img_height, img_width, in_channels), input_shape=(3, img_height, img_width)) ================================================ FILE: tests/ludwig/modules/test_normalization_modules.py ================================================ import pytest import torch from ludwig.modules.normalization_modules import GhostBatchNormalization BATCH_SIZE = 16 OUTPUT_SIZE = 8 @pytest.mark.parametrize("virtual_batch_size", [None, BATCH_SIZE // 2, BATCH_SIZE - 14, BATCH_SIZE - 10]) @pytest.mark.parametrize("mode", [True, False]) # training (True) or eval(False) def test_ghostbatchnormalization(mode: bool, virtual_batch_size: int | None) -> None: # setup up GhostBatchNormalization layer ghost_batch_norm = GhostBatchNormalization(OUTPUT_SIZE, virtual_batch_size=virtual_batch_size) # set training or eval mode ghost_batch_norm.train(mode=mode) # setup inputs to test inputs = torch.randn([BATCH_SIZE, OUTPUT_SIZE], dtype=torch.float32) # run tensor through norm_tensor = ghost_batch_norm(inputs) # check for correctness of output assert isinstance(norm_tensor, torch.Tensor) assert norm_tensor.shape == (BATCH_SIZE, OUTPUT_SIZE) # check for required properties assert ghost_batch_norm.input_shape == inputs.shape[1:] assert ghost_batch_norm.output_shape == inputs.shape[1:] assert ghost_batch_norm.input_dtype == torch.float32 assert isinstance(ghost_batch_norm.moving_mean, torch.Tensor) assert ghost_batch_norm.moving_mean.shape == (OUTPUT_SIZE,) assert isinstance(ghost_batch_norm.moving_variance, torch.Tensor) assert ghost_batch_norm.moving_variance.shape == (OUTPUT_SIZE,) def test_ghostbatchnormalization_chunk_size_2() -> None: """Test GhostBatchNormalization with virtual_batch_size=2 and batch_size=7 This creates chunks of size 2, 2, 2, 1 which should be handled correctly since we should skip applying batch norm to the last chunk since it is size 1.""" # setup up GhostBatchNormalization layer ghost_batch_norm = GhostBatchNormalization(6, virtual_batch_size=2) # setup inputs to test inputs = torch.randn([7, 6], dtype=torch.float32) # Set to training mode ghost_batch_norm.train(mode=True) # run tensor through ghost_batch_norm(inputs) ================================================ FILE: tests/ludwig/modules/test_recurrent_modules.py ================================================ import logging import pytest import torch from ludwig.modules import recurrent_modules logger = logging.getLogger(__name__) @pytest.mark.parametrize("max_sequence_length,expected_output_shape", [(19, [19, 256]), (None, [256])]) def test_recurrent_stack(max_sequence_length, expected_output_shape): recurrent_stack = recurrent_modules.RecurrentStack( input_size=10, max_sequence_length=max_sequence_length, hidden_size=256 ) assert recurrent_stack.output_shape == torch.Size(expected_output_shape) # Batch (N), Length (L), Input (H) inputs = torch.rand(2, 19, 10) hidden, final_state = recurrent_stack(inputs) assert hidden.shape == torch.Size([2, 19, 256]) assert final_state.shape == torch.Size([2, 256]) ================================================ FILE: tests/ludwig/modules/test_reduction_modules.py ================================================ import pytest import torch from ludwig.modules import reduction_modules from ludwig.utils.torch_utils import get_torch_device DEVICE = get_torch_device() @pytest.mark.parametrize("reduce_mode", ["last", "sum", "mean", "avg", "max", "concat", "attention", None]) @pytest.mark.parametrize("test_input_shape", [(16, 1, 4), (4, 10, 16)]) def test_sequence_reducer(reduce_mode: str, test_input_shape: tuple[int, ...]): batch_size, max_sequence_length, encoding_size = test_input_shape sequence_reducer = reduction_modules.SequenceReducer( reduce_mode=reduce_mode, max_sequence_length=max_sequence_length, encoding_size=encoding_size ).to(DEVICE) inputs = torch.zeros(test_input_shape) # Generates random sequence of random length for each instance in batch. for batch_index in range(batch_size): sequence_length = torch.randint(max_sequence_length, (1,)) inputs[batch_index, :sequence_length] = torch.rand((sequence_length, encoding_size)) outputs = sequence_reducer(inputs.to(DEVICE)) assert outputs.shape[1:] == sequence_reducer.output_shape ================================================ FILE: tests/ludwig/modules/test_regex_freezing.py ================================================ import logging import os import re from contextlib import nullcontext as no_error_raised import pytest from ludwig.api import LudwigModel from ludwig.constants import ( BASE_MODEL, BATCH_SIZE, EPOCHS, GENERATION, INPUT_FEATURES, MODEL_LLM, MODEL_TYPE, OUTPUT_FEATURES, TRAINER, TYPE, ) from ludwig.encoders.image.torchvision import TVEfficientNetEncoder from ludwig.schema.trainer import ECDTrainerConfig from ludwig.utils.misc_utils import set_random_seed from ludwig.utils.trainer_utils import freeze_layers_regex from tests.integration_tests.utils import category_feature, generate_data, image_feature, text_feature RANDOM_SEED = 130 @pytest.mark.parametrize( "regex", [ r"(features\.1.*|features\.2.*|features\.3.*|model\.features\.4\.1\.block\.3\.0\.weight)", r"(features\.1.*|features\.2\.*|features\.3.*)", r"(features\.4\.0\.block|features\.4\.\d+\.block)", r"(features\.5\.*|features\.6\.*|features\.7\.*)", r"(features\.8\.\d+\.weight|features\.8\.\d+\.bias)", ], ) def test_tv_efficientnet_freezing(regex): set_random_seed(RANDOM_SEED) pretrained_model = TVEfficientNetEncoder( model_variant="b0", use_pretrained=False, saved_weights_in_checkpoint=True, trainable=True ) config = ECDTrainerConfig(layers_to_freeze_regex=regex) freeze_layers_regex(config, pretrained_model) for name, param in pretrained_model.named_parameters(): if re.search(re.compile(regex), name): assert not param.requires_grad else: assert param.requires_grad def test_llm_freezing(tmpdir, csv_filename): # Force CPU to avoid CUBLAS errors with tiny random LLM models on GPU. old_val = os.environ.get("CUDA_VISIBLE_DEVICES") os.environ["CUDA_VISIBLE_DEVICES"] = "" try: _run_llm_freezing(tmpdir, csv_filename) finally: if old_val is None: os.environ.pop("CUDA_VISIBLE_DEVICES", None) else: os.environ["CUDA_VISIBLE_DEVICES"] = old_val def _run_llm_freezing(tmpdir, csv_filename): input_features = [text_feature(name="input", encoder={"type": "passthrough"})] output_features = [text_feature(name="output")] train_df = generate_data(input_features, output_features, filename=csv_filename, num_examples=25) config = { MODEL_TYPE: MODEL_LLM, BASE_MODEL: "hf-internal-testing/tiny-random-GPTJForCausalLM", INPUT_FEATURES: [text_feature(name="input", encoder={"type": "passthrough"})], OUTPUT_FEATURES: [text_feature(name="output")], TRAINER: {TYPE: "finetune", BATCH_SIZE: 8, EPOCHS: 1, "layers_to_freeze_regex": r"(h\.0\.attn\.*)"}, GENERATION: {"pad_token_id": 0}, } model = LudwigModel(config, logging_level=logging.INFO) output_directory: str = str(tmpdir) model.train(dataset=train_df, output_directory=output_directory, skip_save_processed_input=False) for name, p in model.model.named_parameters(): if "h.0.attn" in name: assert not p.requires_grad else: assert p.requires_grad def test_frozen_tv_training(tmpdir, csv_filename): input_features = [ image_feature(tmpdir, encoder={"type": "efficientnet", "use_pretrained": False, "model_variant": "b0"}) ] output_features = [category_feature()] config = { "input_features": input_features, "output_features": output_features, TRAINER: { "layers_to_freeze_regex": r"(features\.1.*|features\.2\.*|features\.3.*)", "epochs": 1, "train_steps": 1, }, } training_data_csv_path = generate_data(config["input_features"], config["output_features"], csv_filename) model = LudwigModel(config) with no_error_raised(): model.experiment( dataset=training_data_csv_path, skip_save_training_description=True, skip_save_training_statistics=True, skip_save_model=True, skip_save_progress=True, skip_save_log=True, skip_save_processed_input=True, ) ================================================ FILE: tests/ludwig/modules/test_tabnet_modules.py ================================================ import pytest import torch from ludwig.modules.tabnet_modules import AttentiveTransformer, FeatureBlock, FeatureTransformer, TabNet from ludwig.utils.entmax import sparsemax from tests.integration_tests.parameter_update_utils import check_module_parameters_updated RANDOM_SEED = 67 @pytest.mark.parametrize( "input_tensor", [ torch.tensor([[-1.0, 0.0, 1.0], [5.01, 4.0, -2.0]], dtype=torch.float32), torch.tensor( [[1.36762051e8, -1.36762051e8, 1.59594639e20], [1.59594639e37, 1.36762051e7, 1.26e6]], dtype=torch.float32 ), ], ) def test_sparsemax(input_tensor: torch.Tensor) -> None: output_tensor = sparsemax(input_tensor) assert isinstance(output_tensor, torch.Tensor) assert output_tensor.equal(torch.tensor([[0, 0, 1], [1, 0, 0]], dtype=torch.float32)) @pytest.mark.parametrize("bn_virtual_bs", [None, 7]) @pytest.mark.parametrize("external_shared_fc_layer", [True, False]) @pytest.mark.parametrize("apply_glu", [True, False]) @pytest.mark.parametrize("size", [4, 12]) @pytest.mark.parametrize("input_size", [2, 6]) @pytest.mark.parametrize("batch_size", [1, 16]) def test_feature_block( input_size, size: int, apply_glu: bool, external_shared_fc_layer: bool, bn_virtual_bs: int | None, batch_size: int, ) -> None: # setup synthetic tensor torch.manual_seed(RANDOM_SEED) input_tensor = torch.randn([batch_size, input_size], dtype=torch.float32) if external_shared_fc_layer: shared_fc_layer = torch.nn.Linear(input_size, size * 2 if apply_glu else size, bias=False) else: shared_fc_layer = None feature_block = FeatureBlock( input_size, size, apply_glu=apply_glu, shared_fc_layer=shared_fc_layer, bn_virtual_bs=bn_virtual_bs ) output_tensor = feature_block(input_tensor) # check for expected structure and properties assert isinstance(output_tensor, torch.Tensor) assert output_tensor.shape == (batch_size, size) assert feature_block.input_shape[-1] == input_size assert feature_block.output_shape[-1] == size assert feature_block.input_dtype == torch.float32 @pytest.mark.parametrize("num_total_blocks, num_shared_blocks", [(4, 2), (6, 4), (3, 1)]) @pytest.mark.parametrize("virtual_batch_size", [None, 7]) @pytest.mark.parametrize("size", [4, 12]) @pytest.mark.parametrize("input_size", [2, 6]) @pytest.mark.parametrize("batch_size", [1, 16]) def test_feature_transformer( input_size: int, size: int, virtual_batch_size: int | None, num_total_blocks: int, num_shared_blocks: int, batch_size: int, ) -> None: # setup synthetic tensor torch.manual_seed(RANDOM_SEED) input_tensor = torch.randn([batch_size, input_size], dtype=torch.float32) feature_transformer = FeatureTransformer( input_size, size, bn_virtual_bs=virtual_batch_size, num_total_blocks=num_total_blocks, num_shared_blocks=num_shared_blocks, ) output_tensor = feature_transformer(input_tensor) # check for expected structure and properties assert isinstance(output_tensor, torch.Tensor) assert output_tensor.shape == (batch_size, size) assert feature_transformer.input_shape[-1] == input_size assert feature_transformer.output_shape[-1] == size assert feature_transformer.input_dtype == torch.float32 @pytest.mark.parametrize("virtual_batch_size", [None, 7]) @pytest.mark.parametrize("output_size", [10, 12]) @pytest.mark.parametrize("size", [4, 8]) @pytest.mark.parametrize("input_size", [2, 6]) @pytest.mark.parametrize("entmax_mode", [None, "entmax15", "adaptive", "constant"]) @pytest.mark.parametrize("batch_size", [1, 16]) def test_attentive_transformer( entmax_mode: str | None, input_size: int, size: int, output_size: int, virtual_batch_size: int | None, batch_size: int, ) -> None: # setup synthetic tensors torch.manual_seed(RANDOM_SEED) input_tensor = torch.randn([batch_size, input_size], dtype=torch.float32) prior_scales = torch.ones([batch_size, input_size]) # setup required transformers for test feature_transformer = FeatureTransformer(input_size, size + output_size, bn_virtual_bs=virtual_batch_size) attentive_transformer = AttentiveTransformer( size, input_size, bn_virtual_bs=virtual_batch_size, entmax_mode=entmax_mode ) # process synthetic tensor through transformers x = feature_transformer(input_tensor) output_tensor = attentive_transformer(x[:, output_size:], prior_scales) # check for expected shape and properties assert isinstance(output_tensor, torch.Tensor) assert output_tensor.shape == (batch_size, input_size) assert attentive_transformer.input_shape[-1] == size assert attentive_transformer.output_shape[-1] == input_size assert attentive_transformer.input_dtype == torch.float32 if entmax_mode == "adaptive": assert isinstance(attentive_transformer.trainable_alpha, torch.Tensor) # TODO: Need variant of assert_model_parameters_updated() to account for the two step calling sequence # of AttentiveTransformer @pytest.mark.parametrize("virtual_batch_size", [None, 7]) @pytest.mark.parametrize("size", [2, 4, 8]) @pytest.mark.parametrize("output_size", [2, 4, 12]) @pytest.mark.parametrize("input_size", [2]) @pytest.mark.parametrize("entmax_mode", [None, "entmax15", "adaptive", "constant"]) @pytest.mark.parametrize("batch_size", [1, 16]) def test_tabnet( entmax_mode: str | None, input_size: int, output_size: int, size: int, virtual_batch_size: int | None, batch_size: int, ) -> None: # setup synthetic tensor torch.manual_seed(RANDOM_SEED) input_tensor = torch.randn([batch_size, input_size], dtype=torch.float32) tabnet = TabNet( input_size, size, output_size, num_steps=3, num_total_blocks=4, num_shared_blocks=2, entmax_mode=entmax_mode ) output = tabnet(input_tensor) # check for expected shape and properties assert isinstance(output, tuple) assert output[0].shape == (batch_size, output_size) assert tabnet.input_shape[-1] == input_size assert tabnet.output_shape[-1] == output_size assert tabnet.input_dtype == torch.float32 # check for parameter updates target = torch.randn([batch_size, 1]) fpc, tpc, upc, not_updated = check_module_parameters_updated(tabnet, (input_tensor,), target) if batch_size == 1: # for single record batches, batchnorm layer is bypassed, only a subset of parameters are updated assert upc == 17, ( f"Updated parameter count not expected value. Parameters not updated: {not_updated}" f"\nModule structure:\n{tabnet}" ) else: # update count should equal trainable number of parameters assert tpc == upc, ( f"All parameter not updated. Parameters not updated: {not_updated}" f"\nModule structure:\n{tabnet}" ) ================================================ FILE: tests/ludwig/modules/test_utils.py ================================================ import torch from ludwig.utils.torch_utils import LudwigModule def assert_output_shapes(module: LudwigModule, input_shape: tuple[int]): """Runs a unit test to confirm that the out shape matches expected output. module: Module to be tested. input_shape: List of integers of the expected input shape (w/o batch dim). """ inputs = torch.rand(2, *input_shape, dtype=module.input_dtype) output_tensor = module(inputs) assert output_tensor.shape[1:] == module.output_shape ================================================ FILE: tests/ludwig/schema/hyperopt/test_scheduler.py ================================================ import pytest from ludwig.schema.hyperopt.scheduler import BaseSchedulerConfig from ludwig.schema.hyperopt.utils import register_scheduler_config, scheduler_config_registry from ludwig.schema.utils import ludwig_dataclass, ProtectedString @pytest.fixture( params=[ # Tuples of SA name, dependency list, whether it should raise an exception ("no_deps", None, False), ("installed", [("ludwig", "ludwig")], False), ("multiple_installed", [("ludwig", "ludwig"), ("marshmallow", "marshmallow")], False), ("not_installed", [("fake_dependency", "fake_dependency")], True), ("mixed_installed", [("fake_dependency", "fake_dependency"), ("ludwig", "ludwig")], True), ] ) def dependency_check_config(request): key, deps, raises_exception = request.param @register_scheduler_config(key, dependencies=deps) @ludwig_dataclass class DependencyCheckConfig(BaseSchedulerConfig): type: str = ProtectedString(key) yield DependencyCheckConfig(), raises_exception del scheduler_config_registry[key] def test_dependency_check(dependency_check_config): """Test that the hyperopt scheduler dependency check properly identifies missing dependencies. Some schedulers supported by Ray Tune have additional dependencies that may not be installed. The schema records these dependencies and can be used to verify they are installed at run time. """ config, raises_exception = dependency_check_config if raises_exception: with pytest.raises(ImportError): config.dependencies_installed() else: assert config.dependencies_installed() ================================================ FILE: tests/ludwig/schema/hyperopt/test_search_algorithm.py ================================================ import pytest from ludwig.schema.hyperopt.search_algorithm import BaseSearchAlgorithmConfig from ludwig.schema.hyperopt.utils import register_search_algorithm_config, search_algorithm_config_registry from ludwig.schema.utils import ludwig_dataclass, ProtectedString @pytest.fixture( params=[ # Tuples of SA name, dependency list, whether it should raise an exception ("no_deps", None, False), ("installed", [("ludwig", "ludwig")], False), ("multiple_installed", [("ludwig", "ludwig"), ("marshmallow", "marshmallow")], False), ("not_installed", [("fake_dependency", "fake_dependency")], True), ("mixed_installed", [("fake_dependency", "fake_dependency"), ("ludwig", "ludwig")], True), ] ) def dependency_check_config(request): key, deps, raises_exception = request.param @register_search_algorithm_config(key, dependencies=deps) @ludwig_dataclass class DependencyCheckConfig(BaseSearchAlgorithmConfig): type: str = ProtectedString(key) yield DependencyCheckConfig(), raises_exception del search_algorithm_config_registry[key] def test_dependency_check(dependency_check_config): """Test that the hyperopt search alg dependency check properly identifies missing dependencies. Most search algorithms supported by Ray Tune have additional dependencies that may not be installed. The schema records these dependencies and can be used to verify they are installed at run time. """ config, raises_exception = dependency_check_config if raises_exception: with pytest.raises(ImportError): config.dependencies_installed() else: assert config.dependencies_installed() ================================================ FILE: tests/ludwig/schema/test_model_config.py ================================================ import os from tempfile import TemporaryDirectory from typing import Any import pytest import yaml from ludwig.constants import ( ACTIVE, BASE_MODEL, CLIP, COLUMN, COMBINER, DECODER, DEFAULT_VALIDATION_METRIC, DEFAULTS, DEPENDENCIES, ENCODER, HYPEROPT, INPUT_FEATURES, INPUT_SIZE, LOSS, MODEL_ECD, MODEL_LLM, MODEL_TYPE, NAME, NUM_CLASSES, OPTIMIZER, OUTPUT_FEATURES, PREPROCESSING, PROC_COLUMN, REDUCE_DEPENDENCIES, REDUCE_INPUT, TIED, TRAINER, TYPE, ) from ludwig.error import ConfigValidationError from ludwig.schema.decoders.base import ClassifierConfig from ludwig.schema.encoders.text_encoders import BERTConfig from ludwig.schema.features.augmentation.image import RandomBlurConfig, RandomRotateConfig from ludwig.schema.features.image_feature import AUGMENTATION_DEFAULT_OPERATIONS from ludwig.schema.features.number_feature import NumberOutputFeatureConfig from ludwig.schema.features.text_feature import TextOutputFeatureConfig from ludwig.schema.llms.quantization import QuantizationConfig from ludwig.schema.model_config import ModelConfig from ludwig.schema.utils import BaseMarshmallowConfig, convert_submodules config_sections = {INPUT_FEATURES, OUTPUT_FEATURES, PREPROCESSING, TRAINER, COMBINER, DEFAULTS, HYPEROPT} def test_config_object(): config = { "input_features": [ { "name": "text_feature", "type": "text", "preprocessing": { "missing_value_strategy": "drop_row", }, "encoder": { "type": "rnn", "bidirectional": True, "representation": "dense", "num_layers": 2, }, }, { "name": "image_feature_1", "type": "image", "preprocessing": { "height": 32, "width": 32, "num_channels": 4, }, "encoder": { "type": "stacked_cnn", "num_channels": 4, "dropout": 0.1, }, }, ], "output_features": [ { "name": "category_feature", "type": "category", "top_k": 3, "preprocessing": { "missing_value_strategy": "bfill", }, "decoder": { "type": "classifier", "num_classes": 10, "use_bias": False, }, }, ], "combiner": { "type": "concat", "output_size": 512, "weights_initializer": "xavier_uniform", "dropout": 0.2, }, "trainer": { "epochs": 50, "batch_size": "auto", "optimizer": { "type": "adam", "betas": [0.8, 0.999], "eps": 5e-09, }, }, } config_object = ModelConfig.from_dict(config) assert config_object.input_features.text_feature.encoder.type == "rnn" assert config_object.input_features.text_feature.encoder.num_layers == 2 assert config_object.input_features.text_feature.preprocessing.missing_value_strategy == "drop_row" assert config_object.defaults.text.encoder.type != "rnn" assert config_object.defaults.text.preprocessing.missing_value_strategy != "drop_row" assert config_object.output_features.category_feature.decoder.num_classes == 10 assert config_object.output_features.category_feature.top_k == 3 assert config_object.combiner.output_size == 512 assert config_object.combiner.weights_initializer == "xavier_uniform" assert config_object.combiner.fc_layers is None assert config_object.trainer.epochs == 50 assert config_object.trainer.batch_size == "auto" assert config_object.trainer.optimizer.type == "adam" assert config_object.trainer.optimizer.betas[0] == 0.8 assert config_object.trainer.optimizer.betas[1] == 0.999 assert config_object.trainer.optimizer.eps == 5e-09 def test_config_object_defaults(): config = { "input_features": [ {"name": "number_feature", "type": "number"}, { "name": "text_feature_1", "type": "text", "encoder": { "type": "rnn", "activation": "sigmoid", }, }, { "name": "text_feature_2", "type": "text", }, ], "output_features": [ { "name": "number_output_feature", "type": "number", }, ], "defaults": { "number": {"preprocessing": {"missing_value_strategy": "drop_row"}, "encoder": {"type": "dense"}}, "text": { "preprocessing": { "missing_value_strategy": "drop_row", }, "encoder": { "type": "stacked_parallel_cnn", "activation": "tanh", }, }, }, } config_object = ModelConfig.from_dict(config) assert config_object.input_features.number_feature.preprocessing.missing_value_strategy == "drop_row" assert config_object.input_features.number_feature.encoder.type == "dense" assert config_object.input_features.text_feature_1.encoder.type == "rnn" assert config_object.input_features.text_feature_1.encoder.activation == "sigmoid" assert config_object.input_features.text_feature_1.preprocessing.missing_value_strategy == "drop_row" assert config_object.input_features.text_feature_2.encoder.type == "stacked_parallel_cnn" assert config_object.input_features.text_feature_2.encoder.activation == "tanh" assert config_object.input_features.text_feature_2.preprocessing.missing_value_strategy == "drop_row" def test_config_object_to_config_dict(): config = { "input_features": [ {"name": "number_feature", "type": "number"}, ], "output_features": [ { "name": "number_output_feature", "type": "number", }, ], } config_object = ModelConfig.from_dict(config) config_dict = config_object.to_dict() for section in config_sections: assert section in config_dict assert len(config_dict[DEFAULTS]) == 13 assert set(config_dict[INPUT_FEATURES][0].keys()) == { NAME, ACTIVE, TYPE, COLUMN, PROC_COLUMN, TIED, PREPROCESSING, ENCODER, } assert set(config_dict[OUTPUT_FEATURES][0].keys()) == { NAME, ACTIVE, TYPE, COLUMN, PROC_COLUMN, PREPROCESSING, DECODER, LOSS, REDUCE_INPUT, DEPENDENCIES, INPUT_SIZE, CLIP, REDUCE_DEPENDENCIES, NUM_CLASSES, DEFAULT_VALIDATION_METRIC, } def test_update_config_object(): config = { "input_features": [ {"name": "text_feature", "type": "text"}, ], "output_features": [ { "name": "number_output_feature", "type": "number", }, ], } config_object = ModelConfig.from_dict(config) assert config_object.input_features.text_feature.encoder.type == "parallel_cnn" assert config_object.input_features.text_feature.encoder.max_sequence_length is None temp_config = { "input_features": [ {"name": "text_feature", "type": "text", "encoder": {"type": "parallel_cnn", "max_sequence_length": 10}}, ], "output_features": [ { "name": "number_output_feature", "type": "number", }, ], } config_object = ModelConfig.from_dict(temp_config) assert config_object.input_features.text_feature.encoder.max_sequence_length == 10 @pytest.mark.parametrize("model_type", [MODEL_ECD]) def test_config_object_validation_parameters_defaults(model_type: str): config = { "input_features": [ {"name": "category_feature", "type": "category"}, ], "output_features": [ { "name": "number_output_feature", "type": "number", }, ], "model_type": model_type, } config_object = ModelConfig.from_dict(config) assert config_object.trainer.validation_field == "number_output_feature" assert config_object.trainer.validation_metric == NumberOutputFeatureConfig.default_validation_metric def test_config_object_validation_parameters_multiple_output_features(): config = { "input_features": [ {"name": "text_feature", "type": "text"}, ], "output_features": [ { "name": "text_output_feature", "type": "text", }, { "name": "number_output_feature", "type": "number", }, ], } config_object = ModelConfig.from_dict(config) assert config_object.trainer.validation_field == "text_output_feature" assert config_object.trainer.validation_metric == TextOutputFeatureConfig.default_validation_metric # swap features tmp = config["output_features"][0] config["output_features"][0] = config["output_features"][1] config["output_features"][1] = tmp config_object = ModelConfig.from_dict(config) assert config_object.trainer.validation_field == "number_output_feature" assert config_object.trainer.validation_metric == NumberOutputFeatureConfig.default_validation_metric def test_config_object_validation_parameters_explicitly_set_validation_field(): config = { "input_features": [ {"name": "text_feature", "type": "text"}, ], "output_features": [ { "name": "text_output_feature", "type": "text", }, { "name": "number_output_feature", "type": "number", }, ], "trainer": { "validation_field": "combined", }, } config_object = ModelConfig.from_dict(config) assert config_object.trainer.validation_field == "combined" assert config_object.trainer.validation_metric == "loss" def test_config_object_validation_parameters_explicitly_set_validation_metric(): config = { "input_features": [ {"name": "text_feature", "type": "text"}, ], "output_features": [ { "name": "text_output_feature", "type": "text", }, { "name": "number_output_feature", "type": "number", }, ], "trainer": { "validation_metric": NumberOutputFeatureConfig.default_validation_metric, }, } config_object = ModelConfig.from_dict(config) # We find the output feature that the validation_metric corresponds to. assert config_object.trainer.validation_field == "number_output_feature" assert config_object.trainer.validation_metric == NumberOutputFeatureConfig.default_validation_metric def test_config_object_validation_parameters_invalid_metric(): config = { "input_features": [ {"name": "text_feature", "type": "text"}, ], "output_features": [ { "name": "text_output_feature", "type": "text", }, ], "trainer": { "validation_metric": NumberOutputFeatureConfig.default_validation_metric, }, } with pytest.raises(Exception): ModelConfig.from_dict(config) def test_config_object_validation_parameters_metric_conflict(): config = { "input_features": [ {"name": "text_feature", "type": "text"}, ], "output_features": [ { "name": "number_output_feature1", "type": "number", }, { "name": "number_output_feature2", "type": "number", }, ], "trainer": { "validation_metric": NumberOutputFeatureConfig.default_validation_metric, }, } with pytest.raises(Exception): ModelConfig.from_dict(config) def test_constructors_yaml(): config = { "input_features": [ {"name": "text_feature", "type": "text", "encoder": {"type": "parallel_cnn", "max_sequence_length": 10}}, ], "output_features": [ { "name": "number_output_feature", "type": "number", }, ], } with TemporaryDirectory() as tmpdir: file_path = os.path.join(tmpdir, "test.yaml") with open(file_path, "w") as file: yaml.dump(config, file) config_obj = ModelConfig.from_yaml(file_path) for section in config_sections: assert hasattr(config_obj, section) def test_constructors_dict(): config = { "input_features": [ {"name": "text_feature", "type": "text", "encoder": {"type": "parallel_cnn", "max_sequence_length": 10}}, ], "output_features": [ { "name": "number_output_feature", "type": "number", }, ], } config_obj = ModelConfig.from_dict(config) for section in config_sections: assert hasattr(config_obj, section) def test_feature_enabling_disabling(): config = { "input_features": [{"name": "text_feature", "type": "text"}, {"name": "category_feature", "type": "number"}], "output_features": [ { "name": "number_output_feature", "type": "number", }, ], } config_obj = ModelConfig.from_dict(config) assert config_obj.input_features.text_feature.active assert config_obj.input_features.category_feature.active config_obj.input_features.text_feature.disable() assert not config_obj.input_features.text_feature.active def test_sequence_combiner(): config = { "input_features": [{"name": "text_feature", "type": "text"}], "output_features": [{"name": "number_output_feature", "type": "number"}], "combiner": {"type": "sequence", "encoder": {"type": "rnn"}}, } config_obj = ModelConfig.from_dict(config) assert config_obj.combiner.type == "sequence" assert config_obj.combiner.encoder.type == "rnn" assert config_obj.combiner.encoder.cell_type == "rnn" @pytest.mark.parametrize( "session", [ {"sess_id": 0, "encoder": "parallel_cnn", "loss": {"type": "mean_squared_error"}}, {"sess_id": 1, "encoder": "cnnrnn", "loss": {"type": "mean_absolute_error"}}, {"sess_id": 2, "encoder": "parallel_cnn", "loss": {"type": "mean_absolute_error"}}, ], ) def test_shared_state(session): config = { "input_features": [ {"name": "text_feature", "type": "text", "encoder": {"type": session["encoder"]}}, {"name": "text_feature_2", "type": "text"}, ], "output_features": [ {"name": "number_output_feature", "type": "number"}, {"name": "category_feature", "type": "category", "preprocessing": {"missing_value_strategy": "bfill"}}, ], "defaults": {"text": {"encoder": {"type": session["encoder"]}}}, } if session["sess_id"] == 1: del config[OUTPUT_FEATURES][1]["preprocessing"] if session["sess_id"] == 2: del config[INPUT_FEATURES][0]["encoder"] del config[DEFAULTS] config_obj = ModelConfig.from_dict(config) if session["sess_id"] == 0: config_obj.input_features.text_feature.encoder.max_sequence_length = 10 config_obj.input_features.text_feature.tied = "text_feature_2" assert config_obj.defaults.text.encoder.max_sequence_length is None # Test no link w/ defaults config assert config_obj.input_features.text_feature.tied == "text_feature_2" # Test tied set as expected if session["sess_id"] == 1: config_obj.output_features.number_output_feature.loss.weight = 2.0 # Test previous edits to config don't carry over assert config_obj.output_features.category_feature.preprocessing.missing_value_strategy == "drop_row" assert config_obj.defaults.text.encoder.max_sequence_length is None # Test no link w/ previous encoder config assert config_obj.input_features.text_feature.tied is None # Test no link w/ previous text feature config assert config_obj.output_features.number_output_feature.loss.weight == 2.0 # Test loss weight set as expected if session["sess_id"] == 2: assert config_obj.input_features.text_feature.encoder.type == "parallel_cnn" assert config_obj.output_features.number_output_feature.loss.weight == 1.0 # Test no link previous loss config assert config_obj.defaults.text.encoder.max_sequence_length is None # Test no link w/ first encoder config assert config_obj.input_features.text_feature.tied is None # Test no link w/ first tied setting def test_convert_submodules(): config = { "input_features": [ {"name": "text_feature", "type": "text"}, ], "output_features": [{"name": "number_output_feature", "type": "number"}], } config_obj = ModelConfig.from_dict(config) trainer = convert_submodules(config_obj.trainer.__dict__) input_features = config_obj.input_features.to_list() assert not isinstance(trainer[OPTIMIZER], BaseMarshmallowConfig) assert not isinstance(input_features[0][PREPROCESSING], BaseMarshmallowConfig) def test_defaults_mixins(): config = { "input_features": [ {"name": "text_feature", "type": "text"}, ], "output_features": [{"name": "number_output_feature", "type": "number"}], } config_obj = ModelConfig.from_dict(config) assert config_obj.defaults.audio.to_dict().keys() == {ENCODER, PREPROCESSING} assert config_obj.defaults.category.to_dict().keys() == {ENCODER, PREPROCESSING, DECODER, LOSS} def test_initializer_recursion(): config = { "input_features": [ { "name": "category_B9834", "type": "category", "encoder": { "type": "dense", "vocab_size": 2, "embedding_size": 5, }, "reduce_input": "sum", "column": "category_B9834", "proc_column": "category_B9834_mZFLky", }, { "name": "number_0F633", "type": "number", "encoder": { "type": "dense", "norm": "batch", "norm_params": {"momentum": 0.2}, }, }, ], "output_features": [ { "name": "binary_52912", "type": "binary", "column": "binary_52912", "proc_column": "binary_52912_mZFLky", } ], "combiner": {"type": "concat", "weights_initializer": {"type": "normal", "stddev": 0}}, } config_obj = ModelConfig.from_dict(config) assert isinstance(config_obj.combiner.weights_initializer, dict) def test_number_feature_zscore_preprocessing_default(): """Tests that the default value for the number feature preprocessing is 'zscore'.""" config = { "input_features": [ { "name": "number_input_feature1", "type": "number", }, ], "output_features": [ { "name": "number_output_feature1", "type": "number", }, ], } config_obj = ModelConfig.from_dict(config) assert config_obj.input_features.number_input_feature1.preprocessing.normalization == "zscore" @pytest.mark.parametrize( "augmentation,expected", [ (None, []), (False, []), (True, AUGMENTATION_DEFAULT_OPERATIONS), ( [{"type": "random_blur"}, {"type": "random_rotate", "degree": 30}], [RandomBlurConfig(), RandomRotateConfig(degree=30)], ), ], ) def test_augmentation_pipeline(augmentation, expected): """Tests that augmentation pipeline is correctly deserialized and serialized between config.""" config = { "input_features": [ { "name": "input1", "type": "image", "augmentation": augmentation, }, ], "output_features": [ { "name": "output1", "type": "number", }, ], } if augmentation is None: del config["input_features"][0]["augmentation"] config_obj = ModelConfig.from_dict(config) assert config_obj.input_features[0].augmentation == expected # Test serialized dict form is fully rendered config_dict = config_obj.to_dict() assert len(config_dict["input_features"][0]["augmentation"]) == len(expected) for aug in config_dict["input_features"][0]["augmentation"]: assert isinstance(aug, dict) # Test the serializing and reloading yields the same results config_obj2 = ModelConfig.from_dict(config_dict) assert config_obj2.input_features[0].augmentation == config_obj.input_features[0].augmentation @pytest.mark.parametrize( "sequence_length, max_sequence_length, max_sequence_length_expected", [ (None, 100, 100), (50, 100, 100), (100, 50, 100), ], ) def test_preprocessing_max_sequence_length(sequence_length, max_sequence_length, max_sequence_length_expected): config = { "input_features": [ { "name": "text1", "type": "text", "preprocessing": { "sequence_length": sequence_length, "max_sequence_length": max_sequence_length, }, }, { "name": "sequence1", "type": "sequence", "preprocessing": { "sequence_length": sequence_length, "max_sequence_length": max_sequence_length, }, }, ], "output_features": [ { "name": "number1", "type": "number", }, ], } config_obj = ModelConfig.from_dict(config) assert config_obj.input_features[0].preprocessing.max_sequence_length == max_sequence_length_expected assert config_obj.input_features[1].preprocessing.max_sequence_length == max_sequence_length_expected def test_encoder_decoder_values_as_str(): """Tests that encoder / decoder params provided as strings are properly converted to the correct type.""" config = { "input_features": [ {"name": "text_input", "type": "text", "encoder": "bert"}, ], "output_features": [{"name": "cat_output", "type": "category", "decoder": "classifier"}], } config_obj = ModelConfig.from_dict(config) assert isinstance(config_obj.input_features[0].encoder, BERTConfig) assert isinstance(config_obj.output_features[0].decoder, ClassifierConfig) @pytest.mark.parametrize( "base_model_config,model_name", [ ("bloomz-3b", "bigscience/bloomz-3b"), ("vicuna-7b", "lmsys/vicuna-7b-v1.3"), ("huggyllama/llama-7b", "huggyllama/llama-7b"), ], ) def test_llm_base_model_config(base_model_config, model_name): config = { MODEL_TYPE: MODEL_LLM, BASE_MODEL: base_model_config, INPUT_FEATURES: [{NAME: "text_input", TYPE: "text"}], OUTPUT_FEATURES: [{NAME: "text_output", TYPE: "text"}], } config_obj = ModelConfig.from_dict(config) assert config_obj.base_model == model_name @pytest.mark.parametrize( "base_model_config", [ None, "invalid/model/name", ], ) def test_llm_base_model_config_error(base_model_config): config = { MODEL_TYPE: MODEL_LLM, BASE_MODEL: base_model_config, INPUT_FEATURES: [{NAME: "text_input", TYPE: "text"}], OUTPUT_FEATURES: [{NAME: "text_output", TYPE: "text"}], } with pytest.raises(ConfigValidationError): ModelConfig.from_dict(config) @pytest.mark.parametrize( "bits,expected_qconfig", [ (None, None), (4, QuantizationConfig(bits=4)), (8, QuantizationConfig(bits=8)), ], ) def test_llm_quantization_config(bits: int | None, expected_qconfig: QuantizationConfig | None): config = { MODEL_TYPE: MODEL_LLM, BASE_MODEL: "bigscience/bloomz-3b", "quantization": {"bits": bits}, INPUT_FEATURES: [{NAME: "text_input", TYPE: "text"}], OUTPUT_FEATURES: [{NAME: "text_output", TYPE: "text"}], } if bits is None: del config["quantization"] config_obj = ModelConfig.from_dict(config) assert config_obj.quantization == expected_qconfig @pytest.mark.parametrize( "rope_scaling_config", [ ({"type": "linear"}), ({"factor": 2.0}), ({"type": "linear", "factor": 1.0}), ], ) def test_llm_rope_scaling_failure_modes( rope_scaling_config: None | dict[str, Any], ): config = { MODEL_TYPE: MODEL_LLM, BASE_MODEL: "HuggingFaceH4/tiny-random-LlamaForCausalLM", INPUT_FEATURES: [{NAME: "text_input", TYPE: "text"}], OUTPUT_FEATURES: [{NAME: "text_output", TYPE: "text"}], "model_parameters": { "rope_scaling": rope_scaling_config, }, } with pytest.raises(ConfigValidationError): ModelConfig.from_dict(config) def test_llm_model_parameters_no_rope_scaling(): config = { MODEL_TYPE: MODEL_LLM, BASE_MODEL: "HuggingFaceH4/tiny-random-LlamaForCausalLM", INPUT_FEATURES: [{NAME: "text_input", TYPE: "text"}], OUTPUT_FEATURES: [{NAME: "text_output", TYPE: "text"}], "model_parameters": {}, } config_obj = ModelConfig.from_dict(config) assert config_obj.model_parameters.rope_scaling is None assert config_obj.model_parameters.to_dict() == {} def test_llm_finetuning_output_feature_config(): config = { MODEL_TYPE: MODEL_LLM, BASE_MODEL: "HuggingFaceH4/tiny-random-LlamaForCausalLM", INPUT_FEATURES: [{NAME: "text_input", TYPE: "text"}], OUTPUT_FEATURES: [{NAME: "category_output", TYPE: "category"}], "trainer": { "type": "finetune", }, } with pytest.raises(ConfigValidationError): ModelConfig.from_dict(config) config[OUTPUT_FEATURES] = [{NAME: "text_output", TYPE: "text"}] ModelConfig.from_dict(config) @pytest.mark.skip( reason="TODO(geoffrey, arnav): re-enable this when we have reconciled the config with the backend kwarg in api.py" ) @pytest.mark.distributed def test_llm_quantization_backend_compatibility(): config = { MODEL_TYPE: MODEL_LLM, BASE_MODEL: "HuggingFaceH4/tiny-random-LlamaForCausalLM", INPUT_FEATURES: [{NAME: "text_input", TYPE: "text"}], OUTPUT_FEATURES: [{NAME: "text_output", TYPE: "text"}], "quantization": {"bits": 4}, } # Backend not set - defaults to local backend ModelConfig.from_dict(config) # Backend explicitly set to local backend config["backend"] = {"type": "local"} ModelConfig.from_dict(config) # Backend explicitly set to Ray backend config["backend"] = {"type": "ray"} with pytest.raises(ConfigValidationError): ModelConfig.from_dict(config) # Start local ray process import ray ray.init() # Backend not set, but local Ray process is running config.pop("backend") with pytest.raises(ConfigValidationError): ModelConfig.from_dict(config) ray.shutdown() class TestMaxNewTokensOverride: def test_max_new_tokens_override_no_changes_to_max_new_tokens(self): """Tests that the default value for max_new_tokens is respected when explicitly set in the config.""" config = { MODEL_TYPE: MODEL_LLM, BASE_MODEL: "HuggingFaceH4/tiny-random-LlamaForCausalLM", INPUT_FEATURES: [{NAME: "text_input", TYPE: "text"}], # Default value for generation.max_sequence_length is 32 OUTPUT_FEATURES: [{NAME: "text_output", TYPE: "text"}], "generation": {"max_new_tokens": 64}, } config_obj = ModelConfig.from_dict(config) assert config_obj.generation.max_new_tokens == 64 def test_max_new_tokens_override_large_max_sequence_length(self): """Tests that the default value for max_new_tokens is overridden when max_sequence_length is set to a large value than the default max_new_tokens.""" config = { MODEL_TYPE: MODEL_LLM, BASE_MODEL: "HuggingFaceH4/tiny-random-LlamaForCausalLM", INPUT_FEATURES: [{NAME: "text_input", TYPE: "text"}], # Default value for generation.max_sequence_length is 32 OUTPUT_FEATURES: [{NAME: "text_output", TYPE: "text", "preprocessing": {"max_sequence_length": 100}}], } config_obj = ModelConfig.from_dict(config) assert config_obj.generation.max_new_tokens == 100 def test_max_new_tokens_override_large_global_max_sequence_length(self): """Tests that the default value for max_new_tokens is overridden when global_max_sequence_length is set to a larger value than the default max_new_tokens.""" config = { MODEL_TYPE: MODEL_LLM, BASE_MODEL: "HuggingFaceH4/tiny-random-LlamaForCausalLM", INPUT_FEATURES: [{NAME: "text_input", TYPE: "text"}], # Default value for generation.max_sequence_length is 32 OUTPUT_FEATURES: [{NAME: "text_output", TYPE: "text"}], PREPROCESSING: {"global_max_sequence_length": 100}, } config_obj = ModelConfig.from_dict(config) assert config_obj.generation.max_new_tokens == 100 def test_max_new_tokens_override_fallback_to_model_window_size(self): config = { MODEL_TYPE: MODEL_LLM, BASE_MODEL: "HuggingFaceH4/tiny-random-LlamaForCausalLM", INPUT_FEATURES: [{NAME: "text_input", TYPE: "text"}], # Default value for generation.max_sequence_length is 32 OUTPUT_FEATURES: [{NAME: "text_output", TYPE: "text"}], } config_obj = ModelConfig.from_dict(config) # Base model context length is 2048 tokens by default # Since we fallback to setting max_new_tokens to the model context length / 2, we expect it to be 1024 tokens assert config_obj.generation.max_new_tokens == 1024 ================================================ FILE: tests/ludwig/schema/test_schema_utils.py ================================================ from ludwig.constants import TYPE from ludwig.schema import utils as schema_utils def test_remove_duplicate_fields(): props = {TYPE: "random", "probabilities": [0.7, 0.1, 0.2]} schema_utils.remove_duplicate_fields(props, [TYPE]) assert TYPE not in props assert "probabilities" in props ================================================ FILE: tests/ludwig/schema_fields/test_fields_misc.py ================================================ import pytest from pydantic import ValidationError as PydanticValidationError from ludwig.config_validation.validation import get_validator, validate from ludwig.schema import utils as schema_utils from ludwig.schema.utils import ludwig_dataclass def get_marshmallow_field_from_metadata(dfield): """Helper method for extracting the marshmallow field from pydantic field metadata.""" metadata = dfield.metadata if isinstance(metadata, dict): return metadata.get("marshmallow_field") if isinstance(metadata, (list, tuple)): for item in metadata: if hasattr(item, "_deserialize"): return item return None # Simple marshmallow fields: def test_StringOptions(): # Test case of default conflicting with allowed options: test_options = ["one"] with pytest.raises(AssertionError): schema_utils.StringOptions(test_options, default=None, allow_none=False) # Test creating a schema with simple option, null not allowed: test_options = ["one"] @ludwig_dataclass class CustomTestSchema(schema_utils.BaseMarshmallowConfig): foo: str = schema_utils.StringOptions(test_options, "one", allow_none=False) with pytest.raises(PydanticValidationError): CustomTestSchema.Schema().load({"foo": None}) # Complex, custom marshmallow fields: def test_Embed(): # Test simple schema creation: @ludwig_dataclass class CustomTestSchema(schema_utils.BaseMarshmallowConfig): foo: str | int | None = schema_utils.Embed() # Test null/empty loading cases: assert CustomTestSchema.Schema().load({}).foo is None assert CustomTestSchema.Schema().load({"foo": None}).foo is None # Test valid strings/numbers: assert CustomTestSchema.Schema().load({"foo": "add"}).foo == "add" assert CustomTestSchema.Schema().load({"foo": 1}).foo == 1 def test_InitializerOrDict(): # Test default value validation: with pytest.raises(Exception): schema_utils.InitializerOrDict("test") # Test simple schema creation: @ludwig_dataclass class CustomTestSchema(schema_utils.BaseMarshmallowConfig): foo: str | dict | None = schema_utils.InitializerOrDict() # Test valid loads: assert CustomTestSchema.Schema().load({}).foo == "xavier_uniform" assert CustomTestSchema.Schema().load({"foo": "zeros"}).foo == "zeros" # Test valid dict loads: assert CustomTestSchema.Schema().load({"foo": {"type": "zeros"}}).foo == {"type": "zeros"} def test_FloatRangeTupleDataclassField(): # Test dimensional mismatch: with pytest.raises(Exception): schema_utils.FloatRangeTupleDataclassField(n=3, default=(1, 1)) # Test default schema creation: @ludwig_dataclass class CustomTestSchema(schema_utils.BaseMarshmallowConfig): foo: tuple[float, float] | None = schema_utils.FloatRangeTupleDataclassField(allow_none=True) # Test empty load: assert CustomTestSchema.Schema().load({}).foo == (0.9, 0.999) assert CustomTestSchema.Schema().load({"foo": None}).foo is None # Test valid loads: assert CustomTestSchema.Schema().load({"foo": [0.5, 0.6]}).foo == (0.5, 0.6) # Test non-default schema (N=3, other custom metadata): @ludwig_dataclass class CustomTestSchema2(schema_utils.BaseMarshmallowConfig): foo: tuple[float, float, float] | None = schema_utils.FloatRangeTupleDataclassField( n=3, default=(1, 1, 1), min=-10, max=10 ) assert CustomTestSchema2.Schema().load({}).foo == (1, 1, 1) assert CustomTestSchema2.Schema().load({"foo": [2, 2, 2]}).foo == (2, 2, 2) def test_OneOfOptionsField(): @ludwig_dataclass class CustomTestSchema(schema_utils.BaseMarshmallowConfig): foo: float | str = schema_utils.OneOfOptionsField( default=0.1, description="", allow_none=False, field_options=[ schema_utils.FloatRange(default=0.001, min=0, max=1, allow_none=False), schema_utils.StringOptions(options=["placeholder"], default="placeholder", allow_none=False), ], ) # Test valid loads: assert CustomTestSchema.Schema().load({}).foo == 0.1 assert CustomTestSchema().foo == 0.1 # Reverse the order and allow none (via StringOptions): @ludwig_dataclass class CustomTestSchema2(schema_utils.BaseMarshmallowConfig): foo: float | str | None = schema_utils.OneOfOptionsField( default="placeholder", description="", field_options=[ schema_utils.FloatRange(default=0.001, min=0, max=1, allow_none=False), schema_utils.StringOptions(options=["placeholder"], default="placeholder", allow_none=False), ], allow_none=True, ) # Test valid loads: assert CustomTestSchema2.Schema().load({}).foo == "placeholder" assert CustomTestSchema2.Schema().load({"foo": 0.1}).foo == 0.1 assert CustomTestSchema2().foo == "placeholder" CustomTestSchema2.Schema().load({"foo": None}) # Test JSON schema generation: json = schema_utils.unload_jsonschema_from_marshmallow_class(CustomTestSchema2) assert "foo" in json["properties"] def test_OneOfOptionsField_allows_none(): @ludwig_dataclass class CustomTestSchema(schema_utils.BaseMarshmallowConfig): foo: float | str | None = schema_utils.OneOfOptionsField( default=None, allow_none=True, description="", field_options=[ schema_utils.PositiveInteger(description="", default=1, allow_none=False), schema_utils.List(list_type=int, allow_none=False), ], ) json = schema_utils.unload_jsonschema_from_marshmallow_class(CustomTestSchema) schema = { "type": "object", "properties": { "hello": json, }, "definitions": {}, } validate(instance={"hello": {"foo": None}}, schema=schema, cls=get_validator()) def test_OneOfOptionsField_multiple_fields_allow_none(): # With pydantic, multiple fields allowing none is handled by union validation. @ludwig_dataclass class CustomTestSchema(schema_utils.BaseMarshmallowConfig): foo: float | str | None = schema_utils.OneOfOptionsField( default=None, description="", field_options=[ schema_utils.PositiveInteger(description="", default=1, allow_none=True), schema_utils.List(list_type=int, allow_none=True), ], ) assert CustomTestSchema().foo is None def test_OneOfOptionsField_allows_none_one_field_allows_none(): @ludwig_dataclass class CustomTestSchema(schema_utils.BaseMarshmallowConfig): foo: float | str | None = schema_utils.OneOfOptionsField( default=None, description="", field_options=[ schema_utils.PositiveInteger(description="", default=1, allow_none=False), schema_utils.List(list_type=int, allow_none=True), ], ) json = schema_utils.unload_jsonschema_from_marshmallow_class(CustomTestSchema) schema = { "type": "object", "properties": { "hello": json, }, "definitions": {}, } validate(instance={"hello": {"foo": None}}, schema=schema, cls=get_validator()) ================================================ FILE: tests/ludwig/schema_fields/test_fields_optimization.py ================================================ #! /usr/bin/env python import pytest from pydantic import ValidationError as PydanticValidationError import ludwig.schema.optimizers as lso from ludwig.schema import utils as schema_utils from ludwig.schema.utils import ludwig_dataclass def test_torch_description_pull(): example_empty_desc_prop = schema_utils.unload_jsonschema_from_marshmallow_class(lso.AdamOptimizerConfig)[ "properties" ]["eps"] assert ( isinstance(example_empty_desc_prop, dict) and "description" in example_empty_desc_prop and isinstance(example_empty_desc_prop["description"], str) and len(example_empty_desc_prop["description"]) > 3 ) def test_OptimizerDataclassField(): # Test default case: default_optimizer_field = lso.OptimizerDataclassField() assert default_optimizer_field.default_factory is not None assert default_optimizer_field.default_factory() == lso.AdamOptimizerConfig() # Test normal cases: optimizer_field = lso.OptimizerDataclassField("adamax") assert optimizer_field.default_factory is not None assert optimizer_field.default_factory() == lso.AdamaxOptimizerConfig() # Test invalid default case: with pytest.raises(AttributeError): lso.OptimizerDataclassField({}) with pytest.raises(KeyError): lso.OptimizerDataclassField("test") with pytest.raises(AttributeError): lso.OptimizerDataclassField(1) # Test creating a schema with default options: @ludwig_dataclass class CustomTestSchema(schema_utils.BaseMarshmallowConfig): foo: lso.BaseOptimizerConfig | None = lso.OptimizerDataclassField() with pytest.raises((PydanticValidationError, Exception)): CustomTestSchema.Schema().load({"foo": "test"}) assert CustomTestSchema.Schema().load({}).foo == lso.AdamOptimizerConfig() # Test creating a schema with set default: @ludwig_dataclass class CustomTestSchema2(schema_utils.BaseMarshmallowConfig): foo: lso.BaseOptimizerConfig | None = lso.OptimizerDataclassField("adamax") with pytest.raises((PydanticValidationError, Exception)): CustomTestSchema2.Schema().load({"foo": "test"}) assert CustomTestSchema2.Schema().load( {"foo": {"type": "adamax", "betas": (0.2, 0.2)}} ).foo == lso.AdamaxOptimizerConfig(betas=(0.2, 0.2)) def test_ClipperDataclassField(): # Test default case: default_clipper_field = lso.GradientClippingDataclassField(description="", default={}) assert default_clipper_field.default_factory is not None assert default_clipper_field.default_factory() == lso.GradientClippingConfig() # Test normal cases: clipper_field = lso.GradientClippingDataclassField(description="", default={"clipglobalnorm": 0.1}) assert clipper_field.default_factory is not None assert clipper_field.default_factory() == lso.GradientClippingConfig(clipglobalnorm=0.1) clipper_field = lso.GradientClippingDataclassField(description="", default={"clipglobalnorm": None}) assert clipper_field.default_factory is not None assert clipper_field.default_factory() == lso.GradientClippingConfig(clipglobalnorm=None) # Test invalid default case: with pytest.raises(Exception): lso.GradientClippingDataclassField(description="", default="test") with pytest.raises(Exception): lso.GradientClippingDataclassField(description="", default=None) with pytest.raises(Exception): lso.GradientClippingDataclassField(description="", default=1) # Test creating a schema with set default: @ludwig_dataclass class CustomTestSchema(schema_utils.BaseMarshmallowConfig): foo: lso.GradientClippingConfig | None = lso.GradientClippingDataclassField( description="", default={"clipglobalnorm": 0.1} ) with pytest.raises((PydanticValidationError, Exception)): CustomTestSchema.Schema().load({"foo": "test"}) assert CustomTestSchema.Schema().load({}).foo == lso.GradientClippingConfig(clipglobalnorm=0.1) assert CustomTestSchema.Schema().load({"foo": {"clipglobalnorm": 1}}).foo == lso.GradientClippingConfig( clipglobalnorm=1 ) ================================================ FILE: tests/ludwig/schema_fields/test_fields_preprocessing.py ================================================ #! /usr/bin/env python from ludwig.schema.features.preprocessing.binary import BinaryPreprocessingConfig from ludwig.schema.features.preprocessing.category import CategoryPreprocessingConfig from ludwig.schema.features.preprocessing.utils import PreprocessingDataclassField def get_marshmallow_from_dataclass_field(dfield): """Helper method for checking marshmallow metadata succinctly.""" return dfield.metadata["marshmallow_field"] def test_preprocessing_dataclass_field(): binary_preproc_dataclass = PreprocessingDataclassField("binary") assert binary_preproc_dataclass.default_factory is not None assert get_marshmallow_from_dataclass_field(binary_preproc_dataclass).allow_none is False assert binary_preproc_dataclass.default_factory() == BinaryPreprocessingConfig() category_preproc_dataclass = PreprocessingDataclassField("category") assert category_preproc_dataclass.default_factory is not None assert get_marshmallow_from_dataclass_field(category_preproc_dataclass).allow_none is False assert category_preproc_dataclass.default_factory() == CategoryPreprocessingConfig() ================================================ FILE: tests/ludwig/schema_fields/test_marshmallow_misc.py ================================================ import pytest import ludwig.combiners.combiners as lcc from ludwig.schema.trainer import ECDTrainerConfig from ludwig.schema.utils import ( assert_is_a_marshmallow_class, BaseMarshmallowConfig, load_config_with_kwargs, ludwig_dataclass, ) @ludwig_dataclass class CustomTestSchema(BaseMarshmallowConfig): """Sample docstring.""" foo: int = 5 "foo (default: 5)" def test_assert_is_a_marshmallow_clas(): assert_is_a_marshmallow_class(ECDTrainerConfig) with pytest.raises(AssertionError, match=r"Expected.*config class"): assert_is_a_marshmallow_class(lcc.ConcatCombiner) def test_load_config_with_kwargs(): test_kwargs = { "foo": 6, "bar": 6, } initialized_class, leftover = load_config_with_kwargs(CustomTestSchema, test_kwargs) assert initialized_class.foo == 6 assert leftover == {"bar": 6} # TransformerCombiner has no required/non-default arguments: initialized_class, leftover = load_config_with_kwargs(lcc.TransformerCombinerConfig, test_kwargs) assert initialized_class.bias_initializer == "zeros" assert leftover == test_kwargs initialized_class, leftover = load_config_with_kwargs(lcc.TransformerCombinerConfig, {}) assert leftover == {} ================================================ FILE: tests/ludwig/utils/__init__.py ================================================ # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== ================================================ FILE: tests/ludwig/utils/automl/test_type_inference.py ================================================ import random import pytest from ludwig.constants import AUDIO, BINARY, CATEGORY, DATE, IMAGE, NUMBER, TEXT from ludwig.data.dataset_synthesizer import generate_string from ludwig.utils.automl.field_info import FieldInfo from ludwig.utils.automl.type_inference import infer_type, should_exclude ROW_COUNT = 100 TARGET_NAME = "target" @pytest.mark.parametrize( "num_distinct_values,distinct_values,img_values,audio_values,avg_words,missing_vals,expected", [ # Random numbers. (ROW_COUNT, [str(random.random()) for _ in range(ROW_COUNT)], 0, 0, None, 0.0, NUMBER), # Random numbers with NaNs. (ROW_COUNT, [str(random.random()) for _ in range(ROW_COUNT - 1)] + ["NaN"], 0, 0, None, 0.0, NUMBER), # Finite list of numbers. (10, ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"], 0, 0, None, 0.0, CATEGORY), (2, ["1.5", "3.7"], 0, 0, None, 0.1, NUMBER), (2, ["1.5", "3.7", "nan"], 0, 0, None, 0.1, NUMBER), # Bool-like values. (2, ["0", "1"], 0, 0, None, 0.0, BINARY), # Mostly bool-like values. (3, ["0", "1", "True"], 0, 0, None, 0.0, CATEGORY), # Non-conventional booleans are treated as categories since we cannot infer true/false labels. pytest.param(2, ["<=50K", ">50K"], 0, 0, None, 0.0, CATEGORY, id="non-conventional-bools"), # Finite list of strings. (2, ["human", "bot"], 0, 0, None, 0.0, CATEGORY), (10, [generate_string(5) for _ in range(10)], 0, 0, None, 0.0, CATEGORY), (40, [generate_string(5) for _ in range(40)], 0, 0, None, 0.0, CATEGORY), # Mostly random strings. (90, [generate_string(5) for _ in range(90)], 0, 0, None, 0.0, TEXT), # Mostly random strings with capped distinct values. (90, [generate_string(5) for _ in range(10)], 0, 0, None, 0.0, TEXT), # All random strings. (ROW_COUNT, [generate_string(5) for _ in range(ROW_COUNT)], 0, 0, None, 0.0, TEXT), # Images. (ROW_COUNT, [], ROW_COUNT, 0, None, 0.0, IMAGE), # Audio. (ROW_COUNT, [], 0, ROW_COUNT, None, 0.0, AUDIO), # Text with low distinct value percent / high missing value percent (ROW_COUNT // 4, [generate_string(5) for _ in range(ROW_COUNT)], 0, 0, 5, 0.75, TEXT), (ROW_COUNT // 4, [generate_string(5) for _ in range(ROW_COUNT)], 0, 0, 5, 0.25, CATEGORY), ], ) def test_infer_type(num_distinct_values, distinct_values, img_values, audio_values, avg_words, missing_vals, expected): field = FieldInfo( name="foo", dtype="object", num_distinct_values=num_distinct_values, distinct_values=distinct_values, image_values=img_values, audio_values=audio_values, avg_words=avg_words, ) assert infer_type(field, missing_vals, ROW_COUNT) == expected def test_infer_type_explicit_date(): field = FieldInfo( name="foo", distinct_values=["1", "2"], num_distinct_values=2, dtype=DATE, ) assert infer_type(field, 0, ROW_COUNT) == DATE @pytest.mark.parametrize( "idx,num_distinct_values,dtype,name,expected", [ (3, ROW_COUNT, NUMBER, "id", True), (0, ROW_COUNT, NUMBER, "index", True), (1, ROW_COUNT, NUMBER, "index", False), (0, ROW_COUNT, NUMBER, "foo", False), (3, ROW_COUNT, TEXT, "uuid", True), (0, ROW_COUNT, TEXT, "name", False), (0, ROW_COUNT, NUMBER, TARGET_NAME, False), (0, ROW_COUNT - 1, NUMBER, "id", False), (0, 0, CATEGORY, "empty_col", True), ], ) def test_should_exclude(idx, num_distinct_values, dtype, name, expected): column_count = 10 field = FieldInfo(name=name, dtype=dtype, num_distinct_values=num_distinct_values, avg_words=10) assert should_exclude(idx, field, dtype, column_count, ROW_COUNT, {TARGET_NAME}) == expected def test_auto_type_inference_single_value_binary_feature(): field = FieldInfo( name="foo", dtype="object", num_distinct_values=1, distinct_values=["1" for i in range(ROW_COUNT)] ) assert infer_type(field=field, missing_value_percent=0, row_count=ROW_COUNT) == CATEGORY assert should_exclude( idx=3, field=field, dtype="object", column_count=10, row_count=ROW_COUNT, targets={TARGET_NAME} ) @pytest.mark.parametrize( "column_count,avg_words,expected", [ (1, 10, False), (1, 2, False), (5, 2, True), (5, 10, False), ], ) def test_should_exclude_text(column_count, avg_words, expected): field = FieldInfo(name="sentence", dtype=TEXT, avg_words=avg_words, num_distinct_values=ROW_COUNT) assert should_exclude(0, field, TEXT, column_count, ROW_COUNT, {TARGET_NAME}) == expected @pytest.mark.parametrize("negative_class", ("-1", "-1.0"), ids=["-1", "-1.0"]) def test_type_inference_with_negative_positive_binary_values(negative_class): """This test ensures that we infer binary type for a feature with negative and positive values, specifically -1 and 1.""" field = FieldInfo( name="foo", dtype="object", num_distinct_values=2, distinct_values=["1", negative_class], ) assert infer_type(field=field, missing_value_percent=0, row_count=ROW_COUNT) == BINARY ================================================ FILE: tests/ludwig/utils/automl/test_utils.py ================================================ import pandas as pd import pytest from ludwig.utils.automl.utils import avg_num_tokens @pytest.mark.parametrize( "field,expected", [ (pd.Series([None]), 0), (pd.Series(["string1", "string2", "string3"]), 1), (pd.Series([b"string1", b"string2", b"string3"]), 1), (pd.Series([b"string1 string1", b"string2 string2", b"string3 string3"]), 2), (pd.Series([1, 2, 3]), 1), ], ) def test_avg_num_tokens(field, expected): assert avg_num_tokens(field) == expected ================================================ FILE: tests/ludwig/utils/entmax/test_losses.py ================================================ from functools import partial import pytest import torch from torch.autograd import gradcheck from ludwig.constants import IGNORE_INDEX_TOKEN_ID from ludwig.utils.entmax.losses import Entmax15Loss, EntmaxBisectLoss, SparsemaxBisectLoss, SparsemaxLoss # make data Xs = [torch.randn(4, 10, dtype=torch.float64, requires_grad=True) for _ in range(5)] ys = [torch.max(torch.randn_like(X), dim=1)[1] for X in Xs] losses = [ SparsemaxLoss, partial(SparsemaxLoss, k=5), Entmax15Loss, partial(Entmax15Loss, k=5), SparsemaxBisectLoss, EntmaxBisectLoss, ] @pytest.mark.parametrize("Loss", losses) def test_non_neg(Loss): for X, y in zip(Xs, ys): ls = Loss(reduction="none") lval = ls(X, y) assert torch.all(lval >= 0) @pytest.mark.parametrize("Loss", losses) @pytest.mark.parametrize("ignore_index", (False, True)) @pytest.mark.parametrize("reduction", ("sum", "elementwise_mean")) def test_loss(Loss, ignore_index, reduction): for X, y in zip(Xs, ys): iix = y[0] if ignore_index else -100 ls = Loss(ignore_index=iix, reduction=reduction) gradcheck(ls, (X, y), eps=1e-5) @pytest.mark.parametrize("Loss", losses) def test_index_ignored(Loss): x = torch.randn(20, 6, dtype=torch.float64, requires_grad=True) _, y = torch.max(torch.randn_like(x), dim=1) loss_ignore = Loss(reduction="sum", ignore_index=y[0]) loss_noignore = Loss(reduction="sum", ignore_index=IGNORE_INDEX_TOKEN_ID) # Note: since these are sparse losses, it is possible that an element makes no contribution to the loss. assert loss_ignore(x, y) <= loss_noignore(x, y) ================================================ FILE: tests/ludwig/utils/entmax/test_mask.py ================================================ import pytest import torch from ludwig.utils.entmax.activations import Entmax15, Sparsemax from ludwig.utils.entmax.root_finding import entmax_bisect, sparsemax_bisect funcs = [ Sparsemax(dim=1), Entmax15(dim=1), Sparsemax(dim=1, k=512), Entmax15(dim=1, k=512), sparsemax_bisect, entmax_bisect, ] @pytest.mark.parametrize("func", funcs) @pytest.mark.parametrize("dtype", (torch.float32, torch.float64)) def test_mask(func, dtype): torch.manual_seed(42) x = torch.randn(2, 6, dtype=dtype) x[:, 3:] = -float("inf") x0 = x[:, :3] y = func(x) y0 = func(x0) y[:, :3] -= y0 assert torch.allclose(y, torch.zeros_like(y)) @pytest.mark.parametrize("alpha", (1.25, 1.5, 1.75, 2.25)) def test_mask_alphas(alpha): torch.manual_seed(42) x = torch.randn(2, 6) x[:, 3:] = -float("inf") x0 = x[:, :3] y = entmax_bisect(x, alpha) y0 = entmax_bisect(x0, alpha) y[:, :3] -= y0 assert torch.allclose(y, torch.zeros_like(y)) ================================================ FILE: tests/ludwig/utils/entmax/test_root_finding.py ================================================ from functools import partial from itertools import product import pytest import torch from torch.autograd import gradcheck from ludwig.utils.entmax.activations import entmax15, sparsemax from ludwig.utils.entmax.root_finding import entmax_bisect, sparsemax_bisect # @pytest.mark.parametrize("dim", (0, 1, 2)) # def test_dim(dim, Map): # for _ in range(10): # x = torch.randn(5, 6, 7, requires_grad=True, dtype=torch.float64) # # gradcheck(f, (x,)) @pytest.mark.parametrize("training", [True, False]) @pytest.mark.parametrize("bisect_training", [True, False]) def test_sparsemax(training, bisect_training): x = 0.5 * torch.randn(4, 6, dtype=torch.float32) p1 = sparsemax(x, 1, training=training) p2 = sparsemax_bisect(x, training=bisect_training) assert torch.sum((p1 - p2) ** 2) < 1e-7 @pytest.mark.parametrize("training", [True, False]) @pytest.mark.parametrize("bisect_training", [True, False]) def test_entmax15(training, bisect_training): x = 0.5 * torch.randn(4, 6, dtype=torch.float32) p1 = entmax15(x, 1, training=training) p2 = entmax_bisect(x, alpha=1.5, training=bisect_training) assert torch.sum((p1 - p2) ** 2) < 1e-7 def test_sparsemax_grad(): x = torch.randn(4, 6, dtype=torch.float64, requires_grad=True) gradcheck(sparsemax_bisect, (x,), eps=1e-5) @pytest.mark.parametrize("alpha", (0.2, 0.5, 0.75, 1.2, 1.5, 1.75, 2.25)) def test_entmax_grad(alpha): alpha = torch.tensor(alpha, dtype=torch.float64, requires_grad=True) x = torch.randn(4, 6, dtype=torch.float64, requires_grad=True) gradcheck(entmax_bisect, (x, alpha), eps=1e-5) def test_entmax_correct_multiple_alphas(): n = 4 x = torch.randn(n, 6, dtype=torch.float64, requires_grad=True) alpha = 0.05 + 2.5 * torch.rand((n, 1), dtype=torch.float64, requires_grad=True) p1 = entmax_bisect(x, alpha) p2_ = [entmax_bisect(x[i].unsqueeze(0), alpha[i].item()).squeeze() for i in range(n)] p2 = torch.stack(p2_) assert torch.allclose(p1, p2) def test_entmax_grad_multiple_alphas(): n = 4 x = torch.randn(n, 6, dtype=torch.float64, requires_grad=True) alpha = 0.05 + 2.5 * torch.rand((n, 1), dtype=torch.float64, requires_grad=True) gradcheck(entmax_bisect, (x, alpha), eps=1e-5) @pytest.mark.parametrize("dim", (0, 1, 2, 3)) def test_arbitrary_dimension(dim): shape = [3, 4, 2, 5] X = torch.randn(*shape, dtype=torch.float64) alpha_shape = shape alpha_shape[dim] = 1 alphas = 0.05 + 2.5 * torch.rand(alpha_shape, dtype=torch.float64) P = entmax_bisect(X, alpha=alphas, dim=dim) ranges = [list(range(k)) if i != dim else [slice(None)] for i, k in enumerate(shape)] for ix in product(*ranges): x = X[ix].unsqueeze(0) alpha = alphas[ix].item() p_true = entmax_bisect(x, alpha=alpha, dim=-1) assert torch.allclose(P[ix], p_true) @pytest.mark.parametrize("dim", (0, 1, 2, 3)) def test_arbitrary_dimension_grad(dim): shape = [3, 4, 2, 5] alpha_shape = shape alpha_shape[dim] = 1 f = partial(entmax_bisect, dim=dim) X = torch.randn(*shape, dtype=torch.float64, requires_grad=True) alphas = 0.05 + 2.5 * torch.rand(alpha_shape, dtype=torch.float64, requires_grad=True) gradcheck(f, (X, alphas), eps=1e-5) ================================================ FILE: tests/ludwig/utils/entmax/test_topk.py ================================================ import pytest import torch from torch.autograd import gradcheck from ludwig.utils.entmax.activations import ( _entmax_threshold_and_support, _sparsemax_threshold_and_support, Entmax15, Sparsemax, ) @pytest.mark.parametrize("dim", (0, 1, 2)) @pytest.mark.parametrize("Map", (Sparsemax, Entmax15)) def test_mapping(dim, Map): f = Map(dim=dim, k=3) x = torch.randn(3, 4, 5, requires_grad=True, dtype=torch.float64) gradcheck(f, (x,)) @pytest.mark.parametrize("dim", (0, 1, 2)) @pytest.mark.parametrize("coef", (0.00001, 0.5, 10000)) def test_entmax_topk(dim, coef): x = coef * torch.randn(3, 4, 5) tau1, supp1 = _entmax_threshold_and_support(x, dim=dim, k=None) tau2, supp2 = _entmax_threshold_and_support(x, dim=dim, k=5) assert torch.all(tau1 == tau2) assert torch.all(supp1 == supp2) @pytest.mark.parametrize("dim", (0, 1, 2)) @pytest.mark.parametrize("coef", (0.00001, 0.5, 10000)) @pytest.mark.parametrize("k", (5, 30)) def test_sparsemax_topk(dim, coef, k): x = coef * torch.randn(3, 4, 5) tau1, supp1 = _sparsemax_threshold_and_support(x, dim=dim, k=None) tau2, supp2 = _sparsemax_threshold_and_support(x, dim=dim, k=k) assert torch.all(tau1 == tau2) assert torch.all(supp1 == supp2) ================================================ FILE: tests/ludwig/utils/test_algorithm_utils.py ================================================ import pytest from ludwig.utils.algorithms_utils import topological_sort @pytest.mark.parametrize( "unsorted,sorted", [ ( [(2, []), (5, [11]), (11, [2, 9, 10]), (7, [11, 8]), (9, []), (10, []), (8, [9]), (3, [10, 8])], [(2, []), (9, []), (10, []), (8, [9]), (3, [10, 8]), (11, [2, 9, 10]), (7, [11, 8]), (5, [11])], ), ( [("macro", ["action", "contact_type"]), ("contact_type", None), ("action", ["contact_type"])], [("contact_type", []), ("action", ["contact_type"]), ("macro", ["action", "contact_type"])], ), ], ) def test_topological_sort(unsorted: list, sorted: list) -> None: assert topological_sort(unsorted) == sorted ================================================ FILE: tests/ludwig/utils/test_audio_utils.py ================================================ import pytest from ludwig.utils.audio_utils import is_audio_score @pytest.mark.parametrize( "path, score", [ ("data.wav", 1), ("/home/peter/file.amb", 1), ("my.mp3", 1), ("data.ogg", 1), ("data.vorbis", 1), ("data.flac", 1), ("data.opus", 1), ("data.sphere", 1), ("video.mp4", 0), ("image.png", 0), (".wav/image.png", 0), ], ) def test_is_audio_score(path: str, score: int): assert is_audio_score(path) == score ================================================ FILE: tests/ludwig/utils/test_backward_compatibility.py ================================================ import copy import math from typing import Any import pytest from ludwig.constants import ( BATCH_SIZE, BFILL, CLASS_WEIGHTS, DEFAULTS, EVAL_BATCH_SIZE, EXECUTOR, HYPEROPT, INPUT_FEATURES, LEARNING_RATE_SCHEDULER, LOSS, NUMBER, OUTPUT_FEATURES, PREPROCESSING, SCHEDULER, SPLIT, TRAINER, TYPE, ) from ludwig.schema.model_config import ModelConfig from ludwig.schema.trainer import ECDTrainerConfig from ludwig.utils.backward_compatibility import ( _update_backend_cache_credentials, _upgrade_encoder_decoder_params, _upgrade_feature, _upgrade_preprocessing_split, upgrade_config_dict_to_latest_version, upgrade_missing_value_strategy, upgrade_model_progress, ) from ludwig.utils.trainer_utils import TrainerMetric def test_preprocessing_backward_compatibility(): # From v0.5.3. preprocessing_config = { "force_split": False, "split_probabilities": [0.7, 0.1, 0.2], "stratify": None, } _upgrade_preprocessing_split(preprocessing_config) assert preprocessing_config == { "split": {"probabilities": [0.7, 0.1, 0.2], "type": "random"}, } def test_audio_feature_backward_compatibility(): # From v0.5.3. audio_feature_preprocessing_config = { "name": "audio_feature", "type": "audio", "preprocessing": { "audio_file_length_limit_in_s": 7.5, "missing_value_strategy": BFILL, "in_memory": True, "padding_value": 0, "norm": None, "audio_feature": { "type": "fbank", "window_length_in_s": 0.04, "window_shift_in_s": 0.02, "num_fft_points": None, "window_type": "hamming", "num_filter_bands": 80, }, }, } global_preprocessing_config = { "audio": { "audio_file_length_limit_in_s": 7.5, "missing_value_strategy": BFILL, "in_memory": True, "padding_value": 0, "norm": None, "audio_feature": { "type": "fbank", "window_length_in_s": 0.04, "window_shift_in_s": 0.02, "num_fft_points": None, "window_type": "hamming", "num_filter_bands": 80, }, }, } _upgrade_feature(audio_feature_preprocessing_config) _upgrade_preprocessing_split(global_preprocessing_config) assert global_preprocessing_config == { "audio": { "audio_file_length_limit_in_s": 7.5, "missing_value_strategy": BFILL, "in_memory": True, "padding_value": 0, "norm": None, "type": "fbank", "window_length_in_s": 0.04, "window_shift_in_s": 0.02, "num_fft_points": None, "window_type": "hamming", "num_filter_bands": 80, } } assert audio_feature_preprocessing_config == { "name": "audio_feature", "type": "audio", "preprocessing": { "audio_file_length_limit_in_s": 7.5, "missing_value_strategy": BFILL, "in_memory": True, "padding_value": 0, "norm": None, "type": "fbank", "window_length_in_s": 0.04, "window_shift_in_s": 0.02, "num_fft_points": None, "window_type": "hamming", "num_filter_bands": 80, }, } def test_encoder_decoder_backwards_compatibility(): old_config = { "input_features": [ { "name": "text_feature", "type": "text", "preprocessing": { "missing_value_strategy": "drop_row", }, "encoder": "rnn", "bidirectional": True, "representation": "dense", "num_layers": 2, }, { "name": "image_feature_1", "type": "image", "preprocessing": { "height": 7.5, "width": 7.5, "num_channels": 4, }, "encoder": "resnet", "num_channels": 4, "dropout": 0.1, "resnet_size": 100, }, { "name": "image_feature_2", "type": "image", "tied": "image_feature_1", "preprocessing": { "height": 7.5, "width": 7.5, "num_channels": 4, }, "encoder": "resnet", }, ], "output_features": [ { "name": "category_feature", "type": "category", "top_k": 3, "preprocessing": { "missing_value_strategy": BFILL, }, "decoder": "classifier", "num_classes": 10, "use_bias": False, }, { "name": "binary_feature", "type": "binary", "dependencies": ["category_feature"], "loss": { "type": "cross_entropy", }, "reduce_dependencies": "mean", "decoder": "regressor", "use_bias": True, "bias_initializer": "constant", }, { "name": "vector_feature", "type": "vector", "decoder": "projector", "num_fc_layers": 5, "output_size": 128, "activation": "tanh", "dropout": 0.1, }, ], } for feature in old_config[INPUT_FEATURES]: _upgrade_encoder_decoder_params(feature, True) for feature in old_config[OUTPUT_FEATURES]: _upgrade_encoder_decoder_params(feature, False) assert old_config == { "input_features": [ { "name": "text_feature", "type": "text", "preprocessing": { "missing_value_strategy": "drop_row", }, "encoder": { "type": "rnn", "bidirectional": True, "representation": "dense", "num_layers": 2, }, }, { "name": "image_feature_1", "type": "image", "preprocessing": { "height": 7.5, "width": 7.5, "num_channels": 4, }, "encoder": { "type": "resnet", "num_channels": 4, "dropout": 0.1, "resnet_size": 100, }, }, { "name": "image_feature_2", "type": "image", "tied": "image_feature_1", "preprocessing": { "height": 7.5, "width": 7.5, "num_channels": 4, }, "encoder": {"type": "resnet"}, }, ], "output_features": [ { "name": "category_feature", "type": "category", "num_classes": 10, "top_k": 3, "preprocessing": { "missing_value_strategy": BFILL, }, "decoder": { "type": "classifier", "fc_use_bias": False, "use_bias": False, }, }, { "name": "binary_feature", "type": "binary", "dependencies": ["category_feature"], "loss": { "type": "cross_entropy", }, "reduce_dependencies": "mean", "decoder": { "type": "regressor", "fc_use_bias": True, "fc_bias_initializer": "constant", "bias_initializer": "constant", "use_bias": True, }, }, { "name": "vector_feature", "type": "vector", "decoder": { "type": "projector", "num_fc_layers": 5, "fc_output_size": 128, "fc_activation": "tanh", "fc_dropout": 0.1, "output_size": 128, "activation": "tanh", "dropout": 0.1, }, }, ], } def test_deprecated_field_aliases(): config = { "ludwig_version": "0.4", INPUT_FEATURES: [{"name": "num_in", "type": "numerical"}], OUTPUT_FEATURES: [{"name": "num_out", "type": "numerical"}], HYPEROPT: { "parameters": { "training.learning_rate": { "space": "loguniform", "lower": 0.001, "upper": 0.1, }, }, "goal": "minimize", "sampler": {"type": "grid", "num_samples": 2, "scheduler": {"type": "fifo"}}, "executor": { "type": "grid", "search_alg": "bohb", }, }, PREPROCESSING: { "numerical": { "fill_value": 2, "missing_value_strategy": "fill_with_const", }, }, "training": { "epochs": 2, "eval_batch_size": 0, "reduce_learning_rate_on_plateau": 2, "reduce_learning_rate_on_plateau_patience": 5, "decay": True, "learning_rate_warmup_epochs": 2, }, } updated_config = upgrade_config_dict_to_latest_version(config) assert updated_config["input_features"][0][TYPE] == NUMBER assert updated_config["output_features"][0][TYPE] == NUMBER # "numerical" preprocssing directive should be translated to "number" and moved into the defaults section. assert PREPROCESSING not in updated_config assert updated_config[DEFAULTS][NUMBER][PREPROCESSING]["fill_value"] == 2 assert "training" not in updated_config assert updated_config[TRAINER]["epochs"] == 2 assert updated_config[TRAINER][EVAL_BATCH_SIZE] is None assert LEARNING_RATE_SCHEDULER in updated_config[TRAINER] assert updated_config[TRAINER][LEARNING_RATE_SCHEDULER]["reduce_on_plateau"] == 2 assert updated_config[TRAINER][LEARNING_RATE_SCHEDULER]["reduce_on_plateau_patience"] == 5 assert updated_config[TRAINER][LEARNING_RATE_SCHEDULER]["decay"] == "exponential" assert updated_config[TRAINER][LEARNING_RATE_SCHEDULER]["warmup_evaluations"] == 2 hparams = updated_config[HYPEROPT]["parameters"] assert "training.learning_rate" not in hparams assert "trainer.learning_rate" in hparams assert "sampler" not in updated_config[HYPEROPT] assert updated_config[HYPEROPT]["executor"]["type"] == "ray" assert "num_samples" in updated_config[HYPEROPT]["executor"] assert "scheduler" in updated_config[HYPEROPT]["executor"] ModelConfig.from_dict(updated_config) @pytest.mark.parametrize("force_split", [None, False, True]) @pytest.mark.parametrize("stratify", [None, "cat_in"]) def test_deprecated_split_aliases(stratify, force_split): split_probabilities = [0.6, 0.2, 0.2] config = { "ludwig_version": "0.4", INPUT_FEATURES: [{"name": "num_in", "type": "number"}, {"name": "cat_in", "type": "category"}], OUTPUT_FEATURES: [{"name": "num_out", "type": "number"}], PREPROCESSING: { "force_split": force_split, "split_probabilities": split_probabilities, "stratify": stratify, }, } updated_config = upgrade_config_dict_to_latest_version(config) assert "force_split" not in updated_config[PREPROCESSING] assert "split_probabilities" not in updated_config[PREPROCESSING] assert "stratify" not in updated_config[PREPROCESSING] assert SPLIT in updated_config[PREPROCESSING] split = updated_config[PREPROCESSING][SPLIT] assert split["probabilities"] == split_probabilities if stratify is None: if force_split: assert split.get(TYPE) == "random" else: assert split.get(TYPE) == "stratify" assert split.get("column") == stratify @pytest.mark.parametrize("use_scheduler", [True, False]) def test_deprecated_hyperopt_sampler_early_stopping(use_scheduler): sampler = { "type": "ray", "num_samples": 2, } if use_scheduler: sampler[SCHEDULER] = { "type": "async_hyperband", "max_t": 200, "time_attr": "time_total_s", "grace_period": 72, "reduction_factor": 5, } config = { INPUT_FEATURES: [ { "type": "category", "name": "cat_input_feature", }, ], OUTPUT_FEATURES: [ { "type": "number", "name": "num_output_feature", }, ], "hyperopt": { "search_alg": { "type": "variant_generator", }, "executor": { "type": "ray", "time_budget_s": 200, "cpu_resources_per_trial": 1, }, "sampler": sampler, "parameters": { "trainer.batch_size": { "space": "choice", "categories": [64, 128, 256], }, "trainer.learning_rate": { "space": "loguniform", "lower": 0.001, "upper": 0.1, }, }, }, } updated_config = upgrade_config_dict_to_latest_version(config) if use_scheduler: assert SCHEDULER in updated_config[HYPEROPT][EXECUTOR] merged_config = ModelConfig.from_dict(updated_config).to_dict() # When a scheulder is provided, early stopping in the rendered config needs to be disabled to allow the # hyperopt scheduler to manage trial lifecycle. expected_early_stop = -1 if use_scheduler else ECDTrainerConfig().early_stop assert merged_config[TRAINER]["early_stop"] == expected_early_stop def test_validate_old_model_config(): old_valid_config = { "input_features": [ {"name": "feature_1", "type": "category"}, {"name": "Sex", "type": "category", "encoder": "dense"}, ], "output_features": [ {"name": "Survived", "type": "category"}, ], } old_invalid_config = { "input_features": [ {"name": "feature_1", "type": "category"}, {"name": "Sex", "type": "category", "encoder": "fake_encoder"}, ], "output_features": [ {"name": "Survived", "type": "category"}, ], } ModelConfig.from_dict(old_valid_config) with pytest.raises(Exception): ModelConfig.from_dict(old_invalid_config) @pytest.mark.parametrize("missing_value_strategy", ["backfill", "pad"]) def test_update_missing_value_strategy(missing_value_strategy: str): old_valid_config = { "input_features": [ { "name": "input_feature_1", "type": "category", "preprocessing": {"missing_value_strategy": missing_value_strategy}, } ], "output_features": [ {"name": "output_feature_1", "type": "category"}, ], } updated_config = upgrade_missing_value_strategy(old_valid_config) expected_config = copy.deepcopy(old_valid_config) if missing_value_strategy == "backfill": expected_config["input_features"][0]["preprocessing"]["missing_value_strategy"] == "bfill" else: expected_config["input_features"][0]["preprocessing"]["missing_value_strategy"] == "ffill" assert updated_config == expected_config def test_update_increase_batch_size_on_plateau_max(): old_valid_config = { "input_features": [{"name": "input_feature_1", "type": "category"}], "output_features": [{"name": "output_feature_1", "type": "category"}], "trainer": { "increase_batch_size_on_plateau_max": 256, }, } updated_config = upgrade_config_dict_to_latest_version(old_valid_config) del updated_config["ludwig_version"] expected_config = copy.deepcopy(old_valid_config) del expected_config["trainer"]["increase_batch_size_on_plateau_max"] expected_config["trainer"]["max_batch_size"] = 256 assert updated_config == expected_config def test_old_class_weights_default(): old_config = { "input_features": [ { "name": "input_feature_1", "type": "category", } ], "output_features": [ {"name": "output_feature_1", "type": "category", "loss": {"class_weights": 1}}, ], } new_config = { "input_features": [ { "name": "input_feature_1", "type": "category", } ], "output_features": [ {"name": "output_feature_1", "type": "category", "loss": {"class_weights": None}}, ], } upgraded_config = upgrade_config_dict_to_latest_version(old_config) del upgraded_config["ludwig_version"] assert new_config == upgraded_config old_config[OUTPUT_FEATURES][0][LOSS][CLASS_WEIGHTS] = [0.5, 0.8, 1] new_config[OUTPUT_FEATURES][0][LOSS][CLASS_WEIGHTS] = [0.5, 0.8, 1] upgraded_config = upgrade_config_dict_to_latest_version(old_config) del upgraded_config["ludwig_version"] assert new_config == upgraded_config def test_upgrade_model_progress(): old_model_progress = { "batch_size": 64, "best_eval_metric": 0.5, "best_increase_batch_size_eval_metric": math.inf, "best_reduce_learning_rate_eval_metric": math.inf, "epoch": 2, "last_improvement": 1, "last_improvement_epoch": 1, "best_eval_metric_epoch": 1, "last_increase_batch_size": 0, "last_increase_batch_size_epoch": 0, "last_increase_batch_size_eval_metric_improvement": 0, "last_learning_rate_reduction": 0, "last_learning_rate_reduction_epoch": 0, "last_reduce_learning_rate_eval_metric_improvement": 0, "learning_rate": 0.001, "num_increases_batch_size": 0, "num_reductions_learning_rate": 0, "steps": 224, "test_metrics": { "combined": {"loss": [0.59, 0.56]}, "delinquent": { "accuracy": [0.77, 0.78], }, }, "train_metrics": {"combined": {"loss": [0.58, 0.55]}, "delinquent": {"roc_auc": [0.53, 0.54]}}, "vali_metrics": {"combined": {"loss": [0.59, 0.60]}, "delinquent": {"roc_auc": [0.53, 0.44]}}, } new_model_progress = upgrade_model_progress(old_model_progress) assert new_model_progress == { "batch_size": 64, "best_eval_metric_value": 0.5, "best_increase_batch_size_eval_metric": float("inf"), "epoch": 2, "last_improvement_steps": 64, "best_eval_metric_steps": 0, "best_eval_metric_epoch": 1, "last_increase_batch_size": 0, "last_increase_batch_size_eval_metric_improvement": 0, "last_learning_rate_reduction": 0, "learning_rate": 0.001, "num_increases_batch_size": 0, "num_reductions_learning_rate": 0, "steps": 224, "test_metrics": { "combined": { "loss": [TrainerMetric(epoch=1, step=64, value=0.59), TrainerMetric(epoch=2, step=128, value=0.56)] }, "delinquent": { "accuracy": [TrainerMetric(epoch=1, step=64, value=0.77), TrainerMetric(epoch=2, step=128, value=0.78)] }, }, "train_metrics": { "combined": { "loss": [TrainerMetric(epoch=1, step=64, value=0.58), TrainerMetric(epoch=2, step=128, value=0.55)] }, "delinquent": { "roc_auc": [TrainerMetric(epoch=1, step=64, value=0.53), TrainerMetric(epoch=2, step=128, value=0.54)] }, }, "last_learning_rate_reduction_steps": 0, "last_increase_batch_size_steps": 0, "validation_metrics": { "combined": { "loss": [TrainerMetric(epoch=1, step=64, value=0.59), TrainerMetric(epoch=2, step=128, value=0.6)] }, "delinquent": { "roc_auc": [TrainerMetric(epoch=1, step=64, value=0.53), TrainerMetric(epoch=2, step=128, value=0.44)] }, }, "tune_checkpoint_num": 0, "checkpoint_number": 0, "best_eval_metric_checkpoint_number": 0, "best_eval_train_metrics": {}, "best_eval_validation_metrics": {}, "best_eval_test_metrics": {}, } # Verify that we don't make changes to already-valid model progress dicts. # To do so, we modify the batch size value and re-run the upgrade on the otherwise-valid `new_model_progress` dict. new_model_progress["batch_size"] = 1 unchanged_model_progress = upgrade_model_progress(new_model_progress) assert unchanged_model_progress == new_model_progress def test_upgrade_model_progress_already_valid(): # Verify that we don't make changes to already-valid model progress dicts. valid_model_progress = { BATCH_SIZE: 128, "best_eval_metric_checkpoint_number": 7, "best_eval_metric_epoch": 6, "best_eval_metric_steps": 35, "best_eval_metric_value": 0.719, "best_eval_test_metrics": { "Survived": {"accuracy": 0.634, "loss": 3.820, "roc_auc": 0.598}, "combined": {"loss": 3.820}, }, "best_eval_train_metrics": { "Survived": {"accuracy": 0.682, "loss": 4.006, "roc_auc": 0.634}, "combined": {"loss": 4.006}, }, "best_eval_validation_metrics": { "Survived": {"accuracy": 0.719, "loss": 4.396, "roc_auc": 0.667}, "combined": {"loss": 4.396}, }, "best_increase_batch_size_eval_metric": float("inf"), "checkpoint_number": 12, "epoch": 12, "last_increase_batch_size": 0, "last_increase_batch_size_eval_metric_improvement": 0, "last_increase_batch_size_steps": 0, "last_learning_rate_reduction": 0, "last_learning_rate_reduction_steps": 0, "learning_rate": 0.001, "num_increases_batch_size": 0, "num_reductions_learning_rate": 0, "steps": 60, "test_metrics": { "Survived": { "accuracy": [ [0, 5, 0.651], [1, 10, 0.651], ], "loss": [ [0, 5, 4.130], [1, 10, 4.074], ], "roc_auc": [ [0, 5, 0.574], [1, 10, 0.595], ], }, "combined": { "loss": [ [0, 5, 4.130], [1, 10, 4.074], ] }, }, "train_metrics": { "Survived": { "accuracy": [ [0, 5, 0.6875], [1, 10, 0.6875], ], "loss": [ [0, 5, 4.417], [1, 10, 4.344], ], "roc_auc": [ [0, 5, 0.628], [1, 10, 0.629], ], }, "combined": { "loss": [ [0, 5, 4.417], [1, 10, 4.344], ] }, }, "tune_checkpoint_num": 0, "validation_metrics": { "Survived": { "accuracy": [ [0, 5, 0.696], [1, 10, 0.696], ], "loss": [ [0, 5, 4.494], [1, 10, 4.473], ], "roc_auc": [ [0, 5, 0.675], [1, 10, 0.671], ], }, "combined": { "loss": [ [0, 5, 4.494], [1, 10, 4.473], ] }, }, } unchanged_model_progress = upgrade_model_progress(valid_model_progress) assert unchanged_model_progress == valid_model_progress def test_cache_credentials_backward_compatibility(): # From v0.6.3. creds = {"s3": {"client_kwargs": {}}} backend = {"type": "local", "cache_dir": "/foo/bar", "cache_credentials": creds} _update_backend_cache_credentials(backend) assert backend == {"type": "local", "cache_dir": "/foo/bar", "credentials": {"cache": creds}} @pytest.mark.parametrize( "encoder,upgraded_type", [ ({"type": "resnet"}, "resnet"), ({"type": "vit"}, "vit"), ({"type": "resnet", "resnet_size": 50}, "_resnet_legacy"), ({"type": "vit", "num_hidden_layers": 12}, "_vit_legacy"), ], ids=["resnet", "vit", "resnet_legacy", "vit_legacy"], ) def test_legacy_image_encoders(encoder: dict[str, Any], upgraded_type: str): config = { "input_features": [{"name": "image1", "type": "image", "encoder": encoder}], "output_features": [{"name": "binary1", "type": "binary"}], } updated_config = upgrade_config_dict_to_latest_version(config) expected_encoder = { **encoder, **{"type": upgraded_type}, } assert updated_config["input_features"][0]["encoder"] == expected_encoder def test_load_config_missing_hyperopt(): old_valid_config = { "input_features": [ {"name": "feature_1", "type": "category"}, {"name": "Sex", "type": "category", "encoder": "dense"}, ], "output_features": [ {"name": "Survived", "type": "category"}, ], "combiner": {"type": "concat"}, "trainer": {}, "hyperopt": {}, } config_obj = ModelConfig.from_dict(old_valid_config) assert config_obj.hyperopt is None assert config_obj.to_dict()[HYPEROPT] is None def test_type_removed_from_defaults_config(): config = { "input_features": [ {"name": "feature_1", "type": "category"}, {"name": "Sex", "type": "category"}, ], "output_features": [ {"name": "Survived", "type": "category"}, ], "defaults": { "binary": { "encoder": { "type": "passthrough", }, "preprocessing": { "missing_value_strategy": "fill_with_false", }, "type": "binary", }, "category": { "encoder": { "type": "onehot", }, "preprocessing": { "missing_value_strategy": "fill_with_const", "most_common": 10000, }, "type": "category", }, }, "model_type": "ecd", } config_obj = ModelConfig.from_dict(config).to_dict() for feature_type in config_obj.get("defaults"): assert "type" not in config_obj["defaults"][feature_type] ================================================ FILE: tests/ludwig/utils/test_calibration.py ================================================ import numpy as np import pytest from ludwig.utils import calibration @pytest.fixture def uncalibrated_logits_and_labels(): """Returns a pair of logits (10x3) and labels (10).""" return ( np.array( [ [-3.596756, 6.728981, 6.3807454], [-16.818138, -3.5217745, -1.7786252], [-16.060827, 4.7207646, 3.5336719], [-4.784969, 5.062503, 3.515455], [-4.669478, 7.171067, 6.5137157], [-32.596764, -3.5582566, -5.2003713], [-4.4035864, 6.3911495, 4.7273974], [-4.2035627, 7.846533, 6.0476217], [-20.748848, -3.1521742, -4.873552], [-4.8901286, 4.726167, 3.208372], ] ), np.array([2, 0, 2, 1, 1, 2, 0, 1, 0, 1]), ) EPSILON = 0.1 # maximum relative precision error allowed. def test_temperature_scaling_binary(uncalibrated_logits_and_labels): logits, labels = uncalibrated_logits_and_labels # Selects one category of the 3-class test fixture to treat as a binary classifier. binary_logits = logits[:, 1] binary_labels = labels == 1 temperature_scaling = calibration.TemperatureScaling(binary=True) calibration_result = temperature_scaling.train_calibration(binary_logits, binary_labels) # Checks that we got close to optimal temperature assert temperature_scaling.temperature.item() == pytest.approx(8.3, EPSILON) # Checks that negative log-likelhood and expected calibration error are the same or lower post-calibration. assert calibration_result.after_calibration_nll <= calibration_result.before_calibration_nll assert calibration_result.after_calibration_ece <= calibration_result.before_calibration_ece def test_temperature_scaling_category(uncalibrated_logits_and_labels): logits, labels = uncalibrated_logits_and_labels temperature_scaling = calibration.TemperatureScaling(num_classes=logits.shape[-1]) calibration_result = temperature_scaling.train_calibration(logits, labels) # Checks that we got close to optimal temperature # The exact temperature depends on optimizer internals (PyTorch version). # Check it's in a reasonable range and that calibration improved metrics. assert temperature_scaling.temperature.item() > 5.0 assert calibration_result.after_calibration_nll <= calibration_result.before_calibration_nll assert calibration_result.after_calibration_ece <= calibration_result.before_calibration_ece def test_matrix_scaling_category(uncalibrated_logits_and_labels): logits, labels = uncalibrated_logits_and_labels matrix_scaling = calibration.MatrixScaling(num_classes=logits.shape[-1]) calibration_result = matrix_scaling.train_calibration(logits, labels) # Matrix scaling may not have a single optimum, so multiple runs could give different results. # In this case we don't check any specific values # Checks that negative log-likelhood and expected calibration error are the same or lower post-calibration. assert calibration_result.after_calibration_nll <= calibration_result.before_calibration_nll assert calibration_result.after_calibration_ece <= calibration_result.before_calibration_ece ================================================ FILE: tests/ludwig/utils/test_class_balancing.py ================================================ import numpy as np import pandas as pd import pytest from ludwig.backend.base import LocalBackend from ludwig.constants import BALANCE_PERCENTAGE_TOLERANCE, NAME from ludwig.data.preprocessing import balance_data @pytest.mark.parametrize( "method, balance", [ ("oversample_minority", 0.25), ("oversample_minority", 0.5), ("oversample_minority", 0.75), ("undersample_majority", 0.25), ("undersample_majority", 0.5), ("undersample_majority", 0.75), ("undersample_majority", 0.9), ], ) def test_balance(method, balance): config = { "input_features": [ {"name": "Index", "proc_column": "Index", "type": "number"}, {"name": "random_1", "proc_column": "random_1", "type": "number"}, {"name": "random_2", "proc_column": "random_2", "type": "number"}, ], "output_features": [{"name": "Label", "proc_column": "Label", "type": "binary"}], "preprocessing": {"oversample_minority": None, "undersample_majority": None}, } input_df = pd.DataFrame( { "Index": np.arange(0, 200, 1), "random_1": np.random.randint(0, 50, 200), "random_2": np.random.choice(["Type A", "Type B", "Type C", "Type D"], 200), "Label": np.concatenate((np.zeros(180), np.ones(20))), "split": np.zeros(200), } ) config["preprocessing"][method] = balance backend = LocalBackend() test_df = balance_data(input_df, config["output_features"], config["preprocessing"], backend, 42) target = config["output_features"][0][NAME] majority_class = test_df[target].value_counts()[test_df[target].value_counts().idxmax()] minority_class = test_df[target].value_counts()[test_df[target].value_counts().idxmin()] new_class_balance = round(minority_class / majority_class, 2) assert abs(balance - new_class_balance) < BALANCE_PERCENTAGE_TOLERANCE ================================================ FILE: tests/ludwig/utils/test_config_utils.py ================================================ import copy from typing import Any import pytest from ludwig.constants import ( BASE_MODEL, BINARY, ENCODER, INPUT_FEATURES, MODEL_ECD, MODEL_LLM, MODEL_TYPE, NAME, OUTPUT_FEATURES, TEXT, TYPE, ) from ludwig.schema.encoders.text_encoders import BERTConfig from ludwig.schema.encoders.utils import get_encoder_cls from ludwig.schema.features.preprocessing.text import TextPreprocessingConfig from ludwig.schema.model_config import ModelConfig from ludwig.utils.config_utils import config_uses_llm, get_quantization @pytest.mark.parametrize( "pretrained_model_name_or_path", [None, "bert-large-uncased"], ids=["default_model", "override_model"], ) def test_set_fixed_preprocessing_params(pretrained_model_name_or_path: str): expected_model_name = "bert-base-uncased" preprocessing = TextPreprocessingConfig.from_dict( { "tokenizer": "space", "lowercase": True, } ) encoder_params = {} if pretrained_model_name_or_path is not None: encoder_params["pretrained_model_name_or_path"] = pretrained_model_name_or_path expected_model_name = pretrained_model_name_or_path encoder = BERTConfig.from_dict(encoder_params) encoder.set_fixed_preprocessing_params(MODEL_ECD, preprocessing) assert preprocessing.tokenizer == "hf_tokenizer" assert preprocessing.lowercase assert preprocessing.pretrained_model_name_or_path == expected_model_name @pytest.mark.parametrize( "encoder_params,expected", [ ({"type": "parallel_cnn"}, False), ({"type": "bert", "trainable": False}, True), ({"type": "bert", "trainable": True}, False), ], ids=["parallel_cnn", "bert_fixed", "bert_trainable"], ) def test_set_fixed_preprocessing_params_cache_embeddings(encoder_params: dict[str, Any], expected: bool | None): preprocessing = TextPreprocessingConfig.from_dict( { "tokenizer": "space", "lowercase": True, "cache_encoder_embeddings": True, } ) encoder = get_encoder_cls(MODEL_ECD, TEXT, encoder_params[TYPE]).from_dict(encoder_params) encoder.set_fixed_preprocessing_params(MODEL_ECD, preprocessing) assert preprocessing.cache_encoder_embeddings == expected @pytest.fixture(scope="module") def llm_config_dict() -> dict[str, Any]: return { MODEL_TYPE: MODEL_LLM, BASE_MODEL: "HuggingFaceH4/tiny-random-LlamaForCausalLM", INPUT_FEATURES: [{TYPE: TEXT, NAME: "in1"}], OUTPUT_FEATURES: [{TYPE: TEXT, NAME: "out1"}], } @pytest.fixture(scope="module") def ecd_config_dict_llm_encoder() -> dict[str, Any]: return { MODEL_TYPE: MODEL_ECD, INPUT_FEATURES: [ { TYPE: TEXT, NAME: "in1", ENCODER: {TYPE: MODEL_LLM, BASE_MODEL: "HuggingFaceH4/tiny-random-LlamaForCausalLM"}, } ], OUTPUT_FEATURES: [{TYPE: BINARY, NAME: "out1"}], } @pytest.fixture(scope="module") def ecd_config_dict_llm_encoder_multiple_features() -> dict[str, Any]: return { MODEL_TYPE: MODEL_ECD, INPUT_FEATURES: [ {TYPE: BINARY, NAME: "in1"}, { TYPE: TEXT, NAME: "in2", ENCODER: {TYPE: MODEL_LLM, BASE_MODEL: "HuggingFaceH4/tiny-random-LlamaForCausalLM"}, }, ], OUTPUT_FEATURES: [{TYPE: BINARY, NAME: "out1"}], } @pytest.fixture(scope="module") def ecd_config_dict_no_llm_encoder() -> dict[str, Any]: return { MODEL_TYPE: MODEL_ECD, INPUT_FEATURES: [{TYPE: TEXT, NAME: "in1", ENCODER: {TYPE: "parallel_cnn"}}], OUTPUT_FEATURES: [{TYPE: BINARY, NAME: "out1"}], } @pytest.fixture(scope="module") def ecd_config_dict_no_text_features() -> dict[str, Any]: return { MODEL_TYPE: MODEL_ECD, INPUT_FEATURES: [{TYPE: BINARY, NAME: "in1"}], OUTPUT_FEATURES: [{TYPE: BINARY, NAME: "out1"}], } @pytest.mark.parametrize( "config,expectation", [ # LLM configurations ("llm_config_dict", True), # LLM encoder configurations ("ecd_config_dict_llm_encoder", True), # LLM encoder configurations, multiple features ("ecd_config_dict_llm_encoder_multiple_features", True), # ECD configuration with text feature and non-LLM encoder ("ecd_config_dict_no_llm_encoder", False), # ECD configuration with no text features ("ecd_config_dict_no_text_features", False), ], ) @pytest.mark.parametrize("config_type", ["dict", "object"]) def test_is_or_uses_llm(config: dict[str, Any], expectation: bool, config_type, request): """Test LLM detection on a variety of configs. Configs that use an LLM anywhere should return True, otherwise False. Args: config: The name of the config fixture to test expectation: The expected result request: pytest `request` fixture """ config = request.getfixturevalue(config) if config_type == "object": config = ModelConfig.from_dict(config) assert config_uses_llm(config) == expectation @pytest.mark.parametrize("invalid_config", [1, 1.0, "foo", True, False, None, [], {}, {"foo": "bar"}]) def test_is_or_uses_llm_invalid_input(invalid_config): """Sanity checks for invalid config handling. These should all raise an exception. Args: invalid_config: An invalid argument to `config_uses_llm` """ with pytest.raises(ValueError): config_uses_llm(invalid_config) @pytest.fixture(scope="module") def quantization_4bit_config() -> dict[str, Any]: return {"quantization": {"bits": 4}} @pytest.fixture(scope="module") def quantization_8bit_config() -> dict[str, Any]: return {"quantization": {"bits": 8}} @pytest.fixture(scope="module") def llm_config_dict_4bit(llm_config_dict: dict[str, Any], quantization_4bit_config: dict[str, Any]) -> dict[str, Any]: config = copy.deepcopy(llm_config_dict) config.update(quantization_4bit_config) return config @pytest.fixture(scope="module") def llm_config_dict_8bit(llm_config_dict: dict[str, Any], quantization_8bit_config: dict[str, Any]) -> dict[str, Any]: config = copy.deepcopy(llm_config_dict) config.update(quantization_8bit_config) return config @pytest.fixture(scope="module") def ecd_config_dict_llm_encoder_4bit( ecd_config_dict_llm_encoder: dict[str, Any], quantization_4bit_config: dict[str, Any] ) -> dict[str, Any]: config = copy.deepcopy(ecd_config_dict_llm_encoder) config[INPUT_FEATURES][0][ENCODER].update(quantization_4bit_config) return config @pytest.fixture(scope="module") def ecd_config_dict_llm_encoder_8bit( ecd_config_dict_llm_encoder: dict[str, Any], quantization_8bit_config: dict[str, Any] ) -> dict[str, Any]: config = copy.deepcopy(ecd_config_dict_llm_encoder) config[INPUT_FEATURES][0][ENCODER].update(quantization_8bit_config) return config @pytest.mark.parametrize( "config,expectation", [ # LLM configurations ("llm_config_dict", [None]), ("llm_config_dict_4bit", [4]), ("llm_config_dict_8bit", [8]), # LLM encoder configurations with one feature ("ecd_config_dict_llm_encoder", [None]), ("ecd_config_dict_llm_encoder_4bit", [4]), ("ecd_config_dict_llm_encoder_8bit", [8]), ], ) @pytest.mark.parametrize("config_type", ["dict", "object"]) def test_get_quantization( config: dict[str, Any], expectation: int | list[int] | None | list[None], config_type: str, request ): """Test get_quantization with LLM and single-feature ECD configs. Args: config: The configuration to test expectation: The expected quantization config_type: Whether to test the config as a dict or object request: pytest builtin fixture """ config = request.getfixturevalue(config) if config_type == "object": config = ModelConfig.from_dict(config) assert get_quantization(config) == expectation TEST_FEATURE_CONFIGS = [ ( { TYPE: BINARY, }, None, ), ( { TYPE: TEXT, }, None, ), ({TYPE: TEXT, ENCODER: {TYPE: MODEL_LLM, BASE_MODEL: "HuggingFaceH4/tiny-random-LlamaForCausalLM"}}, None), ( { TYPE: TEXT, ENCODER: { TYPE: MODEL_LLM, BASE_MODEL: "HuggingFaceH4/tiny-random-LlamaForCausalLM", "quantization": {"bits": 4}, }, }, 4, ), ( { TYPE: TEXT, ENCODER: { TYPE: MODEL_LLM, BASE_MODEL: "HuggingFaceH4/tiny-random-LlamaForCausalLM", "quantization": {"bits": 8}, }, }, 8, ), ] TEST_FEATURE_CONFIGS_IDS = [BINARY, TEXT, MODEL_LLM, f"{MODEL_LLM}-4bit", f"{MODEL_LLM}-8bit"] @pytest.mark.parametrize("feature1,quantization1", TEST_FEATURE_CONFIGS, ids=TEST_FEATURE_CONFIGS_IDS) @pytest.mark.parametrize("feature2,quantization2", TEST_FEATURE_CONFIGS, ids=TEST_FEATURE_CONFIGS_IDS) @pytest.mark.parametrize("config_type", ["dict", "object"]) def test_get_quantization_multiple_features( ecd_config_dict_llm_encoder_multiple_features: dict[str, Any], feature1: dict[str, Any], quantization1: int, feature2: dict[str, Any], quantization2: int, config_type: str, ): """Test get_quantization with multiple features. Args: ecd_config_dict_llm_encoder_multiple_features: Baseline config to add features to. feature1: First input feature config dict quantization1: First input feature expected quantization feature2: Second input feature config dict quantization2: Second input feature expected quantization config_type: Whether to test the config as a dict or object """ config = copy.deepcopy(ecd_config_dict_llm_encoder_multiple_features) feature1 = dict(name="in1", **feature1) feature2 = dict(name="in2", **feature2) config[INPUT_FEATURES] = [feature1, feature2] if config_type == "object": config = ModelConfig.from_dict(config) assert get_quantization(config) == [quantization1, quantization2] @pytest.mark.parametrize("invalid_config", [1, 1.0, "foo", True, False, None, [], {}, {"foo": "bar"}]) def test_get_quantization_invalid_input(invalid_config): """Test get_quantization with invalid configs. These should always raise a ValueError. Args: invalid_config: The invalid config to test """ with pytest.raises(ValueError): get_quantization(invalid_config) ================================================ FILE: tests/ludwig/utils/test_data_utils.py ================================================ # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import functools import json import logging import numpy as np import pandas as pd import pytest from fsspec.config import conf from ludwig.api import LudwigModel from ludwig.data.cache.types import CacheableDataframe from ludwig.data.dataset_synthesizer import build_synthetic_dataset_df from ludwig.utils.data_utils import ( add_sequence_feature_column, figure_data_format_dataset, get_abs_path, hash_dict, NumpyEncoder, PANDAS_DF, read_csv, read_html, read_parquet, sanitize_column_names, use_credentials, ) try: import dask.dataframe as dd except ImportError: dd = None def test_add_sequence_feature_column(): df = pd.DataFrame([1, 2, 3, 4, 5], columns=["x"]) add_sequence_feature_column(df, "x", 2) assert df.equals( pd.DataFrame( [ [1, "1 2"], [2, "1 2"], [3, "1 2"], [4, "2 3"], [5, "3 4"], ], columns=["x", "x_feature"], ) ) add_sequence_feature_column(df, "x", 1) assert df.equals( pd.DataFrame( [ [1, "1"], [2, "1"], [3, "2"], [4, "3"], [5, "4"], ], columns=["x", "x_feature"], ) ) df = pd.DataFrame([1, 2, 3, 4, 5], columns=["x"]) add_sequence_feature_column(df, "y", 2) assert df.equals(pd.DataFrame([1, 2, 3, 4, 5], columns=["x"])) def test_get_abs_path(): assert get_abs_path("a", "b.jpg") == "a/b.jpg" assert get_abs_path(None, "b.jpg") == "b.jpg" @pytest.mark.parametrize( "path, expected_format", [("s3://path/to.parquet ", "parquet"), ("/Users/path/to.csv \n", "csv")] ) def test_figure_data_format_dataset_strip(path, expected_format): assert figure_data_format_dataset(path) == expected_format @pytest.mark.distributed def test_figure_data_format_dataset(): assert figure_data_format_dataset({"a": "b"}) == dict assert figure_data_format_dataset(pd.DataFrame([1, 2, 3, 4, 5], columns=["x"])) == pd.DataFrame assert ( figure_data_format_dataset( dd.from_pandas(pd.DataFrame([1, 2, 3, 4, 5], columns=["x"]), npartitions=1).reset_index() ) == dd.DataFrame ) assert ( figure_data_format_dataset( CacheableDataframe(df=pd.DataFrame([1, 2, 3, 4, 5], columns=["x"]), name="test", checksum="test123") ) == pd.DataFrame ) assert ( figure_data_format_dataset( CacheableDataframe( df=dd.from_pandas(pd.DataFrame([1, 2, 3, 4, 5], columns=["x"]), npartitions=1).reset_index(), name="test", checksum="test123", ) ) == dd.DataFrame ) def test_hash_dict_numpy_types(): d = {"float32": np.float32(1)} assert hash_dict(d) == b"uqtgWB" def test_use_credentials(): conf.clear() with use_credentials(None): assert len(conf) == 0 s3_creds = { "s3": { "client_kwargs": { "endpoint_url": "http://localhost:9000", "aws_access_key_id": "test", "aws_secret_access_key": "test", } } } with use_credentials(s3_creds): assert len(conf) == 1 assert conf == s3_creds assert len(conf) == 0 def test_numpy_encoder(): # Test Python builtin data type encoding. assert json.dumps(None, cls=NumpyEncoder) == "null" assert json.dumps({}, cls=NumpyEncoder) == "{}" assert json.dumps(1, cls=NumpyEncoder) == "1" assert json.dumps(1.0, cls=NumpyEncoder) == "1.0" assert json.dumps("a", cls=NumpyEncoder) == '"a"' assert json.dumps([0, 1, 2, 3, 4], cls=NumpyEncoder) == "[0, 1, 2, 3, 4]" assert json.dumps((0, 1, 2, 3, 4), cls=NumpyEncoder) == "[0, 1, 2, 3, 4]" assert json.dumps({0, 1, 2, 3, 4}, cls=NumpyEncoder) == "[0, 1, 2, 3, 4]" assert json.dumps({"a": "b"}, cls=NumpyEncoder) == '{"a": "b"}' # Test numpy data type encoding for dtype in [np.byte, np.ubyte, np.short, np.ushort, np.int32, np.int64, np.uint, np.longlong, np.ulonglong]: x = np.arange(5, dtype=dtype) assert json.dumps(x, cls=NumpyEncoder) == "[0, 1, 2, 3, 4]" for i in x: assert json.dumps(i, cls=NumpyEncoder) == f"{i}" for dtype in [np.half, np.single, np.double, np.longdouble]: x = np.arange(5, dtype=dtype) assert json.dumps(x, cls=NumpyEncoder) == "[0.0, 1.0, 2.0, 3.0, 4.0]" for i in x: assert json.dumps(i, cls=NumpyEncoder) == f"{i}" def test_dataset_synthesizer_output_feature_decoder(): config = { "input_features": [{"name": "sentence", "type": "text"}], "output_features": [{"name": "product", "type": "category"}], "trainer": {"epochs": 5}, "model_type": "ecd", } build_synthetic_dataset_df(dataset_size=100, config=config) LudwigModel(config=config, logging_level=logging.INFO) @pytest.fixture def synthetic_1k_files(tmp_path): """Create synthetic 1000-row CSV and Parquet files for chunking tests.""" df = pd.DataFrame({f"col_{i}": range(1000) for i in range(5)}) csv_path = str(tmp_path / "synthetic_1k.csv") parquet_path = str(tmp_path / "synthetic_1k.parquet") df.to_csv(csv_path, index=False) df.to_parquet(parquet_path, index=False) return csv_path, parquet_path @pytest.mark.parametrize("fmt_idx", [0, 1], ids=["csv", "parquet"]) @pytest.mark.parametrize("nrows", [None, 100]) def test_chunking(synthetic_1k_files, fmt_idx, nrows): dataset_path = synthetic_1k_files[fmt_idx] reader_fn = {"csv": read_csv, "parquet": functools.partial(read_parquet, df_lib=PANDAS_DF)} fmt = figure_data_format_dataset(dataset_path) assert reader_fn[fmt](dataset_path, nrows=nrows).shape[0] == (nrows if nrows else 1000) @pytest.mark.parametrize( "df_lib", [pytest.param(pd, id="pandas"), pytest.param(dd, marks=pytest.mark.distributed, id="dask")] ) @pytest.mark.parametrize("nrows", [None, 10]) def test_read_html(df_lib, nrows): HTML_DOCUMENT = """ TITLE
Col 1Col 2
12
""" kwargs = {} if not nrows: kwargs["nrows"] = nrows read_html(HTML_DOCUMENT, df_lib, **kwargs) def test_sanitize_column_names(): df = pd.DataFrame( { "col.one": [1, 2, 3, 4], "col(two)": [4, 5, 6, 7], "col[]:three": [7, 8, 9, 10], "col 'one' (new)": [1, 2, 3, 4], } ) df = sanitize_column_names(df) assert list(df.columns) == ["col_one", "col_two_", "col___three", "col _one_ _new_"] ================================================ FILE: tests/ludwig/utils/test_dataframe_utils.py ================================================ import numpy as np import pandas as pd import pytest from ludwig.backend import create_backend, LOCAL_BACKEND from ludwig.utils.dataframe_utils import to_numpy_dataset, to_scalar_df try: import dask.dataframe as dd except ImportError: pass @pytest.mark.distributed def test_to_numpy_dataset_with_dask(ray_cluster_2cpu): dd_df = dd.from_pandas(pd.DataFrame([[1, 2, 3]], columns=["col1", "col2", "col3"]), npartitions=1) ray_backend = create_backend("ray") np_df = to_numpy_dataset(dd_df, backend=ray_backend) assert np_df == {"col1": np.array([1]), "col2": np.array([2]), "col3": np.array([3])} @pytest.mark.distributed def test_to_numpy_dataset_with_dask_backend_mismatch(): dd_df = dd.from_pandas(pd.DataFrame([[1, 2, 3]], columns=["col1", "col2", "col3"]), npartitions=1) with pytest.raises(AttributeError): to_numpy_dataset(dd_df, backend=LOCAL_BACKEND) def test_to_numpy_dataset_with_pandas(): pd_df = pd.DataFrame([[1, 2, 3]], columns=["col1", "col2", "col3"]) np_df = to_numpy_dataset(pd_df, backend=LOCAL_BACKEND) assert np_df == {"col1": np.array([1]), "col2": np.array([2]), "col3": np.array([3])} def test_to_numpy_dataset_empty_with_columns(): pd_df = pd.DataFrame(columns=["col1", "col2", "col3"]) np_df = to_numpy_dataset(pd_df, backend=LOCAL_BACKEND) assert np_df == {"col1": [], "col2": [], "col3": []} def test_to_numpy_dataset_empty(): pd_df = pd.DataFrame() np_df = to_numpy_dataset(pd_df, backend=LOCAL_BACKEND) assert np_df == {} @pytest.mark.distributed def test_to_numpy_dataset_with_pandas_backend_mismatch(ray_cluster_2cpu): pd_df = pd.DataFrame([[1, 2, 3]], columns=["col1", "col2", "col3"]) ray_backend = create_backend("ray") with pytest.raises(AttributeError): to_numpy_dataset(pd_df, backend=ray_backend) def test_to_scalar_df(): data = [ [True, np.array([1, 2, 3]), 42], [False, np.array([4, 5, 6]), 28], [True, np.array([7, 8, 9]), 99], ] df = pd.DataFrame(data, columns=["bin", "cat_encoded", "num"]) scalar_data = [ [True, 1, 2, 3, 42], [False, 4, 5, 6, 28], [True, 7, 8, 9, 99], ] expected_df = pd.DataFrame(scalar_data, columns=["bin", "cat_encoded_0", "cat_encoded_1", "cat_encoded_2", "num"]) scalar_df = to_scalar_df(df) assert scalar_df.columns.tolist() == expected_df.columns.tolist() assert scalar_df.equals(expected_df) ================================================ FILE: tests/ludwig/utils/test_dataset_utils.py ================================================ import pandas as pd from ludwig.utils.dataset_utils import get_repeatable_train_val_test_split def test_get_repeatable_train_val_test_split(): # Test adding split with stratify df = pd.DataFrame( [ [0, 0], [1, 0], [2, 0], [3, 0], [4, 0], [5, 1], [6, 1], [7, 1], [8, 1], [9, 1], [10, 0], [11, 0], [12, 0], [13, 0], [14, 0], [15, 1], [16, 1], [17, 1], [18, 1], [19, 1], ], columns=["input", "target"], ) split_df = get_repeatable_train_val_test_split(df, "target", random_seed=42) assert split_df.equals( pd.DataFrame( [ [7, 1, 0], [16, 1, 0], [5, 1, 0], [14, 0, 0], [19, 1, 0], [6, 1, 0], [11, 0, 0], [18, 1, 0], [1, 0, 0], [10, 0, 0], [2, 0, 0], [15, 1, 0], [0, 0, 0], [17, 1, 1], [12, 0, 1], [8, 1, 2], [4, 0, 2], [13, 0, 2], [3, 0, 2], [9, 1, 2], ], columns=["input", "target", "split"], ) ) # Test adding split without stratify df = pd.DataFrame( [ [0, 0], [1, 0], [2, 0], [3, 0], [4, 0], [5, 1], [6, 1], [7, 1], [8, 1], [9, 1], [10, 0], [11, 0], [12, 0], [13, 0], [14, 0], [15, 1], [16, 1], [17, 1], [18, 1], [19, 1], ], columns=["input", "target"], ) split_df = get_repeatable_train_val_test_split(df, random_seed=42) assert split_df.equals( pd.DataFrame( [ [3, 0, 0], [4, 0, 0], [5, 1, 0], [7, 1, 0], [8, 1, 0], [10, 0, 0], [11, 0, 0], [12, 0, 0], [13, 0, 0], [14, 0, 0], [15, 1, 0], [16, 1, 0], [18, 1, 0], [19, 1, 0], [0, 0, 1], [17, 1, 1], [1, 0, 2], [2, 0, 2], [9, 1, 2], [6, 1, 2], ], columns=["input", "target", "split"], ) ) # Test needing no change df = pd.DataFrame( [ [0, 0, 0], [1, 0, 0], [2, 0, 0], [5, 1, 0], [6, 1, 0], [7, 1, 0], [10, 0, 0], [11, 0, 0], [14, 0, 0], [15, 1, 0], [16, 1, 0], [18, 1, 0], [19, 1, 0], [12, 0, 1], [17, 1, 1], [3, 0, 2], [4, 0, 2], [8, 1, 2], [9, 1, 2], [13, 0, 2], ], columns=["input", "target", "split"], ) split_df = get_repeatable_train_val_test_split(df, "target", random_seed=42) assert split_df.equals( pd.DataFrame( [ [0, 0, 0], [1, 0, 0], [2, 0, 0], [5, 1, 0], [6, 1, 0], [7, 1, 0], [10, 0, 0], [11, 0, 0], [14, 0, 0], [15, 1, 0], [16, 1, 0], [18, 1, 0], [19, 1, 0], [12, 0, 1], [17, 1, 1], [3, 0, 2], [4, 0, 2], [8, 1, 2], [9, 1, 2], [13, 0, 2], ], columns=["input", "target", "split"], ) ) # Test adding only validation split df = pd.DataFrame( [ [0, 0, 0], [1, 0, 0], [2, 0, 0], [5, 1, 0], [6, 1, 0], [7, 1, 0], [10, 0, 0], [11, 0, 0], [14, 0, 0], [15, 1, 0], [16, 1, 0], [18, 1, 0], [19, 1, 0], [12, 0, 0], [17, 1, 0], [3, 0, 2], [4, 0, 2], [8, 1, 2], [9, 1, 2], [13, 0, 2], ], columns=["input", "target", "split"], ) split_df = get_repeatable_train_val_test_split(df, "target", random_seed=42) assert split_df.equals( pd.DataFrame( [ [0, 0, 0], [1, 0, 0], [2, 0, 0], [5, 1, 0], [6, 1, 0], [7, 1, 0], [10, 0, 0], [11, 0, 0], [14, 0, 0], [16, 1, 0], [19, 1, 0], [12, 0, 0], [17, 1, 0], [15, 1, 1], [18, 1, 1], [3, 0, 2], [4, 0, 2], [8, 1, 2], [9, 1, 2], [13, 0, 2], ], columns=["input", "target", "split"], ) ) ================================================ FILE: tests/ludwig/utils/test_date_utils.py ================================================ import datetime from contextlib import nullcontext as does_not_raise from typing import Any, ContextManager import pytest from ludwig.utils.date_utils import convert_number_to_datetime @pytest.fixture(scope="module") def reference_datetime() -> datetime.datetime: return datetime.datetime.fromtimestamp(1691600953.443032, tz=datetime.UTC).replace(tzinfo=None) @pytest.mark.parametrize( "timestamp,raises", [ pytest.param(1691600953.443032, does_not_raise(), id="float-s"), pytest.param(1691600953443.032, does_not_raise(), id="float-ms"), pytest.param(1691600953, does_not_raise(), id="int-s"), pytest.param(1691600953443, does_not_raise(), id="int-ms"), pytest.param("1691600953.443032", does_not_raise(), id="string[float]-s"), pytest.param("1691600953443.0032", does_not_raise(), id="string[float]-ms"), pytest.param("1691600953", does_not_raise(), id="string[int]-s"), pytest.param("1691600953443", does_not_raise(), id="string[int]-ms"), pytest.param("foo", pytest.raises(ValueError), id="string error"), pytest.param([1691600953.443032], pytest.raises(ValueError), id="list error"), pytest.param(datetime.datetime(2023, 8, 9, 13, 9, 13), pytest.raises(ValueError), id="datetime error"), pytest.param(None, pytest.raises(ValueError), id="NoneType error"), ], ) def test_convert_number_to_datetime(reference_datetime: datetime.datetime, timestamp: Any, raises: ContextManager): """Ensure that numeric timestamps are correctly converted to datetime objects. Args: reference_datetime: A datetime object with the expected date/time timestamp: The timestamp to convert in s or ms raises: context manager to check for expected exceptions """ with raises: dt = convert_number_to_datetime(timestamp) # Check that the returned datetime is accurate to the scale of seconds. assert dt.year == reference_datetime.year assert dt.month == reference_datetime.month assert dt.day == reference_datetime.day assert dt.hour == reference_datetime.hour assert dt.minute == reference_datetime.minute assert dt.second == reference_datetime.second ================================================ FILE: tests/ludwig/utils/test_defaults.py ================================================ import copy import pytest from ludwig.constants import ( CATEGORY, COMBINER, DECODER, DEFAULTS, DEPENDENCIES, DROP_ROW, EARLY_STOP, ENCODER, EXECUTOR, FILL_WITH_MODE, HYPEROPT, INPUT_FEATURES, LOSS, MISSING_VALUE_STRATEGY, MODEL_ECD, MODEL_TYPE, OUTPUT_FEATURES, PREPROCESSING, REDUCE_DEPENDENCIES, REDUCE_INPUT, SCHEDULER, SUM, TIED, TOP_K, TRAINER, TYPE, ) from ludwig.schema.model_config import ModelConfig from ludwig.schema.trainer import ECDTrainerConfig from ludwig.utils.backward_compatibility import upgrade_config_dict_to_latest_version from ludwig.utils.misc_utils import merge_dict, set_default_values from tests.integration_tests.utils import ( binary_feature, category_feature, number_feature, sequence_feature, text_feature, vector_feature, ) HYPEROPT_CONFIG = { "parameters": { "trainer.learning_rate": { "space": "loguniform", "lower": 0.001, "upper": 0.1, }, "combiner.num_fc_layers": {"space": "randint", "lower": 2, "upper": 6}, "utterance.encoder.norm": {"space": "grid_search", "values": ["layer", "batch"]}, "utterance.encoder.dropout": {"space": "choice", "categories": [0.0001, 0.001, 0.01]}, "utterance.encoder.fc_layers": { "space": "choice", "categories": [ [{"output_size": 512}, {"output_size": 256}], [{"output_size": 512}], [{"output_size": 256}], ], }, }, "search_alg": {"type": "variant_generator"}, "executor": {"type": "ray"}, "goal": "minimize", } SCHEDULER_DICT = {"type": "async_hyperband", "time_attr": "time_total_s"} @pytest.mark.parametrize( "use_train,use_hyperopt_scheduler", [ (True, True), (False, True), (True, False), (False, False), ], ) def test_merge_with_defaults_early_stop(use_train, use_hyperopt_scheduler): all_input_features = [ binary_feature(), category_feature(), number_feature(), text_feature(name="utterance"), ] all_output_features = [ category_feature(output_feature=True), sequence_feature(output_feature=True), vector_feature(), ] # validate config with all features config = { INPUT_FEATURES: all_input_features, OUTPUT_FEATURES: all_output_features, HYPEROPT: HYPEROPT_CONFIG, } config = copy.deepcopy(config) if use_train: config[TRAINER] = {"batch_size": 42} if use_hyperopt_scheduler: # hyperopt scheduler cannot be used with early stopping config[HYPEROPT][EXECUTOR][SCHEDULER] = SCHEDULER_DICT merged_config = ModelConfig.from_dict(config).to_dict() # When a scheulder is provided, early stopping in the rendered config needs to be disabled to allow the # hyperopt scheduler to manage trial lifecycle. expected = -1 if use_hyperopt_scheduler else ECDTrainerConfig().early_stop assert merged_config[TRAINER]["early_stop"] == expected def test_missing_outputs_drop_rows(): config = { INPUT_FEATURES: [category_feature()], OUTPUT_FEATURES: [category_feature(output_feature=True)], DEFAULTS: {CATEGORY: {PREPROCESSING: {MISSING_VALUE_STRATEGY: FILL_WITH_MODE}}}, } merged_config = ModelConfig.from_dict(config).to_dict() global_preprocessing = merged_config[DEFAULTS] input_feature_config = merged_config[INPUT_FEATURES][0] output_feature_config = merged_config[OUTPUT_FEATURES][0] assert output_feature_config[PREPROCESSING][MISSING_VALUE_STRATEGY] == DROP_ROW assert global_preprocessing[input_feature_config[TYPE]][PREPROCESSING][MISSING_VALUE_STRATEGY] == FILL_WITH_MODE feature_preprocessing = merge_dict( global_preprocessing[output_feature_config[TYPE]][PREPROCESSING], output_feature_config[PREPROCESSING] ) assert feature_preprocessing[MISSING_VALUE_STRATEGY] == DROP_ROW def test_default_model_type(): config = { INPUT_FEATURES: [category_feature()], OUTPUT_FEATURES: [category_feature(output_feature=True)], } merged_config = ModelConfig.from_dict(config).to_dict() assert merged_config[MODEL_TYPE] == MODEL_ECD def test_set_default_values(): config = { INPUT_FEATURES: [number_feature(encoder={"max_sequence_length": 10})], OUTPUT_FEATURES: [category_feature(decoder={})], } assert TIED not in config[INPUT_FEATURES][0] assert TOP_K not in config[OUTPUT_FEATURES][0] assert DEPENDENCIES not in config[OUTPUT_FEATURES][0] assert REDUCE_INPUT not in config[OUTPUT_FEATURES][0] assert REDUCE_DEPENDENCIES not in config[OUTPUT_FEATURES][0] set_default_values(config[INPUT_FEATURES][0], {ENCODER: {TYPE: "passthrough"}, TIED: None}) set_default_values( config[OUTPUT_FEATURES][0], { DECODER: { TYPE: "classifier", }, TOP_K: 3, DEPENDENCIES: [], REDUCE_INPUT: SUM, REDUCE_DEPENDENCIES: SUM, }, ) assert config[INPUT_FEATURES][0][ENCODER][TYPE] == "passthrough" assert config[INPUT_FEATURES][0][TIED] is None assert config[OUTPUT_FEATURES][0][DECODER][TYPE] == "classifier" assert config[OUTPUT_FEATURES][0][TOP_K] == 3 assert config[OUTPUT_FEATURES][0][DEPENDENCIES] == [] assert config[OUTPUT_FEATURES][0][REDUCE_INPUT] == SUM assert config[OUTPUT_FEATURES][0][REDUCE_DEPENDENCIES] == SUM def test_merge_with_defaults(): # configuration with legacy parameters legacy_config_format = { "ludwig_version": "0.4", INPUT_FEATURES: [ {"type": "numerical", "name": "number_input_feature", "encoder": {"type": "dense"}}, { "type": "image", "name": "image_input_feature", "encoder": "stacked_cnn", "conv_bias": True, "conv_layers": [ {"num_filters": 32, "pool_size": 2, "pool_stride": 2, "bias": False}, { "num_filters": 64, "pool_size": 2, "pool_stride": 2, }, ], }, ], OUTPUT_FEATURES: [ { "type": "numerical", "name": "number_output_feature", }, ], "training": {"eval_batch_size": 0, "optimizer": {"type": "adadelta"}}, HYPEROPT: { "parameters": { "training.learning_rate": {"space": "choice", "categories": [0.0001, 0.001, 0.01]}, "training.early_stop": {"space": "choice", "categories": [5, 10, 15]}, "number_input_feature.encoder.num_layers": {"space": "choice", "categories": [2, 3, 4]}, "number_output_feature.decoder.fc_output_size": {"space": "choice", "categories": [128, 256, 512]}, "number_output_feature.decoder.fc_dropout": {"space": "uniform", "lower": 0, "upper": 1}, }, "executor": { "type": "serial", "search_alg": {TYPE: "variant_generator"}, }, "sampler": { "num_samples": 99, "scheduler": {}, }, }, } updated_config = upgrade_config_dict_to_latest_version(legacy_config_format) merged_config = ModelConfig.from_dict(updated_config).to_dict() assert len(merged_config[DEFAULTS]) == 13 assert ENCODER in merged_config[DEFAULTS][CATEGORY] assert PREPROCESSING in merged_config[DEFAULTS][CATEGORY] assert DECODER in merged_config[DEFAULTS][CATEGORY] assert LOSS in merged_config[DEFAULTS][CATEGORY] assert COMBINER in merged_config assert merged_config[TRAINER][EARLY_STOP] == 5 assert SCHEDULER in merged_config[HYPEROPT][EXECUTOR] assert merged_config[HYPEROPT][EXECUTOR][SCHEDULER]["type"] == "fifo" assert TYPE in merged_config[INPUT_FEATURES][1][ENCODER] assert TYPE in merged_config[OUTPUT_FEATURES][0][DECODER] ================================================ FILE: tests/ludwig/utils/test_error_handling_utils.py ================================================ import pytest from ludwig.constants import TRIES from ludwig.utils.error_handling_utils import default_retry def test_default_retry_success(): ctr = 0 @default_retry() def flaky_function(): nonlocal ctr if ctr < TRIES - 1: ctr += 1 raise Exception(f"Ctr: {ctr} too low.") return flaky_function() def test_default_retry_failure(): ctr = 0 @default_retry() def flaky_function(): nonlocal ctr if ctr < TRIES: ctr += 1 raise Exception(f"Ctr: {ctr} too low.") return with pytest.raises(Exception): flaky_function() def test_default_retry_success_custom_num_tries(): CUSTOM_TRIES = 3 ctr = 0 @default_retry(tries=CUSTOM_TRIES) def flaky_function(): nonlocal ctr if ctr < CUSTOM_TRIES - 1: ctr += 1 raise Exception(f"Ctr: {ctr} too low.") return flaky_function() ================================================ FILE: tests/ludwig/utils/test_errors.py ================================================ import pickle from ludwig.error import ConfigValidationError, InputDataError def test_input_data_error_serializeable(): err = InputDataError( "location", "category", "At least 2 distinct values are required, column only contains ['here']" ) loaded_err: InputDataError = pickle.loads(pickle.dumps(err)) assert loaded_err.column_name == err.column_name assert loaded_err.feature_type == err.feature_type assert loaded_err.message == err.message assert str(err) == str(loaded_err) def test_config_validation_error_serializeable(): err = ConfigValidationError(message="At least 2 distinct values are required, column only contains ['here']") loaded_err: ConfigValidationError = pickle.loads(pickle.dumps(err)) assert loaded_err.message == err.message assert str(err) == str(loaded_err) ================================================ FILE: tests/ludwig/utils/test_fs_utils.py ================================================ import logging import os import platform import tempfile from urllib.parse import quote import pytest from ludwig.utils.fs_utils import get_fs_and_path, list_file_names_in_directory, safe_move_directory logger = logging.getLogger(__name__) def create_file(url): _, path = get_fs_and_path(url) logger.info(f"saving url '{url}' to path '{path}'") with tempfile.TemporaryDirectory() as tmpdir: file_path = os.path.join(tmpdir, path) os.makedirs(os.path.dirname(file_path)) with open(file_path, "w"): return path @pytest.mark.filesystem def test_get_fs_and_path_simple(): assert create_file("http://a/b.jpg") == os.path.join("a", "b.jpg") @pytest.mark.filesystem def test_get_fs_and_path_query_string(): assert create_file("http://a/b.jpg?c=d") == os.path.join("a", "b.jpg") @pytest.mark.filesystem def test_get_fs_and_path_decode(): assert create_file("http://a//b%20c.jpg") == os.path.join("a", "b c.jpg") @pytest.mark.filesystem def test_get_fs_and_path_unicode(): assert create_file("http://a/æ.jpg") == "a/æ.jpg" @pytest.mark.filesystem @pytest.mark.skipif(platform.system() == "Windows", reason="Skipping if windows.") def test_get_fs_and_path_invalid_linux(): invalid_chars = { "\x00": ValueError, "/": FileExistsError, } for c, e in invalid_chars.items(): url = f"http://a/{quote(c)}" with pytest.raises(e): create_file(url) @pytest.mark.filesystem @pytest.mark.skipif(platform.system() != "Windows", reason="Skipping if not windows.") def test_get_fs_and_path_invalid_windows(): invalid_chars = { "\x00": ValueError, "\\": FileExistsError, "/": OSError, ":": OSError, "*": OSError, "?": OSError, '"': OSError, "<": OSError, ">": OSError, "|": OSError, } for c, e in invalid_chars.items(): url = f"http://a/{quote(c)}" with pytest.raises(e): create_file(url) @pytest.mark.filesystem def test_safe_move_directory(tmpdir): src_dir = os.path.join(tmpdir, "src") dst_dir = os.path.join(tmpdir, "dst") os.mkdir(src_dir) os.mkdir(dst_dir) with open(os.path.join(src_dir, "file.txt"), "w") as f: f.write("test") safe_move_directory(src_dir, dst_dir) assert not os.path.exists(src_dir) assert os.path.exists(os.path.join(dst_dir, "file.txt")) with open(os.path.join(dst_dir, "file.txt")) as f: assert f.read() == "test" @pytest.mark.filesystem def test_list_file_names_in_directory(tmpdir): my_dir = os.path.join(tmpdir, "my_dir") os.mkdir(my_dir) with open(os.path.join(my_dir, "my_file.txt"), "w") as f: f.write("test_0") with open(os.path.join(my_dir, "my_other_file.txt"), "w") as f: f.write("test_1") assert set(list_file_names_in_directory(directory_name=my_dir)) == {"my_file.txt", "my_other_file.txt"} ================================================ FILE: tests/ludwig/utils/test_heuristics.py ================================================ from typing import Any import pytest from ludwig.constants import DEFAULTS, ENCODER, TEXT, TRAINABLE, TRAINER, TYPE from ludwig.schema.model_config import ModelConfig from ludwig.utils.heuristics import get_auto_learning_rate @pytest.mark.parametrize( "text_encoder,expected_lr", [ (None, 0.001), ({}, 0.00001), ({"type": "parallel_cnn"}, 0.0001), ({"type": "bert"}, 0.00002), ({"type": "bert", "trainable": True}, 0.00001), ({"type": "bert", "trainable": True, "use_pretrained": False}, 0.0001), ], ids=["no_text", "default_electra", "parallel_cnn", "bert_fixed", "bert_trainable", "bert_untrained"], ) def test_get_auto_learning_rate(text_encoder: dict[str, Any] | None, expected_lr: float): input_features = [{"name": "bin1", "type": "binary"}] if text_encoder is not None: input_features.append({"name": "text1", "type": "text", "encoder": text_encoder}) config = { "input_features": input_features, "output_features": [{"name": "bin2", "type": "binary"}], TRAINER: { "train_steps": 1, "learning_rate": "auto", }, DEFAULTS: { TEXT: { ENCODER: { # Note that encoder defaults are all or nothing: if the encoder type is overridden, trainable # here is ignored TYPE: "electra", TRAINABLE: True, } } }, } config = ModelConfig.from_dict(config) lr = get_auto_learning_rate(config) assert lr == expected_lr ================================================ FILE: tests/ludwig/utils/test_hf_utils.py ================================================ import os import shutil import pytest from transformers import AlbertModel, BertModel, BertTokenizer from ludwig.encoders.text_encoders import ALBERTEncoder, BERTEncoder from ludwig.utils.hf_utils import ( load_pretrained_hf_model_from_hub, load_pretrained_hf_model_with_hub_fallback, upload_folder_to_hfhub, ) @pytest.mark.parametrize( ("model", "name"), [ (AlbertModel, ALBERTEncoder.DEFAULT_MODEL_NAME), (BertTokenizer, "bert-base-uncased"), ], ) def test_load_pretrained_hf_model_from_hub(model: type, name: str, tmpdir: os.PathLike): """Ensure that the HF models used in ludwig download correctly.""" cache_dir = os.path.join(tmpdir, name.replace(os.path.sep, "_") if name else str(model.__name__)) os.makedirs(cache_dir, exist_ok=True) loaded_model = load_pretrained_hf_model_from_hub(model, name, cache_dir=cache_dir, force_download=True) assert isinstance(loaded_model, model) assert os.listdir(cache_dir) def test_load_pretrained_hf_model_with_hub_fallback(tmpdir): """Ensure that the HF models used in ludwig download correctly with S3 or hub fallback.""" # Don't set env var. _, used_fallback = load_pretrained_hf_model_with_hub_fallback(AlbertModel, ALBERTEncoder.DEFAULT_MODEL_NAME) assert used_fallback # Download the model, load it from tmpdir, and set env var. load_pretrained_hf_model_from_hub(AlbertModel, "albert-base-v2").save_pretrained( os.path.join(tmpdir, "albert-base-v2") ) os.environ["LUDWIG_PRETRAINED_MODELS_DIR"] = f"file://{tmpdir}" # noqa: E231 # Needs to be an absolute path. _, used_fallback = load_pretrained_hf_model_with_hub_fallback(AlbertModel, ALBERTEncoder.DEFAULT_MODEL_NAME) assert not used_fallback # Fallback is used for a model that doesn't exist in models directory. _, used_fallback = load_pretrained_hf_model_with_hub_fallback(BertModel, BERTEncoder.DEFAULT_MODEL_NAME) assert used_fallback # Clean up. del os.environ["LUDWIG_PRETRAINED_MODELS_DIR"] @pytest.fixture def tmp_folder_with_file(tmpdir): # Create a temporary folder tmp_folder = str(tmpdir.mkdir("tmp_folder")) # Create a file within the temporary folder file_path = os.path.join(tmp_folder, "test_file.txt") with open(file_path, "w") as f: f.write("Test content") yield tmp_folder # Clean up: Remove the temporary folder and its contents shutil.rmtree(tmp_folder) def test_upload_folder_to_hfhub_folder_not_exist(): with pytest.raises(FileNotFoundError, match=r"Folder .* does not exist."): upload_folder_to_hfhub("test_repo", "/nonexistent_folder") def test_upload_folder_to_hfhub_folder_empty(tmpdir): empty_folder = str(tmpdir.mkdir("empty_folder")) with pytest.raises(ValueError, match=r"Folder .* is empty."): upload_folder_to_hfhub("test_repo", empty_folder) def test_upload_folder_to_hfhub_folder_is_file(tmpdir): file_path = str(tmpdir.join("test_file.txt")) with open(file_path, "w") as f: f.write("Test content") with pytest.raises(ValueError, match=r"Folder .* is a file. Please provide a folder."): upload_folder_to_hfhub("test_repo", file_path) def test_upload_folder_to_hfhub_invalid_repo_type(tmp_folder_with_file): with pytest.raises(ValueError, match=r"Invalid repo_type .*"): upload_folder_to_hfhub("test_repo", tmp_folder_with_file, repo_type="invalid_type") ================================================ FILE: tests/ludwig/utils/test_hyperopt_ray_utils.py ================================================ # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import pytest try: from ray import tune from ludwig.hyperopt.execution import get_build_hyperopt_executor except ImportError: RAY_AVAILABLE = False else: RAY_AVAILABLE = True # from ludwig.hyperopt.sampling import RayTuneSampler TDOO: remove from ludwig.constants import RAY, TYPE HYPEROPT_PARAMS = { "test_1": { "parameters": { "trainer.learning_rate": {"space": "uniform", "lower": 0.001, "upper": 0.1}, "combiner.num_fc_layers": {"space": "qrandint", "lower": 3, "upper": 6, "q": 3}, "utterance.cell_type": {"space": "grid_search", "values": ["rnn", "gru", "lstm"]}, }, }, "test_2": { "parameters": { "trainer.learning_rate": { "space": "loguniform", "lower": 0.001, "upper": 0.1, "base": 10, }, "combiner.num_fc_layers": {"space": "randint", "lower": 2, "upper": 6}, "utterance.cell_type": {"space": "choice", "categories": ["rnn", "gru", "lstm"]}, }, }, } if RAY_AVAILABLE: EXPECTED_SEARCH_SPACE = { "test_1": { "trainer.learning_rate": tune.uniform(0.001, 0.1), "combiner.num_fc_layers": tune.qrandint(3, 6, 3), "utterance.cell_type": tune.grid_search(["rnn", "gru", "lstm"]), }, "test_2": { "trainer.learning_rate": tune.loguniform(0.001, 0.1), "combiner.num_fc_layers": tune.randint(2, 6), "utterance.cell_type": tune.choice(["rnn", "gru", "lstm"]), }, } @pytest.mark.skipif(not RAY_AVAILABLE, reason="Ray is not installed for testing") @pytest.mark.parametrize("key", ["test_1", "test_2"]) def test_grid_strategy(key): hyperopt_test_params = HYPEROPT_PARAMS[key] expected_search_space = EXPECTED_SEARCH_SPACE[key] tune_sampler_params = hyperopt_test_params["parameters"] hyperopt_executor = get_build_hyperopt_executor(RAY)( tune_sampler_params, "output_feature", "mse", "minimize", "validation", search_alg={TYPE: "variant_generator"}, **{"type": "ray", "num_samples": 2, "scheduler": {"type": "fifo"}} ) search_space = hyperopt_executor.search_space actual_params_keys = search_space.keys() expected_params_keys = expected_search_space.keys() for param in search_space: assert isinstance(search_space[param], type(expected_search_space[param])) assert actual_params_keys == expected_params_keys ================================================ FILE: tests/ludwig/utils/test_image_utils.py ================================================ # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== from collections.abc import Callable import pytest import torch import torchvision.transforms.functional as F from ludwig.utils.image_utils import ( crop, crop_or_pad, get_class_mask_from_image, get_image_from_class_mask, get_unique_channels, grayscale, is_image_score, num_channels_in_image, pad, read_image_as_tif, resize_image, ResizeChannels, ) @pytest.mark.parametrize("pad_fn", [pad, torch.jit.script(pad)]) @pytest.mark.parametrize( "img,size,padded_img", [ ( torch.arange(12, dtype=torch.int).reshape(3, 2, 2), 4, torch.Tensor( [ 0, 0, 1, 1, 0, 0, 1, 1, 2, 2, 3, 3, 2, 2, 3, 3, 4, 4, 5, 5, 4, 4, 5, 5, 6, 6, 7, 7, 6, 6, 7, 7, 8, 8, 9, 9, 8, 8, 9, 9, 10, 10, 11, 11, 10, 10, 11, 11, ] ) .type(torch.int) .reshape(3, 4, 4), ) ], ) def test_pad(pad_fn: Callable, img: torch.Tensor, size: int, padded_img: torch.Tensor): output_img = pad_fn(img, size) assert torch.equal(output_img, padded_img) @pytest.mark.parametrize("crop_fn", [crop, torch.jit.script(crop)]) @pytest.mark.parametrize( "img,size,cropped_img", [ ( torch.arange(27, dtype=torch.int).reshape(3, 3, 3), 2, torch.Tensor([0, 1, 3, 4, 9, 10, 12, 13, 18, 19, 21, 22]).type(torch.int).reshape(3, 2, 2), ) ], ) def test_crop(crop_fn: Callable, img: torch.Tensor, size: int, cropped_img: torch.Tensor): output_img = crop_fn(img, size) assert torch.equal(output_img, cropped_img) @pytest.mark.parametrize("crop_or_pad_fn", [crop_or_pad, torch.jit.script(crop_or_pad)]) @pytest.mark.parametrize( "img,new_size,expected_img", [ ( torch.arange(12, dtype=torch.int).reshape(3, 2, 2), 4, torch.Tensor( [ 0, 0, 1, 1, 0, 0, 1, 1, 2, 2, 3, 3, 2, 2, 3, 3, 4, 4, 5, 5, 4, 4, 5, 5, 6, 6, 7, 7, 6, 6, 7, 7, 8, 8, 9, 9, 8, 8, 9, 9, 10, 10, 11, 11, 10, 10, 11, 11, ] ) .type(torch.int) .reshape(3, 4, 4), ), ( torch.arange(27, dtype=torch.int).reshape(3, 3, 3), 2, torch.Tensor([0, 1, 3, 4, 9, 10, 12, 13, 18, 19, 21, 22]).type(torch.int).reshape(3, 2, 2), ), ], ) def test_crop_or_pad(crop_or_pad_fn: Callable, img: torch.Tensor, new_size: int, expected_img: torch.Tensor): output_image = crop_or_pad_fn(img, new_size) assert torch.equal(output_image, expected_img) @pytest.mark.parametrize("resize_image_fn", [resize_image, torch.jit.script(resize_image)]) @pytest.mark.parametrize( "img,new_size,resize_method", [ ( torch.arange(27, dtype=torch.int).reshape(3, 3, 3), 2, "crop_or_pad", ), ( torch.arange(27, dtype=torch.int).reshape(3, 3, 3), 2, "interpolate", ), ], ) def test_resize_image(resize_image_fn: Callable, img: torch.Tensor, new_size: int, resize_method: str): # Get the expected output from the underlying function if resize_method == "crop_or_pad": expected_img = crop_or_pad(img, new_size) else: expected_img = F.resize(img, new_size) output_img = resize_image_fn(img, new_size, resize_method) # Test that resize_image is equivalent to the underlying function output assert torch.equal(output_img, expected_img) @pytest.mark.parametrize("grayscale_fn", [grayscale, torch.jit.script(grayscale)]) @pytest.mark.parametrize( "input_img,grayscale_img", [(torch.arange(12).reshape(3, 2, 2).type(torch.int), torch.Tensor([[[3, 4], [5, 6]]]).type(torch.int))], ) def test_grayscale(grayscale_fn: Callable, input_img: torch.Tensor, grayscale_img: torch.Tensor): output_img = grayscale_fn(input_img) assert torch.equal(output_img, grayscale_img) def test_num_channels_in_image(): image_2d = torch.randint(0, 1, (10, 10)) image_3d = torch.randint(0, 1, (3, 10, 10)) assert num_channels_in_image(image_2d) == 1 assert num_channels_in_image(image_3d) == 3 with pytest.raises(ValueError): num_channels_in_image(torch.rand(5)) num_channels_in_image(None) @pytest.mark.parametrize("image_shape", [(1, 10, 10), (3, 10, 10), (5, 10, 10)]) @pytest.mark.parametrize("num_channels_expected", [1, 2, 3, 4]) def test_ResizeChannels_module(image_shape, num_channels_expected): image = torch.randint(0, 1, image_shape) fn = ResizeChannels(num_channels_expected) assert fn(image).shape == tuple([num_channels_expected] + list(image_shape[1:])) @pytest.mark.parametrize("image_shape", [(2, 1, 10, 10), (2, 3, 10, 10), (2, 5, 10, 10)]) @pytest.mark.parametrize("num_channels_expected", [1, 2, 3, 4]) def test_ResizeChannels_module_with_batch_dim(image_shape, num_channels_expected): image = torch.randint(0, 1, image_shape) fn = ResizeChannels(num_channels_expected) assert fn(image).shape == tuple([image_shape[0], num_channels_expected] + list(image_shape[2:])) def test_read_image_as_tif(): img_bytes = b"II*\x00\x0c\x00\x00\x00\x05 \x8c\xe5\x10\x00\x00\x01\x03\x00\x01\x00\x00\x00\x02\x00\x00\x00\x01\x01\x03\x00\x01\x00\x00\x00\x02\x00\x00\x00\x02\x01\x03\x00\x01\x00\x00\x00\x08\x00\x00\x00\x03\x01\x03\x00\x01\x00\x00\x00\x01\x00\x00\x00\x06\x01\x03\x00\x01\x00\x00\x00\x01\x00\x00\x00\x11\x01\x04\x00\x01\x00\x00\x00\x08\x00\x00\x00\x12\x01\x03\x00\x01\x00\x00\x00\x01\x00\x00\x00\x15\x01\x03\x00\x01\x00\x00\x00\x01\x00\x00\x00\x16\x01\x03\x00\x01\x00\x00\x00\x80\x00\x00\x00\x17\x01\x04\x00\x01\x00\x00\x00\x04\x00\x00\x00\x1a\x01\x05\x00\x01\x00\x00\x00\xd2\x00\x00\x00\x1b\x01\x05\x00\x01\x00\x00\x00\xda\x00\x00\x00\x1c\x01\x03\x00\x01\x00\x00\x00\x01\x00\x00\x00\x1d\x01\x02\x00\x07\x00\x00\x00\xe2\x00\x00\x00(\x01\x03\x00\x01\x00\x00\x00\x02\x00\x00\x00S\x01\x03\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00H\x00\x00\x00\x01\x00\x00\x00H\x00\x00\x00\x01\x00\x00\x004.tiff\x00" # noqa: E501 tensor = read_image_as_tif(img_bytes) assert tensor is not None assert tensor.equal(torch.tensor([[[5, 32], [140, 229]]], dtype=torch.uint8)) @pytest.mark.parametrize( "extension, score", [ ("data.png", 1), ("/home/peter/data.jpg", 1), ("./data/file.jpeg", 1), ("new.tiff", 1), ("b.tif", 1), (".bmp", 1), ("a.gif", 1), ("b.tif", 1), ("audio.wav", 0), (".png/video.mp4", 0), ], ) def test_is_image_score(extension: str, score: int): assert is_image_score(extension) == score @pytest.mark.parametrize( "img_list,num_channels,num_classes,expected_class_map", [ ( [ torch.Tensor([0, 0, 8, 8, 120, 120, 180, 180, 230, 230, 255, 255]).type(torch.uint8).reshape(3, 2, 2), torch.Tensor([1, 2, 3, 4, 131, 132, 133, 134, 241, 242, 243, 244]).type(torch.uint8).reshape(3, 2, 2), ], 3, None, torch.Tensor( [[0, 120, 230], [8, 180, 255], [1, 131, 241], [2, 132, 242], [3, 133, 243], [4, 134, 244]] ).type(torch.uint8), ), ( [ torch.Tensor([0, 255, 255, 0, 255, 255, 255, 0, 0, 0, 255, 255, 0, 255, 255]) .type(torch.uint8) .reshape(1, 3, 5), ], 1, None, torch.Tensor([[0], [255]]).type(torch.uint8), ), ( [ torch.Tensor([0, 31, 17, 185, 192, 173, 55, 76, 24, 128, 255, 238]).type(torch.uint8).reshape(3, 4), ], 1, 2, torch.Tensor([[0], [255]]).type(torch.uint8), ), ], ) def test_unique_channels( img_list: list[torch.Tensor], num_channels: int, num_classes: int, expected_class_map: torch.Tensor ): channel_class_map = get_unique_channels(img_list, num_channels, num_classes) channel_class_map, _ = channel_class_map.sort(dim=0) expected_class_map, _ = expected_class_map.sort(dim=0) assert torch.equal(channel_class_map, expected_class_map) @pytest.mark.parametrize( "img,channel_class_map,expected_mask", [ ( torch.Tensor([1, 2, 3, 4, 131, 132, 133, 134, 241, 242, 243, 244]).type(torch.uint8).reshape(3, 2, 2), torch.Tensor( [[0, 120, 230], [8, 180, 255], [1, 131, 241], [2, 132, 242], [3, 133, 243], [4, 134, 244]] ).type(torch.uint8), torch.Tensor([2, 3, 4, 5]).type(torch.uint8).reshape(2, 2), ), ( torch.Tensor([0, 255, 255, 0, 255, 255, 255, 0, 0, 0, 255, 255, 0, 255, 255]) .type(torch.uint8) .reshape(1, 3, 5), torch.Tensor([[0], [255]]).type(torch.uint8), torch.Tensor([0, 1, 1, 0, 1, 1, 1, 0, 0, 0, 1, 1, 0, 1, 1]).type(torch.uint8).reshape(3, 5), ), ( torch.Tensor([0, 31, 17, 185, 192, 173, 55, 76, 24, 128, 255, 238]).type(torch.uint8).reshape(3, 4), torch.Tensor([[0], [255]]).type(torch.uint8), torch.Tensor([0, 0, 0, 1, 1, 1, 0, 0, 0, 1, 1, 1]).type(torch.uint8).reshape(3, 4), ), ], ) def test_class_mask_from_image(img: torch.Tensor, channel_class_map: torch.Tensor, expected_mask: torch.Tensor): mask = get_class_mask_from_image(channel_class_map, img) assert torch.equal(mask, expected_mask) @pytest.mark.parametrize( "mask,channel_class_map,expected_img", [ ( torch.Tensor([0, 0, 1, 1]).type(torch.uint8).reshape(2, 2), torch.Tensor( [[0, 120, 230], [8, 180, 255], [1, 131, 241], [2, 132, 242], [3, 133, 243], [4, 134, 244]] ).type(torch.uint8), torch.Tensor([0, 0, 8, 8, 120, 120, 180, 180, 230, 230, 255, 255]).type(torch.uint8).reshape(3, 2, 2), ), ( torch.Tensor([2, 3, 4, 5]).type(torch.uint8).reshape(2, 2), torch.Tensor( [[0, 120, 230], [8, 180, 255], [1, 131, 241], [2, 132, 242], [3, 133, 243], [4, 134, 244]] ).type(torch.uint8), torch.Tensor([1, 2, 3, 4, 131, 132, 133, 134, 241, 242, 243, 244]).type(torch.uint8).reshape(3, 2, 2), ), ( torch.Tensor([0, 1, 1, 0, 1, 1, 1, 0, 0, 0, 1, 1, 0, 1, 1]).type(torch.uint8).reshape(3, 5), torch.Tensor([[0], [255]]).type(torch.uint8), torch.Tensor([0, 255, 255, 0, 255, 255, 255, 0, 0, 0, 255, 255, 0, 255, 255]) .type(torch.uint8) .reshape(1, 3, 5), ), ], ) def test_image_from_class_mask(mask: torch.Tensor, channel_class_map: torch.Tensor, expected_img: torch.Tensor): img = get_image_from_class_mask(channel_class_map, mask.numpy()) assert torch.equal(torch.from_numpy(img), expected_img) ================================================ FILE: tests/ludwig/utils/test_llm_utils.py ================================================ import pytest import torch from transformers import AutoConfig, AutoModelForCausalLM from ludwig.constants import LOGITS, PREDICTIONS, PROBABILITIES from ludwig.modules.training_hooks import NEFTuneHook from ludwig.utils.llm_utils import ( add_left_padding, create_attention_mask, FALLBACK_CONTEXT_LEN, find_last_matching_index, generate_merged_ids, get_context_len, get_realigned_target_and_prediction_tensors_for_inference, has_padding_token, pad_target_tensor_for_fine_tuning, remove_left_padding, ) from ludwig.utils.tokenizers import HFTokenizer pytestmark = [pytest.mark.llm] # Pad token ID is 1 for OPT even though it uses the GPT2 tokenizer # BOS token ID is 2 TEST_MODEL_NAME = "hf-internal-testing/tiny-random-OPTForCausalLM" @pytest.fixture def tokenizer(): return HFTokenizer(TEST_MODEL_NAME).tokenizer @pytest.fixture def input_ids(): # Provide sample input IDs tensor return torch.tensor([[3, 4, 5], [6, 7, 8]]) @pytest.fixture def target_ids(): # Provide sample target IDs tensor return torch.tensor([[9, 10, 11], [12, 13, 14]]) class TestSetContextLen: def test_max_sequence_length(self): # Test when 'max_sequence_length' is present in the model configuration config = AutoConfig.from_pretrained("huggyllama/llama-7b") assert get_context_len(config) == config.max_sequence_length def test_max_position_embeddings(self): # Test when 'max_position_embeddings' is present in the model configuration config = AutoConfig.from_pretrained("huggyllama/llama-7b") del config.max_sequence_length assert get_context_len(config) == config.max_position_embeddings def test_n_positions(self): # Test when 'n_positions' is present in the model configuration config = AutoConfig.from_pretrained("hf-internal-testing/tiny-random-GPTJForCausalLM") assert get_context_len(config) == config.n_positions def test_default_value(self): config = AutoConfig.from_pretrained("hf-internal-testing/tiny-random-GPTJForCausalLM") del config.n_positions assert get_context_len(config) == FALLBACK_CONTEXT_LEN def test_has_padding_token_with_padding_tokens(tokenizer): input_sentence = "This is an example sentence." input_ids = tokenizer([input_sentence]) input_ids["input_ids"] = torch.tensor(input_ids["input_ids"]) padded_input_ids = torch.nn.functional.pad(input_ids["input_ids"], (10 - len(input_ids["input_ids"]), 1), value=1) assert has_padding_token(padded_input_ids, tokenizer) def test_has_padding_token_without_padding_tokens(tokenizer): input_sentence = "This is an example sentence." input_ids = tokenizer([input_sentence]) input_ids["input_ids"] = torch.tensor(input_ids["input_ids"]) assert not has_padding_token(input_ids["input_ids"], tokenizer) @pytest.mark.parametrize( "input_ids, expected", [ # No padding (torch.tensor([5]), torch.tensor([5])), (torch.tensor([5, 3]), torch.tensor([5, 3])), # Padding (torch.tensor([1, 5, 5, 3]), torch.tensor([5, 5, 3])), # EOS token (torch.tensor([2, 5, 5, 3]), torch.tensor([2, 5, 5, 3])), # Padding + EOS token (torch.tensor([1, 2, 5, 5, 3]), torch.tensor([2, 5, 5, 3])), ], ) def test_remove_left_padding(input_ids, expected, tokenizer): assert torch.equal(remove_left_padding(input_ids, tokenizer).squeeze(0), expected) @pytest.mark.parametrize( "input_ids, max_length, pad_value, expected", [ (torch.tensor([1, 2, 3]), 3, 0, torch.tensor([1, 2, 3])), (torch.tensor([1, 2, 3]), 5, 0, torch.tensor([0, 0, 1, 2, 3])), (torch.tensor([4, 5, 6, 7]), 6, 2, torch.tensor([2, 2, 4, 5, 6, 7])), (torch.tensor([8, 9]), 3, 1, torch.tensor([1, 8, 9])), ], ) def test_add_left_padding(input_ids, max_length, pad_value, expected): padded = add_left_padding(input_ids, max_length, pad_value).squeeze(0) assert torch.equal(padded, expected) def test_create_attention_mask_last_token_padding(tokenizer): input_ids = torch.tensor([3, 4, tokenizer.pad_token_id]) attention_mask = create_attention_mask(input_ids, tokenizer) assert attention_mask[-1] == 1 @pytest.mark.parametrize( "input_ids, expected_output", [ # No padding (torch.tensor([3, 4, 5]), torch.tensor([1, 1, 1])), (torch.tensor([1, 1, 4, 6, 8]), torch.tensor([0, 0, 1, 1, 1])), # All padding (torch.tensor([1, 1, 1]), torch.tensor([0, 0, 1])), ], ) def test_create_attention_mask(input_ids, expected_output, tokenizer): attention_mask = create_attention_mask(input_ids, tokenizer) assert torch.equal(attention_mask, expected_output) @pytest.mark.parametrize( "tensor_a, tensor_b, expected_index", [ # Matching index at the end (torch.tensor([1, 2, 3, 4, 5, 6, 7, 8]), torch.tensor([6, 7, 8]), 5), # No matching index (torch.tensor([1, 2, 3, 4, 5, 6, 7, 8]), torch.tensor([9, 10]), -1), # Matching index in the middle. Fails because we're only checking the last X elements. (torch.tensor([1, 2, 3, 4, 5, 6, 7, 8]), torch.tensor([4, 5, 6]), -1), ], ) def test_find_last_matching_index(tensor_a, tensor_b, expected_index): last_matching_index = find_last_matching_index(tensor_a, tensor_b) assert last_matching_index == expected_index def test_generate_merged_ids_with_target(tokenizer, input_ids, target_ids): # Test case when target_ids is not None merged_ids, attention_masks = generate_merged_ids(input_ids, target_ids, tokenizer) assert torch.equal(merged_ids, torch.tensor([[3, 4, 5, 9, 10, 11, 2], [6, 7, 8, 12, 13, 14, 2]])) assert merged_ids.shape == (2, 7) # Check the shape of merged_ids assert attention_masks.shape == (2, 7) # Check the shape of attention_masks def test_generate_merged_ids_with_max_sequence_length(tokenizer, input_ids, target_ids): # Test case when max_sequence_length is provided max_sequence_length = 5 merged_ids, attention_masks = generate_merged_ids(input_ids, target_ids, tokenizer, max_sequence_length) assert merged_ids.shape == (2, 5) # Check the shape of merged_ids with truncation assert attention_masks.shape == (2, 5) # Check the shape of attention_masks def test_generate_merged_ids_padding_removal(tokenizer, input_ids, target_ids): # Test case to check removal of left padding from inputs and targets during merge padding_tokens = torch.tensor([tokenizer.pad_token_id, tokenizer.pad_token_id]) # Adds 2 padding tokens to the left of input_ids and target_ids individually. Typically, if we just merged this # naively, we would expect to see [1, 1, 3, 4, 5, 1, 1, 9, 10, 11, 1], but we shouldn't see the padding tokens # except for the padding token at the end. input_ids_with_padding = torch.cat((padding_tokens.unsqueeze(0).expand(input_ids.size(0), -1), input_ids), dim=1) target_ids_with_padding = torch.cat((padding_tokens.unsqueeze(0).expand(target_ids.size(0), -1), target_ids), dim=1) merged_ids, attention_masks = generate_merged_ids(input_ids_with_padding, target_ids_with_padding, tokenizer) assert merged_ids.shape == (2, 7) # Check the shape of merged_ids assert attention_masks.shape == (2, 7) # Check the shape of attention_masks assert torch.equal(merged_ids[0][:3], input_ids[0]) # Check the input_ids part without padding assert torch.equal(merged_ids[0][3:-1], target_ids[0]) # Check the target_ids part without padding assert torch.equal(merged_ids[0][-1], torch.tensor(tokenizer.eos_token_id)) # Check the padding tokens assert torch.all(attention_masks == 1) def test_generate_merged_ids_returns_tensor(tokenizer, input_ids, target_ids): # Test that the function returns torch.Tensor objects merged_ids, attention_masks = generate_merged_ids(input_ids, target_ids, tokenizer) assert isinstance(merged_ids, torch.Tensor) assert isinstance(attention_masks, torch.Tensor) def test_pad_target_tensor_for_fine_tuning(): of_name = "out_1" prediction = { of_name: {PREDICTIONS: torch.tensor([[764, 764, 764, 764, 764, 764, 764, 578, 619, 841, 182, 905, 483, 764]])} } # Scenario 1: Entire target tensor was passed into model inputs model_input = torch.tensor([[0, 0, 24, 52, 654, 529, 221, 78, 79, 504, 76, 397, 84, 0]]) target = {of_name: torch.tensor([[78, 79, 504, 76, 397, 84, 0]])} expected_target = {of_name: torch.tensor([[-100, -100, -100, -100, -100, -100, -100, 78, 79, 504, 76, 397, 84, 0]])} updated_targets = pad_target_tensor_for_fine_tuning(target, prediction, model_input, of_name) assert torch.equal(expected_target[of_name], updated_targets[of_name]) # Scenario 2: Entire target tensor was not passed into model inputs model_input = torch.tensor([[13, 24, 395, 13, 46, 57, 52, 41, 45, 37, 51, 14, 380, 435]]) target = {of_name: torch.tensor([[78, 79, 504, 76, 397, 84, 0]])} expected_target = { of_name: torch.tensor([[-100, -100, -100, -100, -100, -100, -100, -100, -100, -100, -100, -100, -100, -100]]) } updated_targets = pad_target_tensor_for_fine_tuning(target, prediction, model_input, of_name) assert torch.equal(expected_target[of_name], updated_targets[of_name]) # Scenario 3: Partial target tensor was passed into model inputs model_input = torch.tensor([[0, 0, 24, 52, 654, 529, 221, 78, 79, 504, 76, 78, 79, 504]]) target = {of_name: torch.tensor([[78, 79, 504, 76, 397, 84, 0]])} expected_target = { of_name: torch.tensor([[-100, -100, -100, -100, -100, -100, -100, -100, -100, -100, -100, 78, 79, 504]]) } updated_targets = pad_target_tensor_for_fine_tuning(target, prediction, model_input, of_name) assert torch.equal(expected_target[of_name], updated_targets[of_name]) def test_get_realigned_target_and_prediction_tensors_for_inference(tokenizer): of_name = "out_1" vocab_size = 8 # Scenario 1: Prediction and target tensors have the same length, so nothing should change targets = {of_name: torch.tensor([[78, 79, 504, 76, 397, 84, 0]])} predictions = { of_name: { PREDICTIONS: torch.tensor([[78, 79, 504, 76, 397, 84, 0]], dtype=torch.int64), PROBABILITIES: torch.randn(1, 7, vocab_size).to(torch.float32), LOGITS: torch.randn(1, 7, vocab_size).to(torch.float32), } } updated_targets, updated_predictions = get_realigned_target_and_prediction_tensors_for_inference( targets, predictions, of_name, tokenizer ) assert targets == updated_targets assert predictions == updated_predictions assert predictions[of_name][PREDICTIONS].shape[1] == targets[of_name].shape[1] assert predictions[of_name][PROBABILITIES].shape[1] == targets[of_name].shape[1] assert predictions[of_name][LOGITS].shape[1] == targets[of_name].shape[1] # Scenario 2: Prediction length is longer than the target tensor, so we need to realign the target tensor targets = {of_name: torch.tensor([[78, 79, 504, 76, 397, 84, 0]])} predictions = { of_name: { PREDICTIONS: torch.tensor([[98, 47, 78, 79, 504, 76, 397, 84, 0]], dtype=torch.int64), PROBABILITIES: torch.randn(1, 9, vocab_size).to(torch.float32), LOGITS: torch.randn(1, 9, vocab_size).to(torch.float32), } } updated_targets, updated_predictions = get_realigned_target_and_prediction_tensors_for_inference( targets, predictions, of_name, tokenizer ) for key in updated_predictions.keys(): assert torch.equal(updated_predictions[key][PREDICTIONS], predictions[key][PREDICTIONS]) assert torch.equal(updated_predictions[key][PROBABILITIES], predictions[key][PROBABILITIES]) assert torch.equal(updated_predictions[key][LOGITS], predictions[key][LOGITS]) assert torch.equal(updated_targets[of_name], torch.tensor([[78, 79, 504, 76, 397, 84, 0, 1, 1]])) # Scenario 3: Target length is longer than the prediction tensor, so we need to realign them targets = {of_name: torch.tensor([[98, 47, 78, 79, 504, 76, 397, 84, 0]])} predictions = { of_name: { PREDICTIONS: torch.tensor([[78, 79, 504, 76, 397, 84, 0]], dtype=torch.int64), PROBABILITIES: torch.randn(1, 7, vocab_size).to(torch.float32), LOGITS: torch.randn(1, 7, vocab_size).to(torch.float32), } } updated_targets, updated_predictions = get_realigned_target_and_prediction_tensors_for_inference( targets, predictions, of_name, tokenizer ) assert torch.equal(updated_targets[of_name], targets[of_name]) assert torch.equal(updated_predictions[of_name][PREDICTIONS], torch.tensor([[78, 79, 504, 76, 397, 84, 0, 1, 1]])) assert updated_predictions[of_name][PROBABILITIES].shape[1] == targets[of_name].shape[1] assert updated_predictions[of_name][LOGITS].shape[1] == targets[of_name].shape[1] assert torch.equal(updated_predictions[of_name][PROBABILITIES][0][-1], torch.zeros(vocab_size)) assert torch.equal(updated_predictions[of_name][PROBABILITIES][0][-2], torch.zeros(vocab_size)) assert not torch.equal(updated_predictions[of_name][PROBABILITIES][0][-3], torch.zeros(vocab_size)) assert torch.equal(updated_predictions[of_name][LOGITS][0][-1], torch.zeros(vocab_size)) assert torch.equal(updated_predictions[of_name][LOGITS][0][-2], torch.zeros(vocab_size)) assert not torch.equal(updated_predictions[of_name][LOGITS][0][-3], torch.zeros(vocab_size)) def _setup_models_for_neftune(): module_without_hook = AutoModelForCausalLM.from_pretrained(TEST_MODEL_NAME) module_with_hook = AutoModelForCausalLM.from_pretrained(TEST_MODEL_NAME) # Only module_with_hook should have the NEFTuneHook neftune_hook = NEFTuneHook(neftune_noise_alpha=5) module_with_hook = neftune_hook.activate_hook(module_with_hook) return module_without_hook, module_with_hook def _forward_pass_and_assert_neftune_hook(module_without_hook, module_with_hook, mode): assert module_with_hook.get_input_embeddings()._forward_hooks assert not module_without_hook.get_input_embeddings()._forward_hooks if mode == "train": module_without_hook.train() module_with_hook.train() elif mode == "eval": module_without_hook.eval() module_with_hook.eval() input_tensor = torch.tensor([[1, 2, 3]]) output_tensor_with_noise = module_with_hook.get_input_embeddings()(input_tensor) output_tensor_without_noise = module_without_hook.get_input_embeddings()(input_tensor) if mode == "train": assert not torch.equal(output_tensor_with_noise, output_tensor_without_noise) elif mode == "eval": assert torch.equal(output_tensor_with_noise, output_tensor_without_noise) def test_neftune_hook_with_noise_alpha_train_mode(): """Test that the NEFTuneHook is only applied when the module is in training mode.""" module_without_hook, module_with_hook = _setup_models_for_neftune() _forward_pass_and_assert_neftune_hook(module_without_hook, module_with_hook, mode="train") def test_neftune_hook_with_noise_alpha_eval_mode(): """Test that the NEFTuneHook is not applied when the module is in eval mode.""" module_without_hook, module_with_hook = _setup_models_for_neftune() _forward_pass_and_assert_neftune_hook(module_without_hook, module_with_hook, mode="eval") ================================================ FILE: tests/ludwig/utils/test_metric_utils.py ================================================ from collections import OrderedDict import torch from ludwig.utils import metric_utils from ludwig.utils.metric_utils import TrainerMetric def test_dynamic_partition(): data = torch.Tensor([10, 20, 30, 40, 50]) partitions = torch.Tensor([0, 0, 1, 1, 0]) partitioned_data = metric_utils.dynamic_partition(data, partitions, 2) assert torch.equal(partitioned_data[0], torch.Tensor([10.0, 20.0, 50.0])) assert torch.equal(partitioned_data[1], torch.Tensor([30.0, 40.0])) def test_dynamic_partition_2D(): data = torch.Tensor( [ [1, 2, 3, 4, 5, 6, 7, 8, 9], [10, 11, 12, 13, 14, 15, 16, 17, 18], ] ) partitions = torch.Tensor([[1, 1, 1, 1, 1, 1, 1, 1, 0], [1, 1, 1, 1, 1, 1, 1, 1, 0]]) partitioned_data = metric_utils.dynamic_partition(data, partitions, 2) assert torch.equal(partitioned_data[0], torch.Tensor([9, 18])) assert torch.equal( partitioned_data[1], torch.Tensor([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0]), ) def test_masked_correct_predictions(): preds = torch.tensor([[1, 5, 1, 5, 1, 5, 12, 12, 12], [10, 1, 5, 1, 5, 12, 12, 12, 12]]) targets = torch.tensor([[1, 9, 5, 7, 5, 9, 13, 6, 0], [1, 9, 7, 13, 4, 7, 7, 7, 0]]) targets_sequence_length = torch.tensor([8, 8]) result = metric_utils.masked_correct_predictions(targets, preds, targets_sequence_length) assert torch.equal( result, torch.Tensor([1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]) ) def test_reduce_trainer_metrics_dict(): dict_dict_trainer_metrics = { "feature_name": {"metric_name": [metric_utils.TrainerMetric(epoch=1, step=2, value=10)]} } result = metric_utils.reduce_trainer_metrics_dict(dict_dict_trainer_metrics) assert result == {"feature_name": {"metric_name": [10]}} def test_reduce_trainer_metrics_dict_ordered_dict(): dict_dict_trainer_metrics = OrderedDict( [ ( "category_5B6BF", OrderedDict( [ ("loss", [TrainerMetric(epoch=0, step=1, value=0.0)]), ("accuracy", [TrainerMetric(epoch=0, step=1, value=1.0)]), ] ), ), ("combined", {"loss": [TrainerMetric(epoch=0, step=1, value=0.0)]}), ] ) result = metric_utils.reduce_trainer_metrics_dict(dict_dict_trainer_metrics) assert result == {"category_5B6BF": {"accuracy": [1.0], "loss": [0.0]}, "combined": {"loss": [0.0]}} ================================================ FILE: tests/ludwig/utils/test_model_utils.py ================================================ import pytest import torch from transformers import AutoModelForCausalLM from ludwig.utils.model_utils import ( contains_nan_or_inf_tensors, extract_tensors, find_embedding_layer_with_path, replace_tensors, ) class SampleModel(torch.nn.Module): def __init__(self): super().__init__() self.conv = torch.nn.Conv2d(3, 64, kernel_size=3, stride=1, padding=1) self.relu = torch.nn.ReLU() def test_extract_tensors(): # Create a sample model model = SampleModel() # Call extract_tensors function stripped_model, tensors = extract_tensors(model) # Assert that the model and tensors are returned assert isinstance(stripped_model, torch.nn.Module) assert isinstance(tensors, list) # Assert that the tensors contain the expected keys for tensor_dict in tensors: assert "params" in tensor_dict assert "buffers" in tensor_dict # Assert that all model parameters are set to None for module in stripped_model.modules(): for name, param in module.named_parameters(recurse=False): assert param is None for name, buf in module.named_buffers(recurse=False): assert buf is None def test_replace_tensors(): # Create a sample model model = SampleModel() # Call extract_tensors function to get the tensors _, tensors = extract_tensors(model) # Create a new device for testing device = torch.device("cpu") # Call replace_tensors function replace_tensors(model, tensors, device) # Assert that the tensors are restored for module, tensor_dict in zip(model.modules(), tensors): for name, array in tensor_dict["params"].items(): assert name in module._parameters assert torch.allclose(module._parameters[name], torch.as_tensor(array, device=device)) for name, array in tensor_dict["buffers"].items(): assert name in module._buffers assert torch.allclose(module._buffers[name], torch.as_tensor(array, device=device)) class SampleModule(torch.nn.Module): def __init__(self): super().__init__() self.embedding = torch.nn.Embedding(10, 20) self.rnn = torch.nn.LSTM(20, 30) def test_find_embedding_layer_with_path_simple(): # Test case 1: Test the function with a simple module structure module = SampleModule() embedding_layer, path = find_embedding_layer_with_path(module) assert embedding_layer is not None assert isinstance(embedding_layer, torch.nn.Embedding) assert path == "embedding" def test_find_embedding_layer_with_path_complex(): # Test case 2: Test the function with a more complex module structure including AutoModelForCausalLM model = AutoModelForCausalLM.from_pretrained("HuggingFaceM4/tiny-random-LlamaForCausalLM") embedding_layer, path = find_embedding_layer_with_path(model) assert embedding_layer is not None assert isinstance(embedding_layer, torch.nn.Embedding) assert path == "model.embed_tokens" def test_no_embedding_layer(): # Test case 3: Embedding layer is not present no_embedding_model = torch.nn.Sequential(torch.nn.Linear(10, 10), torch.nn.Linear(10, 10)) embedding_layer, path = find_embedding_layer_with_path(no_embedding_model) assert embedding_layer is None assert path is None class TestHasNanOrInfTensors: """Test suite for the 'has_nan_or_inf_tensors' function, which checks for NaN or infinity (inf) values in PyTorch tensors.""" class SampleModel(torch.nn.Module): def __init__(self): super().__init__() self.param = torch.nn.Parameter(torch.tensor(1.0, requires_grad=True)) self.buffer = torch.nn.Parameter(torch.tensor(1.0, requires_grad=True)) @pytest.fixture(autouse=True) def setup(self): self.model_with_nan_or_inf = self.SampleModel() self.model_without_nan_or_inf = self.SampleModel() self.transformer_model = AutoModelForCausalLM.from_pretrained("HuggingFaceM4/tiny-random-LlamaForCausalLM") def test_has_nan_or_inf_tensors_without_nan_or_inf(self): assert contains_nan_or_inf_tensors(self.model_without_nan_or_inf) is False def test_has_nan_or_inf_tensors_with_nan(self): self.model_with_nan_or_inf.param.data = torch.tensor(float("nan")) assert contains_nan_or_inf_tensors(self.model_with_nan_or_inf) is True def test_has_nan_or_inf_tensors_without_nan(self): self.model_with_nan_or_inf.buffer.data = torch.tensor(float("inf")) assert contains_nan_or_inf_tensors(self.model_with_nan_or_inf) is True def test_has_nan_or_inf_tensors_transformer_model(self): assert contains_nan_or_inf_tensors(self.transformer_model) is False def test_has_nan_or_inf_tensors_transformer_model_with_nan(self): self.transformer_model.model.embed_tokens.weight.data[0][0] = float("nan") assert contains_nan_or_inf_tensors(self.transformer_model) is True def test_has_nan_or_inf_tensors_transformer_model_with_inf(self): self.transformer_model.model.embed_tokens.weight.data[0][0] = float("inf") assert contains_nan_or_inf_tensors(self.transformer_model) is True ================================================ FILE: tests/ludwig/utils/test_normalization.py ================================================ # Copyright (c) 2023 Predibase, Inc., 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. # ============================================================================== import numpy as np import pandas as pd import pytest from ludwig.backend import initialize_backend from ludwig.constants import COLUMN, NAME, PROC_COLUMN from ludwig.features.feature_utils import compute_feature_hash from ludwig.features.number_feature import NumberFeatureMixin, numeric_transformation_registry from ludwig.utils.types import DataFrame def number_feature(): feature = {NAME: "x", COLUMN: "x", "type": "number"} feature[PROC_COLUMN] = compute_feature_hash(feature) return feature def get_test_data(backend: str) -> tuple[DataFrame, DataFrame]: """Returns test data for the given backend.""" data_df = pd.DataFrame(pd.Series([2, 4, 6, 8, 10]), columns=["x"]) proc_df = pd.DataFrame(columns=["x"]) if backend == "ray": import dask.dataframe as dd data_df = dd.from_pandas(data_df, npartitions=1).reset_index() proc_df = dd.from_pandas(proc_df, npartitions=1).reset_index() return data_df, proc_df @pytest.mark.parametrize( "backend", [ pytest.param("local", id="local"), pytest.param("ray", id="ray", marks=pytest.mark.distributed), ], ) def test_norm(backend, ray_cluster_2cpu): data_df, proc_df = get_test_data(backend) backend = initialize_backend(backend) feature_1_meta = NumberFeatureMixin.get_feature_meta({}, data_df["x"], {"normalization": "zscore"}, backend, True) feature_2_meta = NumberFeatureMixin.get_feature_meta({}, data_df["x"], {"normalization": "minmax"}, backend, True) feature_3_meta = NumberFeatureMixin.get_feature_meta({}, data_df["x"], {"normalization": "iq"}, backend, True) assert feature_1_meta["mean"] == 6 assert feature_2_meta["min"] == 2 assert feature_2_meta["max"] == 10 assert feature_3_meta["q1"] == 4 assert feature_3_meta["q2"] == 6 assert feature_3_meta["q3"] == 8 # value checks after normalization num_feature = number_feature() NumberFeatureMixin.add_feature_data( feature_config=num_feature, input_df=data_df, proc_df=proc_df, metadata={num_feature[NAME]: feature_1_meta}, preprocessing_parameters={"normalization": "zscore"}, backend=backend, skip_save_processed_input=False, ) assert np.allclose( np.array(proc_df[num_feature[PROC_COLUMN]]), np.array([-1.26491106, -0.63245553, 0, 0.63245553, 1.26491106]) ) NumberFeatureMixin.add_feature_data( feature_config=num_feature, input_df=data_df, proc_df=proc_df, metadata={num_feature[NAME]: feature_2_meta}, preprocessing_parameters={"normalization": "minmax"}, backend=backend, skip_save_processed_input=False, ) assert np.allclose(np.array(proc_df[num_feature[PROC_COLUMN]]), np.array([0, 0.25, 0.5, 0.75, 1])) NumberFeatureMixin.add_feature_data( feature_config=num_feature, input_df=data_df, proc_df=proc_df, metadata={num_feature[NAME]: feature_3_meta}, preprocessing_parameters={"normalization": "iq"}, backend=backend, skip_save_processed_input=False, ) assert np.allclose(np.array(proc_df[num_feature[PROC_COLUMN]]), np.array([-1, -0.5, 0, 0.5, 1])) @pytest.mark.parametrize("transformation", numeric_transformation_registry.keys()) @pytest.mark.parametrize( "backend", [ pytest.param("local", id="local"), pytest.param("ray", id="ray", marks=pytest.mark.distributed), ], ) def test_numeric_transformation_registry(transformation, backend, ray_cluster_2cpu): data_df, proc_df = get_test_data(backend) backend = initialize_backend(backend) feature_meta = NumberFeatureMixin.get_feature_meta( {}, data_df["x"], {"normalization": transformation}, backend, True ) num_feature = number_feature() NumberFeatureMixin.add_feature_data( feature_config=num_feature, input_df=data_df, proc_df=proc_df, metadata={num_feature[NAME]: feature_meta}, preprocessing_parameters={"normalization": transformation}, backend=backend, skip_save_processed_input=False, ) ================================================ FILE: tests/ludwig/utils/test_numerical_test_utils.py ================================================ import numpy as np import pytest from ludwig.utils.numerical_test_utils import assert_all_finite @pytest.fixture def finite_valued_dict(): return { "scalar": 1, "metrics": {"val": 0.2, "series": [0.1, 0.2, 0.3], "ndarray": np.ones((8, 4, 2))}, } def test_assert_all_finite(finite_valued_dict): assert_all_finite(finite_valued_dict) def test_fail_with_nan(finite_valued_dict): finite_valued_dict["scalar"] = float("nan") with pytest.raises(Exception): assert_all_finite(finite_valued_dict) def test_fail_with_inf(finite_valued_dict): finite_valued_dict["scalar"] = float("inf") with pytest.raises(Exception): assert_all_finite(finite_valued_dict) def test_fail_with_nan_in_list(finite_valued_dict): finite_valued_dict["scalar"] = float("nan") with pytest.raises(Exception): assert_all_finite(finite_valued_dict) def test_fail_with_nan_in_ndarray(finite_valued_dict): finite_valued_dict["metrics"]["ndarray"][0, 0, 1] = np.nan with pytest.raises(Exception): assert_all_finite(finite_valued_dict) ================================================ FILE: tests/ludwig/utils/test_output_feature_utils.py ================================================ import pytest import torch from ludwig.utils import output_feature_utils def test_output_feature_utils(): tensor_dict = {} output_feature_utils.set_output_feature_tensor(tensor_dict, "feature_1", "1", torch.Tensor([1])) output_feature_utils.set_output_feature_tensor(tensor_dict, "feature_1", "10", torch.Tensor([10])) output_feature_utils.set_output_feature_tensor(tensor_dict, "feature_2", "2", torch.Tensor([2])) output_feature_utils.set_output_feature_tensor(tensor_dict, "feature_2", "20", torch.Tensor([20])) assert list(tensor_dict.keys()) == ["feature_1::1", "feature_1::10", "feature_2::2", "feature_2::20"] assert output_feature_utils.get_output_feature_tensor(tensor_dict, "feature_1", "1") == torch.Tensor([1]) assert list(output_feature_utils.get_single_output_feature_tensors(tensor_dict, "feature_1").keys()) == ["1", "10"] assert list(output_feature_utils.get_single_output_feature_tensors(tensor_dict, "feature_3").keys()) == [] with pytest.raises(Exception): output_feature_utils.get_output_feature_tensor(tensor_dict, "feature_1", "2") ================================================ FILE: tests/ludwig/utils/test_server_utils.py ================================================ import numpy as np from ludwig.utils.server_utils import NumpyJSONResponse def test_numpy_json_response(): response = NumpyJSONResponse({"message": "Ludwig server is up"}) # Test Python builtin data type encoding. assert response.render(None) == b"null" assert response.render({}) == b"{}" assert response.render(1) == b"1" assert response.render(1.0) == b"1.0" assert response.render("a") == b'"a"' assert response.render([0, 1, 2, 3, 4]) == b"[0,1,2,3,4]" assert response.render((0, 1, 2, 3, 4)) == b"[0,1,2,3,4]" assert response.render({0, 1, 2, 3, 4}) == b"[0,1,2,3,4]" assert response.render({"a": "b"}) == b'{"a":"b"}' # Test numpy data type encoding for dtype in [np.byte, np.ubyte, np.short, np.ushort, np.int32, np.int64, np.uint, np.longlong, np.ulonglong]: x = np.arange(5, dtype=dtype) assert response.render(x) == b"[0,1,2,3,4]" for i in x: assert response.render(i) == f"{i}".encode() for dtype in [np.half, np.single, np.double, np.longdouble]: x = np.arange(5, dtype=dtype) assert response.render(x) == b"[0.0,1.0,2.0,3.0,4.0]" for i in x: assert response.render(i) == f"{i}".encode() ================================================ FILE: tests/ludwig/utils/test_state_dict_backward_compatibility.py ================================================ from ludwig.utils.state_dict_backward_compatibility import update_state_dict def test_update_transformer_module_keys(): state_dict_with_old_keys = { "input_features.module_dict.sentence__ludwig.encoder_obj.transformer.embeddings.LayerNorm.bias": 0.0, "sentence__ludwig.encoder_obj.transformer.encoder.layer.0.attention.output.LayerNorm.weight": 0.0, "module_dict.sentence__ludwig.encoder_obj.transformer.embeddings.word_embeddings.weight": 0.0, } expected_state_dict = { "input_features.module_dict.sentence__ludwig.encoder_obj.transformer.module.embeddings.LayerNorm.bias": 0.0, "sentence__ludwig.encoder_obj.transformer.module.encoder.layer.0.attention.output.LayerNorm.weight": 0.0, "module_dict.sentence__ludwig.encoder_obj.transformer.module.embeddings.word_embeddings.weight": 0.0, } # Ensures that, for models saved before FreezeModule was added, 'module' is added to the key path. updated_state_dict = update_state_dict(state_dict_with_old_keys) assert updated_state_dict == expected_state_dict def test_does_not_update_freeze_module(): state_dict = { "module_dict.sentence__ludwig.encoder_obj.transformer.module.embeddings.LayerNorm.bias": 0.0, "sentence__ludwig.encoder_obj.transformer.module.encoder.layer.0.attention.output.LayerNorm.weight": 0.0, "module_dict.sentence__ludwig.encoder_obj.transformer.module.embeddings.word_embeddings.weight": 0.0, } # Ensures that models saved with FreezeModule aren't modified. updated_state_dict = update_state_dict(state_dict) assert updated_state_dict == state_dict ================================================ FILE: tests/ludwig/utils/test_strings_utils.py ================================================ from collections import defaultdict import numpy as np import pandas as pd import pytest from ludwig.schema.features.preprocessing.text import TextPreprocessingConfig from ludwig.utils import strings_utils def test_is_number(): assert strings_utils.is_number("1.1") assert strings_utils.is_number("1.000001") assert strings_utils.is_number("1000001") assert strings_utils.is_number("Nan") assert strings_utils.is_number("NaN") assert strings_utils.is_number(1) assert strings_utils.is_number(1.1) assert not strings_utils.is_number("NaNaaa") def test_are_sequential_integers(): assert strings_utils.are_sequential_integers(["1.0", "2", "3"]) assert strings_utils.are_sequential_integers(["1", "2", "3"]) assert not strings_utils.are_sequential_integers(["1", "2", "4"]) assert not strings_utils.are_sequential_integers(["1.1", "2", "3"]) assert not strings_utils.are_sequential_integers(["a", "2", "3"]) def test_str_to_bool(): # Global bool mappings are used. assert strings_utils.str2bool("True") assert strings_utils.str2bool(True) assert strings_utils.str2bool("true") assert not strings_utils.str2bool("0") # Error raised if non-mapped value is encountered and no fallback is specified. with pytest.raises(Exception): strings_utils.str2bool("bot") # Fallback label is used. assert strings_utils.str2bool("bot", fallback_true_label="bot") assert not strings_utils.str2bool("human", fallback_true_label="bot") assert strings_utils.str2bool("human", fallback_true_label="human") assert not strings_utils.str2bool("human", fallback_true_label="Human") # Fallback label is used, strictly as a fallback. assert strings_utils.str2bool("True", fallback_true_label="False") def test_are_conventional_bools(): assert strings_utils.are_conventional_bools(["True", "False"]) assert strings_utils.are_conventional_bools([True, False]) assert strings_utils.are_conventional_bools(["True", False, True]) assert strings_utils.are_conventional_bools(["T", "F"]) assert strings_utils.are_conventional_bools(["t", "f"]) assert not strings_utils.are_conventional_bools(["True", "Fails"]) assert strings_utils.are_conventional_bools(["0", "1"]) assert not strings_utils.are_conventional_bools(["0", "2"]) assert strings_utils.are_conventional_bools(["1.0", "0.0"]) assert not strings_utils.are_conventional_bools(["high", "low"]) assert not strings_utils.are_conventional_bools(["human", "bot"]) def test_create_vocabulary_chars(): data = pd.DataFrame(["Hello, I'm a single sentence!", "And another sentence", "And the very very last one"]) column = data[0] preprocessing_parameters = TextPreprocessingConfig().to_dict() vocabulary = strings_utils.create_vocabulary( column, tokenizer_type="characters", num_most_frequent=preprocessing_parameters["most_common"], lowercase=preprocessing_parameters["lowercase"], unknown_symbol=preprocessing_parameters["unknown_symbol"], padding_symbol=preprocessing_parameters["padding_symbol"], pretrained_model_name_or_path=preprocessing_parameters["pretrained_model_name_or_path"], ) vocab = vocabulary.vocab assert len(vocab) == 27 assert vocab[strings_utils.SpecialSymbol.START.value] == strings_utils.START_SYMBOL assert vocab[strings_utils.SpecialSymbol.STOP.value] == strings_utils.STOP_SYMBOL assert vocab[strings_utils.SpecialSymbol.PADDING.value] == strings_utils.PADDING_SYMBOL assert vocab[strings_utils.SpecialSymbol.UNKNOWN.value] == strings_utils.UNKNOWN_SYMBOL def test_create_vocabulary_word(): data = pd.DataFrame(["Hello, I'm a single sentence!", "And another sentence", "And the very very last one"]) column = data[0] preprocessing_parameters = TextPreprocessingConfig().to_dict() vocabulary = strings_utils.create_vocabulary( column, tokenizer_type=preprocessing_parameters["tokenizer"], num_most_frequent=preprocessing_parameters["most_common"], lowercase=preprocessing_parameters["lowercase"], vocab_file=preprocessing_parameters["vocab_file"], unknown_symbol=preprocessing_parameters["unknown_symbol"], padding_symbol=preprocessing_parameters["padding_symbol"], pretrained_model_name_or_path=preprocessing_parameters["pretrained_model_name_or_path"], ) vocab = vocabulary.vocab assert len(vocab) == 19 assert vocab[strings_utils.SpecialSymbol.UNKNOWN.value] == strings_utils.UNKNOWN_SYMBOL assert vocab[strings_utils.SpecialSymbol.STOP.value] == strings_utils.STOP_SYMBOL assert vocab[strings_utils.SpecialSymbol.PADDING.value] == strings_utils.PADDING_SYMBOL assert vocab[strings_utils.SpecialSymbol.UNKNOWN.value] == strings_utils.UNKNOWN_SYMBOL def test_create_vocabulary_no_special_symbols(): data = pd.DataFrame(["Hello, I'm a single sentence!", "And another sentence", "And the very very last one"]) column = data[0] preprocessing_parameters = TextPreprocessingConfig().to_dict() vocabulary = strings_utils.create_vocabulary( column, tokenizer_type=preprocessing_parameters["tokenizer"], num_most_frequent=preprocessing_parameters["most_common"], lowercase=preprocessing_parameters["lowercase"], vocab_file=preprocessing_parameters["vocab_file"], unknown_symbol=preprocessing_parameters["unknown_symbol"], padding_symbol=preprocessing_parameters["padding_symbol"], pretrained_model_name_or_path=preprocessing_parameters["pretrained_model_name_or_path"], add_special_symbols=False, ) vocab = vocabulary.vocab assert len(vocab) == 16 assert vocab[strings_utils.SpecialSymbol.UNKNOWN.value] == strings_utils.UNKNOWN_SYMBOL def test_create_vocabulary_from_hf(): data = pd.DataFrame(["Hello, I'm a single sentence!", "And another sentence", "And the very very last one"]) column = data[0] preprocessing_parameters = TextPreprocessingConfig().to_dict() vocabulary = strings_utils.create_vocabulary( column, tokenizer_type="hf_tokenizer", num_most_frequent=preprocessing_parameters["most_common"], lowercase=preprocessing_parameters["lowercase"], unknown_symbol=preprocessing_parameters["unknown_symbol"], padding_symbol=preprocessing_parameters["padding_symbol"], pretrained_model_name_or_path="albert-base-v2", ) vocab = vocabulary.vocab assert len(vocab) == 30000 def test_create_vocabulary_single_token(): data = pd.DataFrame(["dog", "cat", "bird", "dog", "cat", "super cat"]) column = data[0] vocab, str2idx, str2freq = strings_utils.create_vocabulary_single_token( column, num_most_frequent=10000, ) assert set(vocab) == {"dog", "cat", "bird", "super cat"} assert str2freq == {"dog": 2, "cat": 2, "bird": 1, "super cat": 1} assert strings_utils.UNKNOWN_SYMBOL not in str2idx def test_create_vocabulary_single_token_small_most_frequent(): data = pd.DataFrame(["dog", "cat", "bird", "dog", "cat", "super cat"]) column = data[0] vocab, str2idx, str2freq = strings_utils.create_vocabulary_single_token(column, num_most_frequent=2) assert set(vocab) == {"dog", "cat", strings_utils.UNKNOWN_SYMBOL} assert str2idx[strings_utils.UNKNOWN_SYMBOL] == 0 assert str2freq == {"dog": 2, "cat": 2, strings_utils.UNKNOWN_SYMBOL: 0} def test_build_sequence_matrix(): inverse_vocabulary = { "": 0, "": 1, "": 2, "": 3, "a": 4, "b": 5, "c": 6, } sequences = pd.core.series.Series(["a b c", "c b a"]) sequence_matrix = strings_utils.build_sequence_matrix( sequences, inverse_vocabulary, tokenizer_type="space", length_limit=10 ) assert not ( sequence_matrix.tolist() - np.array([[1, 4, 5, 6, 0, 2, 2, 2, 2, 2], [1, 6, 5, 4, 0, 2, 2, 2, 2, 2]]) ).any() @pytest.mark.parametrize( "pretrained_model_name_or_path", [ "bert-base-uncased", "gpt2", "HuggingFaceH4/zephyr-7b-beta", ], ) def test_get_vocabulary_hf(pretrained_model_name_or_path): tokenizer_type = "hf_tokenizer" vocab_file = None data = pd.DataFrame(["Hello, I'm a single sentence!", "And another sentence", "And the very very last one"]) column = data[0] preprocessing_parameters = ( TextPreprocessingConfig() .from_dict( { "tokenizer": tokenizer_type, "vocab_file": vocab_file, "pretrained_model_name_or_path": pretrained_model_name_or_path, } ) .to_dict() ) vocabulary = strings_utils.create_vocabulary( column, tokenizer_type=preprocessing_parameters["tokenizer"], num_most_frequent=preprocessing_parameters["most_common"], lowercase=preprocessing_parameters["lowercase"], vocab_file=preprocessing_parameters["vocab_file"], unknown_symbol=preprocessing_parameters["unknown_symbol"], padding_symbol=preprocessing_parameters["padding_symbol"], pretrained_model_name_or_path=preprocessing_parameters["pretrained_model_name_or_path"], compute_idf=False, add_special_symbols=False, ) tokenizer = strings_utils.get_tokenizer( tokenizer_type=preprocessing_parameters["tokenizer"], tokenizer_vocab_file=preprocessing_parameters["vocab_file"], pretrained_model_name_or_path=preprocessing_parameters["pretrained_model_name_or_path"], ) # check special tokens assert vocabulary.padding_symbol == tokenizer.get_pad_token() assert vocabulary.pad_idx == tokenizer.convert_token_to_id(tokenizer.get_pad_token()) assert vocabulary.unknown_symbol == tokenizer.get_unk_token() # check all tokens for token, idx in tokenizer.get_vocab().items(): assert vocabulary.str2idx[token] == idx @pytest.mark.parametrize("compute_idf", [False, True]) def test_create_vocabulary_idf(compute_idf: bool): data = pd.DataFrame(["Hello, I'm a single sentence!", "And another sentence", "And the very very last one"]) column = data[0] preprocessing_parameters = TextPreprocessingConfig().to_dict() vocabulary = strings_utils.create_vocabulary( column, tokenizer_type=preprocessing_parameters["tokenizer"], num_most_frequent=preprocessing_parameters["most_common"], lowercase=preprocessing_parameters["lowercase"], vocab_file=preprocessing_parameters["vocab_file"], unknown_symbol=preprocessing_parameters["unknown_symbol"], padding_symbol=preprocessing_parameters["padding_symbol"], pretrained_model_name_or_path=preprocessing_parameters["pretrained_model_name_or_path"], compute_idf=compute_idf, add_special_symbols=False, ) str2idf = vocabulary.str2idf if not compute_idf: assert str2idf is None return idf2str = defaultdict(set) for k, v in str2idf.items(): idf2str[v].add(k) idf_sorted = sorted(idf2str.items(), key=lambda x: x[0]) assert len(idf_sorted) == 3 # Unknown symbol should have the lowest idf as it never appears in any documents assert idf_sorted[0][0] == 0 assert idf_sorted[0][1] == {""} # "sentence" and "and" should be next, as they appear in two docs each assert idf_sorted[1][0] > idf_sorted[0][0] assert idf_sorted[1][1] == {"sentence", "And"} # finally, every token that only appears once assert idf_sorted[2][0] > idf_sorted[1][0] assert idf_sorted[2][1] == { ",", "I", "'", "one", "very", "single", "the", "m", "!", "last", "Hello", "a", "another", } ================================================ FILE: tests/ludwig/utils/test_tokenizers.py ================================================ from ludwig.utils.tokenizers import EnglishLemmatizeFilterTokenizer, NgramTokenizer, StringSplitTokenizer def test_ngram_tokenizer(): inputs = "Hello, I'm a single sentence!" tokenizer = NgramTokenizer(n=2) tokens_expected = [ "Hello,", "I'm", "a", "single", "sentence!", "Hello, I'm", "I'm a", "a single", "single sentence!", ] tokens = tokenizer(inputs) assert tokens == tokens_expected def test_string_split_tokenizer(): inputs = "Multiple,Elements,Are here!" tokenizer = StringSplitTokenizer(",") tokens = tokenizer(inputs) assert tokens == ["Multiple", "Elements", "Are here!"] def test_english_lemmatize_filter_tokenizer(): inputs = "Hello, I'm a single sentence!" tokenizer = EnglishLemmatizeFilterTokenizer() tokens = tokenizer(inputs) assert len(tokens) > 0 ================================================ FILE: tests/ludwig/utils/test_torch_utils.py ================================================ import contextlib import os from unittest.mock import patch import pytest import torch from ludwig.utils.torch_utils import ( _get_torch_init_params, _set_torch_init_params, initialize_pytorch, sequence_length_2D, sequence_length_3D, ) @pytest.mark.parametrize("input_sequence", [[[0, 1, 1], [2, 0, 0], [3, 3, 3]]]) @pytest.mark.parametrize("expected_output", [[3, 2, 3]]) def test_sequence_length_2D(input_sequence: list[list[int]], expected_output: list[int]): output_seq_length = sequence_length_2D(torch.tensor(input_sequence)) assert torch.equal(torch.tensor(expected_output), output_seq_length) @pytest.mark.parametrize("input_sequence", [[[[-1, 0, 1], [1, -2, 0]], [[0, 0, 0], [3, 0, -2]]]]) @pytest.mark.parametrize("expected_output", [[2, 1]]) def test_sequence_length_3D(input_sequence: list[list[list[int]]], expected_output: list[int]): input_sequence = torch.tensor(input_sequence, dtype=torch.int32) expected_output = torch.tensor(expected_output, dtype=torch.int32) output_seq_length = sequence_length_3D(input_sequence) assert torch.equal(expected_output, output_seq_length) @contextlib.contextmanager def clean_params(): prev = _get_torch_init_params() prev_cuda = os.environ.get("CUDA_VISIBLE_DEVICES") try: _set_torch_init_params(None) if "CUDA_VISIBLE_DEVICES" in os.environ: del os.environ["CUDA_VISIBLE_DEVICES"] yield finally: _set_torch_init_params(prev) # Restore CUDA_VISIBLE_DEVICES to prevent contaminating other tests if prev_cuda is not None: os.environ["CUDA_VISIBLE_DEVICES"] = prev_cuda elif "CUDA_VISIBLE_DEVICES" in os.environ: del os.environ["CUDA_VISIBLE_DEVICES"] @patch("ludwig.utils.torch_utils.torch") def test_initialize_pytorch_only_once(mock_torch): mock_torch.cuda.is_available.return_value = True mock_torch.cuda.device_count.return_value = 4 with clean_params(): # During first time initialization, set pytorch parallelism initialize_pytorch(allow_parallel_threads=False) mock_torch.set_num_threads.assert_called_once() mock_torch.set_num_interop_threads.assert_called_once() # Reset call counts on all threading calls mock_torch.reset_mock() # In the second call to initialization, avoid calling these methods again, as pytorch # will raise an exception initialize_pytorch(allow_parallel_threads=False) mock_torch.set_num_threads.assert_not_called() mock_torch.set_num_interop_threads.assert_not_called() # No GPUs were specified, so this should not have been called even once mock_torch.cuda.memory.set_per_process_memory_fraction.assert_not_called() @patch("ludwig.utils.torch_utils.torch") def test_initialize_pytorch_with_gpu_list(mock_torch): # For test purposes, these devices can be anything, we just need to be able to uniquely # identify them. mock_torch.cuda.is_available.return_value = True mock_torch.cuda.device_count.return_value = 4 with clean_params(): initialize_pytorch(gpus=[1, 2]) assert os.environ["CUDA_VISIBLE_DEVICES"] == "1,2" @patch("ludwig.utils.torch_utils.torch") def test_initialize_pytorch_with_gpu_string(mock_torch): mock_torch.cuda.is_available.return_value = True mock_torch.cuda.device_count.return_value = 4 with clean_params(): initialize_pytorch(gpus="1,2") assert os.environ["CUDA_VISIBLE_DEVICES"] == "1,2" @patch("ludwig.utils.torch_utils.torch") def test_initialize_pytorch_with_gpu_int(mock_torch): mock_torch.cuda.is_available.return_value = True mock_torch.cuda.device_count.return_value = 4 with clean_params(): initialize_pytorch(gpus=1) mock_torch.cuda.set_device.assert_called_with(1) assert "CUDA_VISIBLE_DEVICES" not in os.environ @patch("ludwig.utils.torch_utils.torch") def test_initialize_pytorch_without_gpu(mock_torch): mock_torch.cuda.is_available.return_value = True mock_torch.cuda.device_count.return_value = 4 with clean_params(): initialize_pytorch(gpus=-1) assert os.environ["CUDA_VISIBLE_DEVICES"] == "" ================================================ FILE: tests/ludwig/utils/test_trainer_utils.py ================================================ import sys from collections import OrderedDict import pytest from ludwig.constants import AUTO, BATCH_SIZE, COMBINED, LOSS from ludwig.features.category_feature import CategoryOutputFeature from ludwig.features.feature_utils import LudwigFeatureDict from ludwig.schema.features.category_feature import ECDCategoryOutputFeatureConfig from ludwig.schema.trainer import ECDTrainerConfig from ludwig.schema.utils import load_config_with_kwargs from ludwig.utils import trainer_utils from ludwig.utils.metric_utils import TrainerMetric def test_get_latest_metrics_dict(): progress_tracker_metrics = OrderedDict( [ ( "category_92E9E", OrderedDict( [ ( "loss", [ TrainerMetric(epoch=0, step=1, value=0.7929425835609436), TrainerMetric(epoch=1, step=2, value=0.7906522750854492), ], ), ( "accuracy", [ TrainerMetric(epoch=0, step=1, value=0.4117647111415863), TrainerMetric(epoch=1, step=2, value=0.4117647111415863), ], ), ] ), ), ( "combined", { "loss": [ TrainerMetric(epoch=0, step=1, value=0.7929425835609436), TrainerMetric(epoch=1, step=2, value=0.7906522750854492), ] }, ), ] ) latest_metrics_dict = trainer_utils.get_latest_metrics_dict(progress_tracker_metrics) assert latest_metrics_dict == { "category_92E9E": {"accuracy": 0.4117647111415863, "loss": 0.7906522750854492}, "combined": {"loss": 0.7906522750854492}, } def test_get_latest_metrics_dict_empty(): progress_tracker_metrics = OrderedDict( [("category_F18D1", OrderedDict([("loss", []), ("accuracy", [])])), ("combined", {"loss": []})] ) latest_metrics_dict = trainer_utils.get_latest_metrics_dict(progress_tracker_metrics) assert not latest_metrics_dict def test_progress_tracker_empty(): output_features = LudwigFeatureDict() category_feature, _ = load_config_with_kwargs( ECDCategoryOutputFeatureConfig, { "name": "category_feature", "type": "category", "decoder": { "type": "classifier", }, "num_classes": 3, "input_size": 10, }, ) output_features.set("category_feature", CategoryOutputFeature(category_feature, {})) progress_tracker = trainer_utils.get_new_progress_tracker( batch_size=5, best_eval_metric_value=0, best_increase_batch_size_eval_metric=0, learning_rate=0.01, output_features=output_features, ) assert progress_tracker.log_metrics() == { "batch_size": 5, "best_valid_metric": 0, "epoch": 0, "best_eval_metric_steps": 0, "learning_rate": 0.01, "num_increases_bs": 0, "num_reductions_lr": 0, "steps": 0, "tune_checkpoint_num": 0, "best_eval_metric_checkpoint_number": 0, "best_eval_metric_epoch": 0, "checkpoint_number": 0, "last_improvement_steps": 0, "total_tokens_used": 0, } def test_progress_tracker(): output_features = LudwigFeatureDict() category_feature, _ = load_config_with_kwargs( ECDCategoryOutputFeatureConfig, { "name": "category_feature", "type": "category", "decoder": { "type": "classifier", }, "num_classes": 3, "input_size": 10, }, ) output_features.set("category_feature", CategoryOutputFeature(category_feature, {})) progress_tracker = trainer_utils.get_new_progress_tracker( batch_size=5, best_eval_metric_value=0, best_increase_batch_size_eval_metric=0, learning_rate=0.01, output_features=output_features, ) progress_tracker.validation_metrics[COMBINED][LOSS].append(TrainerMetric(epoch=1, step=10, value=0.1)) progress_tracker.validation_metrics[COMBINED][LOSS].append(TrainerMetric(epoch=1, step=20, value=0.2)) assert progress_tracker.log_metrics() == { "batch_size": 5, "best_eval_metric_checkpoint_number": 0, "best_eval_metric_epoch": 0, "best_valid_metric": 0, "checkpoint_number": 0, "epoch": 0, "best_eval_metric_steps": 0, "learning_rate": 0.01, "num_increases_bs": 0, "num_reductions_lr": 0, "steps": 0, "tune_checkpoint_num": 0, "validation_metrics.combined.loss": 0.2, "last_improvement_steps": 0, "total_tokens_used": 0, } def test_full_progress_tracker(): llm_eval_examples = { "inputs": {"input": [1, 2, 3]}, "targets": {"output": [1, 2, 3]}, "outputs": {"output": [1, 2, 3]}, } progress_tracker = trainer_utils.ProgressTracker( **{ BATCH_SIZE: 128, "best_eval_metric_checkpoint_number": 7, "best_eval_metric_epoch": 6, "best_eval_metric_steps": 35, "best_eval_metric_value": 0.719, "last_improvement_steps": 35, "best_eval_test_metrics": { "Survived": {"accuracy": 0.634, "loss": 3.820, "roc_auc": 0.598}, "combined": {"loss": 3.820}, }, "best_eval_train_metrics": { "Survived": {"accuracy": 0.682, "loss": 4.006, "roc_auc": 0.634}, "combined": {"loss": 4.006}, }, "best_eval_validation_metrics": { "Survived": {"accuracy": 0.719, "loss": 4.396, "roc_auc": 0.667}, "combined": {"loss": 4.396}, }, "best_increase_batch_size_eval_metric": sys.float_info.max, "checkpoint_number": 12, "epoch": 12, "last_increase_batch_size": 0, "last_increase_batch_size_eval_metric_improvement": 0, "last_increase_batch_size_steps": 0, "last_learning_rate_reduction": 0, "last_learning_rate_reduction_steps": 0, "learning_rate": 0.001, "num_increases_batch_size": 0, "num_reductions_learning_rate": 0, "steps": 60, "test_metrics": { "Survived": { "accuracy": [ [0, 5, 0.651], [1, 10, 0.651], ], "loss": [ [0, 5, 4.130], [1, 10, 4.074], ], "roc_auc": [ [0, 5, 0.574], [1, 10, 0.595], ], }, "combined": { "loss": [ [0, 5, 4.130], [1, 10, 4.074], ] }, }, "train_metrics": { "Survived": { "accuracy": [ [0, 5, 0.6875], [1, 10, 0.6875], ], "loss": [ [0, 5, 4.417], [1, 10, 4.344], ], "roc_auc": [ [0, 5, 0.628], [1, 10, 0.629], ], }, "combined": { "loss": [ [0, 5, 4.417], [1, 10, 4.344], ] }, }, "tune_checkpoint_num": 0, "validation_metrics": { "Survived": { "accuracy": [ [0, 5, 0.696], [1, 10, 0.696], ], "loss": [ [0, 5, 4.494], [1, 10, 4.473], ], "roc_auc": [ [0, 5, 0.675], [1, 10, 0.671], ], }, "combined": { "loss": [ [0, 5, 4.494], [1, 10, 4.473], ] }, }, "llm_eval_examples": llm_eval_examples, } ) assert progress_tracker.log_metrics() == { BATCH_SIZE: 128, "best.train_metrics.Survived.accuracy": 0.682, "best.train_metrics.Survived.loss": 4.006, "best.train_metrics.Survived.roc_auc": 0.634, "best.train_metrics.combined.loss": 4.006, "best.test_metrics.Survived.accuracy": 0.634, "best.test_metrics.Survived.loss": 3.82, "best.test_metrics.Survived.roc_auc": 0.598, "best.test_metrics.combined.loss": 3.82, "best.validation_metrics.Survived.accuracy": 0.719, "best.validation_metrics.Survived.loss": 4.396, "best.validation_metrics.Survived.roc_auc": 0.667, "best.validation_metrics.combined.loss": 4.396, "best_eval_metric_checkpoint_number": 7, "best_eval_metric_epoch": 6, "best_eval_metric_steps": 35, "best_valid_metric": 0.719, "checkpoint_number": 12, "epoch": 12, "last_improvement_steps": 35, "learning_rate": 0.001, "num_increases_bs": 0, "num_reductions_lr": 0, "steps": 60, "test_metrics.Survived.accuracy": 0.651, "test_metrics.Survived.loss": 4.074, "test_metrics.Survived.roc_auc": 0.595, "test_metrics.combined.loss": 4.074, "train_metrics.Survived.accuracy": 0.6875, "train_metrics.Survived.loss": 4.344, "train_metrics.Survived.roc_auc": 0.629, "train_metrics.combined.loss": 4.344, "tune_checkpoint_num": 0, "validation_metrics.Survived.accuracy": 0.696, "validation_metrics.Survived.loss": 4.473, "validation_metrics.Survived.roc_auc": 0.671, "validation_metrics.combined.loss": 4.473, "llm_eval_examples": { "inputs": {"input": [1, 2, 3]}, "targets": {"output": [1, 2, 3]}, "outputs": {"output": [1, 2, 3]}, }, "total_tokens_used": 0, } def test_get_final_steps_per_checkpoint(): # steps_per_checkpoint and checkpoints_per_epoch cannot both be specified. with pytest.raises(Exception): trainer_utils.get_final_steps_per_checkpoint( steps_per_epoch=1024, steps_per_checkpoint=1, checkpoints_per_epoch=1, ) assert trainer_utils.get_final_steps_per_checkpoint(steps_per_epoch=1024, steps_per_checkpoint=100) == 100 assert trainer_utils.get_final_steps_per_checkpoint(steps_per_epoch=1024, steps_per_checkpoint=2048) == 1024 assert trainer_utils.get_final_steps_per_checkpoint(steps_per_epoch=1024, checkpoints_per_epoch=2) == 512 assert trainer_utils.get_final_steps_per_checkpoint(steps_per_epoch=1024, checkpoints_per_epoch=2.5) == 409 assert trainer_utils.get_final_steps_per_checkpoint(steps_per_epoch=1024, checkpoints_per_epoch=0.5) == 1024 assert trainer_utils.get_final_steps_per_checkpoint(steps_per_epoch=1024) == 1024 assert ( trainer_utils.get_final_steps_per_checkpoint( steps_per_epoch=1024, steps_per_checkpoint=0, checkpoints_per_epoch=0 ) == 1024 ) @pytest.mark.parametrize( "effective_batch_size,batch_size,gradient_accumulation_steps,num_workers,expected_batch_size,expected_grad_accum", [ (128, 16, 4, 2, 16, 4), (AUTO, 16, 4, 2, 16, 4), (128, 16, AUTO, 2, 16, 4), (128, AUTO, 4, 2, 16, 4), (128, AUTO, AUTO, 2, AUTO, AUTO), (AUTO, AUTO, AUTO, 2, AUTO, AUTO), (AUTO, 16, AUTO, 2, 16, 1), (AUTO, AUTO, 4, 2, AUTO, 4), ], ) def test_get_rendered_batch_size_grad_accum( effective_batch_size: str | int, batch_size: str | int, gradient_accumulation_steps: str | int, num_workers: int, expected_batch_size: int, expected_grad_accum: int, ): config = ECDTrainerConfig.from_dict( { "effective_batch_size": effective_batch_size, "batch_size": batch_size, "gradient_accumulation_steps": gradient_accumulation_steps, } ) rendered_batch_size, rendered_grad_accum = trainer_utils.get_rendered_batch_size_grad_accum(config, num_workers) assert rendered_batch_size == expected_batch_size assert rendered_grad_accum == expected_grad_accum ================================================ FILE: tests/ludwig/utils/test_upload_utils.py ================================================ from __future__ import annotations import logging import pathlib import shutil import pytest from ludwig.globals import MODEL_FILE_NAME, MODEL_HYPERPARAMETERS_FILE_NAME, MODEL_WEIGHTS_FILE_NAME from ludwig.utils.upload_utils import HuggingFaceHub logger = logging.getLogger(__name__) def _build_fake_model_repo( destination_directory: str, experiment_name: str, file_names: list[str], *, model_directory_name: str = MODEL_FILE_NAME, model_weights_directory_name: str = MODEL_WEIGHTS_FILE_NAME, ) -> None: """This utility function accepts the "destination_directory" and list of file names on input. It then makes directory hierarchy "my_simple_experiment_run" / "model" / "model_weights" under "destination_directory" and creates empty files for each file name specified in bottom-most (leaf) directory (file names must be leaf file names, not paths). """ # Create a temporary folder designating training output directory. model_directory: pathlib.Path = pathlib.Path(destination_directory) / experiment_name / model_directory_name model_weights_directory: pathlib.Path = model_directory / model_weights_directory_name model_weights_directory.mkdir(parents=True, exist_ok=True) # Create files within the "model_weights" subdirectory. file_name: str for file_name in file_names: pathlib.Path(model_weights_directory / file_name).touch() pathlib.Path(model_directory / MODEL_HYPERPARAMETERS_FILE_NAME).touch() @pytest.fixture def output_directory_manager(tmpdir) -> str: """This convenience fixture creates temporary directory "training_results_output" and yields it to user test functions. When the user test functions complete their execution, this fixture resumes and cleans up the temporary directory. """ # Create a temporary folder designating training output directory. output_directory: str = str(tmpdir.mkdir("training_results_output")) yield output_directory # Clean up: Remove the temporary output directory and its contents. shutil.rmtree(output_directory) @pytest.mark.parametrize( "file_names,error_raised", [ pytest.param( [ "pytorch_model.bin", ], None, id="pretrained_model_weights_bin", ), pytest.param( [ "adapter_model.bin", ], None, id="adapter_model_weights_bin_unmerged", # backward compatibility for peft versions < 0.7.0 ), pytest.param( [ "adapter_model.safetensors", ], None, id="adapter_model_weights_safetensors_unmerged", ), pytest.param( [ "adapter_model.bin", "adapter_model.safetensors", ], None, id="adapter_model_weights_bin_and_safetensors_unmerged", # backward compatibility for peft versions < 0.7.0 ), pytest.param( [ "pytorch_model.bin", "adapter_model.safetensors", ], None, id="pretrained_model_weights_bin_and_adapter_model_weights_safetensors_merged", ), pytest.param( [], ( ValueError, "Can't find model weights at {model_weights_path}. Trained model weights should either be saved as `pytorch_model.bin` for regular model training, or have `adapter_model.bin`or `adapter_model.safetensors` if using parameter efficient fine-tuning methods like LoRA.", # noqa E501 ), id="model_weights_missing", ), pytest.param( [ "pytorch_model.safetensors", ], ( ValueError, "Can't find model weights at {model_weights_path}. Trained model weights should either be saved as `pytorch_model.bin` for regular model training, or have `adapter_model.bin`or `adapter_model.safetensors` if using parameter efficient fine-tuning methods like LoRA.", # noqa E501 ), id="model_weights_unexpected_name_format_combination", ), pytest.param( [ "pytorch_model.unkn", ], ( ValueError, "Can't find model weights at {model_weights_path}. Trained model weights should either be saved as `pytorch_model.bin` for regular model training, or have `adapter_model.bin`or `adapter_model.safetensors` if using parameter efficient fine-tuning methods like LoRA.", # noqa E501 ), id="model_weights_unrecognized_format", ), pytest.param( [ "unknown_model.safetensors", ], ( ValueError, "Can't find model weights at {model_weights_path}. Trained model weights should either be saved as `pytorch_model.bin` for regular model training, or have `adapter_model.bin`or `adapter_model.safetensors` if using parameter efficient fine-tuning methods like LoRA.", # noqa E501 ), id="model_weights_unrecognized_name", ), ], ) @pytest.mark.unit def test_upload_to_hf_hub__validate_upload_parameters( output_directory_manager, file_names: list[str], error_raised: tuple[type, str] | None ): """Test "HuggingFaceHub._validate_upload_parameters()", which is executed in the path of upload to HuggingFace Hub; for example: `upload hf_hub -repo_id "hf-account/repo-name" --model_path. /content/results/api_experiment_run`. Each test case consists of: 1) Populating the temporary output directory ("training_results_output) with zero or more test model weights file; 2) Executing "HuggingFaceHub._validate_upload_parameters()"; and 3) Asserting on presence/absence of errors. """ output_directory: str = output_directory_manager _build_fake_model_repo( destination_directory=output_directory, experiment_name="my_simple_experiment_run", file_names=file_names ) model_path: pathlib.Path = pathlib.Path(output_directory) / "my_simple_experiment_run" model_weights_path: pathlib.Path = pathlib.Path(model_path / MODEL_FILE_NAME / MODEL_WEIGHTS_FILE_NAME) repo_id: str = "test_account/test_repo" model_path: str = str(model_path) if error_raised: error_class: type # noqa [F842] # incorrect flagging of "local variable is annotated but never used error_message: str # noqa [F842] # incorrect flagging of "local variable is annotated but never used error_class, error_message = error_raised with pytest.raises(error_class) as excinfo: HuggingFaceHub._validate_upload_parameters( repo_id=repo_id, model_path=model_path, ) assert str(excinfo.value) == error_message.format(model_weights_path=model_weights_path) else: try: HuggingFaceHub._validate_upload_parameters( repo_id=repo_id, model_path=model_path, ) except Exception as exc: assert False, f'"HuggingFaceHub._validate_upload_parameters()" raised an exception: "{exc}".' ================================================ FILE: tests/ludwig/utils/test_version_transformation.py ================================================ from ludwig.utils.version_transformation import VersionTransformation, VersionTransformationRegistry def test_version_transformation_registry(): def transform_a(config): config["b"] = config["a"] del config["a"] return config def transform_b(config): config["c"] = config["b"] del config["b"] return config def transform_e(e): e["g"] = e["f"] del e["f"] return e transformation_registry = VersionTransformationRegistry() transformation_registry.register(VersionTransformation(transform=transform_a, version="0.1")) transformation_registry.register(VersionTransformation(transform=transform_b, version="0.2")) transformation_registry.register(VersionTransformation(transform=transform_e, version="0.2", prefixes=["e"])) input_config = {"a": "a value", "e": {"f": "f_value"}} transformed_0_1 = transformation_registry.update_config(input_config, from_version="0.0", to_version="0.1") assert "a" not in transformed_0_1 assert transformed_0_1["b"] == "a value" transformed_0_2 = transformation_registry.update_config(input_config, from_version="0.0", to_version="0.2") assert "a" not in transformed_0_2 assert "b" not in transformed_0_2 assert transformed_0_2["c"] == "a value" assert "e" in transformed_0_2 assert "f" not in transformed_0_2["e"] assert transformed_0_2["e"]["g"] == "f_value" def test_version_transformation_order(): v1 = VersionTransformation(transform=lambda x: x, version="0.1") v2 = VersionTransformation(transform=lambda x: x, version="0.2") v3 = VersionTransformation(transform=lambda x: x, version="0.10") assert v1 < v2 assert v1 < v3 assert v2 < v3 ================================================ FILE: tests/regression_tests/automl/golden/adult_census_income.types.json ================================================ [ { "column": "age", "name": "age", "type": "number" }, { "column": "workclass", "name": "workclass", "type": "category" }, { "column": "fnlwgt", "name": "fnlwgt", "type": "number" }, { "column": "education", "name": "education", "type": "category" }, { "column": "education-num", "name": "education-num", "type": "number" }, { "column": "marital-status", "name": "marital-status", "type": "category" }, { "column": "occupation", "name": "occupation", "type": "category" }, { "column": "relationship", "name": "relationship", "type": "category" }, { "column": "race", "name": "race", "type": "category" }, { "column": "sex", "name": "sex", "type": "category" }, { "column": "capital-gain", "name": "capital-gain", "type": "number" }, { "column": "capital-loss", "name": "capital-loss", "type": "number" }, { "column": "hours-per-week", "name": "hours-per-week", "type": "number" }, { "column": "native-country", "name": "native-country", "type": "category" }, { "column": "income", "name": "income", "type": "category" } ] ================================================ FILE: tests/regression_tests/automl/golden/mnist.types.json ================================================ [ { "column": "image_path", "encoder": { "type": "stacked_cnn" }, "name": "image_path", "type": "image" }, { "column": "label", "name": "label", "type": "category" } ] ================================================ FILE: tests/regression_tests/automl/scripts/update_golden_types.py ================================================ #!/usr/bin/env python """This script updates all golden JSON files containing expected data types.""" import json from ludwig.automl import create_auto_config from tests.regression_tests.automl.utils import get_dataset_golden_types_path, get_dataset_object, TEST_DATASET_REGISTRY def write_json_files(): for dataset_name in TEST_DATASET_REGISTRY: dataset_obj = get_dataset_object(dataset_name) dataset = dataset_obj.load(split=False) # NOTE: assuming type inference for input and output features is the same config = create_auto_config( dataset=dataset, target=[], time_limit_s=3600, ) golden_types_path = get_dataset_golden_types_path(dataset_name) with open(golden_types_path, "w") as f: json.dump(config["input_features"], f, indent=4, sort_keys=True) f.write("\n") if __name__ == "__main__": write_json_files() ================================================ FILE: tests/regression_tests/automl/test_auto_type_inference.py ================================================ import json import pytest from tests.regression_tests.automl.utils import get_dataset_golden_types_path, get_dataset_object, TEST_DATASET_REGISTRY try: from ludwig.automl import create_auto_config except ImportError: pass @pytest.mark.slow @pytest.mark.distributed # ludwig.automl has a dependency on ray @pytest.mark.parametrize("dataset_name", TEST_DATASET_REGISTRY) def test_auto_type_inference_regression(dataset_name): golden_types_path = get_dataset_golden_types_path(dataset_name) with open(golden_types_path) as f: golden_types = json.load(f) dataset_obj = get_dataset_object(dataset_name) dataset = dataset_obj.load(split=False) # NOTE: assuming type inference for input and output features is the same config = create_auto_config( dataset=dataset, target=[], time_limit_s=3600, ) assert golden_types == config["input_features"] ================================================ FILE: tests/regression_tests/automl/utils.py ================================================ from pathlib import Path import ludwig.datasets from ludwig.datasets.loaders.dataset_loader import DatasetLoader # Subset of Ludwig Dataset Zoo used for AutoML type inference regression tests. TEST_DATASET_REGISTRY = {"adult_census_income", "mnist"} def get_dataset_golden_types_path(dataset_name: str) -> str: """Returns the path to the golden types file for the given dataset.""" return str(Path(__file__).resolve().parent / "golden" / f"{dataset_name}.types.json") def get_dataset_object(dataset_name: str) -> DatasetLoader: """Returns a Ludwig dataset instance for the given dataset.""" return ludwig.datasets.get_dataset(dataset_name) ================================================ FILE: tests/regression_tests/benchmark/configs/adult_census_income.ecd.yaml ================================================ combiner: type: tabnet defaults: number: preprocessing: missing_value_strategy: fill_with_const normalization: null input_features: - name: age type: number - name: workclass type: category - name: fnlwgt type: number - name: education type: category - name: education-num type: number - name: marital-status type: category - name: occupation type: category - name: relationship type: category - name: race type: category - name: sex type: category - name: capital-gain type: number - name: capital-loss type: number - name: hours-per-week type: number - name: native-country type: category output_features: - name: income type: category trainer: batch_size: 1345 eval_batch_size: 16384 evaluate_training_set: false learning_rate: 0.02714507227517137 ================================================ FILE: tests/regression_tests/benchmark/configs/ames_housing.ecd.yaml ================================================ combiner: type: tabnet defaults: number: preprocessing: missing_value_strategy: fill_with_mean normalization: null input_features: - name: MSSubClass type: category - name: MSZoning type: category - name: LotFrontage type: number - name: LotArea type: number - name: Street type: category - name: Alley type: category - name: LotShape type: category - name: LandContour type: category - name: Utilities type: category - name: LotConfig type: category - name: LandSlope type: category - name: Neighborhood type: category - name: Condition1 type: category - name: Condition2 type: category - name: BldgType type: category - name: HouseStyle type: category - name: OverallQual type: category - name: OverallCond type: category - name: YearBuilt type: number - name: YearRemodAdd type: number - name: RoofStyle type: category - name: RoofMatl type: category - name: Exterior1st type: category - name: Exterior2nd type: category - name: MasVnrType type: category - name: MasVnrArea type: number - name: ExterQual type: category - name: ExterCond type: category - name: Foundation type: category - name: BsmtQual type: category - name: BsmtCond type: category - name: BsmtExposure type: category - name: BsmtFinType1 type: category - name: BsmtFinSF1 type: number - name: BsmtFinType2 type: category - name: BsmtFinSF2 type: number - name: BsmtUnfSF type: number - name: TotalBsmtSF type: number - name: Heating type: category - name: HeatingQC type: category - name: CentralAir type: binary - name: Electrical type: category - name: 1stFlrSF type: number - name: 2ndFlrSF type: number - name: LowQualFinSF type: number - name: GrLivArea type: number - name: BsmtFullBath type: number - name: BsmtHalfBath type: number - name: FullBath type: number - name: HalfBath type: number - name: BedroomAbvGr type: number - name: KitchenAbvGr type: number - name: KitchenQual type: category - name: TotRmsAbvGrd type: number - name: Functional type: category - name: Fireplaces type: number - name: FireplaceQu type: category - name: GarageType type: category - name: GarageYrBlt type: number - name: GarageFinish type: category - name: GarageCars type: number - name: GarageArea type: number - name: GarageQual type: category - name: GarageCond type: category - name: PavedDrive type: category - name: WoodDeckSF type: number - name: OpenPorchSF type: number - name: EnclosedPorch type: number - name: 3SsnPorch type: number - name: ScreenPorch type: number - name: PoolArea type: number - name: PoolQC type: category - name: Fence type: category - name: MiscFeature type: category - name: MiscVal type: number - name: MoSold type: category - name: YrSold type: number - name: SaleType type: category - name: SaleCondition type: category output_features: - name: SalePrice type: number trainer: batch_size: 35 eval_batch_size: 16384 evaluate_training_set: false learning_rate: 0.0858479746528337 ================================================ FILE: tests/regression_tests/benchmark/configs/mercedes_benz_greener.ecd.yaml ================================================ output_features: - name: y type: number input_features: - name: X0 type: category - name: X1 type: category - name: X2 type: category - name: X3 type: category - name: X4 type: category - name: X5 type: category - name: X6 type: category - name: X8 type: category - name: X10 type: binary - name: X11 type: binary - name: X12 type: binary - name: X13 type: binary - name: X14 type: binary - name: X15 type: binary - name: X16 type: binary - name: X17 type: binary - name: X18 type: binary - name: X19 type: binary - name: X20 type: binary - name: X21 type: binary - name: X22 type: binary - name: X23 type: binary - name: X24 type: binary - name: X26 type: binary - name: X27 type: binary - name: X28 type: binary - name: X29 type: binary - name: X30 type: binary - name: X31 type: binary - name: X32 type: binary - name: X33 type: binary - name: X34 type: binary - name: X35 type: binary - name: X36 type: binary - name: X37 type: binary - name: X38 type: binary - name: X39 type: binary - name: X40 type: binary - name: X41 type: binary - name: X42 type: binary - name: X43 type: binary - name: X44 type: binary - name: X45 type: binary - name: X46 type: binary - name: X47 type: binary - name: X48 type: binary - name: X49 type: binary - name: X50 type: binary - name: X51 type: binary - name: X52 type: binary - name: X53 type: binary - name: X54 type: binary - name: X55 type: binary - name: X56 type: binary - name: X57 type: binary - name: X58 type: binary - name: X59 type: binary - name: X60 type: binary - name: X61 type: binary - name: X62 type: binary - name: X63 type: binary - name: X64 type: binary - name: X65 type: binary - name: X66 type: binary - name: X67 type: binary - name: X68 type: binary - name: X69 type: binary - name: X70 type: binary - name: X71 type: binary - name: X73 type: binary - name: X74 type: binary - name: X75 type: binary - name: X76 type: binary - name: X77 type: binary - name: X78 type: binary - name: X79 type: binary - name: X80 type: binary - name: X81 type: binary - name: X82 type: binary - name: X83 type: binary - name: X84 type: binary - name: X85 type: binary - name: X86 type: binary - name: X87 type: binary - name: X88 type: binary - name: X89 type: binary - name: X90 type: binary - name: X91 type: binary - name: X92 type: binary - name: X93 type: binary - name: X94 type: binary - name: X95 type: binary - name: X96 type: binary - name: X97 type: binary - name: X98 type: binary - name: X99 type: binary - name: X100 type: binary - name: X101 type: binary - name: X102 type: binary - name: X103 type: binary - name: X104 type: binary - name: X105 type: binary - name: X106 type: binary - name: X107 type: binary - name: X108 type: binary - name: X109 type: binary - name: X110 type: binary - name: X111 type: binary - name: X112 type: binary - name: X113 type: binary - name: X114 type: binary - name: X115 type: binary - name: X116 type: binary - name: X117 type: binary - name: X118 type: binary - name: X119 type: binary - name: X120 type: binary - name: X122 type: binary - name: X123 type: binary - name: X124 type: binary - name: X125 type: binary - name: X126 type: binary - name: X127 type: binary - name: X128 type: binary - name: X129 type: binary - name: X130 type: binary - name: X131 type: binary - name: X132 type: binary - name: X133 type: binary - name: X134 type: binary - name: X135 type: binary - name: X136 type: binary - name: X137 type: binary - name: X138 type: binary - name: X139 type: binary - name: X140 type: binary - name: X141 type: binary - name: X142 type: binary - name: X143 type: binary - name: X144 type: binary - name: X145 type: binary - name: X146 type: binary - name: X147 type: binary - name: X148 type: binary - name: X150 type: binary - name: X151 type: binary - name: X152 type: binary - name: X153 type: binary - name: X154 type: binary - name: X155 type: binary - name: X156 type: binary - name: X157 type: binary - name: X158 type: binary - name: X159 type: binary - name: X160 type: binary - name: X161 type: binary - name: X162 type: binary - name: X163 type: binary - name: X164 type: binary - name: X165 type: binary - name: X166 type: binary - name: X167 type: binary - name: X168 type: binary - name: X169 type: binary - name: X170 type: binary - name: X171 type: binary - name: X172 type: binary - name: X173 type: binary - name: X174 type: binary - name: X175 type: binary - name: X176 type: binary - name: X177 type: binary - name: X178 type: binary - name: X179 type: binary - name: X180 type: binary - name: X181 type: binary - name: X182 type: binary - name: X183 type: binary - name: X184 type: binary - name: X185 type: binary - name: X186 type: binary - name: X187 type: binary - name: X189 type: binary - name: X190 type: binary - name: X191 type: binary - name: X192 type: binary - name: X194 type: binary - name: X195 type: binary - name: X196 type: binary - name: X197 type: binary - name: X198 type: binary - name: X199 type: binary - name: X200 type: binary - name: X201 type: binary - name: X202 type: binary - name: X203 type: binary - name: X204 type: binary - name: X205 type: binary - name: X206 type: binary - name: X207 type: binary - name: X208 type: binary - name: X209 type: binary - name: X210 type: binary - name: X211 type: binary - name: X212 type: binary - name: X213 type: binary - name: X214 type: binary - name: X215 type: binary - name: X216 type: binary - name: X217 type: binary - name: X218 type: binary - name: X219 type: binary - name: X220 type: binary - name: X221 type: binary - name: X222 type: binary - name: X223 type: binary - name: X224 type: binary - name: X225 type: binary - name: X226 type: binary - name: X227 type: binary - name: X228 type: binary - name: X229 type: binary - name: X230 type: binary - name: X231 type: binary - name: X232 type: binary - name: X233 type: binary - name: X234 type: binary - name: X235 type: binary - name: X236 type: binary - name: X237 type: binary - name: X238 type: binary - name: X239 type: binary - name: X240 type: binary - name: X241 type: binary - name: X242 type: binary - name: X243 type: binary - name: X244 type: binary - name: X245 type: binary - name: X246 type: binary - name: X247 type: binary - name: X248 type: binary - name: X249 type: binary - name: X250 type: binary - name: X251 type: binary - name: X252 type: binary - name: X253 type: binary - name: X254 type: binary - name: X255 type: binary - name: X256 type: binary - name: X257 type: binary - name: X258 type: binary - name: X259 type: binary - name: X260 type: binary - name: X261 type: binary - name: X262 type: binary - name: X263 type: binary - name: X264 type: binary - name: X265 type: binary - name: X266 type: binary - name: X267 type: binary - name: X268 type: binary - name: X269 type: binary - name: X270 type: binary - name: X271 type: binary - name: X272 type: binary - name: X273 type: binary - name: X274 type: binary - name: X275 type: binary - name: X276 type: binary - name: X277 type: binary - name: X278 type: binary - name: X279 type: binary - name: X280 type: binary - name: X281 type: binary - name: X282 type: binary - name: X283 type: binary - name: X284 type: binary - name: X285 type: binary - name: X286 type: binary - name: X287 type: binary - name: X288 type: binary - name: X289 type: binary - name: X290 type: binary - name: X291 type: binary - name: X292 type: binary - name: X293 type: binary - name: X294 type: binary - name: X295 type: binary - name: X296 type: binary - name: X297 type: binary - name: X298 type: binary - name: X299 type: binary - name: X300 type: binary - name: X301 type: binary - name: X302 type: binary - name: X304 type: binary - name: X305 type: binary - name: X306 type: binary - name: X307 type: binary - name: X308 type: binary - name: X309 type: binary - name: X310 type: binary - name: X311 type: binary - name: X312 type: binary - name: X313 type: binary - name: X314 type: binary - name: X315 type: binary - name: X316 type: binary - name: X317 type: binary - name: X318 type: binary - name: X319 type: binary - name: X320 type: binary - name: X321 type: binary - name: X322 type: binary - name: X323 type: binary - name: X324 type: binary - name: X325 type: binary - name: X326 type: binary - name: X327 type: binary - name: X328 type: binary - name: X329 type: binary - name: X330 type: binary - name: X331 type: binary - name: X332 type: binary - name: X333 type: binary - name: X334 type: binary - name: X335 type: binary - name: X336 type: binary - name: X337 type: binary - name: X338 type: binary - name: X339 type: binary - name: X340 type: binary - name: X341 type: binary - name: X342 type: binary - name: X343 type: binary - name: X344 type: binary - name: X345 type: binary - name: X346 type: binary - name: X347 type: binary - name: X348 type: binary - name: X349 type: binary - name: X350 type: binary - name: X351 type: binary - name: X352 type: binary - name: X353 type: binary - name: X354 type: binary - name: X355 type: binary - name: X356 type: binary - name: X357 type: binary - name: X358 type: binary - name: X359 type: binary - name: X360 type: binary - name: X361 type: binary - name: X362 type: binary - name: X363 type: binary - name: X364 type: binary - name: X365 type: binary - name: X366 type: binary - name: X367 type: binary - name: X368 type: binary - name: X369 type: binary - name: X370 type: binary - name: X371 type: binary - name: X372 type: binary - name: X373 type: binary - name: X374 type: binary - name: X375 type: binary - name: X376 type: binary - name: X377 type: binary - name: X378 type: binary - name: X379 type: binary - name: X380 type: binary - name: X382 type: binary - name: X383 type: binary - name: X384 type: binary - name: X385 type: binary combiner: type: tabnet trainer: eval_batch_size: 16384 evaluate_training_set: false learning_rate: 0.02465493752015043 batch_size: 33 defaults: number: preprocessing: normalization: null missing_value_strategy: fill_with_const ================================================ FILE: tests/regression_tests/benchmark/configs/sarcos.ecd.yaml ================================================ combiner: type: tabnet defaults: number: preprocessing: missing_value_strategy: fill_with_const normalization: null input_features: - column: position_1 name: position_1 type: number - column: position_2 name: position_2 type: number - column: position_3 name: position_3 type: number - column: position_4 name: position_4 type: number - column: position_5 name: position_5 type: number - column: position_6 name: position_6 type: number - column: position_7 name: position_7 type: number - column: velocity_1 name: velocity_1 type: number - column: velocity_2 name: velocity_2 type: number - column: velocity_3 name: velocity_3 type: number - column: velocity_4 name: velocity_4 type: number - column: velocity_5 name: velocity_5 type: number - column: velocity_6 name: velocity_6 type: number - column: velocity_7 name: velocity_7 type: number - column: acceleration_1 name: acceleration_1 type: number - column: acceleration_2 name: acceleration_2 type: number - column: acceleration_3 name: acceleration_3 type: number - column: acceleration_4 name: acceleration_4 type: number - column: acceleration_5 name: acceleration_5 type: number - column: acceleration_6 name: acceleration_6 type: number - column: acceleration_7 name: acceleration_7 type: number - column: torque_2 name: torque_2 type: number - column: torque_3 name: torque_3 type: number - column: torque_4 name: torque_4 type: number - column: torque_5 name: torque_5 type: number - column: torque_6 name: torque_6 type: number - column: torque_7 name: torque_7 type: number output_features: - column: torque_1 name: torque_1 type: number trainer: batch_size: 118 learning_rate_scheduler: decay: exponential decay_rate: 0.5371397744663506 eval_batch_size: 16384 evaluate_training_set: false learning_rate: 0.001004563044919135 ================================================ FILE: tests/regression_tests/benchmark/expected_metric.py ================================================ from dataclasses import dataclass from dataclasses_json import dataclass_json @dataclass_json @dataclass class ExpectedMetric: # Output feature name. output_feature_name: str # Metric name. metric_name: str # Expected metric value. expected_value: int | float # The percentage change that would trigger a notification/failure. tolerance_percentage: float ================================================ FILE: tests/regression_tests/benchmark/expected_metrics/adult_census_income.ecd.yaml ================================================ metrics: - output_feature_name: income metric_name: accuracy expected_value: 0.8547970652580261 tolerance_percentage: 0.15 ================================================ FILE: tests/regression_tests/benchmark/expected_metrics/ames_housing.ecd.yaml ================================================ metrics: - output_feature_name: SalePrice metric_name: r2 expected_value: 0.7343850135803223 tolerance_percentage: 0.15 ================================================ FILE: tests/regression_tests/benchmark/expected_metrics/mercedes_benz_greener.ecd.yaml ================================================ metrics: - output_feature_name: y metric_name: r2 expected_value: 0.47405338287353516 tolerance_percentage: 0.15 ================================================ FILE: tests/regression_tests/benchmark/expected_metrics/sarcos.ecd.yaml ================================================ metrics: - output_feature_name: torque_1 metric_name: r2 expected_value: 0.9871084690093994 tolerance_percentage: 0.15 ================================================ FILE: tests/regression_tests/benchmark/test_model_performance.py ================================================ import os import pytest from expected_metric import ExpectedMetric from ludwig.benchmarking.benchmark import benchmark from ludwig.utils.data_utils import load_yaml from tests.integration_tests.utils import parse_flag_from_env SKIPPED_CONFIG_ISSUES = { "mercedes_benz_greener.ecd.yaml": "https://github.com/ludwig-ai/ludwig/issues/2978", "sarcos.ecd.yaml": "Takes more than 300s", "ames_housing.ecd.yaml": "https://github.com/ludwig-ai/ludwig/issues/3344", } CONFIGS_REQUIRING_DATASET_CREDENTIALS = { "mercedes_benz_greener.ecd.yaml", "ames_housing.ecd.yaml", } RUN_PRIVATE = parse_flag_from_env("RUN_PRIVATE", default=False) def update_skipped_configs_issues(config_filename): if not RUN_PRIVATE and config_filename in CONFIGS_REQUIRING_DATASET_CREDENTIALS: SKIPPED_CONFIG_ISSUES[config_filename] = "Requires credentials. Can't run from a forked repo." def get_test_config_filenames() -> list[str]: """Return list of the config filenames used for benchmarking.""" benchmark_directory = "/".join(__file__.split("/")[:-1] + ["configs"]) return [config_fp for config_fp in os.listdir(benchmark_directory)] def get_dataset_from_config_path(config_path: str) -> str: """path/to/config/..yaml -> dataset.""" return os.path.basename(config_path).split(".")[0] @pytest.mark.benchmark @pytest.mark.parametrize("config_filename", get_test_config_filenames()) def test_performance(config_filename, tmpdir): update_skipped_configs_issues(config_filename) if config_filename in SKIPPED_CONFIG_ISSUES: pytest.skip(reason=SKIPPED_CONFIG_ISSUES[config_filename]) return benchmark_directory = "/".join(__file__.split("/")[:-1]) config_path = os.path.join(benchmark_directory, "configs", config_filename) expected_test_statistics_fp = os.path.join(benchmark_directory, "expected_metrics", config_filename) dataset_name = get_dataset_from_config_path(config_path) if not os.path.exists(expected_test_statistics_fp): raise FileNotFoundError("""No corresponding expected metrics found for benchmarking config '{config_path}'. Please add a new metrics YAML file '{expected_test_statistics_fp}'. Suggested content: metrics: - output_feature_name: metric_name: expected_value: tolerance_percent: 0.15""") expected_metrics_dict = load_yaml(expected_test_statistics_fp) benchmarking_config = { "experiment_name": "regression_test", "export": {"export_artifacts": True, "export_base_path": tmpdir}, "experiments": [{"dataset_name": dataset_name, "config_path": config_path}], } benchmarking_artifacts = benchmark(benchmarking_config) experiment_artifact, err = benchmarking_artifacts[dataset_name] if err is not None: raise err expected_metrics: list[ExpectedMetric] = [ ExpectedMetric.from_dict(expected_metric) for expected_metric in expected_metrics_dict["metrics"] ] for expected_metric in expected_metrics: tolerance = expected_metric.tolerance_percentage * expected_metric.expected_value output_feature_name = expected_metric.output_feature_name metric_name = expected_metric.metric_name experiment_metric_value = experiment_artifact.test_statistics[output_feature_name][metric_name] assert abs(expected_metric.expected_value - experiment_metric_value) <= tolerance, ( f"The obtained {metric_name} value ({experiment_metric_value}) was not within" f" {100 * expected_metric.tolerance_percentage}% of the expected value ({expected_metric.expected_value})." ) ================================================ FILE: tests/regression_tests/model/test_old_models.py ================================================ import os import zipfile import pandas as pd import pytest import wget from ludwig.api import LudwigModel from ludwig.data.dataset_synthesizer import build_synthetic_dataset_df from ludwig.globals import MODEL_FILE_NAME NUM_EXAMPLES = 25 def test_model_loaded_from_old_config_prediction_works(tmpdir): # Titanic model based on 0.5.3. old_model_url = "https://predibase-public-us-west-2.s3.us-west-2.amazonaws.com/ludwig_unit_tests/old_model.zip" old_model_filename = wget.download(old_model_url, tmpdir) with zipfile.ZipFile(old_model_filename, "r") as zip_ref: zip_ref.extractall(tmpdir) example_data = { "PassengerId": 892, "Pclass": 3, "Name": "Kelly, Mr. James", "Sex": "male", "Age": 34.5, "SibSp": 0, "Parch": 0, "Ticket": "330911", "Fare": 7.8292, "Cabin": None, "Embarked": "Q", } test_set = pd.DataFrame(example_data, index=[0]) ludwig_model = LudwigModel.load(os.path.join(tmpdir, "old_model/model")) predictions, _ = ludwig_model.predict(dataset=test_set) assert predictions.to_dict()["Survived_predictions"] == {0: False} @pytest.mark.parametrize( "model_url", [ "https://predibase-public-us-west-2.s3.us-west-2.amazonaws.com/ludwig_unit_tests/titanic_v07.zip", "https://predibase-public-us-west-2.s3.us-west-2.amazonaws.com/ludwig_unit_tests/twitter_bots_v05_1.zip", "https://predibase-public-us-west-2.s3.us-west-2.amazonaws.com/ludwig_unit_tests/respiratory_v05.zip", ], ids=["titanic", "twitter_bots", "respiratory"], ) def test_predict_deprecated_model(model_url, tmpdir): model_dir = os.path.join(tmpdir, MODEL_FILE_NAME) os.makedirs(model_dir) archive_path = wget.download(model_url, tmpdir) with zipfile.ZipFile(archive_path, "r") as zip_ref: zip_ref.extractall(model_dir) ludwig_model = LudwigModel.load(model_dir) df = build_synthetic_dataset_df(NUM_EXAMPLES, ludwig_model.config) pred_df, _ = ludwig_model.predict(df) assert len(pred_df) == 25 ================================================ FILE: tests/training_success/__init__.py ================================================ ================================================ FILE: tests/training_success/configs.py ================================================ from ludwig.config_sampling.explore_schema import ( combine_configs, combine_configs_for_comparator_combiner, combine_configs_for_sequence_combiner, ) # A generic tabular to text config used to generate synthetic data and train a model on it. TABULAR_TO_TEXT = """ input_features: - name: category_1 type: category - name: number_1 type: number - name: binary_1 type: binary output_features: - name: text_output_1 type: text """ # A generic tabular config used to generate synthetic data and train a model on it. TABULAR = """ input_features: - name: category_1 type: category - name: number_1 type: number - name: binary_1 type: binary output_features: - name: category_output_1 type: category """ # A generic config with a single text input feature used to generate synthetic data and train a model on it. TEXT_INPUT = """ input_features: - name: text_1 type: text preprocessing: max_sequence_length: 8 output_features: - name: category_output_1 type: category """ # A generic config with a single number input feature used to generate synthetic data and train a model on it. NUMBER_INPUT = """ input_features: - name: number_1 type: number output_features: - name: category_output_1 type: category """ # A generic config with a single category input feature used to generate synthetic data and train a model on it. CATEGORY_INPUT = """ input_features: - name: category_1 type: category output_features: - name: category_output_1 type: category """ # A generic config with a single binary input feature used to generate synthetic data and train a model on it. BINARY_INPUT = """ input_features: - name: binary_1 type: binary output_features: - name: category_output_1 type: category """ # A generic config with a text output feature used to generate synthetic data and train a model on it. TEXT_OUTPUT = """ input_features: - name: text_1 type: text preprocessing: max_sequence_length: 8 output_features: - name: text_output_1 type: text """ # A generic config with a number output feature used to generate synthetic data and train a model on it. NUMBER_OUTPUT = """ input_features: - name: number_1 type: number output_features: - name: number_output_1 type: number """ # A generic config with a category output feature used to generate synthetic data and train a model on it. CATEGORY_OUTPUT = """ input_features: - name: category_1 type: category output_features: - name: category_output_1 type: category """ # A generic config with a binary output feature used to generate synthetic data and train a model on it. BINARY_OUTPUT = """ input_features: - name: binary_1 type: binary output_features: - name: binary_output_1 type: binary """ # Dictionary that maps from feature type to base config used to test the encoder and preprocessing sections. FEATURE_TYPE_TO_CONFIG_FOR_ENCODER_PREPROCESSING = { "number": NUMBER_INPUT, "category": CATEGORY_INPUT, "binary": BINARY_INPUT, "text": TEXT_INPUT, } # Dictionary that maps from feature type to base config used to test the decoder and loss sections. FEATURE_TYPE_TO_CONFIG_FOR_DECODER_LOSS = { "number": NUMBER_OUTPUT, "category": CATEGORY_OUTPUT, "binary": BINARY_OUTPUT, "text": TEXT_OUTPUT, } # Dictionary that maps from config section to base config used to test that section. ECD_CONFIG_SECTION_TO_CONFIG = { "trainer": TABULAR, "comparator": TABULAR, "concat": TABULAR, "project_aggregate": TABULAR, "sequence": TEXT_INPUT, "sequence_concat": TEXT_INPUT, "tabnet": TABULAR, "tabtransformer": TABULAR, "transformer": TABULAR, } # Dictionary that maps from the combiner type to base config used to test that combiner. COMBINER_TYPE_TO_COMBINE_FN_MAP = { "comparator": combine_configs_for_comparator_combiner, "concat": combine_configs, "project_aggregate": combine_configs, "sequence": combine_configs_for_sequence_combiner, "sequence_concat": combine_configs_for_sequence_combiner, "tabnet": combine_configs, "tabtransformer": combine_configs, "transformer": combine_configs, } ================================================ FILE: tests/training_success/test_training_success.py ================================================ import logging from collections import deque from pprint import pprint from typing import Any import pandas as pd import pytest import yaml from ludwig.api import LudwigModel from ludwig.config_sampling.explore_schema import combine_configs, ConfigOption, explore_properties from ludwig.config_validation.validation import get_schema from ludwig.types import ModelConfigDict from .configs import ( COMBINER_TYPE_TO_COMBINE_FN_MAP, ECD_CONFIG_SECTION_TO_CONFIG, FEATURE_TYPE_TO_CONFIG_FOR_DECODER_LOSS, FEATURE_TYPE_TO_CONFIG_FOR_ENCODER_PREPROCESSING, ) def defaults_config_generator( feature_type: str, allow_list: str, static_schema: dict[str, Any] = None ) -> tuple[ModelConfigDict, pd.DataFrame]: """Generate combinatorial configs for the defaults section of the Ludwig config. Args: feature_type: feature type to explore. allow_list: top-level parameter of the defaults sections that should be included. """ assert isinstance(allow_list, str) assert allow_list in {"preprocessing", "encoder", "decoder", "loss"} schema = get_schema() if not static_schema else static_schema properties = schema["properties"]["defaults"]["properties"][feature_type]["properties"] raw_entry = deque([ConfigOption(dict(), False)]) explored = explore_properties( properties, parent_parameter_path=f"defaults.{feature_type}", dq=raw_entry, allow_list=[allow_list] ) if allow_list in ["preprocessing", "encoder"]: config = FEATURE_TYPE_TO_CONFIG_FOR_ENCODER_PREPROCESSING[feature_type] config = yaml.safe_load(config) else: # decoder and loss config = FEATURE_TYPE_TO_CONFIG_FOR_DECODER_LOSS[feature_type] config = yaml.safe_load(config) config["model_type"] = "ecd" config["trainer"] = {"train_steps": 1} combined_configs = combine_configs(explored, config) logging.info(f"Generated {len(combined_configs)} for {feature_type} {allow_list} combinatorial tests.") for config, dataset in combined_configs: yield config, dataset def ecd_trainer_config_generator(static_schema: dict[str, Any] = None) -> tuple[ModelConfigDict, pd.DataFrame]: """Generate combinatorial configs for the ECD trainer section of the Ludwig config.""" schema = get_schema() if not static_schema else static_schema properties = schema["properties"] raw_entry = deque([ConfigOption(dict(), False)]) explored = explore_properties(properties, parent_parameter_path="", dq=raw_entry, allow_list=["trainer"]) config = ECD_CONFIG_SECTION_TO_CONFIG["trainer"] config = yaml.safe_load(config) config["model_type"] = "ecd" config["trainer"] = {"train_steps": 1} combined_configs = combine_configs(explored, config) # HACK(Arnav): Remove configs that have LARS, LAMB or Lion optimizers, or Paged or 8-bit optimizers. # This is because they require GPUs. filtered_configs = [] for config, dataset in combined_configs: optimizer_type = config.get("trainer", {}).get("optimizer", "").get("type", "") if optimizer_type not in {"lars", "lamb", "lion"} and not ( "paged" in optimizer_type or "8bit" in optimizer_type ): filtered_configs.append((config, dataset)) # Replace combined_configs with the filtered_configs combined_configs = filtered_configs logging.info(f"Generated {len(combined_configs)} for ECD trainer combinatorial tests.") for config, dataset in combined_configs: yield config, dataset def combiner_config_generator( combiner_type: str, static_schema: dict[str, Any] = None ) -> tuple[ModelConfigDict, pd.DataFrame]: """Generate combinatorial configs for the combiner section of the Ludwig config. Args: combiner_type: combiner type to explore. """ schema = get_schema() if not static_schema else static_schema properties = schema["properties"] raw_entry = deque([ConfigOption(dict(), False)]) explored = explore_properties(properties, parent_parameter_path="", dq=raw_entry, allow_list=["combiner"]) config = ECD_CONFIG_SECTION_TO_CONFIG[combiner_type] config = yaml.safe_load(config) config["model_type"] = "ecd" config["trainer"] = {"train_steps": 1} combine_configs_fn = COMBINER_TYPE_TO_COMBINE_FN_MAP[combiner_type] combined_configs = combine_configs_fn(explored, config) combined_configs = [ (config, dataset) for config, dataset in combined_configs if config["combiner"]["type"] == combiner_type ] logging.info(f"Generated {len(combined_configs)} for {combiner_type} combiner combinatorial tests.") for config, dataset in combined_configs: yield config, dataset def train_and_evaluate(config: ModelConfigDict, dataset: pd.DataFrame): """Trains and evaluates a model with the given config. Args: config: valid Ludwig config. dataset: Ludwig dataset name to train on. """ # adding print statements to be captured in pytest stdout and help debug tests. print("Config used (trained on synthetic data)") pprint(config) model = LudwigModel(config=config, callbacks=None, logging_level=logging.ERROR) model.train(dataset=dataset) model.evaluate(dataset=dataset) @pytest.mark.combinatorial @pytest.mark.parametrize("config,dataset", combiner_config_generator("sequence_concat")) def test_ecd_sequence_concat_combiner(config, dataset): train_and_evaluate(config, dataset) @pytest.mark.combinatorial @pytest.mark.parametrize("config,dataset", combiner_config_generator("sequence")) def test_ecd_sequence_combiner(config, dataset): train_and_evaluate(config, dataset) @pytest.mark.combinatorial @pytest.mark.parametrize("config,dataset", combiner_config_generator("comparator")) def test_ecd_comparator_combiner(config, dataset): train_and_evaluate(config, dataset) @pytest.mark.combinatorial @pytest.mark.parametrize("config,dataset", combiner_config_generator("concat")) def test_ecd_concat_combiner(config, dataset): train_and_evaluate(config, dataset) @pytest.mark.combinatorial @pytest.mark.parametrize("config,dataset", combiner_config_generator("project_aggregate")) def test_ecd_project_aggregate_combiner(config, dataset): train_and_evaluate(config, dataset) @pytest.mark.combinatorial @pytest.mark.parametrize("config,dataset", combiner_config_generator("tabnet")) def test_ecd_tabnet_combiner(config, dataset): train_and_evaluate(config, dataset) @pytest.mark.combinatorial @pytest.mark.parametrize("config,dataset", combiner_config_generator("tabtransformer")) def test_ecd_tabtransformer_combiner(config, dataset): train_and_evaluate(config, dataset) @pytest.mark.combinatorial @pytest.mark.parametrize("config,dataset", combiner_config_generator("transformer")) def test_ecd_transformer_combiner(config, dataset): train_and_evaluate(config, dataset) @pytest.mark.combinatorial @pytest.mark.parametrize("config,dataset", ecd_trainer_config_generator()) def test_ecd_trainer(config, dataset): train_and_evaluate(config, dataset) @pytest.mark.combinatorial @pytest.mark.parametrize("config,dataset", defaults_config_generator("number", "encoder")) def test_number_encoder_defaults(config, dataset): train_and_evaluate(config, dataset) @pytest.mark.combinatorial @pytest.mark.parametrize("config,dataset", defaults_config_generator("number", "decoder")) def test_number_decoder_defaults(config, dataset): train_and_evaluate(config, dataset) @pytest.mark.combinatorial @pytest.mark.parametrize("config,dataset", defaults_config_generator("number", "loss")) def test_number_encoder_loss(config, dataset): train_and_evaluate(config, dataset) @pytest.mark.combinatorial @pytest.mark.parametrize("config,dataset", defaults_config_generator("number", "preprocessing")) def test_number_preprocessing_defaults(config, dataset): train_and_evaluate(config, dataset) @pytest.mark.combinatorial @pytest.mark.parametrize("config,dataset", defaults_config_generator("category", "encoder")) def test_category_encoder_defaults(config, dataset): train_and_evaluate(config, dataset) @pytest.mark.combinatorial @pytest.mark.parametrize("config,dataset", defaults_config_generator("category", "decoder")) def test_category_decoder_defaults(config, dataset): train_and_evaluate(config, dataset) @pytest.mark.combinatorial @pytest.mark.parametrize("config,dataset", defaults_config_generator("category", "loss")) def test_category_loss_defaults(config, dataset): train_and_evaluate(config, dataset) @pytest.mark.combinatorial @pytest.mark.parametrize("config,dataset", defaults_config_generator("category", "preprocessing")) def test_category_preprocessing_defaults(config, dataset): train_and_evaluate(config, dataset) @pytest.mark.combinatorial @pytest.mark.parametrize("config,dataset", defaults_config_generator("binary", "encoder")) def test_binary_encoder_defaults(config, dataset): train_and_evaluate(config, dataset) @pytest.mark.combinatorial @pytest.mark.parametrize("config,dataset", defaults_config_generator("binary", "decoder")) def test_binary_decoder_defaults(config, dataset): train_and_evaluate(config, dataset) @pytest.mark.combinatorial @pytest.mark.parametrize("config,dataset", defaults_config_generator("binary", "loss")) def test_binary_loss_defaults(config, dataset): train_and_evaluate(config, dataset) @pytest.mark.combinatorial @pytest.mark.parametrize("config,dataset", defaults_config_generator("binary", "preprocessing")) def test_binary_preprocessing_defaults(config, dataset): train_and_evaluate(config, dataset) # @pytest.mark.combinatorial # @pytest.mark.parametrize("config,dataset", defaults_config_generator("text", "preprocessing")) # def test_text_preprocessing_defaults(config, dataset): # train_and_evaluate(config, dataset) # @pytest.mark.combinatorial # @pytest.mark.parametrize("config,dataset", defaults_config_generator("text", "encoder")) # def test_text_encoder_defaults(config): # train_and_evaluate(config, dataset)