Source code for specsr_roman.models.sr1

"""SR1 --- the coarse super-resolution stage.

A fully convolutional 1D ResNet mapping the (upsampled) LR grism spectrum to a
mean and a heteroscedastic log-variance on the HR grid. Architecture is
deliberately identical to the JWST/JADES version in ``specsr`` so the two can
be compared and warm-started across instruments; only the input channel count
differs (Roman feeds ``[flux, err]``, see below).
"""

from __future__ import annotations

import torch
import torch.nn as nn

from .blocks import ResidualBlock1D

__all__ = ["SuperRes1D"]


[docs] class SuperRes1D(nn.Module): """LR spectrum -> (mean, log_var) on the same grid. Parameters ---------- in_channels ``2`` for the canonical Roman models: ``[flux, err]``, both divided by the same per-row flux scale so their ratio is the per-pixel S/N. The network can then matched-filter rather than guess which bumps are noise --- the single largest quality jump in the Roman port. ``1`` is the JWST-compatible flux-only input. hidden_dim, num_res_blocks, dropout Swept on the JWST data and carried over unchanged (120 / 16 / ~0.024). Notes ----- Input and output are the *same length*: the LR spectrum is interpolated onto the HR grid before it reaches the model. A fully convolutional stack with no resampling layers cannot change the axis length, and keeping the grids aligned means the residual ``pred - input`` is meaningful pixel by pixel. The log-variance head is initialised to a constant -2.0 with zero weights, so the model starts by predicting a uniform sigma ~ 0.37 and has to earn any structure in its uncertainty. """ def __init__(self, in_channels: int = 1, hidden_dim: int = 96, num_res_blocks: int = 12, dropout: float = 0.02, activation_fn: nn.Module | None = None): super().__init__() activation_fn = nn.GELU() if activation_fn is None else activation_fn self.initial = nn.Sequential( nn.Conv1d(in_channels, hidden_dim, kernel_size=5, padding=2, bias=True), activation_fn, ) self.resblocks = nn.Sequential(*[ ResidualBlock1D(hidden_dim, p_drop=dropout) for _ in range(num_res_blocks) ]) self.mean_head = nn.Conv1d(hidden_dim, 1, kernel_size=1, bias=True) self.log_var_head = nn.Conv1d(hidden_dim, 1, kernel_size=1, bias=True) nn.init.constant_(self.log_var_head.weight, 0.0) nn.init.constant_(self.log_var_head.bias, -2.0) def forward(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: x = self.initial(x) x = self.resblocks(x) return self.mean_head(x), self.log_var_head(x)
[docs] @classmethod def from_state_dict(cls, state: dict, hidden_dim: int = 120, num_res_blocks: int = 16) -> SuperRes1D: """Build a model whose input width matches a checkpoint, then load it. The 1- vs 2-channel input is the one architectural thing that changed across Roman SR1 generations, and every downstream stage has to load whichever it is handed. Read it off the stem weight rather than making the caller remember. """ in_channels = state["initial.0.weight"].shape[1] model = cls(in_channels=in_channels, hidden_dim=hidden_dim, num_res_blocks=num_res_blocks) model.load_state_dict(state) return model