Source code for pycif.plugins.modes.training.backends.torch_backend

"""PyTorch backend: wraps the CIF obs operator as a ``torch.autograd.Function``.

Training modes
--------------
Mode 2 (online)
    CIF called live as teacher; ``adjoint_consistency=False``.
Mode 3 (adjoint)
    Adds a VJP-agreement loss between the surrogate Jacobian and the CIF
    adjoint; ``adjoint_consistency=True``.  Uses ``create_graph=True`` so
    the adjoint-consistency loss is itself differentiable w.r.t. model params.

ONNX export
-----------
Uses ``torch.onnx.export`` with opset 17.  The batch axis is declared dynamic
so the resulting file accepts any batch size at inference time.
"""
from __future__ import annotations

import os
import numpy as np
from typing import Any, Callable, List, Tuple

from .base import SurrogateBackend


def _sanitize_omp_num_threads() -> None:
    """Ensure OMP_NUM_THREADS is a plain integer before PyTorch is imported.

    On some HPC schedulers (e.g. SLURM) the variable is set to a range such
    as ``"1:2"``, which is invalid for OpenMP and causes PyTorch's thread-pool
    initialisation to abort with an out-of-heap-memory error.  We parse the
    value and keep only the first integer, defaulting to 1 if parsing fails.
    """
    raw = os.environ.get("OMP_NUM_THREADS", "")
    if not raw:
        return
    try:
        int(raw)          # already valid — nothing to do
    except ValueError:
        # Take the first token that looks like an integer (e.g. "1" from "1:2")
        first = raw.split(":")[0].strip()
        os.environ["OMP_NUM_THREADS"] = first if first.isdigit() else "1"


[docs] class TorchBackend(SurrogateBackend): """PyTorch surrogate backend.""" # ------------------------------------------------------------------
[docs] def build_default_model( self, n_state: int, n_obs: int, hidden_size: int, n_hidden_layers: int, ) -> Any: """Return a ``torch.nn.Sequential`` MLP: n_state -> hidden -> n_obs.""" _sanitize_omp_num_threads() try: import torch.nn as nn except ImportError as exc: raise ImportError( "PyTorch is required for the torch backend: pip install torch" ) from exc layers = [nn.Linear(n_state, hidden_size), nn.ReLU()] for _ in range(n_hidden_layers - 1): layers += [nn.Linear(hidden_size, hidden_size), nn.ReLU()] layers.append(nn.Linear(hidden_size, n_obs)) return nn.Sequential(*layers)
# ------------------------------------------------------------------
[docs] def wrap_operator(self, run_mode: str = "fwd", reload_results: bool = False) -> Callable: """Return a ``torch.autograd.Function`` wrapping the CIF operator. Args: run_mode: ``"fwd"`` uses ``obs_operator.forward``; ``"tl"`` uses ``obs_operator.tangent_linear``. The backward pass always calls ``obs_operator.adjoint``. reload_results: Forwarded to every operator call. """ _sanitize_omp_num_threads() try: import torch except ImportError as exc: raise ImportError( "PyTorch is required for the torch backend: pip install torch" ) from exc obs_operator = self.obs_operator fwd_call = (obs_operator.tangent_linear if run_mode == "tl" else obs_operator.forward) class _CIFFunction(torch.autograd.Function): @staticmethod def forward(ctx: Any, x: "torch.Tensor") -> "torch.Tensor": x_np = x.detach().cpu().numpy().astype(np.float64) y_np = fwd_call(x_np, reload_results=reload_results) ctx._x_np = x_np return torch.as_tensor(y_np, dtype=x.dtype, device=x.device) @staticmethod def backward( ctx: Any, grad_output: "torch.Tensor" ) -> "torch.Tensor": dy_np = grad_output.detach().cpu().numpy().astype(np.float64) dx_np = obs_operator.adjoint(ctx._x_np, dy_np, reload_results=reload_results) return torch.as_tensor( dx_np, dtype=grad_output.dtype, device=grad_output.device ) def H(x: "torch.Tensor") -> "torch.Tensor": return _CIFFunction.apply(x) return H
# ------------------------------------------------------------------
[docs] def train( self, model: Any, sample_fn: Callable[[int], Any], n_steps: int = 5000, *, run_mode: str = "fwd", reload_results: bool = False, adjoint_consistency: bool = False, lambda_adj: float = 0.1, ) -> Tuple[Any, List[float]]: """Train *model* against the live CIF operator. Args: model: A ``torch.nn.Module``. sample_fn: ``callable(batch_size) -> Tensor | ndarray`` returning a single state vector of shape ``(N_state,)``. n_steps: Number of gradient steps. run_mode: Forwarded to :meth:`wrap_operator`. reload_results: Forwarded to :meth:`wrap_operator`. adjoint_consistency: Add VJP-agreement loss term (Mode 3). lambda_adj: Weight of the adjoint consistency loss. Returns: ``(trained_model, losses)`` """ try: import torch import torch.nn.functional as F except ImportError as exc: raise ImportError( "PyTorch is required for the torch backend: pip install torch" ) from exc obs_operator = self.obs_operator H = self.wrap_operator(run_mode=run_mode, reload_results=reload_results) optimizer = torch.optim.Adam(model.parameters()) losses: List[float] = [] model.train() for _ in range(n_steps): raw = sample_fn(1) if not isinstance(raw, torch.Tensor): x = torch.as_tensor(raw, dtype=torch.float32) else: x = raw.float() # Mode 2: CIF teacher — no gradient through CIF with torch.no_grad(): y_target = H(x) y_pred = model(x) loss = F.mse_loss(y_pred, y_target) if adjoint_consistency: # Mode 3: match surrogate VJP to CIF adjoint dy = torch.randn_like(y_target) dx_cif = torch.as_tensor( obs_operator.adjoint( x.detach().cpu().numpy().astype(np.float64), dy.detach().cpu().numpy().astype(np.float64), ), dtype=x.dtype, ) x_req = x.detach().requires_grad_(True) y_surr = model(x_req) (dx_surr,) = torch.autograd.grad( y_surr, x_req, grad_outputs=dy, create_graph=True ) loss = loss + lambda_adj * F.mse_loss(dx_surr, dx_cif) optimizer.zero_grad() loss.backward() optimizer.step() losses.append(float(loss.item())) return model, losses
# ------------------------------------------------------------------
[docs] def export_onnx( self, path: str, model: Any, input_shape: Tuple[int, ...] ) -> None: """Export *model* to ONNX via ``torch.onnx.export`` (opset 17). The batch dimension (axis 0) is made dynamic so the ONNX file accepts any batch size at inference time. Args: path: Destination ``.onnx`` file path. model: A trained ``torch.nn.Module``. input_shape: Shape of a single input including batch dim, e.g. ``(1, N_state)``. """ try: import torch except ImportError as exc: raise ImportError( "PyTorch is required for ONNX export: pip install torch" ) from exc model.eval() dummy = torch.zeros(*input_shape) torch.onnx.export( model, dummy, path, input_names=["input"], output_names=["output"], dynamic_axes={"input": {0: "batch"}, "output": {0: "batch"}}, opset_version=14, )