Skip to content

Trainers API

Author: Wenyu Ouyang Date: 2021-12-05 11:21:58 LastEditTime: 2025-11-08 16:02:09 LastEditors: Wenyu Ouyang Description: Main function for training and testing FilePath: orchhydro orchhydro rainers rainer.py Copyright (c) 2021-2022 Wenyu Ouyang. All rights reserved.

ensemble_train_and_evaluate(cfgs)

Function to train and test for ensemble models

Parameters

cfgs Dictionary containing all configs needed to run the model

Returns

None

Source code in torchhydro/trainers/trainer.py
def ensemble_train_and_evaluate(cfgs: Dict):
    """
    Function to train and test for ensemble models

    Parameters
    ----------
    cfgs
        Dictionary containing all configs needed to run the model

    Returns
    -------
    None
    """
    # for basins and models
    ensemble = cfgs["training_cfgs"]["ensemble"]
    if not ensemble:
        raise ValueError(
            "ensemble should be True, otherwise should use train_and_evaluate rather than ensemble_train_and_evaluate"
        )
    ensemble_items = cfgs["training_cfgs"]["ensemble_items"]
    number_of_items = len(ensemble_items)
    if number_of_items == 0:
        raise ValueError("ensemble_items should not be empty")
    keys_list = list(ensemble_items.keys())
    if "kfold" in keys_list:
        _trans_kfold_to_periods(cfgs, ensemble_items, "kfold")
    _nested_loop_train_and_evaluate(keys_list, 0, ensemble_items, cfgs)

set_random_seed(seed)

Set a random seed to guarantee the reproducibility

Parameters

seed a number

Returns

None

Source code in torchhydro/trainers/trainer.py
def set_random_seed(seed):
    """
    Set a random seed to guarantee the reproducibility

    Parameters
    ----------
    seed
        a number

    Returns
    -------
    None
    """
    # print("Random seed:", seed)
    random.seed(seed)
    np.random.seed(seed)
    torch.manual_seed(seed)
    torch.backends.cudnn.deterministic = True
    torch.backends.cudnn.benchmark = False

train_and_evaluate(cfgs)

Function to train and test a Model

Parameters

cfgs Dictionary containing all configs needed to run the model

Returns

None

Source code in torchhydro/trainers/trainer.py
def train_and_evaluate(cfgs: Dict):
    """
    Function to train and test a Model

    Parameters
    ----------
    cfgs
        Dictionary containing all configs needed to run the model

    Returns
    -------
    None
    """
    random_seed = cfgs["training_cfgs"]["random_seed"]
    set_random_seed(random_seed)
    resulter = Resulter(cfgs)
    deephydro = _get_deep_hydro(cfgs)
    # if train_mode is False, we only evaluate the model
    train_mode = deephydro.cfgs["training_cfgs"]["train_mode"]
    # but if train_mode is True, we still need some conditions to train the model
    continue_train = deephydro.cfgs["model_cfgs"]["continue_train"]
    is_transfer_learning = deephydro.cfgs["model_cfgs"]["model_type"] == "TransLearn"
    is_train = train_mode and (
        (deephydro.weight_path is not None and (continue_train or is_transfer_learning))
        or (deephydro.weight_path is None)
    )
    if is_train:
        deephydro.model_train()
        # Explicitly delete optimizer to free its states (momentum, etc.)
        """if hasattr(deephydro, "optimizer"):
            del deephydro.optimizer
        # Note: deephydro.model will be re-initialized in model_evaluate
        import gc

        gc.collect()
        if torch.cuda.is_available():
            torch.cuda.empty_cache()
        if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
            torch.mps.empty_cache()"""

    preds, obss = deephydro.model_evaluate()
    resulter.save_cfg(deephydro.cfgs)
    resulter.save_result(preds, obss)
    resulter.eval_result(preds, obss)

Author: Wenyu Ouyang Date: 2024-04-08 18:15:48 LastEditTime: 2025-07-13 16:25:31 LastEditors: Wenyu Ouyang Description: HydroDL model class FilePath: orchhydro orchhydro rainers\deep_hydro.py Copyright (c) 2024-2024 Wenyu Ouyang. All rights reserved.

DeepHydro (DeepHydroInterface)

The Base Trainer class for Hydrological Deep Learning models

