# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Configuration for pyCSAMT 1-D AI-based EM inversion.
The module exposes :class:`InversionConfig`, a dataclass that collects every
tuneable parameter for :class:`~pycsamt.ai.inversion.inv1d.EMInverter1D` —
architecture, training loop, regularisation, checkpointing, and output.
The recommended workflow mirrors the pattern used by ``ModEmConfig`` and
``OccamConfig``:
1. Call :meth:`InversionConfig.write_template` to generate a fully annotated
source-of-truth file (Python, JSON, or YAML).
2. Edit the file to reflect the desired architecture and training budget.
3. Load the edited file with :meth:`InversionConfig.from_file`.
4. Optionally call :meth:`InversionConfig.validate` to catch range errors.
5. Call :meth:`InversionConfig.to_inverter` to get a ready-to-fit
:class:`~pycsamt.ai.inversion.inv1d.EMInverter1D`.
6. Call ``inv.fit(dataset, **cfg.to_fit_kwargs())`` to train.
Quick start
-----------
Generate a default template, edit it, train::
from pycsamt.ai.inversion.config import InversionConfig
from pycsamt.forward.batch import ForwardDataset
# 1 — write annotated source-of-truth file
InversionConfig.write_template("my_inversion.yml")
# 2 — edit my_inversion.yml …
# 3 — load and train
cfg = InversionConfig.from_file("my_inversion.yml")
cfg.validate()
ds = ForwardDataset.load("mt1d_train.npz")
inv = cfg.to_inverter()
inv.fit(ds, **cfg.to_fit_kwargs())
inv.save(cfg.checkpoint_path())
Snapshot a fitted inverter for reproducibility::
cfg = InversionConfig.from_inverter(inv)
cfg.write_template("snapshot.yml")
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from ...models.config_io import (
ConfigParameter,
read_config_file,
write_config_template,
)
__all__ = ["InversionConfig"]
# ── parameter schema ────────────────────────────────────────────────────────
_INVERSION_CONFIG_SCHEMA: list[ConfigParameter] = [
# ── Architecture ─────────────────────────────────────────────────────────
ConfigParameter(
"arch",
"Neural network architecture for 1-D inversion. "
"Accepted values: 'resnet' — residual network (Liu 2021 style, "
"best accuracy for MT datasets with > 5 000 samples); "
"'cnn1d' — 1-D convolutional network (Puzyrev 2019 style, "
"faster to train, good for smaller datasets); "
"'fcn' — fully-connected network (Moghadas 2020 style, "
"handles variable input length).",
"Architecture",
),
ConfigParameter(
"n_layers",
"Number of earth layers the network inverts for, including the "
"halfspace. The output vector has length 2*n_layers - 1 "
"(n_layers log-resistivities + n_layers-1 thicknesses). "
"Must match the n_layers used during dataset generation.",
"Architecture",
),
ConfigParameter(
"solver",
"EM method the inverter targets. Accepted values: 'mt1d', "
"'csamt1d', 'tem1d'. Determines which default frequency grid "
"is used when coercing Z objects or ForwardResponse inputs to "
"numpy feature arrays for prediction.",
"Architecture",
),
ConfigParameter(
"device",
"Compute device for network training and inference. "
"Accepted values: 'cpu', 'cuda', 'mps', or null/None. "
"When null the backend auto-detects the best available device "
"(CUDA > MPS > CPU). Set explicitly to 'cpu' to force CPU-only "
"execution on a machine with a GPU.",
"Architecture",
),
ConfigParameter(
"include_phase",
"Include impedance phase in the MT/CSAMT input feature vector. "
"When True the feature vector length is 2*n_freqs (log10(rho_a) "
"concatenated with phase in degrees). When False only log10(rho_a) "
"is used (feature vector length n_freqs). Must match the value "
"used during dataset generation.",
"Architecture",
),
ConfigParameter(
"log_thickness",
"Apply log10 to layer thicknesses in the training targets. "
"Strongly recommended when thicknesses span more than two orders of "
"magnitude. Must match the setting used when the dataset was "
"generated if loading a pre-built dataset.",
"Architecture",
),
ConfigParameter(
"augment_noise",
"Standard deviation of Gaussian noise added to each training batch "
"on-the-fly during every epoch. Acts as a form of input "
"regularisation and improves generalisation to field data noise "
"levels that differ from the training noise. Set to 0.0 to disable.",
"Architecture",
),
# ── Training ─────────────────────────────────────────────────────────────
ConfigParameter(
"epochs",
"Maximum number of training epochs. Early stopping (controlled by "
"'patience') usually terminates training before this limit is "
"reached. A value of 200–500 is typical for datasets with "
"10 000–50 000 samples.",
"Training",
),
ConfigParameter(
"batch_size",
"Mini-batch size for stochastic gradient descent. Larger batches "
"reduce variance in the gradient estimate and often converge in fewer "
"epochs, but require more GPU memory. 256 is a good default for "
"most GPU configurations.",
"Training",
),
ConfigParameter(
"lr",
"Initial learning rate for the Adam optimiser. The "
"ReduceLROnPlateau scheduler halves this value automatically when "
"validation loss stagnates. 1e-3 is a robust starting point for "
"most architectures and dataset sizes.",
"Training",
),
ConfigParameter(
"weight_decay",
"L2 regularisation coefficient (Adam weight decay). Controls "
"over-fitting for large networks. 1e-5 is a conservative default "
"that rarely hurts performance. Increase to 1e-4 or 1e-3 if "
"training loss converges but validation loss diverges.",
"Training",
),
ConfigParameter(
"patience",
"Early-stopping patience: number of epochs without a validation-loss "
"improvement larger than 'min_delta' before training is halted and "
"the best checkpoint is restored. 20–50 epochs is standard; "
"increase for large datasets or low learning rates.",
"Training",
),
ConfigParameter(
"min_delta",
"Minimum absolute decrease in validation loss to count as an "
"improvement for early stopping. Setting this too small allows "
"the patience counter to reset on noise fluctuations. "
"1e-5 is appropriate for masked MSE loss on log-scaled targets.",
"Training",
),
ConfigParameter(
"val_frac",
"Fraction of the training dataset held out for validation. "
"Must be in (0, 1). 0.1 (ten percent) is standard. With very "
"large datasets (> 100 000 samples) 0.05 is sufficient.",
"Training",
),
ConfigParameter(
"grad_clip",
"Gradient-norm clipping threshold. Gradients with a norm exceeding "
"this value are rescaled before the optimiser step, preventing "
"exploding gradients in deep networks. 1.0 is a safe default. "
"Set to null/None to disable clipping.",
"Training",
),
ConfigParameter(
"seed",
"Random seed for the train / validation split. Does not affect "
"dataset generation (controlled by ForwardConfig.seed). "
"Set to null/None for a non-reproducible split.",
"Training",
),
# ── Checkpointing ────────────────────────────────────────────────────────
ConfigParameter(
"checkpoint_dir",
"Directory where model checkpoints (.npz files) are saved. "
"Created automatically if it does not exist. Set to null/None to "
"skip automatic checkpoint saving (the fitted inverter is still "
"returned in memory).",
"Checkpointing",
),
ConfigParameter(
"checkpoint_name",
"Base file name (without extension) for checkpoint files. "
"The full path is checkpoint_dir/checkpoint_name.npz. "
"For example 'mt1d_resnet_5l' produces "
"'checkpoints/mt1d_resnet_5l.npz'.",
"Checkpointing",
),
ConfigParameter(
"save_best",
"Automatically save the best-validation-loss checkpoint to "
"checkpoint_path() after training completes. When False the "
"caller is responsible for calling inv.save().",
"Checkpointing",
),
# ── Output ───────────────────────────────────────────────────────────────
ConfigParameter(
"verbose",
"Print per-epoch training summaries and a final report after "
"training completes. Set to False for batch scripts.",
"Output",
),
]
# ── dataclass ────────────────────────────────────────────────────────────────
[docs]
@dataclass
class InversionConfig:
"""Collect settings that define a 1-D AI-based EM inversion run.
``InversionConfig`` is the configuration object for
:class:`~pycsamt.ai.inversion.inv1d.EMInverter1D`. It covers four
concern areas: network architecture, training hyperparameters,
regularisation, and checkpoint management.
The recommended workflow:
1. Generate a template with :meth:`write_template`.
2. Edit the values in the generated file.
3. Load the edited file with :meth:`from_file`.
4. Optionally call :meth:`validate` to catch range errors.
5. Call :meth:`to_inverter` to instantiate a ready-to-fit
:class:`~pycsamt.ai.inversion.inv1d.EMInverter1D`.
6. Pass :meth:`to_fit_kwargs` to ``inv.fit(dataset, **cfg.to_fit_kwargs())``.
Parameters
----------
arch : {'resnet', 'cnn1d', 'fcn'}
Network architecture.
n_layers : int
Number of earth layers (including halfspace).
solver : {'mt1d', 'csamt1d', 'tem1d'}
Forward solver this inverter targets.
device : str or None
Compute device; ``None`` auto-detects (CUDA > MPS > CPU).
include_phase : bool
Include impedance phase in the input feature vector.
log_thickness : bool
Apply log10 to thickness targets during training.
augment_noise : float
On-the-fly per-epoch noise augmentation level.
epochs : int
Maximum training epochs.
batch_size : int
Mini-batch size.
lr : float
Initial Adam learning rate.
weight_decay : float
Adam L2 regularisation coefficient.
patience : int
Early-stopping patience (epochs without improvement).
min_delta : float
Minimum validation-loss decrease to count as an improvement.
val_frac : float
Fraction of data used for validation.
grad_clip : float or None
Gradient-norm clipping threshold; ``None`` disables clipping.
seed : int or None
Random seed for train/val split.
checkpoint_dir : str or None
Directory for checkpoint files; ``None`` disables auto-saving.
checkpoint_name : str
Base file name for checkpoints (without extension).
save_best : bool
Auto-save the best checkpoint after training.
verbose : bool
Print training progress.
Examples
--------
Default configuration (ResNet, 5 layers, MT1D)::
>>> cfg = InversionConfig()
>>> cfg.arch
'resnet'
Deep ResNet for a crystalline-crust survey::
>>> cfg = InversionConfig(
... arch="resnet",
... n_layers=6,
... solver="mt1d",
... epochs=300,
... lr=5e-4,
... seed=0,
... )
Round-trip template::
>>> path = InversionConfig.write_template("inv_config.yml")
>>> cfg = InversionConfig.from_file(path)
>>> cfg.solver
'mt1d'
Snapshot a fitted inverter::
>>> cfg = InversionConfig.from_inverter(inv) # doctest: +SKIP
>>> cfg.write_template("run_snapshot.py") # doctest: +SKIP
"""
# ── Architecture ─────────────────────────────────────────────────────────
arch: str = "resnet"
n_layers: int = 5
solver: str = "mt1d"
device: str | None = None
include_phase: bool = True
log_thickness: bool = True
augment_noise: float = 0.02
# ── Training ─────────────────────────────────────────────────────────────
epochs: int = 100
batch_size: int = 256
lr: float = 1e-3
weight_decay: float = 1e-5
patience: int = 20
min_delta: float = 1e-5
val_frac: float = 0.1
grad_clip: float | None = 1.0
seed: int | None = None
# ── Checkpointing ────────────────────────────────────────────────────────
checkpoint_dir: str | None = "checkpoints"
checkpoint_name: str = "em_inverter"
save_best: bool = True
# ── Output ───────────────────────────────────────────────────────────────
verbose: bool = True
# ─────────────────────────────────────────────────────────────────────────
# Validation
# ─────────────────────────────────────────────────────────────────────────
[docs]
def validate(self) -> None:
"""Check parameter ranges and raise :class:`ValueError` on errors.
Raises
------
ValueError
Descriptive message pointing to the offending parameter.
"""
_VALID_ARCH = {"resnet", "cnn1d", "fcn"}
if self.arch not in _VALID_ARCH:
raise ValueError(
f"arch must be one of {_VALID_ARCH!r}, got {self.arch!r}."
)
_VALID_SOLVERS = {"mt1d", "csamt1d", "tem1d"}
if self.solver not in _VALID_SOLVERS:
raise ValueError(
f"solver must be one of {_VALID_SOLVERS!r}, "
f"got {self.solver!r}."
)
if self.n_layers < 2:
raise ValueError(
"n_layers must be at least 2 (1 layer + halfspace)."
)
if self.device is not None:
_VALID_DEVICES = {"cpu", "cuda", "mps"}
if (
self.device not in _VALID_DEVICES
and not self.device.startswith("cuda:")
):
raise ValueError(
f"device must be one of {_VALID_DEVICES!r} or 'cuda:N', "
f"got {self.device!r}."
)
if self.epochs < 1:
raise ValueError("epochs must be at least 1.")
if self.batch_size < 1:
raise ValueError("batch_size must be at least 1.")
if self.lr <= 0.0:
raise ValueError("lr must be strictly positive.")
if self.weight_decay < 0.0:
raise ValueError("weight_decay must be non-negative.")
if self.patience < 1:
raise ValueError("patience must be at least 1.")
if self.min_delta < 0.0:
raise ValueError("min_delta must be non-negative.")
if not 0.0 < self.val_frac < 1.0:
raise ValueError("val_frac must be in (0, 1).")
if self.grad_clip is not None and self.grad_clip <= 0.0:
raise ValueError("grad_clip must be strictly positive when set.")
if self.augment_noise < 0.0:
raise ValueError("augment_noise must be non-negative.")
# ─────────────────────────────────────────────────────────────────────────
# Assemblers
# ─────────────────────────────────────────────────────────────────────────
[docs]
def to_inverter(self) -> EMInverter1D:
"""Instantiate a :class:`~pycsamt.ai.inversion.inv1d.EMInverter1D`.
Returns an untrained inverter configured according to the
architecture and feature settings stored in this config. Call
``inv.fit(dataset, **cfg.to_fit_kwargs())`` to train it.
Returns
-------
EMInverter1D
Examples
--------
>>> cfg = InversionConfig(arch="cnn1d", n_layers=4, epochs=50)
>>> inv = cfg.to_inverter()
>>> type(inv).__name__
'EMInverter1D'
"""
from .inv1d import EMInverter1D
return EMInverter1D(
arch=self.arch,
n_layers=self.n_layers,
solver=self.solver,
device=self.device,
log_thickness=self.log_thickness,
include_phase=self.include_phase,
augment_noise=self.augment_noise,
)
[docs]
def to_fit_kwargs(self) -> dict[str, Any]:
"""Assemble keyword arguments for :meth:`EMInverter1D.fit`.
The returned dict is ready to be unpacked directly::
inv = cfg.to_inverter()
inv.fit(dataset, **cfg.to_fit_kwargs())
Returns
-------
dict
Keys: ``epochs``, ``batch_size``, ``lr``, ``patience``,
``val_frac``, ``grad_clip``, ``seed``, ``verbose``.
Notes
-----
``weight_decay`` and ``min_delta`` are ``EMTrainer`` parameters
not currently exposed through ``EMInverter1D.fit``. They are
stored in ``InversionConfig`` for documentation and round-trip
reproducibility but are not included in the returned dict.
"""
return dict(
epochs=self.epochs,
batch_size=self.batch_size,
lr=self.lr,
patience=self.patience,
val_frac=self.val_frac,
grad_clip=self.grad_clip,
seed=self.seed,
verbose=self.verbose,
)
[docs]
def checkpoint_path(self) -> Path | None:
"""Return the full checkpoint file path, or ``None`` if disabled.
Returns
-------
pathlib.Path or None
"""
if self.checkpoint_dir is None:
return None
p = Path(self.checkpoint_dir).expanduser()
p.mkdir(parents=True, exist_ok=True)
return p / f"{self.checkpoint_name}.npz"
# ─────────────────────────────────────────────────────────────────────────
# Snapshot / from_inverter
# ─────────────────────────────────────────────────────────────────────────
[docs]
@classmethod
def from_inverter(cls, inv: EMInverter1D) -> InversionConfig:
"""Snapshot a fitted (or unfitted) inverter's architecture settings.
Creates an ``InversionConfig`` whose architecture and feature
fields match those of *inv*. Training hyperparameters are reset
to their defaults because the inverter does not record them after
training.
Use this to generate a reproducible record of a training run::
cfg = InversionConfig.from_inverter(inv)
cfg.write_template("run_snapshot.py")
Parameters
----------
inv : EMInverter1D
Source inverter (fitted or unfitted).
Returns
-------
InversionConfig
"""
return cls(
arch=inv.arch,
n_layers=inv.n_layers,
solver=inv.solver,
device=inv.device,
log_thickness=getattr(inv, "log_thickness", True),
include_phase=getattr(inv, "include_phase", True),
augment_noise=getattr(inv, "augment_noise", 0.02),
)
# ─────────────────────────────────────────────────────────────────────────
# Config file I/O
# ─────────────────────────────────────────────────────────────────────────
[docs]
def to_template(
self,
path: str | Path = "inversion_config.py",
*,
fmt: str | None = None,
) -> Path:
"""Write this configuration to an annotated source-of-truth file.
Parameters
----------
path : path-like, default "inversion_config.py"
Destination file. The suffix selects the output format
(``.py``, ``.json``, ``.yml``).
fmt : {"py", "json", "yml", "yaml"}, optional
Explicit format override.
Returns
-------
pathlib.Path
"""
return write_config_template(
path,
self,
_INVERSION_CONFIG_SCHEMA,
fmt=fmt,
title="PyCSAMT AI inversion configuration",
)
[docs]
@classmethod
def write_template(
cls,
path: str | Path = "inversion_config.py",
*,
fmt: str | None = None,
) -> Path:
"""Generate a documented source-of-truth configuration file.
Creates a file with default parameter values and an inline comment
for every parameter. Edit the file, then load with
:meth:`from_file`.
Parameters
----------
path : path-like, default "inversion_config.py"
Destination file.
fmt : {"py", "json", "yml", "yaml"}, optional
Explicit format override.
Returns
-------
pathlib.Path
Examples
--------
>>> from pycsamt.ai.inversion.config import InversionConfig
>>> path = InversionConfig.write_template("my_inv.yml")
>>> path.suffix
'.yml'
"""
return cls().to_template(path, fmt=fmt)
[docs]
@classmethod
def from_file(
cls,
path: str | Path,
*,
strict: bool = True,
) -> InversionConfig:
"""Load a configuration from a source-of-truth file.
Parameters
----------
path : path-like
Python, JSON, YML, or YAML file generated by
:meth:`write_template` or following the same structure.
strict : bool, default True
If ``True``, unknown keys raise :class:`ValueError`.
If ``False``, unknown keys are silently ignored.
Returns
-------
InversionConfig
Examples
--------
>>> InversionConfig.write_template("inv_config.json")
PosixPath('inv_config.json')
>>> cfg = InversionConfig.from_file("inv_config.json")
>>> cfg.arch
'resnet'
"""
values = read_config_file(path, cls, strict=strict)
return cls(**values)
#: Alias — matches the convention used by ModEmConfig and OccamConfig.
read = from_file
# ─────────────────────────────────────────────────────────────────────────
# repr / summary
# ─────────────────────────────────────────────────────────────────────────
[docs]
def summary(self) -> str:
"""Return a human-readable multi-line summary of the configuration."""
phase_s = "yes" if self.include_phase else "no"
log_th_s = "yes" if self.log_thickness else "no"
clip_s = str(self.grad_clip) if self.grad_clip is not None else "off"
ckpt = (
str(self.checkpoint_path())
if self.checkpoint_path()
else "disabled"
)
lines = [
"InversionConfig",
" ── Architecture ──",
f" {'arch':<22s} = {self.arch!r}",
f" {'n_layers':<22s} = {self.n_layers}",
f" {'solver':<22s} = {self.solver!r}",
f" {'device':<22s} = {self.device!r} (None → auto)",
f" {'include_phase':<22s} = {phase_s}",
f" {'log_thickness':<22s} = {log_th_s}",
f" {'augment_noise':<22s} = {self.augment_noise}",
" ── Training ──",
f" {'epochs':<22s} = {self.epochs}",
f" {'batch_size':<22s} = {self.batch_size}",
f" {'lr':<22s} = {self.lr}",
f" {'weight_decay':<22s} = {self.weight_decay}",
f" {'patience':<22s} = {self.patience} (min_delta={self.min_delta})",
f" {'val_frac':<22s} = {self.val_frac}",
f" {'grad_clip':<22s} = {clip_s}",
f" {'seed':<22s} = {self.seed!r}",
" ── Checkpointing ──",
f" {'checkpoint':<22s} = {ckpt}",
f" {'save_best':<22s} = {self.save_best}",
]
return "\n".join(lines)
def __repr__(self) -> str:
return self.summary()