项目文件夹

文件
wehub-resource-sync 593b94c120
pytest / Unit Tests (push) Has been cancelled
pytest / Integration (integration_tests_a) (push) Has been cancelled
pytest / Integration (integration_tests_b) (push) Has been cancelled
pytest / Integration (integration_tests_c) (push) Has been cancelled
pytest / Integration (integration_tests_d) (push) Has been cancelled
pytest / Integration (integration_tests_e) (push) Has been cancelled
pytest / Integration (integration_tests_f) (push) Has been cancelled
pytest / Integration (integration_tests_g) (push) Has been cancelled
pytest / Integration (integration_tests_h) (push) Has been cancelled
pytest / Integration (integration_tests_i) (push) Has been cancelled
pytest / Integration (integration_tests_j) (push) Has been cancelled
pytest / Distributed (distributed_a) (push) Has been cancelled
pytest / Distributed (distributed_b) (push) Has been cancelled
pytest / Distributed (distributed_c) (push) Has been cancelled
pytest / Distributed (distributed_d) (push) Has been cancelled
pytest / Distributed (distributed_e) (push) Has been cancelled
pytest / Distributed (distributed_f) (push) Has been cancelled
pytest / Minimal Install (push) Has been cancelled
pytest / Event File (push) Has been cancelled
pytest (slow) / py-slow (push) Has been cancelled
Publish JSON Schema / publish-schema (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:49:20 +08:00

2045 行
93 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
# !/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 tempfile
import time
import traceback
from pprint import pformat
from typing import Any
import numpy as np
import pandas as pd
import torch
from tabulate import tabulate
from ludwig.api_annotations import PublicAPI
from ludwig.api_types import EvaluationFrequency, PreprocessedDataset, TrainingResults, TrainingStats
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.experiment_utils import get_experiment_description
from ludwig.features.feature_registries import update_config_with_metadata, update_config_with_model
from ludwig.features.timeseries_feature import incremental_time_delay_embedding
from ludwig.globals import (
MODEL_FILE_NAME,
MODEL_HYPERPARAMETERS_FILE_NAME,
model_weights_exist,
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.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
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_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.trainer_utils import get_training_report
from ludwig.utils.types import DataFrame
from ludwig.utils.upload_utils import HuggingFaceHub
logger = logging.getLogger(__name__)
@PublicAPI
class LudwigModel:
"""High-level interface to Ludwig's train / predict / evaluate / experiment pipelines.
Example:
Train a model::
config = {...}
model = LudwigModel(config)
train_stats, _, _ = model.train(dataset=file_path)
# or with a DataFrame:
train_stats, _, _ = model.train(dataset=dataframe)
Load a previously trained model and predict::
model = LudwigModel.load(model_dir)
predictions, output_dir = model.predict(dataset=file_path)
# or:
predictions, output_dir = model.predict(dataset=dataframe)
Evaluate::
eval_stats, _, _ = model.evaluate(dataset=file_path)
"""
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:
"""Initialize a LudwigModel.
Args:
config: In-memory config dict or path to a YAML config file.
logging_level: Log level sent to stderr (e.g., logging.INFO).
backend: Backend instance or string name (e.g., "local", "ray") used for
preprocessing and training.
gpus: GPUs to use; same syntax as CUDA_VISIBLE_DEVICES.
gpu_memory_limit: Maximum memory fraction [0, 1] allowed per GPU device.
allow_parallel_threads: Allow Torch to use multi-threading for performance
at the cost of determinism.
callbacks: List of `ludwig.callbacks.Callback` objects that provide hooks
into the Ludwig pipeline.
"""
# 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
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"))
logger.info(f"Using backend: {self.backend.BACKEND_TYPE}")
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_for_zero_shot()
def _get_or_create_model(
self, config_obj: ModelConfig | None = None, random_seed: int = default_random_seed
) -> None:
"""Single entry point for model instantiation.
Creates self.model from config_obj (or self.config_obj) if it hasn't been created yet. Safe to call multiple
times — no-ops if model exists.
"""
if self.model is not None:
return
cfg = config_obj or self.config_obj
logger.info(f"Creating {cfg.model_type} model")
self.model = LudwigModel.create_model(cfg, random_seed=random_seed)
def _initialize_llm_for_zero_shot(self, random_seed: int = default_random_seed):
"""Initialize the LLM for zero-shot (InferenceOnlyTrainer) inference only."""
self._get_or_create_model(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,
callbacks: list[Callback] | None = None,
**kwargs: Any,
) -> TrainingResults:
"""Train the model on the provided dataset.
Results are saved to `[output_directory]/[experiment_name]_[model_name]_n`,
where `n` increments to differentiate repeated runs.
Args:
dataset: Source containing the full dataset. If it has a split column (0=train,
1=validation, 2=test) it is used for splitting; otherwise the dataset is
split randomly. Mutually exclusive with `training_set`.
training_set: Source containing training data only.
validation_set: Source containing validation data only.
test_set: Source containing test data only.
training_set_metadata: Pre-computed metadata dict or path to a `.meta.json`
file produced by a previous Ludwig run on the same dataset.
data_format: Format hint for data sources. Inferred automatically when
`None`. Valid values: `'auto'`, `'csv'`, `'df'`, `'dict'`,
`'excel'`, `'feather'`, `'fwf'`, `'hdf5'`, `'html'`, `'json'`,
`'jsonl'`, `'parquet'`, `'pickle'`, `'sas'`, `'spss'`, `'stata'`,
`'tsv'`.
experiment_name: Name used when creating the output directory.
model_name: Name used when creating the output directory.
model_resume_path: Resume training from this checkpoint directory.
Config, optimizer state, and training statistics are all restored.
skip_save_training_description: Skip saving the experiment description JSON.
skip_save_training_statistics: Skip saving training statistics JSON.
skip_save_model: Skip saving model weights after each improvement.
The returned model will have end-of-training weights rather than
best-validation weights, and the model cannot be reloaded later.
skip_save_progress: Skip saving per-epoch checkpoints used for resuming.
skip_save_log: Skip saving TensorBoard logs.
skip_save_processed_input: Skip caching the preprocessed HDF5/JSON files.
output_directory: Root directory for all saved outputs.
random_seed: Seed for data splitting, weight initialization, and shuffling.
callbacks: Additional callbacks for this specific ``train()`` call. These are
merged with any callbacks already attached to the model via
``LudwigModel(callbacks=[...])``. Useful for per-run instrumentation
(e.g., attaching a WandB logger to one run without rebuilding the model).
Callbacks added here do not persist after this call returns.
**kwargs: Additional keyword arguments forwarded to preprocessing.
Returns:
A `TrainingResults` namedtuple with fields:
- `training_set_metadata`: feature-level preprocessing metadata.
- `preprocessed_data`: `(training_set, validation_set, test_set)` datasets.
- `output_directory`: path where all outputs were saved.
"""
# 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):
# Merge per-call callbacks with the model's own callbacks for the duration of this train() call.
train_callbacks = self.callbacks + (callbacks or [])
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 = PreprocessedDataset(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(
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 = preprocessed_data.training_set
validation_set = preprocessed_data.validation_set
test_set = preprocessed_data.test_set
training_set_metadata = preprocessed_data.training_set_metadata
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
assert model_dir is not None
os.makedirs(model_dir, exist_ok=True)
save_json(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 train_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_and_grad_accum(trainer, training_set, random_seed=random_seed)
if (
self.config_obj.model_type == MODEL_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 train_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
# Calibrate output probabilities and save model (coordinator-only).
# Must run after training completes, before final model parameters are saved.
if self.backend.is_coordinator():
calibrator = Calibrator(
self.model,
self.backend,
batch_size=trainer.eval_batch_size,
)
self._run_calibration(calibrator, validation_set, training_set, skip_save_model, 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 train_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)
# Save model card alongside the model (always)
if self.backend.is_coordinator() and not skip_save_model:
try:
from ludwig.utils.model_card import save_model_card
save_model_card(
output_directory=output_directory,
config=self.config_obj.to_dict(),
training_set_metadata=training_set_metadata,
train_stats=train_stats,
model_dir=model_dir,
)
except Exception:
logger.warning("Failed to generate model card.", exc_info=True)
# Save training report (always, alongside the model)
if self.backend.is_coordinator() and not skip_save_model:
try:
from ludwig.utils.training_report import save_training_report
save_training_report(
output_directory=output_directory,
config=self.config_obj.to_dict(),
training_set_metadata=training_set_metadata,
train_stats=train_stats,
model_dir=model_dir,
random_seed=random_seed,
)
except Exception:
logger.warning("Failed to generate training report.", exc_info=True)
# 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:
"""Train the model for one epoch on `dataset` (online / incremental learning).
Args:
dataset: Source containing the training data for this epoch.
training_set_metadata: Pre-computed metadata from a prior run. When
`None`, metadata is derived from the provided dataset.
data_format: Format hint for the data source. Inferred when `'auto'`.
random_seed: Seed for data splitting and parameter initialization.
"""
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):
training_dataset, _, _, training_set_metadata = preprocess_for_training(
self.config_obj,
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_and_grad_accum(self._online_trainer, dataset, random_seed=random_seed)
self.model = self._online_trainer.train_online(training_dataset)
def _run_calibration(
self,
calibrator: Calibrator,
validation_set: Dataset | None,
training_set: Dataset,
skip_save_model: bool,
model_dir: str,
) -> None:
"""Run post-training probability calibration and save the model.
Must be called only on the coordinator node, after training completes and
before the final model is saved.
"""
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 must 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)
def _tune_batch_size_and_grad_accum(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)
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, 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
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, 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,
callbacks: list[Callback] | None = None,
) -> str | list[str]:
"""A simple generate() method that directly uses the underlying transformers library to generate text.
Args:
input_strings: Input text or list of texts to generate from.
generation_config: Configuration for text generation.
streaming: If True, enable streaming output.
callbacks: Optional callbacks for this generate call.
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 required for loading quantized models. See https://github.com/ludwig-ai/ludwig/issues/3695.
raise ValueError(
"A CUDA GPU is required for generate() with quantized LLMs, but none was detected.\n"
"Either run on a GPU machine or disable quantization in your model config."
)
# Decoder-only models require left-padding for correct generation results (right-padding causes HF warnings).
padding_side = "left" if not getattr(self.model.model.config, "is_encoder_decoder", False) 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.debug(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.debug("----------------------")
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 = 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: type = pd.DataFrame,
callbacks: list[Callback] | None = None,
**kwargs: Any,
) -> tuple[dict | pd.DataFrame, str]:
"""Make predictions from a trained model on the provided dataset.
Args:
dataset: Source containing the dataset to predict on.
data_format: Format hint for the data source. Inferred automatically when `None`.
Valid values: `'auto'`, `'csv'`, `'df'`, `'dict'`, `'excel'`, `'feather'`,
`'fwf'`, `'hdf5'`, `'html'`, `'json'`, `'jsonl'`, `'parquet'`, `'pickle'`,
`'sas'`, `'spss'`, `'stata'`, `'tsv'`.
split: Which split of the data to use when the dataset contains a split column.
One of `'full'`, `'training'`, `'validation'`, `'test'`.
batch_size: Number of rows per prediction batch.
generation_config: LLM-only generation parameters. When `None`, the config used
at training time is applied. Ignored for non-LLM models.
skip_save_unprocessed_output: When `False`, raw numpy tensors are saved alongside
the postprocessed CSV files. When `True` (default), only CSVs are written.
skip_save_predictions: Skip writing prediction CSV files.
output_directory: Root directory for saved prediction outputs.
return_type: Format of the returned predictions (`pd.DataFrame` or `dict`).
callbacks: Extra callbacks for this predict call; combined with any callbacks
already registered to the model.
**kwargs: Forwarded to the underlying predictor.
Returns:
A tuple `(predictions, output_directory)` where `predictions` is a
`pd.DataFrame` (or `dict`) of model outputs and `output_directory` is
the path where results were saved.
"""
self._check_initialization()
# preprocessing
start_time = time.time()
logger.debug(f"Preprocessing dataset for prediction (batch_size={batch_size})")
dataset, _ = self._preprocess_for_prediction(
dataset,
data_format=data_format,
split=split,
include_outputs=False,
callbacks=callbacks,
)
logger.debug(f"Running batch prediction (batch_size={batch_size})")
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 predictions")
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: type = pd.DataFrame,
**kwargs: Any,
) -> tuple[dict, dict | pd.DataFrame, str]:
"""Evaluate a trained model and compute performance statistics.
Args:
dataset: Source containing the dataset to evaluate.
data_format: Format hint for the data source. Inferred automatically when `None`.
Valid values: `'auto'`, `'csv'`, `'df'`, `'dict'`, `'excel'`, `'feather'`,
`'fwf'`, `'hdf5'`, `'html'`, `'json'`, `'jsonl'`, `'parquet'`, `'pickle'`,
`'sas'`, `'spss'`, `'stata'`, `'tsv'`.
split: Which split of the data to use when the dataset contains a split column.
One of `'full'`, `'training'`, `'validation'`, `'test'`.
batch_size: Number of rows per evaluation batch. Defaults to `eval_batch_size`
from the trainer config.
skip_save_unprocessed_output: When `False`, raw numpy tensors are saved alongside
postprocessed CSV files. When `True` (default), only CSVs are written.
skip_save_predictions: Skip writing prediction CSV files.
skip_save_eval_stats: Skip writing evaluation statistics JSON.
collect_predictions: Collect and return postprocessed predictions.
collect_overall_stats: Compute and include dataset-level aggregate metrics.
output_directory: Root directory for saved evaluation outputs.
return_type: Format for returned predictions (`pd.DataFrame` or `dict`).
**kwargs: Forwarded to preprocessing.
Returns:
A tuple `(eval_stats, predictions, output_directory)` where `eval_stats` is a
nested dict of feature → metric → value, `predictions` is a `pd.DataFrame` or
`dict` of model outputs, and `output_directory` is the path where results were
saved.
"""
self._check_initialization()
for callback in self.callbacks:
callback.on_evaluation_start()
# preprocessing
logger.debug("Preprocessing dataset for evaluation")
dataset, training_set_metadata = self._preprocess_for_prediction(
dataset,
data_format=data_format,
split=split,
include_outputs=True,
)
# 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
trainer_dict = self.config_obj.trainer.to_dict()
batch_size = trainer_dict.get(EVAL_BATCH_SIZE) or trainer_dict.get(BATCH_SIZE)
if batch_size is None:
raise ValueError(
"batch_size not specified and no default found in trainer config. "
"Set batch_size or eval_batch_size in your trainer config."
)
logger.debug(f"Running batch evaluation (batch_size={batch_size})")
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 predictions")
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",
callbacks: list[Callback] | None = None,
) -> DataFrame:
"""Forecast `horizon` steps ahead using an iterative single-pass strategy.
Preprocessing is performed once for the initial lookback window. Each subsequent horizon step slides the window
by one position using incremental_time_delay_embedding, reducing preprocessing complexity from O(horizon ×
window_size) to O(window_size + horizon).
"""
self._check_initialization()
# Load raw DataFrame once
dataset, _, _, _ = load_dataset_uris(dataset, None, None, None, self.backend)
if isinstance(dataset, CacheableDataset):
dataset = dataset.unwrap()
df = load_dataset(dataset, data_format=data_format, df_lib=self.backend.df_engine.df_lib)
ts_input_features = [f for f in self.config_obj.input_features if f.type == TIMESERIES]
ts_output_features = [f for f in self.config_obj.output_features if f.type == TIMESERIES]
if not ts_input_features:
raise ValueError("Forecasting requires at least one input feature of type `timeseries`.")
if horizon <= 0:
return_cols = [f.column for f in ts_output_features]
return pd.DataFrame({col: pd.Series(dtype=float) for col in return_cols})
max_window_size = max(f.preprocessing.window_size for f in ts_input_features)
# Build a mapping from ts output column name → ts output feature config
ts_output_by_col = {f.column: f for f in ts_output_features}
# Step 1: Preprocess the initial lookback window once
initial_df = df.tail(max_window_size)
preprocessed, _ = self._preprocess_for_prediction(
initial_df,
include_outputs=False,
callbacks=callbacks,
)
# Collect the last preprocessed embedding for each input feature.
# Non-timeseries features stay constant; timeseries features are slid per step.
# Keyed by proc_column of the model's input features.
last_embeddings: dict[str, np.ndarray] = {}
for i_feat in self.model.input_features.values():
pc = i_feat.proc_column
if pc in preprocessed.dataset:
last_embeddings[pc] = preprocessed.dataset[pc][-1].copy()
# Build a mapping: ts_input_feature.column → (proc_column, window_size, padding_value)
ts_input_info: list[tuple[str, str, int, float]] = []
for ts_feat in ts_input_features:
i_feat = self.model.input_features.get(ts_feat.name)
if i_feat is not None and i_feat.proc_column in last_embeddings:
ts_input_info.append(
(
ts_feat.column,
i_feat.proc_column,
ts_feat.preprocessing.window_size,
ts_feat.preprocessing.padding_value,
)
)
# Step 2: Incremental prediction loop — O(horizon) steps, each O(1) preprocessing
predicted_rows: list[pd.DataFrame] = []
total_forecasted = 0
with self.backend.create_predictor(self.model, batch_size=1) as predictor:
while total_forecasted < horizon:
# Build a single-sample batch from the last embeddings
batch = {pc: emb[np.newaxis] for pc, emb in last_embeddings.items()}
# Run model forward pass on one sample, then postprocess
raw_preds = predictor.predict_single(batch)
postproc_preds = postprocess(
raw_preds,
self.model.output_features,
self.training_set_metadata,
backend=self.backend,
skip_save_unprocessed_output=True,
)
# Extract predicted values for each timeseries output feature
next_series: dict[str, pd.Series] = {}
for feat in ts_output_features:
key = f"{feat.name}_predictions"
next_series[feat.column] = pd.Series(postproc_preds[key].iloc[0])
next_preds = pd.DataFrame(next_series)
predicted_rows.append(next_preds)
total_forecasted += len(next_preds)
# Step 3: Update embeddings incrementally for the next step.
# For each timeseries input feature, slide the window by one position.
for ts_col, proc_col, window_size, padding_value in ts_input_info:
# Use the predicted value if this ts input is also an output, else padding_value
# (matches the NaN-fill behavior of the original full-reprocessing path).
new_val = float(next_preds[ts_col].iloc[-1]) if ts_col in ts_output_by_col else padding_value
last_embeddings[proc_col] = incremental_time_delay_embedding(
new_val, last_embeddings[proc_col], window_size, padding_value
)
results_df = pd.concat(predicted_rows, ignore_index=True).head(horizon)
return_cols = [f.column for f in ts_output_features]
results_df = results_df[return_cols]
if output_directory is not None:
if self.backend.is_coordinator():
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: Any,
) -> tuple[dict | None, TrainingStats, PreprocessedDataset, str]:
"""Train a model and immediately evaluate it on a held-out split.
Combines `train()` and `evaluate()` in one call. Saves the model,
training statistics, and evaluation results to `output_directory`.
Args:
dataset: Source containing the full dataset. Mutually exclusive with
`training_set` / `validation_set` / `test_set`.
training_set: Source containing training data only.
validation_set: Source containing validation data only.
test_set: Source containing test data only.
training_set_metadata: Pre-computed metadata dict or path to a `.meta.json`
file from a prior Ludwig run on the same dataset.
data_format: Format hint for data sources. Inferred automatically when `None`.
Valid values: `'auto'`, `'csv'`, `'df'`, `'dict'`, `'excel'`, `'feather'`,
`'fwf'`, `'hdf5'`, `'html'`, `'json'`, `'jsonl'`, `'parquet'`, `'pickle'`,
`'sas'`, `'spss'`, `'stata'`, `'tsv'`.
experiment_name: Name used when creating the output directory.
model_name: Name used when creating the output directory.
model_resume_path: Resume training from this checkpoint directory.
eval_split: Which split to evaluate after training. One of `'training'`,
`'validation'`, `'test'`.
skip_save_training_description: Skip saving the experiment description JSON.
skip_save_training_statistics: Skip saving training statistics JSON.
skip_save_model: Skip saving model weights after each improvement.
skip_save_progress: Skip saving per-epoch checkpoints for resuming.
skip_save_log: Skip saving TensorBoard logs.
skip_save_processed_input: Skip caching the preprocessed HDF5/JSON files.
skip_save_unprocessed_output: Skip saving raw numpy prediction tensors.
skip_save_predictions: Skip writing prediction CSV files.
skip_save_eval_stats: Skip writing evaluation statistics JSON.
skip_collect_predictions: Do not collect postprocessed predictions.
skip_collect_overall_stats: Do not compute dataset-level aggregate metrics.
output_directory: Root directory for all saved outputs.
random_seed: Seed for weight initialization, data splitting, and shuffling.
**kwargs: Forwarded to preprocessing.
Returns:
A tuple `(eval_stats, train_stats, preprocessed_data, output_directory)` where
`eval_stats` is performance metrics on the eval split (or `None` if eval was
skipped), `train_stats` is per-epoch training metrics, `preprocessed_data`
holds the three split datasets, and `output_directory` is where results were
saved.
"""
if self._user_config.get(HYPEROPT):
print_boxed("WARNING")
logger.warning(HYPEROPT_WARNING)
train_result = 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,
)
train_stats = train_result.train_stats
preprocessed_data = train_result.preprocessed_data
output_directory = train_result.output_directory
eval_set = preprocessed_data.validation_set
if eval_split == TRAINING:
eval_set = preprocessed_data.training_set
elif eval_split == VALIDATION:
eval_set = preprocessed_data.validation_set
elif eval_split == TEST:
eval_set = preprocessed_data.test_set
else:
logger.warning(f"Eval split {eval_split} not supported. 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. Skipping evaluation")
eval_stats = None
return eval_stats, train_stats, preprocessed_data, output_directory
def collect_weights(self, tensor_names: list[str] | None = None, **kwargs: Any) -> list:
"""Return the named tensors (weight matrices) from the trained model.
Args:
tensor_names: Names of tensors to retrieve. When `None`, all tensors
are returned.
**kwargs: Unused; accepted for forward-compatibility.
Returns:
List of `(name, tensor)` tuples.
"""
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: Any,
) -> list:
"""Collect intermediate-layer activations for the given dataset.
Args:
layer_names: Names of layers in the model to collect activations from.
dataset: Source containing the data to run through the model.
data_format: Format hint for the data source. Inferred when `None`.
split: Which data split to use when the dataset has a split column.
One of `'full'`, `'training'`, `'validation'`, `'test'`.
batch_size: Number of rows per inference batch.
**kwargs: Unused; accepted for forward-compatibility.
Returns:
List of activation tensors, one per layer name.
"""
self._check_initialization()
# preprocessing
logger.debug("Preprocessing dataset for activation collection")
dataset, training_set_metadata = self._preprocess_for_prediction(
dataset,
data_format=data_format,
split=split,
include_outputs=False,
)
logger.debug(f"Collecting activations for layers: {layer_names} (batch_size={batch_size})")
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: Any,
) -> PreprocessedDataset:
"""Preprocess a dataset and return it split into training / validation / test sets.
Args:
dataset: Source containing the full dataset. Mutually exclusive with
`training_set` / `validation_set` / `test_set`.
training_set: Source containing training data only.
validation_set: Source containing validation data only.
test_set: Source containing test data only.
training_set_metadata: Pre-computed metadata dict or `.meta.json` path
from a prior Ludwig run on the same dataset.
data_format: Format hint for data sources. Inferred when `None`.
Valid values: `'auto'`, `'csv'`, `'df'`, `'dict'`, `'excel'`,
`'feather'`, `'fwf'`, `'hdf5'`, `'html'`, `'json'`, `'jsonl'`,
`'parquet'`, `'pickle'`, `'sas'`, `'spss'`, `'stata'`, `'tsv'`.
skip_save_processed_input: Skip caching the preprocessed HDF5/JSON files.
random_seed: Seed for data splitting and shuffling.
**kwargs: Forwarded to the underlying preprocessing function.
Returns:
A `PreprocessedDataset` namedtuple with fields `training_set`,
`validation_set`, `test_set`, and `training_set_metadata`.
Raises:
RuntimeError: If preprocessing fails (e.g., empty training set after
filtering, or lazy loading incompatible with RayBackend).
"""
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):
preprocessed_data = preprocess_for_training(
self.config_obj,
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:
logger.debug(traceback.format_exc())
raise
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 = None,
from_checkpoint: bool = False,
) -> "LudwigModel": # return is an instance of ludwig.api.LudwigModel class
"""Load a previously trained LudwigModel from disk.
Args:
model_dir: Path to the saved model directory (typically
`results/<experiment>/<model>/model/`).
logging_level: Log level sent to stderr (e.g., `logging.INFO`).
backend: Backend instance or string name used for preprocessing.
gpus: GPUs to use; same syntax as CUDA_VISIBLE_DEVICES.
gpu_memory_limit: Maximum memory fraction [0, 1] allowed per GPU.
allow_parallel_threads: Allow Torch multi-threading for performance
at the cost of determinism.
callbacks: List of `Callback` objects providing hooks into the pipeline.
from_checkpoint: When `True`, load from the latest training checkpoint
in `training_checkpoints/` instead of the final model weights.
Returns:
A fully initialized `LudwigModel` ready for inference.
Example::
model = LudwigModel.load("results/experiment/run/model")
predictions, _ = model.predict(dataset=df)
"""
# 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
)
logger.info(f"Loading model from {model_dir}")
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._get_or_create_model(config_obj)
# load model weights
logger.info(f"Loading model weights from {model_dir}")
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:
"""Load model weights from a saved model directory.
Args:
model_dir: Path to the saved model directory.
from_checkpoint: When `True`, load from the latest training checkpoint
instead of the final model weights.
"""
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:
"""Save the model config, weights, and training metadata to `save_path`.
Args:
save_path: Directory where the model will be saved. Created if it
does not exist. Contains `model_hyperparameters.json`, weight
files, and `training_set_metadata.json`.
"""
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.
Args:
repo_id: A namespace (user or an organization) and a repo name separated by a `/`.
model_path: 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.
private: Whether the model repo should be private. Defaults to False.
repo_type: 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: The summary / title / first line of the generated commit.
commit_description: The description of the generated commit.
Returns:
True for success, False for failure.
"""
if model_weights_exist(os.path.join(model_path, MODEL_FILE_NAME)) and os.path.exists(
os.path.join(model_path, MODEL_FILE_NAME, MODEL_HYPERPARAMETERS_FILE_NAME)
):
experiment_path = model_path
elif model_weights_exist(model_path) 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.
Args:
save_path: filepath string to save config as a JSON file.
"""
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 export_model(self, save_path: str, format: str = "safetensors", sample_input: dict | None = None) -> None:
"""Export the model in various formats.
Args:
save_path: Directory to save the exported model.
format: Export format. One of "safetensors", "torch_export", "onnx".
sample_input: Example input for tracing (required for torch_export and onnx).
"""
from ludwig.utils.model_export import ModelExporter
exporter = ModelExporter(self.model)
if format == "safetensors":
return exporter.export_safetensors(save_path)
elif format == "torch_export":
return exporter.export_torch(save_path, sample_input)
elif format == "onnx":
return exporter.export_onnx(save_path, sample_input)
else:
raise ValueError(f"Unknown export format: {format}. Options: safetensors, torch_export, onnx")
def _preprocess_for_prediction(
self,
dataset: str | dict | pd.DataFrame | Dataset,
data_format: str | None = None,
split: str | None = None,
include_outputs: bool = False,
callbacks: list | None = None,
):
"""Shared preprocessing wrapper for predict, evaluate, and collect_activations."""
return preprocess_for_prediction(
self.config_obj,
dataset=dataset,
training_set_metadata=self.training_set_metadata,
data_format=data_format,
split=split,
include_outputs=include_outputs,
backend=self.backend,
callbacks=self.callbacks + (callbacks or []),
)
def _check_initialization(self):
missing = []
if self.model is None:
missing.append("model")
if self._user_config is None:
missing.append("config")
if self.training_set_metadata is None:
missing.append("training_set_metadata")
if missing:
raise ValueError(
f"Model is not initialized (missing: {', '.join(missing)}). "
"Call train() or load() before predict/evaluate."
)
def free_gpu_memory(self) -> None:
"""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.
Args:
config_obj: Ludwig config object.
random_seed: Random seed used for weights initialization, splits and any other random function.
Returns:
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.
Args:
logging_level: Set/Update the logging level. Use logging constants like `logging.DEBUG`,
`logging.INFO` and `logging.ERROR`.
"""
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:
"""Return True if this model is an LLM configured to merge_and_unload QLoRA adapter weights."""
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 = None,
data_format: 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,
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: Any,
) -> tuple[dict, dict]:
"""Perform k-fold cross-validation and return aggregated metrics.
Args:
num_folds: Number of folds for cross-validation.
config: Model config dict or path to a YAML config file.
dataset: Source containing the full dataset. Note: `'hdf5'` format is
not supported for k-fold cross-validation.
data_format: Format hint for the data source. Inferred automatically when
`None`. Valid values: `'auto'`, `'csv'`, `'df'`, `'dict'`, `'excel'`,
`'feather'`, `'fwf'`, `'html'`, `'json'`, `'jsonl'`, `'parquet'`,
`'pickle'`, `'sas'`, `'spss'`, `'stata'`, `'tsv'`.
skip_save_training_description: Skip saving the experiment description JSON.
skip_save_training_statistics: Skip saving training statistics JSON.
skip_save_model: Skip saving model weights after each improvement.
skip_save_progress: Skip saving per-epoch checkpoints for resuming.
skip_save_log: Skip saving TensorBoard logs.
skip_save_processed_input: Skip caching preprocessed HDF5/JSON files.
skip_save_predictions: Skip writing prediction CSV files.
skip_save_eval_stats: Skip writing evaluation statistics JSON.
skip_collect_predictions: Do not collect postprocessed predictions.
skip_collect_overall_stats: Do not compute dataset-level aggregate metrics.
output_directory: Root directory for saved outputs.
random_seed: Seed for weight initialization, data splitting, and shuffling.
gpus: GPUs to use; same syntax as CUDA_VISIBLE_DEVICES.
gpu_memory_limit: Maximum memory fraction [0, 1] allowed per GPU device.
allow_parallel_threads: Allow Torch multi-threading at the cost of determinism.
backend: Backend instance or string name for preprocessing and training.
logging_level: Log level sent to stderr.
**kwargs: Forwarded to each fold's `experiment()` call.
Returns:
A tuple `(kfold_cv_statistics, kfold_split_indices)` where
`kfold_cv_statistics` maps fold name → training + eval metrics, and
`kfold_split_indices` maps fold name → training/test index arrays.
"""
# 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