Source code in torchhydro/trainers/deep_hydro.py
class DeepHydro(DeepHydroInterface):
    """
    The Base Trainer class for Hydrological Deep Learning models
    """

    def __init__(
        self,
        cfgs: Dict,
        pre_model=None,
    ):
        """
        Parameters
        ----------
        cfgs
            configs for the model
        pre_model
            a pre-trained model, if it is not None,
            we will use its weights to initialize this model
            by default None
        """
        super().__init__(cfgs)
        self.split_validation = validate_split_ranges(self.cfgs)
        # Initialize fabric based on configuration
        self.fabric = create_fabric_wrapper(cfgs.get("training_cfgs", {}))
        self.pre_model = pre_model
        self.model = self.fabric.setup_module(self.load_model())
        if cfgs["training_cfgs"]["train_mode"]:
            self.traindataset = self.make_dataset("train")
            self.traindataset.split_evaluation_semantics = self.split_validation[
                "evaluation_semantics"
            ]
            if cfgs["data_cfgs"]["t_range_valid"] is not None:
                self.validdataset = self.make_dataset("valid")
                self.validdataset.split_evaluation_semantics = self.split_validation[
                    "evaluation_semantics"
                ]
        self.testdataset: BaseDataset = self.make_dataset("test")
        self.testdataset.split_evaluation_semantics = self.split_validation[
            "evaluation_semantics"
        ]

    @property
    def device(self):
        """Get the device from fabric wrapper"""
        return self.fabric._device

    def load_model(self, mode="train"):
        """
        Load a time series forecast model in pytorch_model_dict in model_dict_function.py

        Returns
        -------
        object
            model in pytorch_model_dict in model_dict_function.py
        """
        if mode == "infer":
            if self.weight_path is None or self.cfgs["model_cfgs"]["continue_train"]:
                # if no weight path is provided
                # or weight file is provided but continue train again,
                # we will use the trained model in the new case_dir directory
                self.weight_path = self._get_trained_model()
        elif mode != "train":
            raise ValueError("Invalid mode; must be 'train' or 'infer'")
        model_cfgs = self.cfgs["model_cfgs"]
        model_name = model_cfgs["model_name"]
        if model_name not in pytorch_model_dict:
            raise NotImplementedError(
                f"Error the model {model_name} was not found in the model dict. Please add it."
            )
        if self.pre_model is not None:
            return self._load_pretrain_model()
        elif self.weight_path is not None:
            return self._load_model_from_pth()
        else:
            return pytorch_model_dict[model_name](**model_cfgs["model_hyperparam"])

    def _load_pretrain_model(self):
        """load a pretrained model as the initial model"""
        return self.pre_model

    def _load_model_from_pth(self):
        weight_path = self.weight_path
        model_cfgs = self.cfgs["model_cfgs"]
        model_name = model_cfgs["model_name"]
        model = pytorch_model_dict[model_name](**model_cfgs["model_hyperparam"])
        checkpoint = torch.load(
            weight_path, map_location=self.device, weights_only=False
        )
        model.load_state_dict(checkpoint)
        print("Weights sucessfully loaded")
        return model

    def make_dataset(self, is_tra_val_te: str):
        """
        Initializes a pytorch dataset.

        Parameters
        ----------
        is_tra_val_te
            train or valid or test

        Returns
        -------
        object
            an object initializing from class in datasets_dict in data_dict.py
        """
        data_cfgs = self.cfgs["data_cfgs"]
        dataset_name = data_cfgs["dataset"]

        if dataset_name in list(datasets_dict.keys()):
            dataset = datasets_dict[dataset_name](self.cfgs, is_tra_val_te)
        else:
            raise NotImplementedError(
                f"Error the dataset {str(dataset_name)} was not found in the dataset dict. Please add it."
            )
        return dataset

    def model_train(self) -> None:
        """train a hydrological DL model"""
        # A dictionary of the necessary parameters for training
        training_cfgs = self.cfgs["training_cfgs"]
        # The file path to load model weights from; defaults to "model_save"
        model_filepath = self.cfgs["data_cfgs"]["case_dir"]
        data_cfgs = self.cfgs["data_cfgs"]
        es = None
        if training_cfgs["early_stopping"]:
            es = EarlyStopper(training_cfgs["patience"])
        criterion = self._get_loss_func(training_cfgs)
        opt = self._get_optimizer(training_cfgs)
        scheduler = self._get_scheduler(training_cfgs, opt)
        max_epochs = training_cfgs["epochs"]
        start_epoch = training_cfgs["start_epoch"]
        # use PyTorch's DataLoader to load the data into batches in each epoch
        data_loader, validation_data_loader = self._get_dataloader(
            training_cfgs, data_cfgs
        )
        logger = TrainLogger(model_filepath, self.cfgs, opt)
        performance_monitor = (
            TrainingPerformanceMonitor(
                Path(model_filepath) / "performance",
                self.device,
                gpu_sample_interval=float(
                    training_cfgs.get("performance_gpu_sample_interval", 1.0)
                ),
                gpu_idle_threshold=float(
                    training_cfgs.get("performance_gpu_idle_threshold", 5.0)
                ),
            )
            if training_cfgs.get("performance_monitor", False)
            else None
        )
        for epoch in range(start_epoch, max_epochs + 1):
            if performance_monitor is not None:
                performance_monitor.start_epoch(epoch)
            with logger.log_epoch_train(epoch) as train_logs:
                total_loss, n_iter_ep = torch_single_train(
                    self.model,
                    opt,
                    criterion,
                    data_loader,
                    device=self.device,
                    which_first_tensor=training_cfgs["which_first_tensor"],
                    non_blocking_transfer=training_cfgs.get(
                        "non_blocking_transfer",
                        bool(training_cfgs.get("pin_memory", False)),
                    ),
                    cuda_prefetch=bool(training_cfgs.get("cuda_prefetch", False)),
                    cuda_prefetch_batches=int(
                        training_cfgs.get("cuda_prefetch_batches", 2)
                    ),
                    performance_monitor=performance_monitor,
                    amp=bool(training_cfgs.get("amp", False)),
                    amp_dtype=training_cfgs.get("amp_dtype"),
                    mixed_precision=training_cfgs.get("mixed_precision", "off"),
                    nonfinite_check_interval=int(
                        training_cfgs.get("nonfinite_check_interval", 100)
                    ),
                )
                train_logs["train_loss"] = total_loss
                train_logs["model"] = self.model

            if performance_monitor is not None:
                performance = performance_monitor.finish_epoch(
                    sampler=getattr(data_loader, "batch_sampler", None),
                    dataset=self.traindataset,
                )
                print(
                    "Performance epoch {epoch}: {rate:.1f} samples/s, "
                    "loader wait {wait:.4f}s/batch, GPU util {gpu}, "
                    "GPU idle {idle}".format(
                        epoch=epoch,
                        rate=performance["samples_per_second"],
                        wait=performance["avg_dataloader_wait_seconds"] or 0.0,
                        gpu=(
                            f'{performance["gpu_util_avg_percent"]:.1f}%'
                            if performance["gpu_util_avg_percent"] is not None
                            else "n/a"
                        ),
                        idle=(
                            f'{performance["gpu_idle_percent"]:.1f}%'
                            if performance["gpu_idle_percent"] is not None
                            else "n/a"
                        ),
                    )
                )

            valid_loss = None
            valid_metrics = None
            if data_cfgs["t_range_valid"] is not None:
                with logger.log_epoch_valid(epoch) as valid_logs:
                    valid_loss, valid_metrics = self._1epoch_valid(
                        training_cfgs, criterion, validation_data_loader, valid_logs
                    )

            self._scheduler_step(training_cfgs, scheduler, valid_loss)
            logger.save_session_param(
                epoch, total_loss, n_iter_ep, valid_loss, valid_metrics
            )
            logger.save_model_and_params(self.model, epoch, self.cfgs)
            if es and not es.check_loss(
                self.model,
                valid_loss,
                self.cfgs["data_cfgs"]["case_dir"],
            ):
                print("Stopping model now")
                break
        # logger.plot_model_structure(self.model)
        logger.tb.close()

        # return the trained model weights and bias and the epoch loss
        return self.model.state_dict(), sum(logger.epoch_loss) / len(logger.epoch_loss)

    def _get_scheduler(self, training_cfgs, opt):
        lr_scheduler_cfg = training_cfgs["lr_scheduler"]

        if "lr" in lr_scheduler_cfg and "lr_factor" not in lr_scheduler_cfg:
            target_lr = lr_scheduler_cfg["lr"]
            scheduler = LambdaLR(
                opt, lr_lambda=lambda epoch: target_lr / opt.param_groups[0]["lr"]
            )
            # scheduler = LambdaLR(opt, lr_lambda=lambda epoch: 1.0)
        elif isinstance(lr_scheduler_cfg, dict) and all(
            isinstance(epoch, int) for epoch in lr_scheduler_cfg
        ):
            # piecewise constant learning rate
            epochs = sorted(lr_scheduler_cfg.keys())
            values = [lr_scheduler_cfg[e] for e in epochs]

            def lr_lambda(epoch):
                idx = bisect.bisect_right(epochs, epoch) - 1
                return 1.0 if idx < 0 else values[idx]

            scheduler = LambdaLR(opt, lr_lambda=lr_lambda)
        elif "lr_factor" in lr_scheduler_cfg and "lr_patience" not in lr_scheduler_cfg:
            scheduler = ExponentialLR(opt, gamma=lr_scheduler_cfg["lr_factor"])
        elif "lr_factor" in lr_scheduler_cfg:
            scheduler = ReduceLROnPlateau(
                opt,
                mode="min",
                factor=lr_scheduler_cfg["lr_factor"],
                patience=lr_scheduler_cfg["lr_patience"],
            )
        else:
            raise ValueError("Invalid lr_scheduler configuration")

        return scheduler

    def _scheduler_step(self, training_cfgs, scheduler, valid_loss):
        lr_scheduler_cfg = training_cfgs["lr_scheduler"]
        required_keys = {"lr_factor", "lr_patience"}
        if required_keys.issubset(lr_scheduler_cfg.keys()):
            scheduler.step(valid_loss)
        else:
            scheduler.step()

    def _1epoch_valid(
        self, training_cfgs, criterion, validation_data_loader, valid_logs
    ):
        valid_obss_np, valid_preds_np, valid_loss = compute_validation(
            self.model,
            criterion,
            validation_data_loader,
            device=self.device,
            which_first_tensor=training_cfgs["which_first_tensor"],
            mixed_precision=training_cfgs.get("mixed_precision", "off"),
            non_blocking=bool(
                training_cfgs.get(
                    "non_blocking_transfer", training_cfgs.get("pin_memory", False)
                )
            ),
            empty_cache_during_validation=bool(
                training_cfgs.get("empty_cache_during_validation", False)
            ),
        )
        valid_logs["valid_loss"] = valid_loss
        if (
            self.cfgs["training_cfgs"]["valid_batch_mode"] == "test"
            and self.cfgs["training_cfgs"]["calc_metrics"]
        ):
            # NOTE: Now we only evaluate the metrics for test-mode validation
            target_col = self.cfgs["data_cfgs"]["target_cols"]
            valid_metrics = evaluate_validation(
                validation_data_loader,
                valid_preds_np,
                valid_obss_np,
                self.cfgs["evaluation_cfgs"],
                target_col,
            )
            valid_logs["valid_metrics"] = valid_metrics
            return valid_loss, valid_metrics
        return valid_loss, None

    def _get_trained_model(self):
        model_loader = self.cfgs["evaluation_cfgs"]["model_loader"]
        model_pth_dir = self.cfgs["data_cfgs"]["case_dir"]
        return read_pth_from_model_loader(model_loader, model_pth_dir)

    def model_evaluate(self):
        """
        Evaluate the model, and perform SHAP analysis if needed.

        Returns
        -------
        preds_xr, obss_xr
        """
        self.model = self.load_model(mode="infer").to(self.device)

        data_cfgs = self.cfgs["data_cfgs"]
        training_cfgs = self.cfgs["training_cfgs"]

        # Create dataloader in advance as it is used for both inference and SHAP
        test_dataloader = self._get_dataloader(training_cfgs, data_cfgs, mode="infer")

        # Decide whether to perform SHAP analysis based on configuration
        eval_cfgs = self.cfgs.get("evaluation_cfgs", {})
        shap_cfgs = eval_cfgs.get("shap_cfgs", {})

        # Inference returns X_all for SHAP analysis only if enabled
        if shap_cfgs.get("enable", False):
            pred_xr, obs_xr, X_all = self.inference(test_dataloader, return_inputs=True)
            print("SHAP analysis enabled.")
        else:
            pred_xr, obs_xr = self.inference(test_dataloader, return_inputs=False)
            X_all = None
            print("SHAP analysis disabled.")

        if shap_cfgs.get("enable", False):
            self.run_shap_analysis(
                X_all=X_all,
                save_dir=shap_cfgs.get("save_dir", "./shap_results"),
                max_sample=shap_cfgs.get("max_sample", 3000),
                background_size=shap_cfgs.get("background_size", 100),
            )

        return pred_xr, obs_xr

    def inference(self, dataloader, return_inputs=True):
        self.model.eval()

        preds = []
        obss = []
        X_all = []

        with torch.no_grad():
            for batch in dataloader:
                # Handle Seq2Seq / multi-input format: batch[0] might be a
                # list or tuple of inputs (e.g., [encoder, decoder] or (x, z)).
                if isinstance(batch[0], (list, tuple)):
                    # Multi-input format: move each element to device and
                    # transpose time-series tensors to (seq, batch, feature).
                    x = [
                        inp.permute(1, 0, 2).to(self.device)
                        if inp.ndim == 3
                        else inp.to(self.device)
                        for inp in batch[0]
                    ]
                    pred = self.model(*x)
                    # Transpose prediction back to (batch, seq, feature)
                    if isinstance(pred, torch.Tensor) and pred.ndim == 3:
                        pred = pred.permute(1, 0, 2)

                else:
                    # Traditional tensor or separated static-input tuple.
                    x = _move_batch_value_to_device(
                        batch[0],
                        self.device,
                        self.cfgs["training_cfgs"].get("which_first_tensor")
                        == "sequence",
                    )

                    pred = self.model(x)

                    # --- TRANSPOSE BACK TO BATCH-FIRST ---
                    if (
                        self.cfgs["training_cfgs"].get("which_first_tensor")
                        == "sequence"
                        and isinstance(pred, torch.Tensor)
                        and pred.ndim == 3
                    ):
                        pred = pred.transpose(0, 1)

                y = batch[1].to(self.device).cpu()

                # --- DO NOT TRANSPOSE LABELS ---
                # Labels from _flood_event_collate_fn or default stack are already (Batch, Seq, Feat).
                # Transposing them here would make them (Seq, Batch, Feat), which breaks concatenation.

                # Handle models that return dict (e.g., Seq2Seq with attention)
                if isinstance(pred, dict):
                    # If this was a sequence-first dict, permute its tensors
                    if (
                        self.cfgs["training_cfgs"].get("which_first_tensor")
                        == "sequence"
                    ):
                        for k, v in pred.items():
                            if isinstance(v, torch.Tensor) and v.ndim == 3:
                                # Avoid double-transposition if it starts with 'f' (frequency branches)
                                # and we already know where it came from. Actually, safer to check shape.
                                # seq_first: (Seq, Batch, Feat). Batch size is usually smaller than Seq len.
                                # But in inference, Seq len (Flood events) can be smaller too.
                                # Let's assume if it came through model(x) it is Seq-first.
                                pred[k] = v.transpose(0, 1)
                    if "f1" in pred:
                        # Use f1 for 1-hour scale predictions
                        pred = pred["f1"]
                    # elif 'f0' in pred:
                    # Fallback to f0
                    # pred = pred['f0']
                    else:
                        # Generic handling for other dict outputs
                        pred_tensor = None
                        for key in ["predictions", "output", "pred", "logits"]:
                            if key in pred and isinstance(pred[key], torch.Tensor):
                                pred_tensor = pred[key]
                                break
                        if pred_tensor is None:
                            pred_tensor = next(iter(pred.values()))
                        pred = pred_tensor

                preds.append(pred.cpu())
                obss.append(y.cpu())

                if return_inputs:
                    # Handle both formats for X_all
                    if isinstance(x, list):
                        # For Seq2Seq, store the encoder input (first element)
                        x_for_shap = x[0]
                    else:
                        x_for_shap = x

                    # Ensure SHAP input is Batch-first
                    if (
                        self.cfgs["training_cfgs"].get("which_first_tensor")
                        == "sequence"
                        and x_for_shap.ndim == 3
                    ):
                        x_for_shap = x_for_shap.transpose(0, 1)

                    X_all.append(x_for_shap.cpu())

        # Convert to numpy
        # Pad sequence dimension independently for preds and obss
        if len(preds) > 0:
            max_p_s = max(p.shape[1] for p in preds)
            max_o_s = max(o.shape[1] for o in obss)

            padded_preds = []
            padded_obss = []

            for p, o in zip(preds, obss):
                if p.shape[1] < max_p_s:
                    pad_p = torch.full(
                        (p.shape[0], max_p_s - p.shape[1], p.shape[2]), np.nan
                    )
                    p = torch.cat([p, pad_p], dim=1)
                if o.shape[1] < max_o_s:
                    pad_o = torch.full(
                        (o.shape[0], max_o_s - o.shape[1], o.shape[2]), np.nan
                    )
                    o = torch.cat([o, pad_o], dim=1)
                padded_preds.append(p)
                padded_obss.append(o)

            pred_np = torch.cat(padded_preds, 0).numpy()
            obs_np = torch.cat(padded_obss, 0).numpy()
        else:
            pred_np = np.array([])
            obs_np = np.array([])

        # Convert to xarray ---- KEEP THIS (TorchHydro requirement)
        obs_xr, pred_xr = get_preds_to_be_eval(
            dataloader, self.cfgs["evaluation_cfgs"], pred_np, obs_np
        )

        if return_inputs:
            return pred_xr, obs_xr, X_all
        else:
            return pred_xr, obs_xr

    def run_shap_analysis(
        self, X_all, save_dir, max_sample=3000, feature_names=None, background_size=100
    ):
        import torch
        import numpy as np
        import shap
        import matplotlib.pyplot as plt
        import os

        print("Running FAST SHAP analysis...")

        eval_cfgs = self.cfgs.get("evaluation_cfgs", {}) or {}
        shap_cfgs = eval_cfgs.get("shap_cfgs", {}) or {}
        sequence_enable = bool(shap_cfgs.get("sequence_enable", False))
        plot_mode = shap_cfgs.get("plot_mode")
        if not isinstance(plot_mode, str):
            plot_mode = "all"
        plot_mode = plot_mode.lower()

        plot_fast = True
        plot_seq = True
        plot_hybrid = True
        plot_split = False

        if plot_mode == "split_only":
            plot_fast = False
            plot_seq = False
            plot_hybrid = False
            plot_split = True
            sequence_enable = True

        # ======================================================
        # 1. Optimize Memory & Diversity: Sample sparsely from MANY batches
        # ======================================================
        # X_all is a list of tensors, each (B, T, F)

        if not X_all:
            print("[WARNING] No data for SHAP analysis.")
            return

        # Get dimensions from first batch
        _, T, F_total = X_all[0].shape

        # We need max_sample samples.
        # To ensure spatial diversity (static attributes variation), we must sample from many batches.
        # Strategy: Randomly select N batches, then sample k items from each batch.

        import random

        n_batches = len(X_all)

        # We want to sample from at least min(50, n_batches) batches to cover different basins
        target_batch_count = min(50, n_batches)

        # How many samples per batch?
        # We want total ~ max_sample * 2 (safety margin)
        samples_per_batch = max(1, (max_sample * 2) // target_batch_count)

        print(
            f"[INFO] Sampling strategy: {target_batch_count} batches, ~{samples_per_batch} samples/batch"
        )

        batch_indices = list(range(n_batches))
        random.shuffle(batch_indices)
        selected_batch_indices = batch_indices[:target_batch_count]

        collected_subsets = []

        for idx in selected_batch_indices:
            batch = X_all[idx]  # (B, T, F)
            B = batch.shape[0]

            # Flatten this batch to (B*T, F)
            batch_flat = batch.reshape(-1, F_total)
            M_batch = batch_flat.shape[0]

            # Sample randomly from this batch
            if M_batch > samples_per_batch:
                # Random indices
                indices = torch.randperm(M_batch)[:samples_per_batch]
                batch_subset = batch_flat[indices]
            else:
                batch_subset = batch_flat

            collected_subsets.append(batch_subset)

        # Concatenate subsets
        X_subset = torch.cat(collected_subsets, dim=0)  # (N_total_subset, F)

        # Handle NaNs: Replace NaNs with 0 (mean of normalized data)
        if torch.isnan(X_subset).any():
            print(
                "[WARNING] Input data contains NaNs. Filling with 0 for SHAP analysis."
            )
            X_subset = torch.nan_to_num(X_subset, nan=0.0)

        print(f"[INFO] Collected subset samples: {X_subset.shape[0]}")

        # ======================================================
        # 2. Flatten to 2D: Already flattened in loop
        # ======================================================
        X_flat = X_subset
        M = X_flat.shape[0]
        print(f"[INFO] Flattened subset samples: {M}")

        # ======================================================
        # 3. Sample input data for acceleration
        # ======================================================
        # Ensure we don't sample more than we have
        sample_size = min(max_sample, M)
        if sample_size < M:
            # We already shuffled and sampled, but let's do one final random selection to be sure
            idx = np.random.choice(M, sample_size, replace=False)
            X_sample = X_flat[idx]
        else:
            X_sample = X_flat

        X_sample_np = X_sample.cpu().numpy()  # numpy,shape=(sample_size, F)

        print(f"[INFO] Using {sample_size} samples for SHAP")

        # ======================================================
        # 4. Select background for SHAP
        # ======================================================
        # background_size is passed as argument
        if M > background_size:
            idx_bg = np.random.choice(M, background_size, replace=False)
            background = X_flat[idx_bg]
        else:
            background = X_flat

        background_np = background.cpu().numpy()
        print(f"[INFO] Using {background.shape[0]} samples as SHAP background")

        # ======================================================
        # 5. Wrap the model: SamplingExplainer uses a python function
        # ======================================================
        # Move model to CPU to avoid MPS bugs with SHAP/LSTM
        original_device = self.device
        shap_device = torch.device("cpu")
        print(
            f"[INFO] Moving model to {shap_device} for SHAP analysis to avoid MPS errors..."
        )
        self.model.to(shap_device)
        self.model.eval()

        def model_predict(x_np):
            """
            Args:
                x_np: numpy array with shape (batch, F).

            Returns:
                numpy array with shape (batch, 1).

            Note:
                Converts numpy array to torch.Tensor, feeds it to the model,
                and reshapes the output to (batch, 1).
            """
            x_tensor = torch.tensor(
                x_np, dtype=torch.float32, device=shap_device
            )  # shape=(B,F)

            # SimpleLSTM (and default nn.LSTM) expects (Time, Batch, Features) if batch_first=False
            # We treat SHAP samples as independent time steps (Time=1)
            x_tensor = x_tensor.unsqueeze(0)  # (1, B, F)

            if torch.isnan(x_tensor).any():
                print(f"[WARNING] SHAP input x_tensor contains NaNs!")

            with torch.no_grad():
                out = self.model(x_tensor)  # (1, B, 1)

            if torch.isnan(out).any():
                print(f"[WARNING] SHAP model output contains NaNs!")
                # Optional: Replace output NaNs with 0 to allow SHAP to continue (but this hides model issues)
                # out = torch.nan_to_num(out, nan=0.0)

            # --- SamplingExplainer requires 2D output ---
            if out.shape[0] == 1:
                out = out.squeeze(0)
            else:
                print(
                    f"[WARNING] Unexpected output shape {out.shape} in SHAP. Squeezing dim 0."
                )
                out = out.squeeze(0)

            # Debug output range
            # print(f"[DEBUG] SHAP model output range: [{out.min().item():.4f}, {out.max().item():.4f}]")

            return out.cpu().numpy()

        # ======================================================
        # 6. Run SHAP
        # ======================================================
        print(f"[INFO] Computing SHAP values...")
        explainer = shap.SamplingExplainer(model_predict, background_np)

        # Increased nsamples for stability
        shap_values = explainer.shap_values(X_sample_np, nsamples=200)

        # Check shap_values
        if isinstance(shap_values, list):
            shap_values = shap_values[0]  # Take the first output channel

        print(f"[INFO] SHAP shape = {np.array(shap_values).shape}")

        # Handle NaNs in SHAP values
        if np.isnan(shap_values).any():
            nan_count = np.isnan(shap_values).sum()
            total_count = shap_values.size
            print(
                f"[WARNING] SHAP values contain {nan_count}/{total_count} NaNs! Filling with 0."
            )
            shap_values = np.nan_to_num(shap_values, nan=0.0)

        # ======================================================
        # 7. Construct feature names
        # ======================================================
        if feature_names is None:
            # Attempt to retrieve real feature names from configuration and stat file
            feature_names = self._get_feature_names(F_total)

            if feature_names is not None:
                print(f"[INFO] Using reconstructed real feature names: {feature_names}")
            else:
                print(
                    f"[WARNING] Could not reconstruct feature names. Using default names."
                )
                data_cfgs = self.cfgs.get("data_cfgs", {})
                relevant_cols = data_cfgs.get("relevant_cols", [])
                feature_names = [f"DYNAMIC_{i}" for i in range(len(relevant_cols))] + [
                    f"STATIC_{i}" for i in range(F_total - len(relevant_cols))
                ]

        # ======================================================
        # 8. Output plots
        # ======================================================
        os.makedirs(save_dir, exist_ok=True)

        shap_values_fast = np.array(shap_values)
        X_fast_np = X_sample_np

        if plot_fast:
            plt.figure()
            shap.summary_plot(
                shap_values,
                X_sample_np,
                feature_names=feature_names,
                max_display=100,
                show=False,
            )
            plt.savefig(
                f"{save_dir}/shap_summary_fast.png", dpi=300, bbox_inches="tight"
            )
            plt.close()

            plt.figure()
            shap.summary_plot(
                shap_values,
                X_sample_np,
                feature_names=feature_names,
                plot_type="bar",
                max_display=100,
                show=False,
            )
            plt.savefig(f"{save_dir}/shap_bar_fast.png", dpi=300, bbox_inches="tight")
            plt.close()

        print(f"[INFO] SHAP analysis saved to {save_dir}")

        if sequence_enable:
            training_cfgs = self.cfgs.get("training_cfgs", {}) or {}
            warmup_length = training_cfgs.get("warmup_length")
            hindcast_length = training_cfgs.get("hindcast_length")
            forecast_length = training_cfgs.get("forecast_length")

            seq_len = shap_cfgs.get("sequence_length")
            if not isinstance(seq_len, int):
                if (
                    isinstance(warmup_length, int)
                    and isinstance(hindcast_length, int)
                    and isinstance(forecast_length, int)
                ):
                    seq_len = warmup_length + hindcast_length + forecast_length
                else:
                    seq_len = T

            seq_max_sample = shap_cfgs.get("sequence_max_sample")
            if not isinstance(seq_max_sample, int):
                seq_max_sample = min(128, max_sample)
            seq_max_sample = max(1, seq_max_sample)

            seq_background_size = shap_cfgs.get("sequence_background_size")
            if not isinstance(seq_background_size, int):
                seq_background_size = min(32, background_size)
            seq_background_size = max(1, seq_background_size)

            seq_nsamples = shap_cfgs.get("sequence_nsamples")
            if not isinstance(seq_nsamples, int):
                seq_nsamples = 50

            time_agg = shap_cfgs.get("time_agg")
            if not isinstance(time_agg, str):
                time_agg = "abs_sum"
            time_agg = time_agg.lower()

            which_first_tensor = (
                self.cfgs.get("training_cfgs", {}).get("which_first_tensor") or "batch"
            )

            import random

            n_batches = len(X_all)
            target_batch_count = min(50, n_batches)
            seqs_per_batch = max(1, (seq_max_sample * 2) // max(1, target_batch_count))
            batch_indices = list(range(n_batches))
            random.shuffle(batch_indices)
            selected_batch_indices = batch_indices[:target_batch_count]

            seq_list = []
            for idx in selected_batch_indices:
                batch = X_all[idx]  # (B, T, F)
                if not isinstance(batch, torch.Tensor) or batch.ndim != 3:
                    continue
                B = int(batch.shape[0])
                if B <= 0:
                    continue
                take = min(B, seqs_per_batch)
                seq_indices = torch.randperm(B)[:take]
                for sidx in seq_indices:
                    seq = batch[sidx]  # (T, F)
                    if not isinstance(seq, torch.Tensor) or seq.ndim != 2:
                        continue
                    T_seq = int(seq.shape[0])
                    if T_seq <= 0:
                        continue
                    if T_seq >= seq_len:
                        seq_use = seq[-seq_len:]
                    else:
                        pad_len = seq_len - T_seq
                        pad = torch.zeros(
                            (pad_len, F_total), dtype=seq.dtype, device=seq.device
                        )
                        seq_use = torch.cat([pad, seq], dim=0)
                    seq_list.append(seq_use)

            if not seq_list:
                print(
                    "[WARNING] sequence_enable=True but no sequences collected; skipping."
                )
            else:
                X_seq = torch.stack(seq_list, dim=0)  # (N, seq_len, F)
                if torch.isnan(X_seq).any():
                    X_seq = torch.nan_to_num(X_seq, nan=0.0)

                if X_seq.shape[0] > seq_max_sample:
                    X_seq = X_seq[torch.randperm(X_seq.shape[0])[:seq_max_sample]]

                N_seq = int(X_seq.shape[0])
                bg_n = min(seq_background_size, N_seq)
                if bg_n < N_seq:
                    idx_bg = np.random.choice(N_seq, bg_n, replace=False)
                    background_seq = X_seq[idx_bg]
                else:
                    background_seq = X_seq

                print(
                    f"[INFO] SHAP sequence enabled: N={N_seq}, T={seq_len}, F={F_total}, bg={int(background_seq.shape[0])}, nsamples={seq_nsamples}, time_agg={time_agg}"
                )

                self.model.to(original_device)
                self.model.eval()

                try:
                    from torch.nn.modules.rnn import RNNBase
                except Exception:
                    RNNBase = ()

                rnn_modules = []
                rnn_training_states = []
                for m in self.model.modules():
                    if isinstance(m, RNNBase):
                        rnn_modules.append(m)
                        rnn_training_states.append(m.training)
                        m.train()

                class _ShapSeqWrapper(torch.nn.Module):
                    def __init__(self, model):
                        super().__init__()
                        self.model = model

                    def forward(self, x):
                        if which_first_tensor == "sequence":
                            x = x.transpose(0, 1)
                        out = self.model(x)
                        if isinstance(out, dict):
                            if "f1" in out:
                                out = out["f1"]
                            else:
                                out = next(iter(out.values()))
                        if isinstance(out, torch.Tensor) and out.ndim == 3:
                            out = (
                                out[-1]
                                if which_first_tensor == "sequence"
                                else out[:, -1]
                            )
                        if isinstance(out, torch.Tensor) and out.ndim == 1:
                            out = out.unsqueeze(1)
                        return out

                wrapper = _ShapSeqWrapper(self.model).to(original_device)
                background_t = background_seq.to(original_device).float()
                sample_t = X_seq.to(original_device).float()

                try:
                    with torch.backends.cudnn.flags(enabled=False):
                        explainer = shap.GradientExplainer(wrapper, background_t)
                        shap_values = explainer.shap_values(
                            sample_t, nsamples=seq_nsamples
                        )
                finally:
                    for m, st in zip(rnn_modules, rnn_training_states):
                        m.train(st)

                if isinstance(shap_values, list):
                    shap_values = shap_values[0]
                shap_values = np.array(shap_values)
                if shap_values.ndim == 4 and shap_values.shape[-1] == 1:
                    shap_values = shap_values[..., 0]
                if np.isnan(shap_values).any():
                    shap_values = np.nan_to_num(shap_values, nan=0.0)

                if time_agg == "sum":
                    shap_values_feat = shap_values.sum(axis=1)
                else:
                    shap_values_feat = np.abs(shap_values).sum(axis=1)

                X_feat = sample_t.detach().cpu().numpy().mean(axis=1)
                suffix = "sum" if time_agg == "sum" else "abs_sum"

                if plot_seq:
                    plt.figure()
                    shap.summary_plot(
                        shap_values_feat,
                        X_feat,
                        feature_names=feature_names,
                        max_display=100,
                        show=False,
                    )
                    plt.savefig(
                        f"{save_dir}/shap_summary_seq_{suffix}.png",
                        dpi=300,
                        bbox_inches="tight",
                    )
                    plt.close()

                    plt.figure()
                    shap.summary_plot(
                        shap_values_feat,
                        X_feat,
                        feature_names=feature_names,
                        plot_type="bar",
                        max_display=100,
                        show=False,
                    )
                    plt.savefig(
                        f"{save_dir}/shap_bar_seq_{suffix}.png",
                        dpi=300,
                        bbox_inches="tight",
                    )
                    plt.close()

                    print(f"[INFO] SHAP sequence plots saved to {save_dir}")

                data_cfgs = self.cfgs.get("data_cfgs", {}) or {}
                relevant_cols = data_cfgs.get("relevant_cols", []) or []
                static_start = int(len(relevant_cols))
                static_start = max(0, min(static_start, F_total))

                hybrid_n = int(
                    min(512, shap_values_fast.shape[0], shap_values_feat.shape[0])
                )
                if hybrid_n > 0 and static_start < F_total:
                    if shap_values_fast.shape[0] > hybrid_n:
                        idx_fast = np.random.choice(
                            shap_values_fast.shape[0], hybrid_n, replace=False
                        )
                    else:
                        idx_fast = np.arange(shap_values_fast.shape[0])

                    if shap_values_feat.shape[0] > hybrid_n:
                        idx_seq = np.random.choice(
                            shap_values_feat.shape[0], hybrid_n, replace=False
                        )
                    else:
                        idx_seq = np.arange(shap_values_feat.shape[0])

                    hybrid_shap = shap_values_fast[idx_fast].copy()
                    hybrid_x = X_fast_np[idx_fast].copy()

                    x_seq_last = sample_t.detach().cpu().numpy()[:, -1, :]
                    seq_shap_sel = shap_values_feat[idx_seq]
                    seq_x_sel = x_seq_last[idx_seq]

                    hybrid_shap[:, static_start:] = seq_shap_sel[:, static_start:]
                    hybrid_x[:, static_start:] = seq_x_sel[:, static_start:]

                    hybrid_dynamic_mode = shap_cfgs.get("hybrid_dynamic_mode")
                    if not isinstance(hybrid_dynamic_mode, str):
                        hybrid_dynamic_mode = "fast"
                    hybrid_dynamic_mode = hybrid_dynamic_mode.lower()

                    dyn_seq_idx = []
                    if hybrid_dynamic_mode == "seq_all":
                        dyn_seq_idx = list(range(static_start))
                    elif hybrid_dynamic_mode == "seq_bucket0":
                        feature_buckets = (
                            (self.cfgs.get("model_cfgs", {}) or {})
                            .get("model_hyperparam", {})
                            .get("feature_buckets")
                        )
                        if (
                            isinstance(feature_buckets, (list, tuple))
                            and len(feature_buckets) >= static_start
                        ):
                            dyn_seq_idx = [
                                i
                                for i in range(static_start)
                                if int(feature_buckets[i]) == 0
                            ]

                    extra_seq_features = shap_cfgs.get("hybrid_dynamic_seq_features")
                    if (
                        isinstance(extra_seq_features, (list, tuple))
                        and static_start > 0
                    ):
                        name_to_idx = {
                            str(n): i
                            for i, n in enumerate(feature_names[:static_start])
                        }
                        for fn in extra_seq_features:
                            idx = name_to_idx.get(str(fn))
                            if idx is not None:
                                dyn_seq_idx.append(idx)

                    if dyn_seq_idx:
                        dyn_seq_idx = sorted(set(dyn_seq_idx))
                        hybrid_shap[:, dyn_seq_idx] = seq_shap_sel[:, dyn_seq_idx]
                        hybrid_x[:, dyn_seq_idx] = seq_x_sel[:, dyn_seq_idx]

                    if plot_hybrid:
                        plt.figure()
                        shap.summary_plot(
                            hybrid_shap,
                            hybrid_x,
                            feature_names=feature_names,
                            max_display=100,
                            show=False,
                        )
                        plt.savefig(
                            f"{save_dir}/shap_summary_hybrid.png",
                            dpi=300,
                            bbox_inches="tight",
                        )
                        plt.close()

                        plt.figure()
                        shap.summary_plot(
                            hybrid_shap,
                            hybrid_x,
                            feature_names=feature_names,
                            plot_type="bar",
                            max_display=100,
                            show=False,
                        )
                        plt.savefig(
                            f"{save_dir}/shap_bar_hybrid.png",
                            dpi=300,
                            bbox_inches="tight",
                        )
                        plt.close()

                        print(f"[INFO] SHAP hybrid plots saved to {save_dir}")

                    if plot_split:
                        feature_buckets = (
                            (self.cfgs.get("model_cfgs", {}) or {})
                            .get("model_hyperparam", {})
                            .get("feature_buckets")
                        )
                        fb = None
                        if isinstance(feature_buckets, (list, tuple)):
                            if len(feature_buckets) >= F_total:
                                fb = [int(x) for x in feature_buckets[:F_total]]
                            elif len(feature_buckets) == static_start:
                                fb = [int(x) for x in feature_buckets] + [0] * (
                                    F_total - static_start
                                )

                        if fb is None:
                            low_idx = list(range(static_start, F_total))
                            high_idx = list(range(0, static_start))
                        else:
                            low_idx = [i for i in range(F_total) if int(fb[i]) == 0]
                            high_idx = [i for i in range(F_total) if int(fb[i]) > 0]

                        if low_idx:
                            low_shap = hybrid_shap[:, low_idx]
                            low_x = hybrid_x[:, low_idx]
                            low_names = [feature_names[i] for i in low_idx]

                            plt.figure()
                            shap.summary_plot(
                                low_shap,
                                low_x,
                                feature_names=low_names,
                                max_display=100,
                                show=False,
                            )
                            plt.savefig(
                                f"{save_dir}/shap_summary_hybrid_lowfreq.png",
                                dpi=300,
                                bbox_inches="tight",
                            )
                            plt.close()

                            plt.figure()
                            shap.summary_plot(
                                low_shap,
                                low_x,
                                feature_names=low_names,
                                plot_type="bar",
                                max_display=100,
                                show=False,
                            )
                            plt.savefig(
                                f"{save_dir}/shap_bar_hybrid_lowfreq.png",
                                dpi=300,
                                bbox_inches="tight",
                            )
                            plt.close()

                        if high_idx:
                            high_shap = hybrid_shap[:, high_idx]
                            high_x = hybrid_x[:, high_idx]
                            high_names = [feature_names[i] for i in high_idx]

                            plt.figure()
                            shap.summary_plot(
                                high_shap,
                                high_x,
                                feature_names=high_names,
                                max_display=100,
                                show=False,
                            )
                            plt.savefig(
                                f"{save_dir}/shap_summary_hybrid_highfreq.png",
                                dpi=300,
                                bbox_inches="tight",
                            )
                            plt.close()

                            plt.figure()
                            shap.summary_plot(
                                high_shap,
                                high_x,
                                feature_names=high_names,
                                plot_type="bar",
                                max_display=100,
                                show=False,
                            )
                            plt.savefig(
                                f"{save_dir}/shap_bar_hybrid_highfreq.png",
                                dpi=300,
                                bbox_inches="tight",
                            )
                            plt.close()

                        print(
                            f"[INFO] SHAP hybrid high/low frequency split plots saved to {save_dir}"
                        )

                        clean_extra = shap_cfgs.get("clean_extra")
                        if not isinstance(clean_extra, bool):
                            clean_extra = True
                        if clean_extra:
                            keep = {
                                "shap_bar_hybrid_lowfreq.png",
                                "shap_bar_hybrid_highfreq.png",
                                "shap_summary_hybrid_lowfreq.png",
                                "shap_summary_hybrid_highfreq.png",
                            }
                            for fn in os.listdir(save_dir):
                                if not (fn.startswith("shap_") and fn.endswith(".png")):
                                    continue
                                if fn in keep:
                                    continue
                                try:
                                    os.remove(os.path.join(save_dir, fn))
                                except Exception:
                                    pass

        # Restore model to original device
        self.model.to(original_device)
        print(f"[INFO] Model restored to {original_device}")

    def _get_feature_names(self, F_total):
        """
        Reconstruct feature names, handling One-Hot Encoding and extra features (e.g. embeddings) found in stat_dict.
        """
        import json

        data_cfgs = self.cfgs.get("data_cfgs", {})
        relevant_cols = data_cfgs.get("relevant_cols", [])
        constant_cols = data_cfgs.get("constant_cols", [])
        target_cols = data_cfgs.get("target_cols", [])

        # Check for One-Hot Encoding
        scaler_params = data_cfgs.get("scaler_params", {})
        one_hot_cols = scaler_params.get("one_hot_cols", [])

        # Try to load stat_dict
        # Priority 1: Directory of the weight path (best_model.pth)
        stat_dict = {}
        stat_file_loaded = False

        search_paths = []
        if self.weight_path:
            search_paths.append(
                os.path.join(
                    os.path.dirname(self.weight_path), "dapengscaler_stat.json"
                )
            )
        if "case_dir" in data_cfgs:
            search_paths.append(
                os.path.join(data_cfgs["case_dir"], "dapengscaler_stat.json")
            )

        for stat_file in search_paths:
            if os.path.exists(stat_file):
                try:
                    with open(stat_file, "r") as fp:
                        stat_dict = json.load(fp)
                    print(f"[INFO] Loaded stat file from {stat_file}")
                    stat_file_loaded = True
                    break
                except Exception as e:
                    print(f"[WARNING] Failed to load stat file {stat_file}: {e}")

        if not stat_file_loaded:
            print(
                "[WARNING] Could not load dapengscaler_stat.json from any expected location."
            )

        feature_names = []

        # 1. Dynamic features (relevant_cols)
        feature_names.extend(relevant_cols)

        # 2. Static features (constant_cols) from config
        # We need to track which keys we have used to identify "extra" ones later
        used_keys = set(relevant_cols) | set(target_cols)

        for col in constant_cols:
            used_keys.add(col)
            if col in one_hot_cols:
                # Expand One-Hot columns
                categories = stat_dict.get(f"{col}_categories", [])
                if categories:
                    for cat in categories:
                        feature_names.append(f"{col}_{cat}")
                else:
                    # Fallback
                    feature_names.append(f"{col}_UNKNOWN")
            else:
                feature_names.append(col)

        # 3. Check for missing features (e.g. AlphaEarth embeddings)
        if len(feature_names) < F_total and stat_dict:
            print(
                f"[INFO] Feature name count ({len(feature_names)}) < Model Input ({F_total}). Searching for extra features in stat_dict..."
            )

            extra_candidates = []
            for key in stat_dict.keys():
                # Skip known columns
                if key in used_keys:
                    continue
                # Skip One-Hot category metadata
                if key.endswith("_categories"):
                    continue
                # Skip One-Hot original columns if they were expanded (already handled by checking used_keys? No, one_hot_cols are in constant_cols, so in used_keys)

                # Add to candidates
                extra_candidates.append(key)

            # Sort candidates to ensure deterministic order
            # We assume extra features (like embeddings) are appended in alphabetical or numerical order
            extra_candidates.sort()

            # Try appending extra candidates
            # We only append enough to match F_total if possible, or just all of them and see

            # Heuristic: If we need exactly N more features, and we found M extra candidates.
            needed = F_total - len(feature_names)

            if len(extra_candidates) >= needed:
                # If we have enough candidates, take them.
                # If we have MORE than needed, it's ambiguous. But usually embeddings are distinct.
                # Let's assume all valid extra candidates are part of the input.

                # However, stat_dict might contain other auxiliary variables.
                # If we match exactly, great.
                if len(extra_candidates) == needed:
                    print(
                        f"[INFO] Found exactly {needed} extra features. Appending them."
                    )
                    feature_names.extend(extra_candidates)
                else:
                    print(
                        f"[WARNING] Found {len(extra_candidates)} extra candidates, but needed {needed}. Appending first {needed} sorted keys."
                    )
                    feature_names.extend(extra_candidates[:needed])
            else:
                print(
                    f"[WARNING] Found {len(extra_candidates)} extra candidates, but needed {needed}. Appending all available."
                )
                feature_names.extend(extra_candidates)

        # Final check
        if len(feature_names) != F_total:
            print(
                f"[WARNING] Reconstructed feature names length ({len(feature_names)}) does not match model input ({F_total})."
            )
            return None

        return feature_names

    def _get_optimizer(self, training_cfgs):
        params_in_opt = self.model.parameters()
        return pytorch_opt_dict[training_cfgs["optimizer"]](
            params_in_opt, **training_cfgs["optim_params"]
        )

    def _get_loss_func(self, training_cfgs):
        criterion_init_params = {}
        if "criterion_params" in training_cfgs:
            loss_param = training_cfgs["criterion_params"]
            if loss_param is not None:
                for key in loss_param.keys():
                    if key in ("loss_funcs", "eval_loss_func"):
                        criterion_init_params[key] = pytorch_criterion_dict[
                            loss_param[key]
                        ]()
                    else:
                        criterion_init_params[key] = loss_param[key]
        return pytorch_criterion_dict[training_cfgs["criterion"]](
            **criterion_init_params
        )

    def _flood_event_collate_fn(self, batch):
        """自定义的洪水事件 collate 函数,确保所有样本长度一致"""

        # Check if this is Seq2Seq format: ([x1, x2, ...], y)
        if isinstance(batch[0][0], list):
            # Seq2Seq format: use default collate for list of tensors
            from torch.utils.data.dataloader import default_collate

            return default_collate(batch)

        # 找到这个批次中最长的序列长度
        max_len = max(tensor_data[0].shape[0] for tensor_data in batch)

        # 调整所有样本到相同长度
        processed_batch = []
        for tensor_data in batch:
            # 获取x和y(假设tensor_data[0]是x,tensor_data[1]是y)
            x = tensor_data[0]
            y = tensor_data[1] if len(tensor_data) > 1 else None

            current_len = x.shape[0]
            if current_len < max_len:
                # 使用 NaN 填充 x
                padding_x = torch.full(
                    (max_len - current_len, x.shape[1]), np.nan, dtype=x.dtype
                )
                padded_x = torch.cat([x, padding_x], dim=0)

                # 如果有y,也进行填充
                if y is not None:
                    padding_y = torch.full(
                        (max_len - current_len, y.shape[1]), np.nan, dtype=y.dtype
                    )
                    padded_y = torch.cat([y, padding_y], dim=0)
                else:
                    padded_y = None
            else:
                # 如果更长则截断
                padded_x = x[:max_len]
                padded_y = y[:max_len] if y is not None else None

            if padded_y is not None:
                processed_batch.append((padded_x, padded_y))
            else:
                processed_batch.append(padded_x)

        # 根据数据结构返回堆叠后的结果
        if len(processed_batch) > 0 and isinstance(processed_batch[0], tuple):
            return (
                torch.stack([x for x, _ in processed_batch], 0),
                torch.stack([y for _, y in processed_batch], 0),
            )
        else:
            return torch.stack(processed_batch, 0)

    def _get_dataloader(self, training_cfgs, data_cfgs, mode="train"):
        if mode == "infer":
            _collate_fn = None
            # Use GNN collate function for GNN/graph datasets in inference mode
            if hasattr(self.testdataset, "__class__") and (
                "GNN" in self.testdataset.__class__.__name__
                or "TgHydroDataset" in self.testdataset.__class__.__name__
            ):
                _collate_fn = gnn_collate_fn
            # 使用自定义的 collate 函数处理 FloodEventDataset
            elif (
                hasattr(self.testdataset, "__class__")
                and "FloodEvent" in self.testdataset.__class__.__name__
            ):
                _collate_fn = self._flood_event_collate_fn
            return DataLoader(
                self.testdataset,
                batch_size=training_cfgs["batch_size"],
                shuffle=False,
                sampler=None,
                batch_sampler=None,
                drop_last=False,
                timeout=0,
                worker_init_fn=None,
                collate_fn=_collate_fn,
            )
        worker_num = 0
        pin_memory = False
        if "num_workers" in training_cfgs:
            worker_num = training_cfgs["num_workers"]
            print(f"using {str(worker_num)} workers")
        if "pin_memory" in training_cfgs:
            pin_memory = training_cfgs["pin_memory"]
            print(f"Pin memory set to {str(pin_memory)}")
        dataloader_extra_kwargs = {}
        if worker_num > 0:
            if "persistent_workers" in training_cfgs:
                dataloader_extra_kwargs["persistent_workers"] = training_cfgs[
                    "persistent_workers"
                ]
            if "prefetch_factor" in training_cfgs:
                dataloader_extra_kwargs["prefetch_factor"] = training_cfgs[
                    "prefetch_factor"
                ]
            multiprocessing_context = training_cfgs.get("multiprocessing_context")
            if multiprocessing_context not in (None, "auto"):
                dataloader_extra_kwargs["multiprocessing_context"] = (
                    multiprocessing_context
                )
            if "in_order" in inspect.signature(DataLoader).parameters:
                dataloader_extra_kwargs["in_order"] = bool(
                    training_cfgs.get("dataloader_in_order", False)
                )
        sampler = self._get_sampler(data_cfgs, training_cfgs, self.traindataset)
        batch_sampler = sampler if getattr(sampler, "is_batch_sampler", False) else None
        sampler_arg = None if batch_sampler is not None else sampler
        _collate_fn = None
        if training_cfgs["variable_length_cfgs"]["use_variable_length"]:
            _collate_fn = varied_length_collate_fn
        # Use GNN collate function for GNN/graph datasets
        elif hasattr(self.traindataset, "__class__") and (
            "GNN" in self.traindataset.__class__.__name__
            or "TgHydroDataset" in self.traindataset.__class__.__name__
        ):
            _collate_fn = gnn_collate_fn

        use_batch_level_dataloader = bool(
            training_cfgs.get("batch_level_dataloader", False)
        )
        separate_static_input = bool(
            training_cfgs.get("separate_static_input", False)
        )
        if hasattr(self.traindataset, "_return_static_attrs_separately"):
            self.traindataset._return_static_attrs_separately = False
        if hasattr(self.traindataset, "_batch_level_dataloader_enabled"):
            self.traindataset._batch_level_dataloader_enabled = False
        if hasattr(self.traindataset, "_separate_static_input"):
            self.traindataset._separate_static_input = False
        train_supports_prebatched = bool(
            getattr(self.traindataset, "supports_prebatched_getitems", False)
        )
        if (
            (sampler is None or batch_sampler is not None)
            and _collate_fn is None
            and (
                self.traindataset.__class__ is BaseDataset or train_supports_prebatched
            )
        ):
            self.traindataset._separate_static_input = separate_static_input
            if (use_batch_level_dataloader or train_supports_prebatched) and hasattr(
                self.traindataset, "_get_batch_level_items"
            ):
                self.traindataset._batch_level_dataloader_enabled = True
                _collate_fn = prebatched_collate_fn
            elif self.traindataset.__class__ is BaseDataset:
                self.traindataset._return_static_attrs_separately = True
                _collate_fn = static_attr_batch_collate_fn
        elif use_batch_level_dataloader:
            warnings.warn(
                "batch_level_dataloader=True was ignored because this "
                "dataset uses a custom sampler/collate path or is not the "
                "standard BaseDataset."
            )
        loader_kwargs = {
            "num_workers": worker_num,
            "pin_memory": pin_memory,
            "timeout": (
                float(training_cfgs.get("dataloader_timeout", 0.0))
                if worker_num > 0
                else 0
            ),
            "collate_fn": _collate_fn,
            **dataloader_extra_kwargs,
        }
        if batch_sampler is not None:
            loader_kwargs["batch_sampler"] = batch_sampler
        else:
            loader_kwargs.update(
                {
                    "batch_size": training_cfgs["batch_size"],
                    "shuffle": sampler_arg is None,
                    "sampler": sampler_arg,
                }
            )
        data_loader = DataLoader(self.traindataset, **loader_kwargs)
        if data_cfgs["t_range_valid"] is not None:
            # Use the same collate function for validation dataset
            _val_collate_fn = None
            if training_cfgs["variable_length_cfgs"]["use_variable_length"]:
                _val_collate_fn = varied_length_collate_fn
            elif hasattr(self.validdataset, "__class__") and (
                "GNN" in self.validdataset.__class__.__name__
                or "TgHydroDataset" in self.validdataset.__class__.__name__
            ):
                _val_collate_fn = gnn_collate_fn
            if hasattr(self.validdataset, "_return_static_attrs_separately"):
                self.validdataset._return_static_attrs_separately = False
            if hasattr(self.validdataset, "_batch_level_dataloader_enabled"):
                self.validdataset._batch_level_dataloader_enabled = False
            if hasattr(self.validdataset, "_separate_static_input"):
                self.validdataset._separate_static_input = False
            valid_supports_prebatched = bool(
                getattr(self.validdataset, "supports_prebatched_getitems", False)
            )
            if _val_collate_fn is None and (
                self.validdataset.__class__ is BaseDataset or valid_supports_prebatched
            ):
                self.validdataset._separate_static_input = separate_static_input
                if (
                    use_batch_level_dataloader or valid_supports_prebatched
                ) and hasattr(self.validdataset, "_get_batch_level_items"):
                    self.validdataset._batch_level_dataloader_enabled = True
                    _val_collate_fn = prebatched_collate_fn
                elif self.validdataset.__class__ is BaseDataset:
                    self.validdataset._return_static_attrs_separately = True
                    _val_collate_fn = static_attr_batch_collate_fn
            elif use_batch_level_dataloader:
                warnings.warn(
                    "batch_level_dataloader=True was ignored for validation "
                    "because this dataset uses a custom collate path or is "
                    "not the standard BaseDataset."
                )

            validation_data_loader = DataLoader(
                self.validdataset,
                batch_size=training_cfgs["batch_size"],
                shuffle=False,
                num_workers=worker_num,
                pin_memory=pin_memory,
                timeout=(
                    float(training_cfgs.get("dataloader_timeout", 0.0))
                    if worker_num > 0
                    else 0
                ),
                collate_fn=_val_collate_fn,
                **dataloader_extra_kwargs,
            )
            return data_loader, validation_data_loader

        return data_loader, None

    def _get_sampler(self, data_cfgs, training_cfgs, train_dataset):
        """
        return data sampler based on the provided configuration and training dataset.

        Parameters
        ----------
        data_cfgs : dict
            Configuration dictionary containing parameters for data sampling. Expected keys are:
            - "sampler": dict, containing:
            - "name": str, name of the sampler to use.
            - "sampler_hyperparam": dict, optional hyperparameters for the sampler.
        training_cfgs: dict
            Configuration dictionary containing parameters for training. Expected keys are:
            - "batch_size": int, size of each batch.
        train_dataset : Dataset
            The training dataset object which contains the data to be sampled. Expected attributes are:
            - ngrid: int, number of grids in the dataset.
            - nt: int, number of time steps in the dataset.
            - rho: int, length of the input sequence.
            - warmup_length: int, length of the warmup period.
            - horizon: int, length of the forecast horizon.

        Returns
        -------
        sampler_class
            An instance of the specified sampler class, initialized with the provided dataset and hyperparameters.

        Raises
        ------
        NotImplementedError
            If the specified sampler name is not found in the `data_sampler_dict`.
        """
        if data_cfgs["sampler"] is None:
            return None
        batch_size = training_cfgs["batch_size"]
        rho = train_dataset.rho
        warmup_length = train_dataset.warmup_length
        horizon = train_dataset.horizon
        ngrid = train_dataset.ngrid
        nt = train_dataset.nt
        sampler_name = data_cfgs["sampler"]
        if sampler_name not in data_sampler_dict:
            raise NotImplementedError(f"Sampler {sampler_name} not implemented yet")
        sampler_class = data_sampler_dict[sampler_name]
        sampler_hyperparam = dict(data_cfgs.get("sampler_hyperparam") or {})
        if sampler_name == "KuaiSampler":
            sampler_hyperparam |= {
                "batch_size": batch_size,
                "warmup_length": warmup_length,
                "rho_horizon": rho + horizon,
                "ngrid": ngrid,
                "nt": nt,
            }
        elif sampler_name == "WindowLenBatchSampler":
            sampler_hyperparam |= {
                "batch_size": batch_size,
            }
        elif sampler_name == "CloudZarrChunkBatchSampler":
            sampler_hyperparam |= {
                "batch_size": batch_size,
                "worker_affinity_count": max(
                    1, int(training_cfgs.get("num_workers", 0))
                ),
            }

        return sampler_class(train_dataset, **sampler_hyperparam)

device property readonly

Get the device from fabric wrapper

__init__(self, cfgs, pre_model=None) special

Parameters

cfgs configs for the model pre_model a pre-trained model, if it is not None, we will use its weights to initialize this model by default None

Source code in torchhydro/trainers/deep_hydro.py
def __init__(
    self,
    cfgs: Dict,
    pre_model=None,
):
    """
    Parameters
    ----------
    cfgs
        configs for the model
    pre_model
        a pre-trained model, if it is not None,
        we will use its weights to initialize this model
        by default None
    """
    super().__init__(cfgs)
    self.split_validation = validate_split_ranges(self.cfgs)
    # Initialize fabric based on configuration
    self.fabric = create_fabric_wrapper(cfgs.get("training_cfgs", {}))
    self.pre_model = pre_model
    self.model = self.fabric.setup_module(self.load_model())
    if cfgs["training_cfgs"]["train_mode"]:
        self.traindataset = self.make_dataset("train")
        self.traindataset.split_evaluation_semantics = self.split_validation[
            "evaluation_semantics"
        ]
        if cfgs["data_cfgs"]["t_range_valid"] is not None:
            self.validdataset = self.make_dataset("valid")
            self.validdataset.split_evaluation_semantics = self.split_validation[
                "evaluation_semantics"
            ]
    self.testdataset: BaseDataset = self.make_dataset("test")
    self.testdataset.split_evaluation_semantics = self.split_validation[
        "evaluation_semantics"
    ]

load_model(self, mode='train')

Load a time series forecast model in pytorch_model_dict in model_dict_function.py

Returns

object model in pytorch_model_dict in model_dict_function.py

Source code in torchhydro/trainers/deep_hydro.py
def load_model(self, mode="train"):
    """
    Load a time series forecast model in pytorch_model_dict in model_dict_function.py

    Returns
    -------
    object
        model in pytorch_model_dict in model_dict_function.py
    """
    if mode == "infer":
        if self.weight_path is None or self.cfgs["model_cfgs"]["continue_train"]:
            # if no weight path is provided
            # or weight file is provided but continue train again,
            # we will use the trained model in the new case_dir directory
            self.weight_path = self._get_trained_model()
    elif mode != "train":
        raise ValueError("Invalid mode; must be 'train' or 'infer'")
    model_cfgs = self.cfgs["model_cfgs"]
    model_name = model_cfgs["model_name"]
    if model_name not in pytorch_model_dict:
        raise NotImplementedError(
            f"Error the model {model_name} was not found in the model dict. Please add it."
        )
    if self.pre_model is not None:
        return self._load_pretrain_model()
    elif self.weight_path is not None:
        return self._load_model_from_pth()
    else:
        return pytorch_model_dict[model_name](**model_cfgs["model_hyperparam"])

make_dataset(self, is_tra_val_te)

Initializes a pytorch dataset.

Parameters

is_tra_val_te train or valid or test

Returns

object an object initializing from class in datasets_dict in data_dict.py

Source code in torchhydro/trainers/deep_hydro.py
def make_dataset(self, is_tra_val_te: str):
    """
    Initializes a pytorch dataset.

    Parameters
    ----------
    is_tra_val_te
        train or valid or test

    Returns
    -------
    object
        an object initializing from class in datasets_dict in data_dict.py
    """
    data_cfgs = self.cfgs["data_cfgs"]
    dataset_name = data_cfgs["dataset"]

    if dataset_name in list(datasets_dict.keys()):
        dataset = datasets_dict[dataset_name](self.cfgs, is_tra_val_te)
    else:
        raise NotImplementedError(
            f"Error the dataset {str(dataset_name)} was not found in the dataset dict. Please add it."
        )
    return dataset

model_evaluate(self)

Evaluate the model, and perform SHAP analysis if needed.

Returns

preds_xr, obss_xr

Source code in torchhydro/trainers/deep_hydro.py
def model_evaluate(self):
    """
    Evaluate the model, and perform SHAP analysis if needed.

    Returns
    -------
    preds_xr, obss_xr
    """
    self.model = self.load_model(mode="infer").to(self.device)

    data_cfgs = self.cfgs["data_cfgs"]
    training_cfgs = self.cfgs["training_cfgs"]

    # Create dataloader in advance as it is used for both inference and SHAP
    test_dataloader = self._get_dataloader(training_cfgs, data_cfgs, mode="infer")

    # Decide whether to perform SHAP analysis based on configuration
    eval_cfgs = self.cfgs.get("evaluation_cfgs", {})
    shap_cfgs = eval_cfgs.get("shap_cfgs", {})

    # Inference returns X_all for SHAP analysis only if enabled
    if shap_cfgs.get("enable", False):
        pred_xr, obs_xr, X_all = self.inference(test_dataloader, return_inputs=True)
        print("SHAP analysis enabled.")
    else:
        pred_xr, obs_xr = self.inference(test_dataloader, return_inputs=False)
        X_all = None
        print("SHAP analysis disabled.")

    if shap_cfgs.get("enable", False):
        self.run_shap_analysis(
            X_all=X_all,
            save_dir=shap_cfgs.get("save_dir", "./shap_results"),
            max_sample=shap_cfgs.get("max_sample", 3000),
            background_size=shap_cfgs.get("background_size", 100),
        )

    return pred_xr, obs_xr

model_train(self)

train a hydrological DL model

Source code in torchhydro/trainers/deep_hydro.py
def model_train(self) -> None:
    """train a hydrological DL model"""
    # A dictionary of the necessary parameters for training
    training_cfgs = self.cfgs["training_cfgs"]
    # The file path to load model weights from; defaults to "model_save"
    model_filepath = self.cfgs["data_cfgs"]["case_dir"]
    data_cfgs = self.cfgs["data_cfgs"]
    es = None
    if training_cfgs["early_stopping"]:
        es = EarlyStopper(training_cfgs["patience"])
    criterion = self._get_loss_func(training_cfgs)
    opt = self._get_optimizer(training_cfgs)
    scheduler = self._get_scheduler(training_cfgs, opt)
    max_epochs = training_cfgs["epochs"]
    start_epoch = training_cfgs["start_epoch"]
    # use PyTorch's DataLoader to load the data into batches in each epoch
    data_loader, validation_data_loader = self._get_dataloader(
        training_cfgs, data_cfgs
    )
    logger = TrainLogger(model_filepath, self.cfgs, opt)
    performance_monitor = (
        TrainingPerformanceMonitor(
            Path(model_filepath) / "performance",
            self.device,
            gpu_sample_interval=float(
                training_cfgs.get("performance_gpu_sample_interval", 1.0)
            ),
            gpu_idle_threshold=float(
                training_cfgs.get("performance_gpu_idle_threshold", 5.0)
            ),
        )
        if training_cfgs.get("performance_monitor", False)
        else None
    )
    for epoch in range(start_epoch, max_epochs + 1):
        if performance_monitor is not None:
            performance_monitor.start_epoch(epoch)
        with logger.log_epoch_train(epoch) as train_logs:
            total_loss, n_iter_ep = torch_single_train(
                self.model,
                opt,
                criterion,
                data_loader,
                device=self.device,
                which_first_tensor=training_cfgs["which_first_tensor"],
                non_blocking_transfer=training_cfgs.get(
                    "non_blocking_transfer",
                    bool(training_cfgs.get("pin_memory", False)),
                ),
                cuda_prefetch=bool(training_cfgs.get("cuda_prefetch", False)),
                cuda_prefetch_batches=int(
                    training_cfgs.get("cuda_prefetch_batches", 2)
                ),
                performance_monitor=performance_monitor,
                amp=bool(training_cfgs.get("amp", False)),
                amp_dtype=training_cfgs.get("amp_dtype"),
                mixed_precision=training_cfgs.get("mixed_precision", "off"),
                nonfinite_check_interval=int(
                    training_cfgs.get("nonfinite_check_interval", 100)
                ),
            )
            train_logs["train_loss"] = total_loss
            train_logs["model"] = self.model

        if performance_monitor is not None:
            performance = performance_monitor.finish_epoch(
                sampler=getattr(data_loader, "batch_sampler", None),
                dataset=self.traindataset,
            )
            print(
                "Performance epoch {epoch}: {rate:.1f} samples/s, "
                "loader wait {wait:.4f}s/batch, GPU util {gpu}, "
                "GPU idle {idle}".format(
                    epoch=epoch,
                    rate=performance["samples_per_second"],
                    wait=performance["avg_dataloader_wait_seconds"] or 0.0,
                    gpu=(
                        f'{performance["gpu_util_avg_percent"]:.1f}%'
                        if performance["gpu_util_avg_percent"] is not None
                        else "n/a"
                    ),
                    idle=(
                        f'{performance["gpu_idle_percent"]:.1f}%'
                        if performance["gpu_idle_percent"] is not None
                        else "n/a"
                    ),
                )
            )

        valid_loss = None
        valid_metrics = None
        if data_cfgs["t_range_valid"] is not None:
            with logger.log_epoch_valid(epoch) as valid_logs:
                valid_loss, valid_metrics = self._1epoch_valid(
                    training_cfgs, criterion, validation_data_loader, valid_logs
                )

        self._scheduler_step(training_cfgs, scheduler, valid_loss)
        logger.save_session_param(
            epoch, total_loss, n_iter_ep, valid_loss, valid_metrics
        )
        logger.save_model_and_params(self.model, epoch, self.cfgs)
        if es and not es.check_loss(
            self.model,
            valid_loss,
            self.cfgs["data_cfgs"]["case_dir"],
        ):
            print("Stopping model now")
            break
    # logger.plot_model_structure(self.model)
    logger.tb.close()

    # return the trained model weights and bias and the epoch loss
    return self.model.state_dict(), sum(logger.epoch_loss) / len(logger.epoch_loss)

DeepHydroInterface (ABC)

An abstract class used to handle different configurations of hydrological deep learning models + hyperparams for training, test, and predict functions. This class assumes that data is already split into test train and validation at this point.

Source code in torchhydro/trainers/deep_hydro.py
class DeepHydroInterface(ABC):
    """
    An abstract class used to handle different configurations
    of hydrological deep learning models + hyperparams for training, test, and predict functions.
    This class assumes that data is already split into test train and validation at this point.
    """

    def __init__(self, cfgs: Dict):
        """
        Parameters
        ----------
        cfgs
            configs for initializing DeepHydro
        """

        self._cfgs = cfgs

    @property
    def cfgs(self):
        """all configs"""
        return self._cfgs

    @property
    def weight_path(self):
        """weight path"""
        return self._cfgs["model_cfgs"]["weight_path"]

    @weight_path.setter
    def weight_path(self, weight_path):
        self._cfgs["model_cfgs"]["weight_path"] = weight_path

    @abstractmethod
    def load_model(self, mode="train") -> object:
        """Get a Hydro DL model"""
        raise NotImplementedError

    @abstractmethod
    def make_dataset(self, is_tra_val_te: str) -> object:
        """
        Initializes a pytorch dataset.

        Parameters
        ----------
        is_tra_val_te
            train or valid or test

        Returns
        -------
        object
            a dataset class loading data from data source
        """
        raise NotImplementedError

    @abstractmethod
    def model_train(self):
        """
        Train the model
        """
        raise NotImplementedError

    @abstractmethod
    def model_evaluate(self):
        """
        Evaluate the model
        """
        raise NotImplementedError

cfgs property readonly

all configs

weight_path property writable

weight path

__init__(self, cfgs) special

Parameters

cfgs configs for initializing DeepHydro

Source code in torchhydro/trainers/deep_hydro.py
def __init__(self, cfgs: Dict):
    """
    Parameters
    ----------
    cfgs
        configs for initializing DeepHydro
    """

    self._cfgs = cfgs

load_model(self, mode='train')

Get a Hydro DL model

Source code in torchhydro/trainers/deep_hydro.py
@abstractmethod
def load_model(self, mode="train") -> object:
    """Get a Hydro DL model"""
    raise NotImplementedError

make_dataset(self, is_tra_val_te)

Initializes a pytorch dataset.

Parameters

is_tra_val_te train or valid or test

Returns

object a dataset class loading data from data source

Source code in torchhydro/trainers/deep_hydro.py
@abstractmethod
def make_dataset(self, is_tra_val_te: str) -> object:
    """
    Initializes a pytorch dataset.

    Parameters
    ----------
    is_tra_val_te
        train or valid or test

    Returns
    -------
    object
        a dataset class loading data from data source
    """
    raise NotImplementedError

model_evaluate(self)

Evaluate the model

Source code in torchhydro/trainers/deep_hydro.py
@abstractmethod
def model_evaluate(self):
    """
    Evaluate the model
    """
    raise NotImplementedError

model_train(self)

Train the model

Source code in torchhydro/trainers/deep_hydro.py
@abstractmethod
def model_train(self):
    """
    Train the model
    """
    raise NotImplementedError

FedLearnHydro (DeepHydro)

Federated Learning Hydrological DL model

Source code in torchhydro/trainers/deep_hydro.py
class FedLearnHydro(DeepHydro):
    """Federated Learning Hydrological DL model"""

    def __init__(self, cfgs: Dict):
        super().__init__(cfgs)
        # a user group which is a dict where the keys are the user index
        # and the values are the corresponding data for each of those users
        train_dataset = self.traindataset
        fl_hyperparam = self.cfgs["model_cfgs"]["fl_hyperparam"]
        # sample training data amongst users
        if fl_hyperparam["fl_sample"] == "basin":
            # Sample a basin for a user
            user_groups = fl_sample_basin(train_dataset)
        elif fl_hyperparam["fl_sample"] == "region":
            # Sample a region for a user
            user_groups = fl_sample_region(train_dataset)
        else:
            raise NotImplementedError()
        self.user_groups = user_groups

    @property
    def num_users(self):
        """number of users in federated learning"""
        return len(self.user_groups)

    def model_train(self) -> None:
        # BUILD MODEL
        global_model = self.model

        # copy weights
        global_weights = global_model.state_dict()

        # Training
        train_loss, train_accuracy = [], []
        print_every = 2

        training_cfgs = self.cfgs["training_cfgs"]
        model_cfgs = self.cfgs["model_cfgs"]
        max_epochs = training_cfgs["epochs"]
        start_epoch = training_cfgs["start_epoch"]
        fl_hyperparam = model_cfgs["fl_hyperparam"]
        # total rounds in a FL system is max_epochs
        for epoch in tqdm(range(start_epoch, max_epochs + 1)):
            local_weights, local_losses = [], []
            print(f"\n | Global Training Round : {epoch} |\n")

            global_model.train()
            m = max(int(fl_hyperparam["fl_frac"] * self.num_users), 1)
            # randomly select m users, they will be the clients in this round
            idxs_users = np.random.choice(range(self.num_users), m, replace=False)

            for idx in idxs_users:
                # each user will be used to train the model locally
                # user_gourps[idx] means the idx of dataset for a user
                user_cfgs = self._get_a_user_cfgs(idx)
                local_model = DeepHydro(
                    user_cfgs,
                    pre_model=copy.deepcopy(global_model),
                )
                w, loss = local_model.model_train()
                local_weights.append(copy.deepcopy(w))
                local_losses.append(copy.deepcopy(loss))

            # update global weights
            global_weights = average_weights(local_weights)

            # update global weights
            global_model.load_state_dict(global_weights)

            loss_avg = sum(local_losses) / len(local_losses)
            train_loss.append(loss_avg)

            # Calculate avg training accuracy over all users at every epoch
            list_acc = []
            global_model.eval()
            for c in range(self.num_users):
                one_user_cfg = self._get_a_user_cfgs(c)
                local_model = DeepHydro(
                    one_user_cfg,
                    pre_model=global_model,
                )
                acc, _, _ = local_model.model_evaluate()
                list_acc.append(acc)
            values = [list(d.values())[0][0] for d in list_acc]
            filtered_values = [v for v in values if not np.isnan(v)]
            train_accuracy.append(sum(filtered_values) / len(filtered_values))

            # print global training loss after every 'i' rounds
            if (epoch + 1) % print_every == 0:
                print(f" \nAvg Training Stats after {epoch+1} global rounds:")
                print(f"Training Loss : {np.mean(np.array(train_loss))}")
                print("Train Accuracy: {:.2f}% \n".format(100 * train_accuracy[-1]))

    def _get_a_user_cfgs(self, idx):
        """To get a user's configs for local training"""
        user = self.user_groups[idx]

        # update data_cfgs
        # Use defaultdict to collect dates for each basin
        basin_dates = defaultdict(list)

        for _, (basin, time) in user.items():
            basin_dates[basin].append(time)

        # Initialize a list to store distinct basins
        basins = []

        # for each basin, we can find its date range
        date_ranges = {}
        for basin, times in basin_dates.items():
            basins.append(basin)
            date_ranges[basin] = (np.min(times), np.max(times))
        # get the longest date range
        longest_date_range = max(date_ranges.values(), key=lambda x: x[1] - x[0])
        # transform the date range of numpy data into string
        longest_date_range = [
            np.datetime_as_string(dt, unit="D") for dt in longest_date_range
        ]
        user_cfgs = copy.deepcopy(self.cfgs)
        # update data_cfgs
        update_nested_dict(
            user_cfgs, ["data_cfgs", "t_range_train"], longest_date_range
        )
        # for local training in FL, we don't need a validation set
        update_nested_dict(user_cfgs, ["data_cfgs", "t_range_valid"], None)
        # for local training in FL, we don't need a test set, but we should set one to avoid error
        update_nested_dict(user_cfgs, ["data_cfgs", "t_range_test"], longest_date_range)
        update_nested_dict(user_cfgs, ["data_cfgs", "object_ids"], basins)

        # update training_cfgs
        # we also need to update some training params for local training from FL settings
        update_nested_dict(
            user_cfgs,
            ["training_cfgs", "epochs"],
            user_cfgs["model_cfgs"]["fl_hyperparam"]["fl_local_ep"],
        )
        update_nested_dict(
            user_cfgs,
            ["evaluation_cfgs", "test_epoch"],
            user_cfgs["model_cfgs"]["fl_hyperparam"]["fl_local_ep"],
        )
        # don't need to save model weights for local training
        update_nested_dict(
            user_cfgs,
            ["training_cfgs", "save_epoch"],
            None,
        )
        # there are two settings for batch size in configs, we need to update both of them
        update_nested_dict(
            user_cfgs,
            ["training_cfgs", "batch_size"],
            user_cfgs["model_cfgs"]["fl_hyperparam"]["fl_local_bs"],
        )
        update_nested_dict(
            user_cfgs,
            ["data_cfgs", "batch_size"],
            user_cfgs["model_cfgs"]["fl_hyperparam"]["fl_local_bs"],
        )

        # update model_cfgs finally
        # For local model, its model_type is Normal
        update_nested_dict(user_cfgs, ["model_cfgs", "model_type"], "Normal")
        update_nested_dict(
            user_cfgs,
            ["model_cfgs", "fl_hyperparam"],
            None,
        )
        return user_cfgs

num_users property readonly

number of users in federated learning

model_train(self)

train a hydrological DL model

Source code in torchhydro/trainers/deep_hydro.py
def model_train(self) -> None:
    # BUILD MODEL
    global_model = self.model

    # copy weights
    global_weights = global_model.state_dict()

    # Training
    train_loss, train_accuracy = [], []
    print_every = 2

    training_cfgs = self.cfgs["training_cfgs"]
    model_cfgs = self.cfgs["model_cfgs"]
    max_epochs = training_cfgs["epochs"]
    start_epoch = training_cfgs["start_epoch"]
    fl_hyperparam = model_cfgs["fl_hyperparam"]
    # total rounds in a FL system is max_epochs
    for epoch in tqdm(range(start_epoch, max_epochs + 1)):
        local_weights, local_losses = [], []
        print(f"\n | Global Training Round : {epoch} |\n")

        global_model.train()
        m = max(int(fl_hyperparam["fl_frac"] * self.num_users), 1)
        # randomly select m users, they will be the clients in this round
        idxs_users = np.random.choice(range(self.num_users), m, replace=False)

        for idx in idxs_users:
            # each user will be used to train the model locally
            # user_gourps[idx] means the idx of dataset for a user
            user_cfgs = self._get_a_user_cfgs(idx)
            local_model = DeepHydro(
                user_cfgs,
                pre_model=copy.deepcopy(global_model),
            )
            w, loss = local_model.model_train()
            local_weights.append(copy.deepcopy(w))
            local_losses.append(copy.deepcopy(loss))

        # update global weights
        global_weights = average_weights(local_weights)

        # update global weights
        global_model.load_state_dict(global_weights)

        loss_avg = sum(local_losses) / len(local_losses)
        train_loss.append(loss_avg)

        # Calculate avg training accuracy over all users at every epoch
        list_acc = []
        global_model.eval()
        for c in range(self.num_users):
            one_user_cfg = self._get_a_user_cfgs(c)
            local_model = DeepHydro(
                one_user_cfg,
                pre_model=global_model,
            )
            acc, _, _ = local_model.model_evaluate()
            list_acc.append(acc)
        values = [list(d.values())[0][0] for d in list_acc]
        filtered_values = [v for v in values if not np.isnan(v)]
        train_accuracy.append(sum(filtered_values) / len(filtered_values))

        # print global training loss after every 'i' rounds
        if (epoch + 1) % print_every == 0:
            print(f" \nAvg Training Stats after {epoch+1} global rounds:")
            print(f"Training Loss : {np.mean(np.array(train_loss))}")
            print("Train Accuracy: {:.2f}% \n".format(100 * train_accuracy[-1]))

TransLearnHydro (DeepHydro)

Source code in torchhydro/trainers/deep_hydro.py
class TransLearnHydro(DeepHydro):
    def __init__(self, cfgs: Dict, pre_model=None):
        super().__init__(cfgs, pre_model)

    def load_model(self, mode="train"):
        """Load model for transfer learning"""
        model_cfgs = self.cfgs["model_cfgs"]
        if self.weight_path is None and self.pre_model is None:
            raise NotImplementedError(
                "For transfer learning, we need a pre-trained model"
            )
        if mode == "train":
            model = super().load_model(mode)
        elif mode == "infer":
            self.weight_path = self._get_trained_model()
            model = self._load_model_from_pth()
            model.to(self.device)
        if (
            "weight_path_add" in model_cfgs
            and "freeze_params" in model_cfgs["weight_path_add"]
        ):
            freeze_params = model_cfgs["weight_path_add"]["freeze_params"]
            for param in freeze_params:
                exec(f"model.{param}.requires_grad = False")
        return model

    def _load_model_from_pth(self):
        weight_path = self.weight_path
        model_cfgs = self.cfgs["model_cfgs"]
        model_name = model_cfgs["model_name"]
        model = pytorch_model_dict[model_name](**model_cfgs["model_hyperparam"])
        checkpoint = torch.load(
            weight_path, map_location=self.device, weights_only=False
        )
        if "weight_path_add" in model_cfgs:
            if "excluded_layers" in model_cfgs["weight_path_add"]:
                # delete some layers from source model if we don't need them
                excluded_layers = model_cfgs["weight_path_add"]["excluded_layers"]
                for layer in excluded_layers:
                    del checkpoint[layer]
                print("sucessfully deleted layers")
            else:
                print("directly loading identically-named layers of source model")
        model.load_state_dict(checkpoint, strict=False)
        print("Weights sucessfully loaded")
        return model

load_model(self, mode='train')

Load model for transfer learning

Source code in torchhydro/trainers/deep_hydro.py
def load_model(self, mode="train"):
    """Load model for transfer learning"""
    model_cfgs = self.cfgs["model_cfgs"]
    if self.weight_path is None and self.pre_model is None:
        raise NotImplementedError(
            "For transfer learning, we need a pre-trained model"
        )
    if mode == "train":
        model = super().load_model(mode)
    elif mode == "infer":
        self.weight_path = self._get_trained_model()
        model = self._load_model_from_pth()
        model.to(self.device)
    if (
        "weight_path_add" in model_cfgs
        and "freeze_params" in model_cfgs["weight_path_add"]
    ):
        freeze_params = model_cfgs["weight_path_add"]["freeze_params"]
        for param in freeze_params:
            exec(f"model.{param}.requires_grad = False")
    return model

Author: Wenyu Ouyang Date: 2024-04-08 18:16:26 LastEditTime: 2025-12-07 09:07:22 LastEditors: Wenyu Ouyang Description: Some basic functions for training FilePath: orchhydro orchhydro rainers rain_utils.py Copyright (c) 2024-2024 Wenyu Ouyang. All rights reserved.

EarlyStopper

Source code in torchhydro/trainers/train_utils.py
class EarlyStopper(object):
    def __init__(
        self,
        patience: int,
        min_delta: float = 0.0,
        cumulative_delta: bool = False,
    ):
        """
        EarlyStopping handler can be used to stop the training if no improvement after a given number of events.

        Parameters
        ----------
        patience
            Number of events to wait if no improvement and then stop the training.
        min_delta
            A minimum increase in the score to qualify as an improvement,
            i.e. an increase of less than or equal to `min_delta`, will count as no improvement.
        cumulative_delta
            It True, `min_delta` defines an increase since the last `patience` reset, otherwise,
        it defines an increase after the last event. Default value is False.
        """

        if patience < 1:
            raise ValueError("Argument patience should be positive integer.")

        if min_delta < 0.0:
            raise ValueError("Argument min_delta should not be a negative number.")

        self.patience = patience
        self.min_delta = min_delta
        self.cumulative_delta = cumulative_delta
        self.counter = 0
        self.best_score = None

    def check_loss(self, model, validation_loss, save_dir) -> bool:
        score = validation_loss
        if self.best_score is None:
            self.save_model_checkpoint(model, save_dir)
            self.best_score = score

        elif score + self.min_delta >= self.best_score:
            self.counter += 1
            print("Epochs without Model Update:", self.counter)
            if self.counter >= self.patience:
                return False
        else:
            self.save_model_checkpoint(model, save_dir)
            print("Model Update")
            self.best_score = score
            self.counter = 0
        return True

    def save_model_checkpoint(self, model, save_dir):
        torch.save(model.state_dict(), os.path.join(save_dir, "best_model.pth"))

__init__(self, patience, min_delta=0.0, cumulative_delta=False) special

EarlyStopping handler can be used to stop the training if no improvement after a given number of events.

Parameters

patience Number of events to wait if no improvement and then stop the training. min_delta A minimum increase in the score to qualify as an improvement, i.e. an increase of less than or equal to min_delta, will count as no improvement. cumulative_delta It True, min_delta defines an increase since the last patience reset, otherwise, it defines an increase after the last event. Default value is False.

Source code in torchhydro/trainers/train_utils.py
def __init__(
    self,
    patience: int,
    min_delta: float = 0.0,
    cumulative_delta: bool = False,
):
    """
    EarlyStopping handler can be used to stop the training if no improvement after a given number of events.

    Parameters
    ----------
    patience
        Number of events to wait if no improvement and then stop the training.
    min_delta
        A minimum increase in the score to qualify as an improvement,
        i.e. an increase of less than or equal to `min_delta`, will count as no improvement.
    cumulative_delta
        It True, `min_delta` defines an increase since the last `patience` reset, otherwise,
    it defines an increase after the last event. Default value is False.
    """

    if patience < 1:
        raise ValueError("Argument patience should be positive integer.")

    if min_delta < 0.0:
        raise ValueError("Argument min_delta should not be a negative number.")

    self.patience = patience
    self.min_delta = min_delta
    self.cumulative_delta = cumulative_delta
    self.counter = 0
    self.best_score = None

average_weights(w)

Returns the average of the weights.

Source code in torchhydro/trainers/train_utils.py
def average_weights(w):
    """
    Returns the average of the weights.
    """
    w_avg = copy.deepcopy(w[0])
    for key in w_avg.keys():
        for i in range(1, len(w)):
            w_avg[key] += w[i][key]
        w_avg[key] = torch.div(w_avg[key], len(w))
    return w_avg

cellstates_when_inference(seq_first, data_cfgs, pred)

get cell states when inference

Source code in torchhydro/trainers/train_utils.py
def cellstates_when_inference(seq_first, data_cfgs, pred):
    """get cell states when inference"""
    cs_out = (
        pred.detach().cpu().numpy().swapaxes(0, 1)
        if seq_first
        else pred.detach().cpu().numpy()
    )
    cs_out_lst = [cs_out]
    cell_state = reduce(lambda a, b: np.vstack((a, b)), cs_out_lst)
    np.save(os.path.join(data_cfgs["case_dir"], "cell_states.npy"), cell_state)
    # model.zero_grad()
    torch.cuda.empty_cache()
    return pred, cell_state

compute_loss(labels, output, criterion, **kwargs)

Function for computing the loss

Parameters

labels The real values for the target. Shape can be variable but should follow (batch_size, time) output The output of the model. Can be a tensor or a dict with 'loss' key for generative models. criterion loss function validation_dataset Only passed when unscaling of data is needed. m defaults to 1

Returns

torch.Tensor the computed loss

Source code in torchhydro/trainers/train_utils.py
def compute_loss(labels: torch.Tensor, output, criterion, **kwargs) -> torch.Tensor:
    """
    Function for computing the loss

    Parameters
    ----------
    labels
        The real values for the target. Shape can be variable but should follow (batch_size, time)
    output
        The output of the model. Can be a tensor or a dict with 'loss' key for generative models.
    criterion
        loss function
    validation_dataset
        Only passed when unscaling of data is needed.
    m
        defaults to 1

    Returns
    -------
    torch.Tensor
        the computed loss
    """
    # Handle generative models that compute loss internally
    # These models return a dict with 'loss' key
    if isinstance(output, dict) and "loss" in output:
        return output["loss"]

    if isinstance(criterion, GaussianLoss):
        if len(output[0].shape) > 2:
            g_loss = GaussianLoss(output[0][:, :, 0], output[1][:, :, 0])
        else:
            g_loss = GaussianLoss(output[0][:, 0], output[1][:, 0])
        return g_loss(labels)
    if isinstance(criterion, FloodBaseLoss):
        # labels has one more column than output, which is the flood mask
        # so we need to remove the last column of labels to get targets
        flood_mask = labels[:, :, -1:]  # Extract flood mask from last column
        targets = labels[:, :, :-1]  # Extract targets (remove last column)
        return criterion(output, targets, flood_mask)
    if isinstance(criterion, PyGraphLoss):
        node_mask = kwargs.get("node_mask", None)
        return criterion(output, labels, node_mask=node_mask)
    # MultiOutLoss: 适配 PyG 批次的 [batch*num_nodes, horizon] 到 [time, batch, num_nodes]
    if isinstance(criterion, MultiOutLoss):
        batch_vector = kwargs.get("batch_vector", None)
        node_mask = kwargs.get("node_mask", None)
        if output.ndim == 2 and labels.ndim == 2 and batch_vector is not None:
            # Output/Labels: [B*N_total, H]
            # PyG batch processing: Directly compute loss on flattened data for efficiency

            # 1. Apply Node Mask
            if node_mask is not None:
                if not isinstance(node_mask, torch.Tensor):
                    node_mask = torch.tensor(
                        node_mask, dtype=torch.bool, device=labels.device
                    )
                else:
                    node_mask = node_mask.to(labels.device).bool()

                # Ensure mask is 1D [B*N_total]
                if node_mask.dim() > 1:
                    node_mask = node_mask.flatten()

                output = output[node_mask]
                labels = labels[node_mask]

            # 2. Handle NaN in labels (consistent with MultiOutLoss behavior)
            # Mask out any NaN values in the target
            mask_nan = ~torch.isnan(labels)
            output = output[mask_nan]
            labels = labels[mask_nan]

            # 3. Compute Loss
            # Assume homogeneous loss function for all nodes in PyG batch
            loss_func = criterion.loss_funcs
            if isinstance(loss_func, list):
                # Use the first loss function if a list is provided
                loss_func = loss_func[0]

            return loss_func(output, labels)

        elif output.ndim == 2 and labels.ndim == 2:
            # 无 batch_vector 情况,退化为 batch=1
            output = output.transpose(0, 1).unsqueeze(1)
            labels = labels.transpose(0, 1).unsqueeze(1)
        n_out = labels.shape[-1]
        try:
            iw = criterion.item_weight
            if not isinstance(iw, list) or len(iw) != n_out:
                criterion.item_weight = [1.0] * n_out
        except Exception:
            pass
        try:
            dg = criterion.data_gap
            if isinstance(dg, list) and len(dg) != n_out:
                criterion.data_gap = [0] * n_out
        except Exception:
            pass
        return criterion(output, labels.float())

    if (
        isinstance(output, torch.Tensor)
        and len(labels.shape) != len(output.shape)
        and len(labels.shape) > 1
    ):
        if labels.shape[1] == output.shape[1]:
            labels = labels.unsqueeze(2)
        else:
            labels = labels.unsqueeze(0)
    assert labels.shape == output.shape
    valid_target = torch.isfinite(labels)
    if not valid_target.all():
        if not valid_target.any():
            return output.sum() * 0.0
        output = output[valid_target]
        labels = labels[valid_target]
    return criterion(output, labels.float())

compute_validation(model, criterion, data_loader, device=None, **kwargs)

Function to compute the validation loss metrics

Parameters

model the trained model criterion torch.nn.modules.loss dataloader The data-loader of either validation or test-data device torch.device

Returns

tuple validation observations (numpy array), predictions (numpy array) and the loss of validation

Source code in torchhydro/trainers/train_utils.py
def compute_validation(
    model,
    criterion,
    data_loader: DataLoader,
    device: torch.device = None,
    **kwargs,
):
    """
    Function to compute the validation loss metrics

    Parameters
    ----------
    model
        the trained model
    criterion
        torch.nn.modules.loss
    dataloader
        The data-loader of either validation or test-data
    device
        torch.device

    Returns
    -------
    tuple
        validation observations (numpy array), predictions (numpy array) and the loss of validation
    """
    model.eval()
    seq_first = kwargs["which_first_tensor"] != "batch"
    mixed_precision = kwargs.get("mixed_precision", "off")
    non_blocking = bool(kwargs.get("non_blocking", False))
    empty_cache_during_validation = bool(
        kwargs.get(
            "empty_cache_during_validation",
            getattr(data_loader.dataset, "training_cfgs", {}).get(
                "empty_cache_during_validation", False
            ),
        )
    )
    valid_loss = 0.0
    obs_parts = []
    pred_parts = []
    with torch.no_grad():
        iter_num = 0
        for batch in tqdm(data_loader, desc="Evaluating", total=len(data_loader)):
            batch_vector = getattr(batch, "batch", None)
            node_mask = getattr(batch, "node_mask", None)
            with mixed_precision_autocast(device, mixed_precision):
                trg, output = model_infer(
                    seq_first, device, model, batch, non_blocking=non_blocking
                )
                valid_loss_ = compute_loss(
                    trg,
                    output,
                    criterion,
                    batch_vector=batch_vector,
                    node_mask=node_mask,
                )
            if torch.isnan(valid_loss_):
                # for not-train mode, we may get all nan data for trg
                # so we skip this batch
                continue
                print("NAN loss detected, skipping this batch")
            valid_loss = valid_loss + valid_loss_.item()
            iter_num = iter_num + 1

            # For flood datasets, remove the flood_mask column from observations
            # to match the prediction dimensions for evaluation
            trg_for_eval = (
                trg[:, :, :-1] if isinstance(criterion, FloodBaseLoss) else trg
            )
            obs_parts.append(trg_for_eval.detach().float().cpu())
            pred_parts.append(output.detach().float().cpu())
            del trg, output
            if empty_cache_during_validation and torch.cuda.is_available():
                torch.cuda.empty_cache()
    valid_loss = valid_loss / iter_num
    obs_final = torch.cat(obs_parts, dim=0)
    pred_final = torch.cat(pred_parts, dim=0)
    y_obs = obs_final.numpy()
    y_pred = pred_final.numpy()

    # Adaptation for PyGraphLoss: Convert Time-Major to Basin-Major
    if isinstance(criterion, PyGraphLoss):
        dataset = data_loader.dataset
        # Assuming the dataset has 'sites_id' in 't_s_dict' which indicates number of nodes/basins
        if hasattr(dataset, "t_s_dict") and "sites_id" in dataset.t_s_dict:
            num_nodes = len(dataset.t_s_dict["sites_id"])
            if y_obs.shape[0] % num_nodes == 0:
                time_steps = y_obs.shape[0] // num_nodes
                # Reshape from [Time*Nodes, ...] to [Time, Nodes, ...]
                # Then transpose to [Nodes, Time, ...]
                # Then flatten to [Nodes*Time, ...]

                # Handle arbitrary trailing dimensions
                # Ensure we have feature dimension. If shape is [Batch, Horizon], add feature dim -> [Batch, Horizon, 1]
                if y_obs.ndim == 2:
                    y_obs = y_obs[..., np.newaxis]
                if y_pred.ndim == 2:
                    y_pred = y_pred[..., np.newaxis]

                obs_shape = y_obs.shape
                pred_shape = y_pred.shape

                # Reshape to [Time, Nodes, Horizon, Features]
                y_obs = y_obs.reshape(time_steps, num_nodes, *obs_shape[1:])
                # Transpose to [Nodes, Time, Horizon, Features]
                permute_dims = [1, 0] + list(range(2, y_obs.ndim))
                y_obs = y_obs.transpose(permute_dims)

                # Same for predictions
                y_pred = y_pred.reshape(time_steps, num_nodes, *pred_shape[1:])
                y_pred = y_pred.transpose(permute_dims)

    return y_obs, y_pred, valid_loss

create_amp_grad_scaler(device, mixed_precision)

Create a GradScaler for CUDA FP16 training; BF16 does not need scaling.

Source code in torchhydro/trainers/train_utils.py
def create_amp_grad_scaler(device: torch.device | None, mixed_precision):
    """Create a GradScaler for CUDA FP16 training; BF16 does not need scaling."""
    mode = _normalize_mixed_precision_mode(mixed_precision)
    enabled = bool(mode == "fp16" and device is not None and device.type == "cuda")
    if hasattr(torch, "amp") and hasattr(torch.amp, "GradScaler"):
        try:
            return torch.amp.GradScaler("cuda", enabled=enabled)
        except TypeError:
            return torch.amp.GradScaler(enabled=enabled)
    return torch.cuda.amp.GradScaler(enabled=enabled)

evaluate_validation(validation_data_loader, output, labels, evaluation_cfgs, target_col)

calculate metrics for validation

Parameters

output model output labels model target evaluation_cfgs evaluation configs target_col target columns

Returns

tuple metrics

Source code in torchhydro/trainers/train_utils.py
def evaluate_validation(
    validation_data_loader,
    output,
    labels,
    evaluation_cfgs,
    target_col,
):
    """
    calculate metrics for validation

    Parameters
    ----------
    output
        model output
    labels
        model target
    evaluation_cfgs
        evaluation configs
    target_col
        target columns

    Returns
    -------
    tuple
        metrics
    """
    fill_nan = evaluation_cfgs["fill_nan"]
    if isinstance(fill_nan, list) and len(fill_nan) != len(target_col):
        raise ValueError("Length of fill_nan must be equal to length of target_col.")
    eval_log = {}
    evaluation_metrics = evaluation_cfgs["metrics"]
    obss_xr, preds_xr = get_preds_to_be_eval(
        validation_data_loader,
        evaluation_cfgs,
        output,
        labels,
    )
    # obss_xr_list
    # preds_xr_list
    # if type()
    # for i in range(obs.shape[0]): # 第几个预见期
    ## obs_ = obs[i]
    if isinstance(obss_xr, list):
        obss_xr_list = obss_xr
        preds_xr_list = preds_xr
        for horizon_idx in range(len(obss_xr_list)):
            obss_xr = obss_xr_list[horizon_idx]
            preds_xr = preds_xr_list[horizon_idx]
            for i, col in enumerate(target_col):
                obs = obss_xr[col].to_numpy()
                pred = preds_xr[col].to_numpy()
                # eval_log will be updated rather than completely replaced, no need to use eval_log["key"]
                eval_log = calculate_and_record_metrics(
                    obs,
                    pred,
                    evaluation_metrics,
                    col,
                    fill_nan[i] if isinstance(fill_nan, list) else fill_nan,
                    eval_log,
                    horizon_idx + 1,
                )
        return eval_log

    for i, col in enumerate(target_col):
        obs = obss_xr[col].to_numpy()
        pred = preds_xr[col].to_numpy()
        # eval_log will be updated rather than completely replaced, no need to use eval_log["key"]
        eval_log = calculate_and_record_metrics(
            obs,
            pred,
            evaluation_metrics,
            col,
            fill_nan[i] if isinstance(fill_nan, list) else fill_nan,
            eval_log,
        )
    return eval_log

get_latest_pbm_param_file(param_dir)

Get the latest parameter file of physics-based models in the current directory.

Parameters

param_dir : str The directory of parameter files.

Returns

str The latest parameter file.

Source code in torchhydro/trainers/train_utils.py
def get_latest_pbm_param_file(param_dir):
    """Get the latest parameter file of physics-based models in the current directory.

    Parameters
    ----------
    param_dir : str
        The directory of parameter files.

    Returns
    -------
    str
        The latest parameter file.
    """
    param_file_lst = [
        os.path.join(param_dir, f)
        for f in os.listdir(param_dir)
        if f.startswith("pb_params") and f.endswith(".csv")
    ]
    param_files = [Path(f) for f in param_file_lst]
    param_file_names_lst = [param_file.stem.split("_") for param_file in param_files]
    ctimes = [
        int(param_file_names[param_file_names.index("params") + 1])
        for param_file_names in param_file_names_lst
    ]
    return param_files[ctimes.index(max(ctimes))] if ctimes else None

get_latest_tensorboard_event_file(log_dir)

Get the latest event file in the log_dir directory.

Parameters

log_dir : str The directory where the event files are stored.

Returns

str The latest event file.

Source code in torchhydro/trainers/train_utils.py
def get_latest_tensorboard_event_file(log_dir):
    """Get the latest event file in the log_dir directory.

    Parameters
    ----------
    log_dir : str
        The directory where the event files are stored.

    Returns
    -------
    str
        The latest event file.
    """
    event_file_lst = [
        os.path.join(log_dir, f) for f in os.listdir(log_dir) if f.startswith("events")
    ]
    event_files = [Path(f) for f in event_file_lst]
    event_file_names_lst = [event_file.stem.split(".") for event_file in event_files]
    ctimes = [
        int(event_file_names[event_file_names.index("tfevents") + 1])
        for event_file_names in event_file_names_lst
    ]
    return event_files[ctimes.index(max(ctimes))]

get_masked_tensors(variable_length_cfgs, batch, seq_first)

Get the mask for the data

Parameters

variable_length_cfgs : dict The variable length configuration batch : tuple or list or torch_geometric.data.Batch The batch data from collate_fn or dataset (can be PyG Batch object) seq_first : bool Whether the data is in sequence first format

Returns

tuple For standard datasets: (xs, ys, xs_mask, ys_mask, xs_lens, ys_lens) For GNN datasets: (xs, ys, edge_index, edge_weight, xs_mask, ys_mask, xs_lens, ys_lens) For GNN with batch vector: (xs, ys, edge_index, edge_weight, batch_vector, xs_mask, ys_mask, xs_lens, ys_lens)

Source code in torchhydro/trainers/train_utils.py
def get_masked_tensors(variable_length_cfgs, batch, seq_first):
    """Get the mask for the data

    Parameters
    ----------
    variable_length_cfgs : dict
        The variable length configuration
    batch : tuple or list or torch_geometric.data.Batch
        The batch data from collate_fn or dataset (can be PyG Batch object)
    seq_first : bool
        Whether the data is in sequence first format

    Returns
    -------
    tuple
        For standard datasets: (xs, ys, xs_mask, ys_mask, xs_lens, ys_lens)
        For GNN datasets: (xs, ys, edge_index, edge_weight, xs_mask, ys_mask, xs_lens, ys_lens)
        For GNN with batch vector: (xs, ys, edge_index, edge_weight, batch_vector, xs_mask, ys_mask, xs_lens, ys_lens)
    """
    # Check if batch is a PyG Batch/Data object
    try:
        from torch_geometric.data import Batch as PyGBatch, Data as PyGData

        is_pyg_batch = isinstance(batch, (PyGBatch, PyGData))
    except ImportError:
        is_pyg_batch = False

    xs_mask = None
    ys_mask = None
    xs_lens = None
    ys_lens = None
    edge_index = None
    edge_weight = None
    batch_vector = None

    # Handle PyG Data/Batch objects
    if is_pyg_batch:
        # Extract data from PyG Batch
        xs = (
            batch.x
        )  # Node features: [batch_nodes, hindcast_length, features] or similar
        ys = batch.y  # Target values: [batch_nodes, forecast_length] or similar
        edge_index = batch.edge_index  # Edge connectivity: [2, num_edges]
        edge_weight = batch.edge_attr if hasattr(batch, "edge_attr") else None
        batch_vector = batch.batch if hasattr(batch, "batch") else None

        # For PyG batch, return appropriate format
        if batch_vector is not None:
            return (
                xs,
                ys,
                edge_index,
                edge_weight,
                batch_vector,
                xs_mask,
                ys_mask,
                xs_lens,
                ys_lens,
            )
        elif edge_index is not None:
            return xs, ys, edge_index, edge_weight, xs_mask, ys_mask, xs_lens, ys_lens
        else:
            return xs, ys, xs_mask, ys_mask, xs_lens, ys_lens

    if variable_length_cfgs is None:
        # Check batch length to determine format
        if len(batch) == 5:
            # GNN batch with batch_vector: [sxc, y, edge_index, edge_weight, batch_vector]
            xs, ys, edge_index, edge_weight, batch_vector = batch
            return (
                xs,
                ys,
                edge_index,
                edge_weight,
                batch_vector,
                xs_mask,
                ys_mask,
                xs_lens,
                ys_lens,
            )
        elif len(batch) == 4:
            # GNN batch: [sxc, y, edge_index, edge_weight]
            xs, ys, edge_index, edge_weight = batch
            return xs, ys, edge_index, edge_weight, xs_mask, ys_mask, xs_lens, ys_lens
        else:
            # Standard batch: [xs, ys]
            xs, ys = batch[0], batch[1]
            return xs, ys, xs_mask, ys_mask, xs_lens, ys_lens

    if variable_length_cfgs.get("use_variable_length", False):
        # When using variable length training, batch comes from varied_length_collate_fn
        # which returns [xs_pad, ys_pad, xs_lens, ys_lens, xs_mask, ys_mask]
        if len(batch) >= 6:
            xs, ys, xs_lens, ys_lens, xs_mask_bool, ys_mask_bool = batch[:6]
        else:
            # Fallback: treat as regular batch with first two elements
            xs, ys = batch[0], batch[1]
            xs_lens = ys_lens = xs_mask_bool = ys_mask_bool = None

        if xs_mask_bool is None and ys_mask_bool is None:
            # sometime even you choose to use variable length training, the batch data may still be fixed length
            # so we need to return the batch data directly
            return xs, ys, xs_mask_bool, ys_mask_bool, xs_lens, ys_lens
        # Convert masks to the format expected by model (float tensor with shape [..., 1])
        xs_mask = xs_mask_bool.unsqueeze(-1).float()  # [batch, seq, 1]
        ys_mask = ys_mask_bool.unsqueeze(-1).float()  # [batch, seq, 1]

        # Convert to appropriate format for model if needed
        if seq_first:
            xs_mask = xs_mask.transpose(0, 1)  # [seq, batch, 1]
            ys_mask = ys_mask.transpose(0, 1)  # [seq, batch, 1]
    else:
        # Check batch length to determine format
        if len(batch) == 5:
            # GNN batch with batch_vector: [sxc, y, edge_index, edge_weight, batch_vector]
            xs, ys, edge_index, edge_weight, batch_vector = batch
        elif len(batch) == 4:
            # GNN batch: [sxc, y, edge_index, edge_weight]
            xs, ys, edge_index, edge_weight = batch
        else:
            # Standard batch: [xs, ys]
            xs, ys = batch[0], batch[1]

    # Return appropriate format based on what we have
    if edge_index is not None and edge_weight is not None:
        return (
            xs,
            ys,
            edge_index,
            edge_weight,
            batch_vector,
            xs_mask,
            ys_mask,
            xs_lens,
            ys_lens,
        )
    else:
        return xs, ys, xs_mask, ys_mask, xs_lens, ys_lens

get_mixed_precision_dtype(device, mixed_precision)

Return autocast dtype for the requested mixed precision mode.

Source code in torchhydro/trainers/train_utils.py
def get_mixed_precision_dtype(device: torch.device | None, mixed_precision) -> torch.dtype | None:
    """Return autocast dtype for the requested mixed precision mode."""
    mode = _normalize_mixed_precision_mode(mixed_precision)
    if mode == "off":
        return None
    if device is None:
        raise ValueError("mixed precision requires an explicit torch.device.")
    if device.type not in {"cuda", "cpu"}:
        raise ValueError(
            f"mixed precision mode {mode!r} is not supported on device {device}."
        )
    if mode == "bf16":
        if device.type == "cuda" and not torch.cuda.is_bf16_supported():
            raise RuntimeError("CUDA device does not report BF16 support.")
        return torch.bfloat16
    return torch.float16

get_preds_to_be_eval(valorte_data_loader, evaluation_cfgs, output, labels)

Get prediction results prepared for evaluation: the denormalized data without metrics by different eval ways

Parameters

valorte_data_loader : DataLoader validation or test data loader evaluation_cfgs : dict evaluation configs output : np.ndarray model output labels : np.ndarray model target

Returns

tuple description

Source code in torchhydro/trainers/train_utils.py
def get_preds_to_be_eval(
    valorte_data_loader,
    evaluation_cfgs,
    output,
    labels,
):
    """
    Get prediction results prepared for evaluation:
    the denormalized data without metrics by different eval ways

    Parameters
    ----------
    valorte_data_loader : DataLoader
        validation or test data loader
    evaluation_cfgs : dict
        evaluation configs
    output : np.ndarray
        model output
    labels : np.ndarray
        model target

    Returns
    -------
    tuple
        _description_
    """
    evaluator = evaluation_cfgs["evaluator"]
    # this test_rolling means how we perform prediction during testing
    test_rolling = evaluation_cfgs["rolling"]
    batch_size = valorte_data_loader.batch_size
    target_scaler = valorte_data_loader.dataset.target_scaler
    target_data = target_scaler.data_target
    rho = valorte_data_loader.dataset.rho
    horizon = valorte_data_loader.dataset.horizon
    warmup_length = valorte_data_loader.dataset.warmup_length
    hindcast_output_window = target_scaler.data_cfgs["hindcast_output_window"]
    nf = valorte_data_loader.dataset.noutputvar  # number of features
    # number of time steps after warmup as outputs typically don't include warmup period
    nt = valorte_data_loader.dataset.nt - warmup_length
    basin_num = len(target_data.basin)
    data_shape = (basin_num, nt, nf)
    # Check if GNN mode (PyG output)
    is_gnn = False
    dataset = valorte_data_loader.dataset
    if hasattr(dataset, "lookup_table") and len(dataset.lookup_table) > 0:
        # lookup_table[0] is (basin, time, len). For GNN, basin is None.
        if dataset.lookup_table[0][0] is None:
            is_gnn = True

    if evaluator["eval_way"] == "once":
        if is_gnn:
            raise NotImplementedError(
                "eval_way='once' is not currently supported for PyG/GNN models. "
                "Please use eval_way='rolling' which supports automatic reshaping of GNN outputs."
            )

        stride = evaluator["stride"]
        if stride > 0:
            if horizon != stride:
                raise NotImplementedError(
                    "horizon should be equal to stride in evaluator if you chose eval_way to be once, or else you need to change the eval_way to be 1pace or rolling"
                )
            obs = _rolling_preds_for_once_eval(
                (basin_num, horizon, nf),
                rho,
                evaluation_cfgs["forecast_length"],
                stride,
                hindcast_output_window,
                target_data.reshape(basin_num, horizon, nf),
            )
            pred = _rolling_preds_for_once_eval(
                (basin_num, horizon, nf),
                rho,
                evaluation_cfgs["forecast_length"],
                stride,
                hindcast_output_window,
                output.reshape(batch_size, horizon, nf),
            )
        else:
            if test_rolling > 0:
                raise RuntimeError(
                    "please set rolling to 0 when you chose eval way as once and stride=0"
                )
            obs = labels.reshape(basin_num, -1, nf)
            pred = output.reshape(basin_num, -1, nf)
    elif evaluator["eval_way"] == "1pace":
        if test_rolling < 1:
            raise NotImplementedError(
                "rolling should be larger than 0 if you chose eval_way to be 1pace"
            )
        pace_idx = evaluator["pace_idx"]
        # stride = evaluator.get("stride", 1)
        # for 1pace with pace_idx meaning which value of output was chosen to show
        # 1st, we need to transpose data to 4-dim to show the whole data

        # TODO:check should we select which def
        pred = _recover_samples_to_basin(output, valorte_data_loader, pace_idx)
        obs = _recover_samples_to_basin(labels, valorte_data_loader, pace_idx)

    elif evaluator["eval_way"] == "rolling":
        # 获取滚动预测所需的参数
        stride = evaluator.get("stride", 1)
        if stride != 1:
            raise NotImplementedError(
                "if stride is not equal to 1, we think it is meaningless"
            )
        # 重组预测结果和观测值
        basin_num = len(target_data.basin)

        # 新增:根据配置选择不同的数据组织方式
        recover_mode = evaluator.get("recover_mode", "bybasins")
        stride = evaluator.get("stride", 1)
        data_shape = (basin_num, nt, nf)

        if recover_mode == "bybasins":

            pred = _recover_samples_to_4d_by_basins(
                data_shape,
                valorte_data_loader,
                stride,
                hindcast_output_window,
                output,
            )
            obs = _recover_samples_to_4d_by_basins(
                data_shape,
                valorte_data_loader,
                stride,
                hindcast_output_window,
                labels,
            )
        elif recover_mode == "byforecast":
            pred = _recover_samples_to_4d_by_forecast(
                data_shape,
                valorte_data_loader,
                stride,
                hindcast_output_window,
                output,  # samples, seq_length, nf
            )
            obs = _recover_samples_to_4d_by_forecast(
                data_shape,
                valorte_data_loader,
                stride,
                hindcast_output_window,
                labels,
            )
        elif recover_mode == "byensembles":
            pred = _recover_samples_to_3d_by_4d_ensembles(
                data_shape,
                valorte_data_loader,
                stride,
                hindcast_output_window,
                output,
            )
            obs = _recover_samples_to_3d_by_4d_ensembles(
                data_shape,
                valorte_data_loader,
                stride,
                hindcast_output_window,
                labels,
            )
        else:
            raise ValueError(
                f"Unsupported recover_mode: {recover_mode}, must be 'bybasins' or 'byforecast' or 'byensembles'"
            )
    elif evaluator["eval_way"] == "floodevent":
        # For flood event evaluation, stride is not typically used, but we set it to 1 for consistency
        stride = evaluator.get("stride", 1)
        pred = _recover_samples_to_continuous_by_floodevent(
            data_shape,
            valorte_data_loader,
            stride,
            hindcast_output_window,
            output,
        )
        obs = _recover_samples_to_continuous_by_floodevent(
            data_shape,
            valorte_data_loader,
            stride,
            hindcast_output_window,
            labels,
        )
    else:
        raise ValueError("eval_way should be rolling or 1pace")

    # pace_idx = np.nan
    recover_mode = evaluator.get("recover_mode")
    valte_dataset = valorte_data_loader.dataset
    # 检查数据维度并进行适当处理
    if pred.ndim == 4:
        # 如果是四维数据,需要根据评估方式选择合适的处理方法
        if evaluator["eval_way"] == "1pace" and "pace_idx" in evaluator:
            # 对于1pace模式,选择特定的预测步长
            pace_idx = evaluator["pace_idx"]
            # 选择特定预测步长的数据
            pred_3d = pred[:, :, pace_idx, :]
            obs_3d = obs[:, :, pace_idx, :]
            preds_xr = valte_dataset.denormalize(pred_3d, pace_idx)
            obss_xr = valte_dataset.denormalize(obs_3d, pace_idx)
        elif evaluator["eval_way"] == "rolling" and recover_mode == "byforecast":
            # 对于byforecast模式,需要特殊处理
            # 创建一个列表存储每个预测步长的结果
            preds_xr_list = []
            obss_xr_list = []
            if is_gnn:
                start_idx = rho + 1
            else:
                start_idx = rho
            for i in range(pred.shape[2]):
                pred_3d = pred[:, :, i, :]
                obs_3d = obs[:, :, i, :]
                the_array_pred_ = np.full(target_data.shape, np.nan)
                the_array_obs_ = np.full(target_data.shape, np.nan)
                start = start_idx + i  # TODO:need check
                end = start + pred_3d.shape[1]
                assert end <= the_array_pred_.shape[1]
                the_array_pred_[:, start:end, :] = pred_3d
                the_array_obs_[:, start:end, :] = obs_3d
                preds_xr_list.append(valte_dataset.denormalize(the_array_pred_, i))
                obss_xr_list.append(valte_dataset.denormalize(the_array_obs_, i))
            # 合并结果
            # preds_xr = xr.concat(preds_xr_list, dim="horizon")
            # obss_xr = xr.concat(obss_xr_list, dim="horizon")
            return obss_xr_list, preds_xr_list
        elif evaluator["eval_way"] == "rolling" and recover_mode == "bybasins":
            # 对于其他情况,可以考虑将四维数据转换为三维
            # 例如,取最后一个预测步长
            # 每个流域取最后一个预测步长 j = forecast_length-1,其绝对时间为
            # warmup + τ + rho + (forecast_length-1),与 byforecast 分支对
            # 最后一个步长(start_idx = rho + i)的放置一致。
            selected_data = target_scaler.data_target
            the_array_pred_ = np.full(selected_data.shape, np.nan)
            the_array_obs_ = np.full(selected_data.shape, np.nan)
            start = rho + pred.shape[2] - 1  # pred.shape[2] = forecast_length
            assert (
                start + pred.shape[1] <= the_array_pred_.shape[1]
            ), "填充范围超出目标数组的边界"
            for i in range(pred.shape[0]):
                _place_bybasins_last_forecast(the_array_pred_, i, start, pred[i])
                _place_bybasins_last_forecast(the_array_obs_, i, start, obs[i])
            preds_xr = valte_dataset.denormalize(the_array_pred_, -1)
            obss_xr = valte_dataset.denormalize(the_array_obs_, -1)
    else:
        # for 3d data, directly process
        # TODO: maybe need more test for the pace_idx case
        preds_xr = valte_dataset.denormalize(pred)
        obss_xr = valte_dataset.denormalize(obs)

    def _align_and_order(_obs, _pred):
        # 先移除重复的 time 坐标,避免 pandas 索引重复导致对齐失败
        def _ensure_unique_time(ds):
            if hasattr(ds, "indexes") and "time" in ds.indexes:
                idx = ds.indexes["time"]
                if getattr(idx, "has_duplicates", False):
                    keep = ~idx.duplicated()
                    ds = ds.isel(time=keep)
            return ds

        _obs = _ensure_unique_time(_obs)
        _pred = _ensure_unique_time(_pred)

        # 对齐到公共 (basin,time,variable) 的交集,避免 outer 引入 NaN
        _obs, _pred = xr.align(_obs, _pred, join="inner")
        # time 维为空(无交集)时直接抛错,避免进入 nanmean
        if _obs.sizes.get("time", 0) == 0:
            raise ValueError(
                "No overlapping timestamps between observations and predictions "
                f"(obs.time len={_obs.sizes.get('time',0)}, pred.time len={_pred.sizes.get('time',0)})."
            )
        # 按时间排序(保险)
        if "time" in _obs.dims:
            _obs = _obs.sortby("time")
        if "time" in _pred.dims:
            _pred = _pred.sortby("time")
        # 规范维度顺序(若存在)
        wanted = [d for d in ("basin", "time", "variable") if d in _obs.dims]
        _obs = _obs.transpose(*wanted, missing_dims="ignore")
        _pred = _pred.transpose(*wanted, missing_dims="ignore")
        return _obs, _pred

    # 单对象 vs 列表分别处理
    if preds_xr is not None and obss_xr is not None:
        obss_xr, preds_xr = _align_and_order(obss_xr, preds_xr)
        return obss_xr, preds_xr

    elif preds_xr_list is not None and obss_xr_list is not None:
        obss_aligned, preds_aligned = [], []
        for _o, _p in zip(obss_xr_list, preds_xr_list):
            _o2, _p2 = _align_and_order(_o, _p)
            obss_aligned.append(_o2)
            preds_aligned.append(_p2)
        return obss_aligned, preds_aligned

    else:
        # 理论不应走到这
        raise RuntimeError("Failed to build preds_xr / obss_xr for evaluation.")

gnn_collate_fn(batch)

Custom collate function for GNN datasets that handles variable-sized graphs.

Parameters:

Name Type Description Default
batch

A list of samples, where each sample is a tuple of (sxc, y, edge_index, edge_weight).

required

Returns:

Type Description
A list containing batched tensors

[batched_sxc, batched_y, batched_edge_index, batched_edge_weight, batch_vector]

Source code in torchhydro/trainers/train_utils.py
def gnn_collate_fn(batch):
    """Custom collate function for GNN datasets that handles variable-sized graphs.

    Args:
        batch: A list of samples, where each sample is a tuple of
            (sxc, y, edge_index, edge_weight).

    Returns:
        A list containing batched tensors:
        [batched_sxc, batched_y, batched_edge_index, batched_edge_weight, batch_vector]
    """
    import torch

    if len(batch) == 0:
        return []

    # Unpack the batch
    sxc_list, y_list, edge_index_list, edge_weight_list = zip(*batch)

    # Batch the target values (y) - these should have the same shape
    batched_y = torch.stack(y_list, dim=0)  # [batch_size, forecast_length, output_dim]

    # Find the maximum number of nodes in this batch
    max_num_nodes = max(sxc.shape[0] for sxc in sxc_list)

    # Get dimensions
    batch_size = len(sxc_list)
    seq_length = sxc_list[0].shape[1]
    feature_dim = sxc_list[0].shape[2]

    # Create padded tensor for node features
    batched_sxc = torch.zeros(batch_size, max_num_nodes, seq_length, feature_dim)

    # Create batched edge indices and weights
    # For each graph in the batch, we need to offset node indices
    batched_edge_index = []
    batched_edge_weight = []
    batch_vector = []
    node_offset = 0
    for i, (sxc, edge_index, edge_weight) in enumerate(
        zip(sxc_list, edge_index_list, edge_weight_list)
    ):
        num_nodes = sxc.shape[0]
        # Fill the padded tensor with actual node features
        batched_sxc[i, :num_nodes] = sxc
        # For edge indices, we need to offset by node_offset to make them unique across batch
        if edge_index.numel() > 0:
            if edge_index.max() >= num_nodes:
                print(
                    f"Warning: Graph {i} has edge indices {edge_index.max().item()} >= num_nodes {num_nodes}"
                )
                valid_mask = (edge_index[0] < num_nodes) & (edge_index[1] < num_nodes)
                edge_index = edge_index[:, valid_mask]
                edge_weight = edge_weight[valid_mask]
            if edge_index.numel() > 0:
                offset_edge_index = edge_index + node_offset
                batched_edge_index.append(offset_edge_index)
                batched_edge_weight.append(edge_weight)
        # batch_vector: for each node in this graph, assign batch index i
        batch_vector.append(torch.full((num_nodes,), i, dtype=torch.long))
        node_offset += num_nodes
    # Concatenate edge indices and weights if they exist
    if batched_edge_index:
        batched_edge_index = torch.cat(batched_edge_index, dim=1)  # [2, total_edges]
        batched_edge_weight = torch.cat(batched_edge_weight, dim=0)  # [total_edges]
    else:
        batched_edge_index = torch.empty((2, 0), dtype=torch.long)
        batched_edge_weight = torch.empty(0)
    batch_vector = torch.cat(batch_vector, dim=0)  # [total_nodes]
    return [
        batched_sxc,
        batched_y,
        batched_edge_index,
        batched_edge_weight,
        batch_vector,
    ]

mixed_precision_autocast(device, mixed_precision)

Create an autocast context for training/validation.

Source code in torchhydro/trainers/train_utils.py
def mixed_precision_autocast(device: torch.device | None, mixed_precision):
    """Create an autocast context for training/validation."""
    dtype = get_mixed_precision_dtype(device, mixed_precision)
    if dtype is None:
        return nullcontext()
    return torch.autocast(device_type=device.type, dtype=dtype)

model_infer(seq_first, device, model, batch, variable_length_cfgs=None, return_key=None, non_blocking=False)

Unified model inference function with variable length support

Parameters

seq_first : bool if True, the input data is sequence first device : torch.device cpu or gpu model : torch.nn.Module the model batch : tuple or list batch data from collate_fn or dataset variable_length_cfgs : dict, optional variable length configuration containing mask settings return_key : str, optional when model returns a dict, choose which key (e.g., "f2") to return. if None, defaults to the last frequency (max key).

Source code in torchhydro/trainers/train_utils.py
def model_infer(
    seq_first,
    device,
    model,
    batch,
    variable_length_cfgs=None,
    return_key=None,
    non_blocking: bool = False,
):
    """
    Unified model inference function with variable length support

    Parameters
    ----------
    seq_first : bool
        if True, the input data is sequence first
    device : torch.device
        cpu or gpu
    model : torch.nn.Module
        the model
    batch : tuple or list
        batch data from collate_fn or dataset
    variable_length_cfgs : dict, optional
        variable length configuration containing mask settings
    return_key : str, optional
        when model returns a dict, choose which key (e.g., "f2") to return.
        if None, defaults to the last frequency (max key).
    """
    result = get_masked_tensors(variable_length_cfgs, batch, seq_first)

    # --- unpack inputs ---
    if len(result) == 9:
        (
            xs,
            ys,
            edge_index,
            edge_weight,
            batch_vector,
            xs_mask,
            ys_mask,
            xs_lens,
            ys_lens,
        ) = result
    elif len(result) == 8:
        xs, ys, edge_index, edge_weight, xs_mask, ys_mask, xs_lens, ys_lens = result
        batch_vector = None
    else:
        xs, ys, xs_mask, ys_mask, xs_lens, ys_lens = result
        edge_index = edge_weight = batch_vector = None

    # --- move xs to device ---
    if isinstance(xs, list):
        xs = [
            _move_batch_value_to_device(x, device, seq_first, non_blocking)
            for x in xs
        ]
    else:
        xs = [_move_batch_value_to_device(xs, device, seq_first, non_blocking)]

    # --- move ys to device ---
    if ys is not None:
        ys = (
            ys.permute(1, 0, 2).to(device, non_blocking=non_blocking)
            if seq_first and ys.ndim == 3
            else ys.to(device, non_blocking=non_blocking)
        )

    # --- move graph data ---
    if edge_index is not None:
        edge_index = edge_index.to(device, non_blocking=non_blocking)
    if edge_weight is not None:
        edge_weight = edge_weight.to(device, non_blocking=non_blocking)
    if batch_vector is not None:
        batch_vector = batch_vector.to(device, non_blocking=non_blocking)

    # --- check if model is a generative model (e.g., Diffusion) ---
    # Generative models require both input (xs) and target (ys) in forward pass
    is_generative = getattr(model, "is_generative", False)
    inner_model = getattr(model, "module", model)
    # Also check wrapped model (e.g., DistributedDataParallel)
    if not is_generative:
        is_generative = getattr(inner_model, "is_generative", False)

    # --- forward ---
    if is_generative:
        # Generative models behave differently in train vs eval mode:
        # - Train mode: forward(x_condition, y_target) returns {'loss': loss}
        # - Eval mode: sample(x_condition) returns generated samples
        if model.training:
            # Training: compute loss with forward pass
            output = inner_model(*xs, ys)
        else:
            # Inference: generate samples using sample method
            output = inner_model.sample(*xs)
    elif xs_mask is not None and ys_mask is not None:
        if edge_index is not None and edge_weight is not None:
            output = model(
                *xs,
                edge_index=edge_index,
                edge_weight=edge_weight,
                batch_vector=batch_vector,
                mask=xs_mask,
                seq_lengths=xs_lens,
            )
        else:
            output = model(*xs, mask=xs_mask, seq_lengths=xs_lens)
    else:
        if edge_index is not None and edge_weight is not None:
            output = model(
                *xs,
                edge_index=edge_index,
                edge_weight=edge_weight,
                batch_vector=batch_vector,
            )
        else:
            if edge_index is not None and edge_weight is None:
                output = model(
                    *xs,
                    edge_index=edge_index,
                    # batch_vector=batch_vector,
                )
            else:
                output = model(*xs)

    # --- handle model outputs ---
    if isinstance(output, tuple):
        output = output[0]

    if isinstance(output, dict):
        # Check if this is a generative model that returns loss directly
        if "loss" in output:
            # Generative models return {'loss': loss_value}, pass it through
            output = output
        else:
            # Multi-frequency output, pick the specified or highest frequency key
            if return_key is None:
                return_key = sorted(output.keys())[-1]  # e.g., "f2"
            if return_key not in output:
                raise KeyError(
                    f"Model returned keys {list(output.keys())}, but return_key='{return_key}' not found"
                )
            output = output[return_key]

    if ys_mask is not None:
        ys = ys.masked_fill(ys_mask == 0, torch.nan)

    # --- seq_first transpose back ---
    if seq_first:
        # Don't transpose dict outputs (generative models return {'loss': ...} during training)
        if isinstance(output, torch.Tensor):
            output = output.transpose(0, 1)
        if ys is not None:
            ys = ys.transpose(0, 1)

    return ys, output

torch_single_train(model, opt, criterion, data_loader, device=None, **kwargs)

Training function for one epoch

Parameters

model a PyTorch model inherit from nn.Module opt optimizer function from PyTorch optim.Optimizer criterion loss function data_loader object for loading data to the model device where we put the tensors and models

Returns

tuple(torch.Tensor, int) loss of this epoch and number of all iterations

Raises

ValueError if nan exits, raise a ValueError

Source code in torchhydro/trainers/train_utils.py
def torch_single_train(
    model,
    opt: optim.Optimizer,
    criterion,
    data_loader: DataLoader,
    device=None,
    **kwargs,
):
    """
    Training function for one epoch

    Parameters
    ----------
    model
        a PyTorch model inherit from nn.Module
    opt
        optimizer function from PyTorch optim.Optimizer
    criterion
        loss function
    data_loader
        object for loading data to the model
    device
        where we put the tensors and models

    Returns
    -------
    tuple(torch.Tensor, int)
        loss of this epoch and number of all iterations

    Raises
    --------
    ValueError
        if nan exits, raise a ValueError
    """
    # we will set model.eval() in the validation function so here we should set model.train()
    model.train()
    n_iter_ep = 0
    which_first_tensor = kwargs["which_first_tensor"]
    mixed_precision = kwargs.get("mixed_precision", "off")
    seq_first = which_first_tensor != "batch"
    variable_length_cfgs = data_loader.dataset.training_cfgs.get(
        "variable_length_cfgs", None
    )
    non_blocking_transfer = bool(
        kwargs.get("non_blocking_transfer", kwargs.get("non_blocking", False))
    )
    torch_device = torch.device(device) if device is not None else torch.device("cpu")
    running_loss = 0.0
    use_cuda_prefetch = (
        bool(kwargs.get("cuda_prefetch", False)) and torch_device.type == "cuda"
    )
    train_iterable = (
        CudaPrefetcher(
            data_loader,
            torch_device,
            non_blocking=non_blocking_transfer,
            prefetch_batches=int(kwargs.get("cuda_prefetch_batches", 2)),
            performance_monitor=kwargs.get("performance_monitor"),
        )
        if use_cuda_prefetch
        else data_loader
    )
    pbar = tqdm(train_iterable, total=len(data_loader))
    performance_monitor = kwargs.get("performance_monitor")
    amp_dtype_name = kwargs.get("amp_dtype")
    mixed_mode = _normalize_mixed_precision_mode(mixed_precision)
    if amp_dtype_name is None:
        amp_dtype_name = "float16" if mixed_mode == "fp16" else "bfloat16"
    amp_dtype_name = str(amp_dtype_name).lower()
    if amp_dtype_name in {"bfloat16", "bf16"}:
        amp_dtype = torch.bfloat16
    elif amp_dtype_name in {"float16", "fp16"}:
        amp_dtype = torch.float16
    else:
        raise ValueError("amp_dtype must be bfloat16/bf16 or float16/fp16")
    use_amp = (bool(kwargs.get("amp", False)) or mixed_mode != "off") and (
        torch_device.type in {"cuda", "cpu"}
    )
    grad_scaler = torch.amp.GradScaler(
        "cuda",
        enabled=use_amp and amp_dtype == torch.float16 and torch_device.type == "cuda",
    )
    nonfinite_check_interval = max(0, int(kwargs.get("nonfinite_check_interval", 100)))
    train_iterator = iter(pbar)

    while True:
        wait_started = time.perf_counter()
        try:
            batch = next(train_iterator)
        except StopIteration:
            break
        if performance_monitor is not None:
            performance_monitor.record_data_wait(time.perf_counter() - wait_started)
            compute_events = performance_monitor.cuda_event_pair()
            if compute_events is not None:
                compute_events[0].record(torch.cuda.current_stream(torch_device))
        else:
            compute_events = None
        compute_started = time.perf_counter()
        opt.zero_grad(set_to_none=True)
        with torch.autocast(
            device_type=torch_device.type,
            dtype=amp_dtype,
            enabled=use_amp,
        ):
            # mask handling is already done inside model_infer function
            trg, output = model_infer(
                seq_first,
                device,
                model,
                batch,
                variable_length_cfgs,
                non_blocking=non_blocking_transfer,
            )
            batch_vector = getattr(batch, "batch", None)
            node_mask = getattr(batch, "node_mask", None)
            loss = compute_loss(
                trg,
                output,
                criterion,
                batch_vector=batch_vector,
                node_mask=node_mask,
                **kwargs,
            )

            # Add auxiliary loss from MoE models (if available)
            if hasattr(model, "get_aux_loss"):
                aux_loss = model.get_aux_loss()
                if aux_loss is not None and aux_loss.device != loss.device:
                    aux_loss = aux_loss.to(loss.device)
                loss = loss + aux_loss

        # One scalar read handles validation, warnings, and epoch loss
        # accumulation. Calling bool() or item() on several CUDA tensors
        # separately introduces repeated host/device synchronization.
        loss_value = loss.detach().item()
        _validate_training_loss_value(loss_value)
        grad_scaler.scale(loss).backward()
        grad_scaler.step(opt)
        grad_scaler.update()
        if compute_events is not None:
            compute_events[1].record(torch.cuda.current_stream(torch_device))
            performance_monitor.record_compute_events(compute_events)
        if performance_monitor is not None:
            performance_monitor.record_batch(
                batch, time.perf_counter() - compute_started
            )
        running_loss += loss_value
        n_iter_ep += 1
        if (
            nonfinite_check_interval
            and n_iter_ep % nonfinite_check_interval == 0
            and not math.isfinite(running_loss)
        ):
            raise ValueError(
                "Non-finite training loss detected. Check normalization and input data."
            )
    if n_iter_ep == 0:
        raise ValueError(
            "All batch computations of loss result in NAN. Please check the data."
        )
    if not math.isfinite(running_loss):
        raise ValueError(
            "Non-finite training loss detected. Check normalization and input data."
        )
    return running_loss / float(n_iter_ep), n_iter_ep

varied_length_collate_fn(batch)

Collate function for variable length training

This function is automatically used by DataLoader when variable_length_cfgs["use_variable_length"] is True. It pads sequences to the same length and generates corresponding masks.

Parameters

batch : list of tuples The batch data after the dataset getitem method

Returns

list [xs_pad, ys_pad, xs_lens, ys_lens, xs_mask, ys_mask] - xs_pad: padded input sequences [batch, max_seq_len, input_dim] - ys_pad: padded output sequences [batch, max_seq_len, output_dim] - xs_lens: original sequence lengths for input - ys_lens: original sequence lengths for output - xs_mask: valid position mask for input [batch, max_seq_len] - ys_mask: valid position mask for output [batch, max_seq_len]

Source code in torchhydro/trainers/train_utils.py
def varied_length_collate_fn(batch):
    """Collate function for variable length training

    This function is automatically used by DataLoader when variable_length_cfgs["use_variable_length"] is True.
    It pads sequences to the same length and generates corresponding masks.

    Parameters
    ----------
    batch : list of tuples
        The batch data after the dataset __getitem__ method

    Returns
    -------
    list
        [xs_pad, ys_pad, xs_lens, ys_lens, xs_mask, ys_mask]
        - xs_pad: padded input sequences [batch, max_seq_len, input_dim]
        - ys_pad: padded output sequences [batch, max_seq_len, output_dim]
        - xs_lens: original sequence lengths for input
        - ys_lens: original sequence lengths for output
        - xs_mask: valid position mask for input [batch, max_seq_len]
        - ys_mask: valid position mask for output [batch, max_seq_len]
    """

    xs, ys = zip(*batch)
    # sometimes x is a tuple like in dpl dataset, then we can get the shape of the first element as the length
    xs_lens = [x[0].shape[0] if type(x) in [tuple, list] else x.shape[0] for x in xs]
    ys_lens = [y[0].shape[0] if type(y) in [tuple, list] else y.shape[0] for y in ys]
    # if all ys_lens are the same, use default collate_fn to create tensors
    if len(set(ys_lens)) == 1 and len(set(xs_lens)) == 1:
        xs_tensor = default_collate(xs)
        ys_tensor = default_collate(ys)
        return [xs_tensor, ys_tensor, None, None, None, None]

    # pad the batch data with padding value 0
    xs_pad = pad_sequence(xs, batch_first=True, padding_value=0)
    ys_pad = pad_sequence(ys, batch_first=True, padding_value=0)

    # generate the mask for the batch data
    # xs_mask: [batch_size, max_seq_len] or [batch_size, max_seq_len, 1]
    batch_size = len(xs_lens)
    max_xs_len = max(xs_lens)
    max_ys_len = max(ys_lens)

    # create the mask for the input sequence (True for valid positions, False for padding positions)
    xs_mask = torch.zeros(batch_size, max_xs_len, dtype=torch.bool)
    for i, length in enumerate(xs_lens):
        xs_mask[i, :length] = True

    # create the mask for the output sequence
    ys_mask = torch.zeros(batch_size, max_ys_len, dtype=torch.bool)
    for i, length in enumerate(ys_lens):
        ys_mask[i, :length] = True

    return [
        xs_pad,
        ys_pad,
        xs_lens,
        ys_lens,
        xs_mask,
        ys_mask,
    ]

Resulter

Source code in torchhydro/trainers/resulter.py
class Resulter:
    def __init__(self, cfgs) -> None:
        self.cfgs = cfgs
        self.result_dir = cfgs["data_cfgs"]["case_dir"]
        if not os.path.exists(self.result_dir):
            os.makedirs(self.result_dir)

    @property
    def pred_name(self):
        return f"epoch{str(self.chosen_trained_epoch)}flow_pred"

    @property
    def obs_name(self):
        return f"epoch{str(self.chosen_trained_epoch)}flow_obs"

    @property
    def chosen_trained_epoch(self):
        model_loader = self.cfgs["evaluation_cfgs"]["model_loader"]
        if model_loader["load_way"] == "specified":
            epoch_name = str(model_loader["test_epoch"])
        elif model_loader["load_way"] == "best":
            # NOTE: TO make it consistent with the name in case of model_loader["load_way"] == "pth", the name have to be "best_model.pth"
            epoch_name = "best_model.pth"
        elif model_loader["load_way"] == "latest":
            epoch_name = str(self.cfgs["training_cfgs"]["epochs"])
        elif model_loader["load_way"] == "pth":
            epoch_name = model_loader["pth_path"].split(os.sep)[-1]
        else:
            raise ValueError("Invalid load_way")
        return epoch_name

    def save_cfg(self, cfgs):
        # save the cfgs after training
        # update the cfgs with the latest one
        self.cfgs = cfgs
        param_file_exist = any(
            (
                fnmatch.fnmatch(file, "*.json")
                and "_stat" not in file  # statistics json file
                and "_dict" not in file  # data cache json file
            )
            for file in os.listdir(self.result_dir)
        )
        if not param_file_exist:
            # although we save params log during training, but sometimes we directly evaluate a model
            # so here we still save params log if param file does not exist
            # no param file was saved yet, here we save data and params setting
            save_model_params_log(cfgs, self.result_dir)

    def save_result(self, pred, obs):
        """
        save the pred value of testing period and obs value

        Parameters
        ----------
        pred
            predictions
        obs
            observations
        pred_name
            the file name of predictions
        obs_name
            the file name of observations

        Returns
        -------
        None
        """
        save_dir = self.result_dir
        flow_pred_file = os.path.join(save_dir, self.pred_name)
        flow_obs_file = os.path.join(save_dir, self.obs_name)

        if isinstance(pred, list) and isinstance(obs, list):
            # Case for recover_mode="byforecast", where we have a list of DataArrays per horizon
            for i, (p, o) in enumerate(zip(pred, obs)):
                max_len = max(len(basin) for basin in p.basin.values)
                encoding = {"basin": {"dtype": f"U{max_len}"}}
                p.to_netcdf(f"{flow_pred_file}_horizon{i+1}.nc", encoding=encoding)
                o.to_netcdf(f"{flow_obs_file}_horizon{i+1}.nc", encoding=encoding)
        else:
            # Standard case
            max_len = max(len(basin) for basin in pred.basin.values)
            encoding = {"basin": {"dtype": f"U{max_len}"}}
            pred.to_netcdf(flow_pred_file + ".nc", encoding=encoding)
            obs.to_netcdf(flow_obs_file + ".nc", encoding=encoding)

    def eval_result(self, preds_xr, obss_xr):
        # Handle list input for byforecast mode
        if isinstance(preds_xr, list) and isinstance(obss_xr, list):
            # Evaluate each horizon separately
            for i, (p, o) in enumerate(zip(preds_xr, obss_xr)):
                # We can reuse the same logic, but maybe append horizon to logs or filenames
                # For simplicity, we just evaluate the first horizon or average?
                # Usually we want metrics per horizon.
                # Here we temporarily adapt to evaluate each one but overwrite or use simple logic
                # A better approach: The user might want to see metrics for EACH horizon.
                # Let's modify the internal logic to handle this loop if needed, 
                # OR, for now, we just evaluate them sequentially and print/save separately.

                print(f"Evaluating Horizon {i+1}...")
                self._eval_single_result(p, o, suffix=f"_horizon{i+1}")
        else:
            self._eval_single_result(preds_xr, obss_xr)

    def _eval_single_result(self, preds_xr, obss_xr, suffix=""):
        # types of observations
        target_col = self.cfgs["data_cfgs"]["target_cols"]
        evaluation_metrics = self.cfgs["evaluation_cfgs"]["metrics"]
        basin_ids = self.cfgs["data_cfgs"]["object_ids"]
        test_path = self.cfgs["data_cfgs"]["case_dir"]
        # Assume object_ids like ['changdian_61561']
        # fill_nan: "no" means ignoring the NaN value;
        #           "sum" means calculate the sum of the following values in the NaN locations.
        #           For example, observations are [1, nan, nan, 2], and predictions are [0.3, 0.3, 0.3, 1.5].
        #           Then, "no" means [1, 2] v.s. [0.3, 1.5] while "sum" means [1, 2] v.s. [0.3 + 0.3 + 0.3, 1.5].
        #           If it is a str, then all target vars use same fill_nan method;
        #           elif it is a list, each for a var
        fill_nan = self.cfgs["evaluation_cfgs"]["fill_nan"]
        #  Then evaluate the model metrics
        if isinstance(fill_nan, list) and len(fill_nan) != len(target_col):
            raise ValueError("length of fill_nan must be equal to target_col's")
        for i, col in enumerate(target_col):
            eval_log = {}
            obs = obss_xr[col].to_numpy()
            pred = preds_xr[col].to_numpy()

            eval_log = calculate_and_record_metrics(
                obs,
                pred,
                evaluation_metrics,
                col,
                fill_nan[i] if isinstance(fill_nan, list) else fill_nan,
                eval_log,
            )
            # Create pandas DataFrames from eval_log for each target variable (e.g., streamflow)
            # Create a dictionary to hold the data for the DataFrame
            data = {}
            # Iterate over metrics in eval_log
            for metric, values in eval_log.items():
                # Remove 'of streamflow' (or similar) from the metric name
                clean_metric = metric.replace(f"of {col}", "").strip()

                # Add the cleaned metric to the data dictionary
                data[clean_metric] = values

            # Create a DataFrame using object_ids as the index and metrics as columns
            df = pd.DataFrame(data, index=basin_ids)

            # Save the DataFrame to a CSV file
            output_file = os.path.join(test_path, f"metric_{col}{suffix}.csv")
            df.to_csv(output_file, index_label="basin_id")

        # Finally, try to explain model behaviour using shap
        is_shap = self.cfgs["evaluation_cfgs"]["explainer"] == "shap"
        if is_shap and suffix == "": # Only run SHAP once or for main result
            shap_summary_plot(self.model, self.traindataset, self.testdataset)
            # deep_explain_model_summary_plot(self.model, test_data)
            # deep_explain_model_heatmap(self.model, test_data)

    def _convert_streamflow_units(self, ds):
        """convert the streamflow units to m^3/s

        Parameters
        ----------
        ds : xr.Dataset
            xarray Dataset containing predictions or observations

        Returns
        -------
        xr.Dataset
        """
        data_cfgs = self.cfgs["data_cfgs"]
        from torchhydro.configs.data_resolver import open_dataset_from_source_cfgs

        sc = data_cfgs["source_cfgs"]
        data_source = open_dataset_from_source_cfgs(sc)
        basin_id = data_cfgs["object_ids"]
        try:
            basin_area = data_source.read_area(basin_id)
        except (AttributeError, NotImplementedError) as e:
            raise AttributeError(
                f"Data source '{sc.get('dataset_id')}' does not implement "
                f"read_area(), which is required for streamflow unit conversion."
            ) from e
        target_unit = "m^3/s"
        target_cols = data_cfgs["target_cols"]
        if not target_cols:
            raise ValueError(
                "target_cols is empty — cannot determine the flow variable "
                "for unit conversion. Add a flow variable (e.g. 'streamflow') "
                "to target_cols."
            )
        var_flow = target_cols[0]
        streamflow_ds = ds[[var_flow]]
        ds_ = streamflow_unit_conv(
            streamflow_ds, basin_area, target_unit=target_unit, inverse=True
        )
        new_ds = ds.copy(deep=True)
        new_ds[var_flow] = ds_[var_flow]
        return new_ds

    def load_result(self, convert_flow_unit=False) -> Tuple[np.array, np.array]:
        """load the pred value of testing period and obs value"""
        save_dir = self.result_dir
        pred_file = os.path.join(save_dir, self.pred_name + ".nc")
        obs_file = os.path.join(save_dir, self.obs_name + ".nc")
        pred = xr.open_dataset(pred_file)
        obs = xr.open_dataset(obs_file)
        if convert_flow_unit:
            pred = self._convert_streamflow_units(pred)
            obs = self._convert_streamflow_units(obs)
        return pred, obs

    def save_intermediate_results(self, **kwargs):
        """Load model weights and deal with some intermediate results"""
        is_cell_states = kwargs.get("is_cell_states", False)
        is_pbm_params = kwargs.get("is_pbm_params", False)
        cfgs = self.cfgs
        cfgs["training_cfgs"]["train_mode"] = False
        training_cfgs = cfgs["training_cfgs"]
        seq_first = training_cfgs["which_first_tensor"] == "sequence"
        if is_cell_states:
            raise NotImplementedError(
                "return_cell_states is not supported yet "
                "(save_intermediate_results)"
            )
        if is_pbm_params:
            self._save_pbm_params(cfgs, seq_first)

    def _save_pbm_params(self, cfgs, seq_first):
        training_cfgs = cfgs["training_cfgs"]
        model_loader = cfgs["evaluation_cfgs"]["model_loader"]
        model_pth_dir = cfgs["data_cfgs"]["case_dir"]
        weight_path = read_pth_from_model_loader(model_loader, model_pth_dir)
        cfgs["model_cfgs"]["weight_path"] = weight_path
        cfgs["training_cfgs"]["device"] = [0] if torch.cuda.is_available() else [-1]
        deephydro = DeepHydro(cfgs)
        device = deephydro.device
        dl_model = deephydro.model.dl_model
        pb_model = deephydro.model.pb_model
        param_func = deephydro.model.param_func
        # TODO: check for dplnnmodule model
        param_test_way = deephydro.model.param_test_way
        test_dataloader = DataLoader(
            deephydro.testdataset,
            batch_size=training_cfgs["batch_size"],
            shuffle=False,
            sampler=None,
            batch_sampler=None,
            drop_last=False,
            timeout=0,
            worker_init_fn=None,
        )
        deephydro.model.eval()
        # here the batch is just an index of lookup table, so any batch size could be chosen
        params_lst = []
        with torch.no_grad():
            for batch in test_dataloader:
                ys, gen = model_infer(seq_first, device, dl_model, batch)
                # we set all params' values in [0, 1] and will scale them when forwarding
                if param_func == "clamp":
                    params_ = torch.clamp(gen, min=0.0, max=1.0)
                elif param_func == "sigmoid":
                    params_ = F.sigmoid(gen)
                else:
                    raise NotImplementedError(
                        "We don't provide this way to limit parameters' range!! Please choose sigmoid or clamp"
                    )
                # just get one-period values, here we use the final period's values
                params = params_[:, -1, :]
                params_lst.append(params)
        pb_params = reduce(lambda a, b: torch.cat((a, b), dim=0), params_lst)
        # trans tensor to pandas dataframe
        sites = deephydro.cfgs["data_cfgs"]["object_ids"]
        params_names = pb_model.params_names
        params_df = pd.DataFrame(
            pb_params.cpu().numpy(), columns=params_names, index=sites
        )
        save_param_file = os.path.join(
            model_pth_dir, f"pb_params_{int(time.time())}.csv"
        )
        params_df.to_csv(save_param_file, index_label="GAGE_ID")

    def read_tensorboard_log(self, **kwargs):
        """read tensorboard log files"""
        is_scalar = kwargs.get("is_scalar", False)
        is_histogram = kwargs.get("is_histogram", False)
        log_dir = self.cfgs["data_cfgs"]["case_dir"]
        if is_scalar:
            scalar_file = os.path.join(log_dir, "tb_scalars.csv")
            if not os.path.exists(scalar_file):
                reader = SummaryReader(log_dir)
                df_scalar = reader.scalars
                df_scalar.to_csv(scalar_file, index=False)
            else:
                df_scalar = pd.read_csv(scalar_file)
        if is_histogram:
            histogram_file = os.path.join(log_dir, "tb_histograms.csv")
            if not os.path.exists(histogram_file):
                reader = SummaryReader(log_dir)
                df_histogram = reader.histograms
                df_histogram.to_csv(histogram_file, index=False)
            else:
                df_histogram = pd.read_csv(histogram_file)
        if is_scalar and is_histogram:
            return df_scalar, df_histogram
        elif is_scalar:
            return df_scalar
        elif is_histogram:
            return df_histogram

    # TODO: the following code is not finished yet
    def load_ensemble_result(
        self, save_dirs, test_epoch, flow_unit="m3/s", basin_areas=None
    ) -> Tuple[np.array, np.array]:
        """
        load ensemble mean value

        Parameters
        ----------
        save_dirs
        test_epoch
        flow_unit
            default is m3/s, if it is not m3/s, transform the results
        basin_areas
            if unit is mm/day it will be used, default is None

        Returns
        -------

        """
        preds = []
        obss = []
        for save_dir in save_dirs:
            pred_i, obs_i = self.load_result(save_dir, test_epoch)
            if pred_i.ndim == 3 and pred_i.shape[-1] == 1:
                pred_i = pred_i.reshape(pred_i.shape[0], pred_i.shape[1])
                obs_i = obs_i.reshape(obs_i.shape[0], obs_i.shape[1])
            preds.append(pred_i)
            obss.append(obs_i)
        preds_np = np.array(preds)
        obss_np = np.array(obss)
        pred_mean = np.mean(preds_np, axis=0)
        obs_mean = np.mean(obss_np, axis=0)
        if flow_unit == "mm/day":
            if basin_areas is None:
                raise ArithmeticError("No basin areas we cannot calculate")
            basin_areas = np.repeat(basin_areas, obs_mean.shape[1], axis=0).reshape(
                obs_mean.shape
            )
            obs_mean = obs_mean * basin_areas * 1e-3 * 1e6 / 86400
            pred_mean = pred_mean * basin_areas * 1e-3 * 1e6 / 86400
        elif flow_unit == "m3/s":
            pass
        elif flow_unit == "ft3/s":
            obs_mean = obs_mean / 35.314666721489
            pred_mean = pred_mean / 35.314666721489
        return pred_mean, obs_mean

    def eval_ensemble_result(
        self,
        save_dirs,
        test_epoch,
        return_value=False,
        flow_unit="m3/s",
        basin_areas=None,
    ) -> Tuple[np.array, np.array]:
        """calculate statistics for ensemble results

        Parameters
        ----------
        save_dirs : _type_
            where the results save
        test_epoch : _type_
            we name the results files with the test_epoch
        return_value : bool, optional
            if True, return (inds_df, pred_mean, obs_mean), by default False
        flow_unit : str, optional
            arg for load_ensemble_result, by default "m3/s"
        basin_areas : _type_, optional
            arg for load_ensemble_result, by default None

        Returns
        -------
        Tuple[np.array, np.array]
            inds_df or (inds_df, pred_mean, obs_mean)
        """
        pred_mean, obs_mean = self.load_ensemble_result(
            save_dirs, test_epoch, flow_unit=flow_unit, basin_areas=basin_areas
        )
        inds = stat_error(obs_mean, pred_mean)
        inds_df = pd.DataFrame(inds)
        return (inds_df, pred_mean, obs_mean) if return_value else inds_df

eval_ensemble_result(self, save_dirs, test_epoch, return_value=False, flow_unit='m3/s', basin_areas=None)

calculate statistics for ensemble results

Parameters

save_dirs : type where the results save test_epoch : type we name the results files with the test_epoch return_value : bool, optional if True, return (inds_df, pred_mean, obs_mean), by default False flow_unit : str, optional arg for load_ensemble_result, by default "m3/s" basin_areas : type, optional arg for load_ensemble_result, by default None

Returns

Tuple[np.array, np.array] inds_df or (inds_df, pred_mean, obs_mean)

Source code in torchhydro/trainers/resulter.py
def eval_ensemble_result(
    self,
    save_dirs,
    test_epoch,
    return_value=False,
    flow_unit="m3/s",
    basin_areas=None,
) -> Tuple[np.array, np.array]:
    """calculate statistics for ensemble results

    Parameters
    ----------
    save_dirs : _type_
        where the results save
    test_epoch : _type_
        we name the results files with the test_epoch
    return_value : bool, optional
        if True, return (inds_df, pred_mean, obs_mean), by default False
    flow_unit : str, optional
        arg for load_ensemble_result, by default "m3/s"
    basin_areas : _type_, optional
        arg for load_ensemble_result, by default None

    Returns
    -------
    Tuple[np.array, np.array]
        inds_df or (inds_df, pred_mean, obs_mean)
    """
    pred_mean, obs_mean = self.load_ensemble_result(
        save_dirs, test_epoch, flow_unit=flow_unit, basin_areas=basin_areas
    )
    inds = stat_error(obs_mean, pred_mean)
    inds_df = pd.DataFrame(inds)
    return (inds_df, pred_mean, obs_mean) if return_value else inds_df

load_ensemble_result(self, save_dirs, test_epoch, flow_unit='m3/s', basin_areas=None)

load ensemble mean value

Parameters

save_dirs test_epoch flow_unit default is m3/s, if it is not m3/s, transform the results basin_areas if unit is mm/day it will be used, default is None

Returns
Source code in torchhydro/trainers/resulter.py
def load_ensemble_result(
    self, save_dirs, test_epoch, flow_unit="m3/s", basin_areas=None
) -> Tuple[np.array, np.array]:
    """
    load ensemble mean value

    Parameters
    ----------
    save_dirs
    test_epoch
    flow_unit
        default is m3/s, if it is not m3/s, transform the results
    basin_areas
        if unit is mm/day it will be used, default is None

    Returns
    -------

    """
    preds = []
    obss = []
    for save_dir in save_dirs:
        pred_i, obs_i = self.load_result(save_dir, test_epoch)
        if pred_i.ndim == 3 and pred_i.shape[-1] == 1:
            pred_i = pred_i.reshape(pred_i.shape[0], pred_i.shape[1])
            obs_i = obs_i.reshape(obs_i.shape[0], obs_i.shape[1])
        preds.append(pred_i)
        obss.append(obs_i)
    preds_np = np.array(preds)
    obss_np = np.array(obss)
    pred_mean = np.mean(preds_np, axis=0)
    obs_mean = np.mean(obss_np, axis=0)
    if flow_unit == "mm/day":
        if basin_areas is None:
            raise ArithmeticError("No basin areas we cannot calculate")
        basin_areas = np.repeat(basin_areas, obs_mean.shape[1], axis=0).reshape(
            obs_mean.shape
        )
        obs_mean = obs_mean * basin_areas * 1e-3 * 1e6 / 86400
        pred_mean = pred_mean * basin_areas * 1e-3 * 1e6 / 86400
    elif flow_unit == "m3/s":
        pass
    elif flow_unit == "ft3/s":
        obs_mean = obs_mean / 35.314666721489
        pred_mean = pred_mean / 35.314666721489
    return pred_mean, obs_mean

load_result(self, convert_flow_unit=False)

load the pred value of testing period and obs value

Source code in torchhydro/trainers/resulter.py
def load_result(self, convert_flow_unit=False) -> Tuple[np.array, np.array]:
    """load the pred value of testing period and obs value"""
    save_dir = self.result_dir
    pred_file = os.path.join(save_dir, self.pred_name + ".nc")
    obs_file = os.path.join(save_dir, self.obs_name + ".nc")
    pred = xr.open_dataset(pred_file)
    obs = xr.open_dataset(obs_file)
    if convert_flow_unit:
        pred = self._convert_streamflow_units(pred)
        obs = self._convert_streamflow_units(obs)
    return pred, obs

read_tensorboard_log(self, **kwargs)

read tensorboard log files

Source code in torchhydro/trainers/resulter.py
def read_tensorboard_log(self, **kwargs):
    """read tensorboard log files"""
    is_scalar = kwargs.get("is_scalar", False)
    is_histogram = kwargs.get("is_histogram", False)
    log_dir = self.cfgs["data_cfgs"]["case_dir"]
    if is_scalar:
        scalar_file = os.path.join(log_dir, "tb_scalars.csv")
        if not os.path.exists(scalar_file):
            reader = SummaryReader(log_dir)
            df_scalar = reader.scalars
            df_scalar.to_csv(scalar_file, index=False)
        else:
            df_scalar = pd.read_csv(scalar_file)
    if is_histogram:
        histogram_file = os.path.join(log_dir, "tb_histograms.csv")
        if not os.path.exists(histogram_file):
            reader = SummaryReader(log_dir)
            df_histogram = reader.histograms
            df_histogram.to_csv(histogram_file, index=False)
        else:
            df_histogram = pd.read_csv(histogram_file)
    if is_scalar and is_histogram:
        return df_scalar, df_histogram
    elif is_scalar:
        return df_scalar
    elif is_histogram:
        return df_histogram

save_intermediate_results(self, **kwargs)

Load model weights and deal with some intermediate results

Source code in torchhydro/trainers/resulter.py
def save_intermediate_results(self, **kwargs):
    """Load model weights and deal with some intermediate results"""
    is_cell_states = kwargs.get("is_cell_states", False)
    is_pbm_params = kwargs.get("is_pbm_params", False)
    cfgs = self.cfgs
    cfgs["training_cfgs"]["train_mode"] = False
    training_cfgs = cfgs["training_cfgs"]
    seq_first = training_cfgs["which_first_tensor"] == "sequence"
    if is_cell_states:
        raise NotImplementedError(
            "return_cell_states is not supported yet "
            "(save_intermediate_results)"
        )
    if is_pbm_params:
        self._save_pbm_params(cfgs, seq_first)

save_result(self, pred, obs)

save the pred value of testing period and obs value

Parameters

pred predictions obs observations pred_name the file name of predictions obs_name the file name of observations

Returns

None

Source code in torchhydro/trainers/resulter.py
def save_result(self, pred, obs):
    """
    save the pred value of testing period and obs value

    Parameters
    ----------
    pred
        predictions
    obs
        observations
    pred_name
        the file name of predictions
    obs_name
        the file name of observations

    Returns
    -------
    None
    """
    save_dir = self.result_dir
    flow_pred_file = os.path.join(save_dir, self.pred_name)
    flow_obs_file = os.path.join(save_dir, self.obs_name)

    if isinstance(pred, list) and isinstance(obs, list):
        # Case for recover_mode="byforecast", where we have a list of DataArrays per horizon
        for i, (p, o) in enumerate(zip(pred, obs)):
            max_len = max(len(basin) for basin in p.basin.values)
            encoding = {"basin": {"dtype": f"U{max_len}"}}
            p.to_netcdf(f"{flow_pred_file}_horizon{i+1}.nc", encoding=encoding)
            o.to_netcdf(f"{flow_obs_file}_horizon{i+1}.nc", encoding=encoding)
    else:
        # Standard case
        max_len = max(len(basin) for basin in pred.basin.values)
        encoding = {"basin": {"dtype": f"U{max_len}"}}
        pred.to_netcdf(flow_pred_file + ".nc", encoding=encoding)
        obs.to_netcdf(flow_obs_file + ".nc", encoding=encoding)

Author: Wenyu Ouyang Date: 2023-07-25 16:47:19 LastEditTime: 2025-06-17 10:39:32 LastEditors: Wenyu Ouyang Description: Lightning Fabric wrapper for debugging and distributed training FilePath: orchhydro orchhydro rainers abric_wrapper.py Copyright (c) 2025-2026 Wenyu Ouyang. All rights reserved.

FabricWrapper

A wrapper class that can switch between Lightning Fabric and normal PyTorch operations based on configuration settings.

TODO: the fabric wrapper is not fully used for parallel training yet

Source code in torchhydro/trainers/fabric_wrapper.py
class FabricWrapper:
    """
    A wrapper class that can switch between Lightning Fabric and normal PyTorch operations
    based on configuration settings.

    TODO: the fabric wrapper is not fully used for parallel training yet
    """

    def __init__(self, use_fabric: bool = True, fabric_config: Optional[Dict] = None):
        """
        Initialize the Fabric wrapper.

        Parameters
        ----------
        use_fabric : bool
            Whether to use Lightning Fabric or normal PyTorch operations
        fabric_config : Optional[Dict]
            Configuration for Fabric (devices, strategy, etc.)
        """
        self.use_fabric = use_fabric
        self.fabric_config = fabric_config or {}
        self._fabric: Optional[Any] = None
        self._device: Optional[torch.device] = None

        if self.use_fabric:
            self._init_fabric()
        else:
            self._init_pytorch()

    def _init_fabric(self) -> None:
        """Initialize Lightning Fabric"""
        try:
            import lightning as L

            # Default fabric configuration
            default_config = {
                "accelerator": "auto",
                "devices": "auto",
                "strategy": "auto",
                "precision": "32-true",
            }

            # Update with user config
            default_config.update(self.fabric_config)

            self._fabric = L.Fabric(**default_config)
            print("✅ Lightning Fabric initialized successfully")

        except ImportError:
            print("❌ Lightning not found, falling back to normal PyTorch")
            self.use_fabric = False
            self._init_pytorch()

    def _init_pytorch(self) -> None:
        """Initialize normal PyTorch setup"""
        self.device_num = self.fabric_config["devices"]
        # self.device_num = [0]
        self._device = get_the_device(self.device_num)
        print(f"[OK] Normal PyTorch initialized, using device: {self._device}")

    def setup_module(self, model: torch.nn.Module) -> torch.nn.Module:
        """Setup model for training"""
        if self.use_fabric:
            return self._fabric.setup_module(model)
        else:
            return model.to(self._device)

    def setup_optimizers(
        self, optimizer: torch.optim.Optimizer
    ) -> torch.optim.Optimizer:
        """Setup optimizer"""
        if self.use_fabric:
            return self._fabric.setup_optimizers(optimizer)
        else:
            return optimizer

    def setup_dataloaders(
        self, *dataloaders: torch.utils.data.DataLoader
    ) -> Tuple[torch.utils.data.DataLoader, ...]:
        """Setup dataloaders"""
        if self.use_fabric:
            return self._fabric.setup_dataloaders(*dataloaders)
        else:
            return dataloaders

    def save(self, path: str, state_dict: Dict[str, Any]) -> None:
        """Save model state"""
        if self.use_fabric:
            self._fabric.save(path, state_dict)
        else:
            torch.save(state_dict, path)

    def load(self, path: str, model: Optional[torch.nn.Module] = None) -> Any:
        """Load model state"""
        if self.use_fabric:
            return self._fabric.load(path, model)
        else:
            return torch.load(path, map_location=self._device)

    def load_raw(self, path: str, model: torch.nn.Module) -> None:
        """Load raw model weights"""
        if self.use_fabric:
            checkpoint = self._fabric.load(path)
            model.load_state_dict(checkpoint)
        else:
            checkpoint = torch.load(path, map_location=self._device)
            model.load_state_dict(checkpoint)

    def launch(self, fn: Optional[Any] = None, *args: Any, **kwargs: Any) -> Any:
        """Launch training function"""
        if self.use_fabric:
            if fn is None:
                # This is called without a function, just launch fabric
                return self._fabric.launch()
            else:
                return self._fabric.launch(fn, *args, **kwargs)
        else:
            # Normal PyTorch, just call the function directly
            if fn is not None:
                return fn(*args, **kwargs)
            else:
                return None

    def backward(self, loss: torch.Tensor) -> None:
        """Backward pass"""
        if self.use_fabric:
            self._fabric.backward(loss)
        else:
            loss.backward()

    def clip_gradients(
        self,
        model: torch.nn.Module,
        optimizer: torch.optim.Optimizer,
        max_norm: float = 1.0,
    ) -> None:
        """Clip gradients"""
        if self.use_fabric:
            self._fabric.clip_gradients(model, optimizer, max_norm=max_norm)
        else:
            torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm)

    @property
    def device(self) -> torch.device:
        """Get current device"""
        if self.use_fabric:
            return self._fabric.device
        else:
            return self._device

    @property
    def local_rank(self) -> int:
        """Get local rank"""
        if self.use_fabric:
            return self._fabric.local_rank
        else:
            return 0

    @property
    def global_rank(self) -> int:
        """Get global rank"""
        if self.use_fabric:
            return self._fabric.global_rank
        else:
            return 0

    @property
    def world_size(self) -> int:
        """Get world size"""
        if self.use_fabric:
            return self._fabric.world_size
        else:
            return 1

    def barrier(self) -> None:
        """Synchronization barrier"""
        if self.use_fabric:
            self._fabric.barrier()
        else:
            pass  # No barrier needed for single process

    def print(self, *args: Any, **kwargs: Any) -> None:
        """Print only on rank 0"""
        if self.use_fabric:
            self._fabric.print(*args, **kwargs)
        else:
            print(*args, **kwargs)

device: device property readonly

Get current device

global_rank: int property readonly

Get global rank

local_rank: int property readonly

Get local rank

world_size: int property readonly

Get world size

__init__(self, use_fabric=True, fabric_config=None) special

Initialize the Fabric wrapper.

Parameters

use_fabric : bool Whether to use Lightning Fabric or normal PyTorch operations fabric_config : Optional[Dict] Configuration for Fabric (devices, strategy, etc.)

Source code in torchhydro/trainers/fabric_wrapper.py
def __init__(self, use_fabric: bool = True, fabric_config: Optional[Dict] = None):
    """
    Initialize the Fabric wrapper.

    Parameters
    ----------
    use_fabric : bool
        Whether to use Lightning Fabric or normal PyTorch operations
    fabric_config : Optional[Dict]
        Configuration for Fabric (devices, strategy, etc.)
    """
    self.use_fabric = use_fabric
    self.fabric_config = fabric_config or {}
    self._fabric: Optional[Any] = None
    self._device: Optional[torch.device] = None

    if self.use_fabric:
        self._init_fabric()
    else:
        self._init_pytorch()

backward(self, loss)

Backward pass

Source code in torchhydro/trainers/fabric_wrapper.py
def backward(self, loss: torch.Tensor) -> None:
    """Backward pass"""
    if self.use_fabric:
        self._fabric.backward(loss)
    else:
        loss.backward()

barrier(self)

Synchronization barrier

Source code in torchhydro/trainers/fabric_wrapper.py
def barrier(self) -> None:
    """Synchronization barrier"""
    if self.use_fabric:
        self._fabric.barrier()
    else:
        pass  # No barrier needed for single process

clip_gradients(self, model, optimizer, max_norm=1.0)

Clip gradients

Source code in torchhydro/trainers/fabric_wrapper.py
def clip_gradients(
    self,
    model: torch.nn.Module,
    optimizer: torch.optim.Optimizer,
    max_norm: float = 1.0,
) -> None:
    """Clip gradients"""
    if self.use_fabric:
        self._fabric.clip_gradients(model, optimizer, max_norm=max_norm)
    else:
        torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm)

