"""Typed configuration loading with lightweight YAML inheritance.""" from __future__ import annotations from dataclasses import asdict, dataclass, field, is_dataclass from pathlib import Path from typing import Any import json try: import yaml except ModuleNotFoundError: yaml = None @dataclass class DataConfig: path: str timestamp_col: str target_col: str feature_cols: list[str] | None = None resample_freq: str | None = None @dataclass class WindowConfig: lookback: int horizon: int stride: int @dataclass class SplitConfig: train_ratio: float val_ratio: float test_ratio: float @dataclass class NormPreprocessingConfig: method: str = "standard" @dataclass class WaveletPreprocessingConfig: wavelet_name: str = "db1" level: int = 1 mode: str = "concat" @dataclass class PatchPreprocessingConfig: patch_len: int = 24 patch_stride: int = 12 @dataclass class PreprocessingConfig: norm: NormPreprocessingConfig = field(default_factory=NormPreprocessingConfig) wavelet: WaveletPreprocessingConfig = field(default_factory=WaveletPreprocessingConfig) patch: PatchPreprocessingConfig = field(default_factory=PatchPreprocessingConfig) @dataclass class CNNConfig: conv_channels: list[int] kernel_size: int use_pooling: bool pool_kernel: int activation: str dropout: float @dataclass class BiLSTMConfig: hidden_size: int num_layers: int dropout: float head_hidden_size: int @dataclass class XLSTMConfig: hidden_size: int num_layers: int dropout: float projection_size: int gate_clamp: float stability_eps: float head_hidden_size: int @dataclass class TransformerConfig: d_model: int nhead: int num_layers: int dim_feedforward: int dropout: float activation: str pooling: str head_hidden_size: int max_len: int = 10000 num_decoder_layers: int | None = None feature_dim: int | None = None kernel_type: str | None = None @dataclass class ModelConfig: name: str patch_embed_dim: int = 64 cnn: CNNConfig | None = None bilstm: BiLSTMConfig | None = None xlstm: XLSTMConfig | None = None transformer: TransformerConfig | None = None @dataclass class TrainingConfig: batch_size: int epochs: int lr: float weight_decay: float patience: int lr_scheduler_patience: int lr_scheduler_factor: float grad_clip: float num_workers: int @dataclass class OutputConfig: root_dir: str @dataclass class ExperimentConfig: experiment_name: str seed: int device: str data: DataConfig window: WindowConfig split: SplitConfig preprocessing: PreprocessingConfig model: ModelConfig training: TrainingConfig outputs: OutputConfig def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: merged = dict(base) for key, value in override.items(): if key in merged and isinstance(merged[key], dict) and isinstance(value, dict): merged[key] = _deep_merge(merged[key], value) else: merged[key] = value return merged def _read_yaml(path: Path) -> dict[str, Any]: raw_text = path.read_text(encoding="utf-8") if yaml is not None: data = yaml.safe_load(raw_text) or {} else: data = json.loads(raw_text) if not isinstance(data, dict): raise ValueError(f"YAML config must deserialize to a mapping: {path}") return data def load_yaml_config(path: str | Path) -> dict[str, Any]: """Load a YAML config file with optional relative base inheritance.""" config_path = Path(path) raw_config = _read_yaml(config_path) base_name = raw_config.pop("base_config", None) if base_name: base_path = (config_path.parent / base_name).resolve() base_config = load_yaml_config(base_path) return _deep_merge(base_config, raw_config) return raw_config def _build_data_config(data: dict[str, Any]) -> DataConfig: return DataConfig( path=data["path"], timestamp_col=data["timestamp_col"], target_col=data["target_col"], feature_cols=data.get("feature_cols"), resample_freq=data.get("resample_freq"), ) def _build_window_config(data: dict[str, Any]) -> WindowConfig: return WindowConfig( lookback=int(data["lookback"]), horizon=int(data["horizon"]), stride=int(data["stride"]), ) def _build_split_config(data: dict[str, Any]) -> SplitConfig: return SplitConfig( train_ratio=float(data["train_ratio"]), val_ratio=float(data["val_ratio"]), test_ratio=float(data["test_ratio"]), ) def _build_preprocessing_config(data: dict[str, Any]) -> PreprocessingConfig: return PreprocessingConfig( norm=NormPreprocessingConfig(**data.get("norm", {})), wavelet=WaveletPreprocessingConfig(**data.get("wavelet", {})), patch=PatchPreprocessingConfig(**data.get("patch", {})), ) def _build_model_config(data: dict[str, Any]) -> ModelConfig: cnn_data = data.get("cnn") bilstm_data = data.get("bilstm") xlstm_data = data.get("xlstm") transformer_data = data.get("transformer") return ModelConfig( name=data["name"], patch_embed_dim=int(data.get("patch_embed_dim", 64)), cnn=CNNConfig(**cnn_data) if cnn_data else None, bilstm=BiLSTMConfig(**bilstm_data) if bilstm_data else None, xlstm=XLSTMConfig(**xlstm_data) if xlstm_data else None, transformer=TransformerConfig(**transformer_data) if transformer_data else None, ) def _build_training_config(data: dict[str, Any]) -> TrainingConfig: return TrainingConfig( batch_size=int(data["batch_size"]), epochs=int(data["epochs"]), lr=float(data["lr"]), weight_decay=float(data["weight_decay"]), patience=int(data["patience"]), lr_scheduler_patience=int(data["lr_scheduler_patience"]), lr_scheduler_factor=float(data["lr_scheduler_factor"]), grad_clip=float(data["grad_clip"]), num_workers=int(data["num_workers"]), ) def _build_output_config(data: dict[str, Any]) -> OutputConfig: return OutputConfig(root_dir=data["root_dir"]) def build_experiment_config(config_data: dict[str, Any]) -> ExperimentConfig: """Construct the typed experiment config.""" return ExperimentConfig( experiment_name=config_data["experiment_name"], seed=int(config_data["seed"]), device=config_data["device"], data=_build_data_config(config_data["data"]), window=_build_window_config(config_data["window"]), split=_build_split_config(config_data["split"]), preprocessing=_build_preprocessing_config(config_data["preprocessing"]), model=_build_model_config(config_data["model"]), training=_build_training_config(config_data["training"]), outputs=_build_output_config(config_data["outputs"]), ) def load_experiment_config(path: str | Path) -> ExperimentConfig: """Load and type-check an experiment config file.""" return build_experiment_config(load_yaml_config(path)) def experiment_config_to_dict(config: ExperimentConfig) -> dict[str, Any]: """Convert a typed config into a plain serializable mapping.""" if is_dataclass(config): return asdict(config) raise TypeError("Expected a dataclass-backed ExperimentConfig.")