launch(self, fn=None, *args, **kwargs)

Launch training function

Source code in torchhydro/trainers/fabric_wrapper.py
def launch(self, fn: Optional[Any] = None, *args: Any, **kwargs: Any) -> Any:
    """Launch training function"""
    if self.use_fabric:
        if fn is None:
            # This is called without a function, just launch fabric
            return self._fabric.launch()
        else:
            return self._fabric.launch(fn, *args, **kwargs)
    else:
        # Normal PyTorch, just call the function directly
        if fn is not None:
            return fn(*args, **kwargs)
        else:
            return None

load(self, path, model=None)

Load model state

Source code in torchhydro/trainers/fabric_wrapper.py
def load(self, path: str, model: Optional[torch.nn.Module] = None) -> Any:
    """Load model state"""
    if self.use_fabric:
        return self._fabric.load(path, model)
    else:
        return torch.load(path, map_location=self._device)

load_raw(self, path, model)

Load raw model weights

Source code in torchhydro/trainers/fabric_wrapper.py
def load_raw(self, path: str, model: torch.nn.Module) -> None:
    """Load raw model weights"""
    if self.use_fabric:
        checkpoint = self._fabric.load(path)
        model.load_state_dict(checkpoint)
    else:
        checkpoint = torch.load(path, map_location=self._device)
        model.load_state_dict(checkpoint)

print(self, *args, **kwargs)

Print only on rank 0

Source code in torchhydro/trainers/fabric_wrapper.py
def print(self, *args: Any, **kwargs: Any) -> None:
    """Print only on rank 0"""
    if self.use_fabric:
        self._fabric.print(*args, **kwargs)
    else:
        print(*args, **kwargs)

save(self, path, state_dict)

Save model state

Source code in torchhydro/trainers/fabric_wrapper.py
def save(self, path: str, state_dict: Dict[str, Any]) -> None:
    """Save model state"""
    if self.use_fabric:
        self._fabric.save(path, state_dict)
    else:
        torch.save(state_dict, path)

setup_dataloaders(self, *dataloaders)

Setup dataloaders

Source code in torchhydro/trainers/fabric_wrapper.py
def setup_dataloaders(
    self, *dataloaders: torch.utils.data.DataLoader
) -> Tuple[torch.utils.data.DataLoader, ...]:
    """Setup dataloaders"""
    if self.use_fabric:
        return self._fabric.setup_dataloaders(*dataloaders)
    else:
        return dataloaders

setup_module(self, model)

Setup model for training

Source code in torchhydro/trainers/fabric_wrapper.py
def setup_module(self, model: torch.nn.Module) -> torch.nn.Module:
    """Setup model for training"""
    if self.use_fabric:
        return self._fabric.setup_module(model)
    else:
        return model.to(self._device)

setup_optimizers(self, optimizer)

Setup optimizer

Source code in torchhydro/trainers/fabric_wrapper.py
def setup_optimizers(
    self, optimizer: torch.optim.Optimizer
) -> torch.optim.Optimizer:
    """Setup optimizer"""
    if self.use_fabric:
        return self._fabric.setup_optimizers(optimizer)
    else:
        return optimizer

create_fabric_wrapper(training_cfgs)

Create a fabric wrapper based on training configuration.

Parameters

training_cfgs : Dict Training configuration dictionary

Returns

FabricWrapper Initialized fabric wrapper

Source code in torchhydro/trainers/fabric_wrapper.py
def create_fabric_wrapper(training_cfgs: Dict) -> FabricWrapper:
    """
    Create a fabric wrapper based on training configuration.

    Parameters
    ----------
    training_cfgs : Dict
        Training configuration dictionary

    Returns
    -------
    FabricWrapper
        Initialized fabric wrapper
    """
    # Check if we should use fabric
    fabric_strategy = training_cfgs.get("fabric_strategy")
    use_fabric = fabric_strategy is not None

    # Check if we have multiple devices
    devices = training_cfgs.get("device", [0])
    if isinstance(devices, list) and len(devices) == 1 and use_fabric:
        print("📱 Single device detected - we can disable Fabric")
        use_fabric = False

    # Fabric configuration
    if devices == "auto" or devices == ["auto"]:
        final_devices = "auto"
    else:
        final_devices = devices if isinstance(devices, list) else [devices]

    fabric_config = {
        "devices": final_devices,
        "strategy": fabric_strategy,
        "precision": training_cfgs.get("precision", "32-true"),
        "accelerator": training_cfgs.get("accelerator", "auto"),
    }

    return FabricWrapper(use_fabric=use_fabric, fabric_config=fabric_config)