Refactor code structure and remove redundant code blocks for improved readability and maintainability. Added Sphinx API documentation
4672e76 Download forecasting/model.py from griffingoodwin04/FOXES: direct link, hf CLI and curl.
- Browser
- Download file 57.6 kB
-
https://huggingface.co/griffingoodwin04/FOXES/resolve/main/forecasting/model.py
- Command line
-
hf download hf://griffingoodwin04/FOXES/forecasting/model.py
-
curl -L -o model.py https://huggingface.co/griffingoodwin04/FOXES/resolve/main/forecasting/model.py
57.6 kB
| """Core FOXES models: released deterministic and coupled single-query Gaussian.""" | |
| from collections import deque | |
| import math | |
| import numpy as np | |
| import torch | |
| import torch.nn as nn | |
| import torch.nn.functional as F | |
| import pytorch_lightning as pl | |
| from torch.optim.lr_scheduler import CosineAnnealingLR | |
| SXR_LOG_OFFSET = 1e-8 | |
| def validate_bias_penalty_weight(value): | |
| """Accept only finite, nonnegative bias penalty coefficients.""" | |
| if (isinstance(value, bool) or not isinstance(value, (int, float)) | |
| or not math.isfinite(value) or value < 0): | |
| raise ValueError('bias_penalty_weight must be a finite, nonnegative number') | |
| return float(value) | |
| def mean_class_signed_error_penalty(prediction, target, target_raw, weight): | |
| """Average squared signed bias across the GOES classes in a batch.""" | |
| residual = (prediction - target).reshape(-1) | |
| flux = target_raw.reshape(-1) | |
| thresholds = SXRRegressionDynamicLoss.CLASS_THRESHOLDS | |
| class_index = ( | |
| (flux >= thresholds['c']).long() | |
| + (flux >= thresholds['m']).long() | |
| + (flux >= thresholds['x']).long() | |
| ) | |
| sums = residual.new_zeros(4).scatter_add(0, class_index, residual) | |
| counts = residual.new_zeros(4).scatter_add( | |
| 0, class_index, torch.ones_like(residual), | |
| ) | |
| class_means = sums / counts.clamp_min(1) | |
| return weight * class_means.square().sum() / (counts > 0).sum() | |
| def normalize_sxr(unnormalized_values, sxr_norm): | |
| """Convert physical flux using the FOXES ``log10(F + 1e-8)`` convention.""" | |
| log_values = torch.log10(unnormalized_values + SXR_LOG_OFFSET) | |
| normalized = (log_values - float(sxr_norm[0].item())) / float(sxr_norm[1].item()) | |
| return normalized | |
| def unnormalize_sxr(normalized_values, sxr_norm): | |
| """Invert :func:`normalize_sxr` back to physical flux.""" | |
| return ( | |
| 10 ** ( | |
| normalized_values * float(sxr_norm[1].item()) | |
| + float(sxr_norm[0].item()) | |
| ) | |
| - SXR_LOG_OFFSET | |
| ) | |
| class ViTLocal(pl.LightningModule): | |
| """ | |
| Parameters | |
| ---------- | |
| model_kwargs : dict | |
| Forwarded to VisionTransformerLocal (embed_dim, num_heads, mask_mode, ...). | |
| sxr_norm : np.ndarray | |
| (mean, std) used to log-normalize SXR targets. | |
| base_weights : dict, optional | |
| Per-class loss weights (see SXRRegressionDynamicLoss). If None, falls | |
| back to SXRRegressionDynamicLoss's built-in defaults, which were fit to | |
| the originally released dataset — pass real weights (e.g. from | |
| training/train.py's get_four_class_macro_weights) when training on different data. | |
| weight_decay : float | |
| AdamW weight decay. | |
| scheduler_kwargs : dict, optional | |
| Passed to CosineAnnealingLR (T_max, eta_min). ``T_max`` is measured in | |
| epochs because the scheduler is stepped once per epoch. | |
| loss_kwargs : dict, optional | |
| Forwarded to SXRRegressionDynamicLoss (window_size, huber_delta, | |
| adaptive_multipliers) — see that class for defaults. | |
| bias_penalty_weight : float | |
| Coefficient on the mean squared class-specific signed error in | |
| normalized log10 flux. Zero (the default) disables the penalty. | |
| diagnostic_every_n_steps : int | |
| How often (in training steps) to log the adaptive loss's per-class | |
| multipliers to the logger. | |
| """ | |
| DEFAULT_SCHEDULER_KWARGS = {'T_max': 250, 'eta_min': 1e-7} | |
| def __init__(self, model_kwargs, sxr_norm, base_weights=None, weight_decay=1e-5, | |
| scheduler_kwargs=None, loss_kwargs=None, diagnostic_every_n_steps=200, | |
| bias_penalty_weight=0.0): | |
| super().__init__() | |
| self.model_kwargs = model_kwargs | |
| self.lr = model_kwargs.get('learning_rate', model_kwargs.get('lr', 1e-4)) | |
| self.save_hyperparameters() | |
| filtered_kwargs = dict(model_kwargs) | |
| filtered_kwargs.pop('learning_rate', None) | |
| filtered_kwargs.pop('lr', None) | |
| filtered_kwargs.pop('num_classes', None) | |
| self.model = VisionTransformerLocal(**filtered_kwargs) | |
| self.base_weights = base_weights | |
| self.weight_decay = weight_decay | |
| self.scheduler_kwargs = {**self.DEFAULT_SCHEDULER_KWARGS, **(scheduler_kwargs or {})} | |
| self.diagnostic_every_n_steps = diagnostic_every_n_steps | |
| self.adaptive_loss = SXRRegressionDynamicLoss(base_weights=self.base_weights, **(loss_kwargs or {})) | |
| self.bias_penalty_weight = validate_bias_penalty_weight(bias_penalty_weight) | |
| self.huber_delta = self.adaptive_loss.huber_delta | |
| self.sxr_norm = sxr_norm | |
| def forward(self, x, return_attention=False): | |
| return self.model(x, self.sxr_norm, return_attention=return_attention) | |
| def configure_optimizers(self): | |
| # Use AdamW with weight decay for better regularization | |
| optimizer = torch.optim.AdamW( | |
| self.parameters(), | |
| lr=self.lr, | |
| weight_decay=self.weight_decay, | |
| ) | |
| scheduler = CosineAnnealingLR(optimizer, **self.scheduler_kwargs) | |
| return { | |
| 'optimizer': optimizer, | |
| 'lr_scheduler': { | |
| 'scheduler': scheduler, | |
| 'interval': 'epoch', | |
| 'frequency': 1, | |
| 'name': 'learning_rate' | |
| } | |
| } | |
| def _calculate_loss(self, batch, mode="train"): | |
| imgs, sxr = batch | |
| raw_preds, _ = self.model(imgs, self.sxr_norm) | |
| raw_preds_squeezed = torch.squeeze(raw_preds) | |
| if not torch.isfinite(raw_preds_squeezed).all(): | |
| raise RuntimeError( | |
| f"Non-finite model output in {mode} step at global_step={self.global_step}: " | |
| f"raw_preds range=[{raw_preds_squeezed.min().item()}, {raw_preds_squeezed.max().item()}], " | |
| f"imgs range=[{imgs.min().item()}, {imgs.max().item()}], " | |
| f"sxr (normalized target) range=[{sxr.min().item()}, {sxr.max().item()}]. " | |
| "This means the model itself produced NaN/Inf before reaching the loss -- " | |
| "an activation blowup, not a data or loss-weighting issue." | |
| ) | |
| sxr_un = unnormalize_sxr(sxr, self.sxr_norm) | |
| norm_preds_squeezed = normalize_sxr(raw_preds_squeezed, self.sxr_norm) | |
| # Use adaptive rare event loss. Only train batches should feed the | |
| # per-class error history the adaptive multipliers are computed from -- | |
| # otherwise val/test performance silently leaks into training dynamics. | |
| base_loss, _ = self.adaptive_loss.calculate_loss( | |
| norm_preds_squeezed, sxr, sxr_un, update_tracking=(mode == "train") | |
| ) | |
| signed_error = (norm_preds_squeezed - sxr).mean() | |
| bias_penalty = mean_class_signed_error_penalty( | |
| norm_preds_squeezed, sxr, sxr_un, self.bias_penalty_weight, | |
| ) | |
| loss = base_loss + bias_penalty | |
| # Also calculate huber loss for logging | |
| huber_loss = F.huber_loss(norm_preds_squeezed, sxr, delta=self.huber_delta) | |
| mse_loss = F.mse_loss(norm_preds_squeezed, sxr) | |
| mae_loss = F.l1_loss(norm_preds_squeezed, sxr) | |
| rmse_loss = torch.sqrt(mse_loss) | |
| # Log adaptation info | |
| if mode == "train": | |
| # Always log learning rate (every step) | |
| current_lr = self.trainer.optimizers[0].param_groups[0]['lr'] | |
| self.log('train/learning_rate', current_lr, on_step=True, on_epoch=False, | |
| prog_bar=True, logger=True, sync_dist=True) | |
| self.log("train/total_loss", loss, on_step=True, on_epoch=True, | |
| prog_bar=True, logger=True, sync_dist=True) | |
| self.log("train/bias_dex", signed_error * float(self.sxr_norm[1]), | |
| on_step=True, on_epoch=True, logger=True, sync_dist=True) | |
| self.log("train/bias_penalty", bias_penalty, on_step=True, | |
| on_epoch=True, logger=True, sync_dist=True) | |
| self.log("train/huber_loss", huber_loss, on_step=True, on_epoch=True, | |
| prog_bar=True, logger=True, sync_dist=True) | |
| self.log("train/mse_loss", mse_loss, on_step=True, on_epoch=True, | |
| prog_bar=True, logger=True, sync_dist=True) | |
| self.log("train/mae_loss", mae_loss, on_step=True, on_epoch=True, | |
| prog_bar=True, logger=True, sync_dist=True) | |
| self.log("train/rmse_loss", rmse_loss, on_step=True, on_epoch=True, | |
| prog_bar=True, logger=True, sync_dist=True) | |
| # Detailed diagnostics only every N steps | |
| if self.global_step % self.diagnostic_every_n_steps == 0: | |
| multipliers = self.adaptive_loss.get_current_multipliers() | |
| for key, value in multipliers.items(): | |
| self.log(f"adaptive/{key}", value, on_step=True, on_epoch=False, sync_dist=True) | |
| if mode == "val": | |
| # Validation: typically only log epoch aggregates | |
| multipliers = self.adaptive_loss.get_current_multipliers() | |
| for key, value in multipliers.items(): | |
| self.log(f"val/adaptive/{key}", value, on_step=False, on_epoch=True) | |
| self.log("val_total_loss", loss, on_step=False, on_epoch=True, prog_bar=True, logger=True, sync_dist=True) | |
| self.log("val/bias_dex", signed_error * float(self.sxr_norm[1]), | |
| on_step=False, on_epoch=True, logger=True, sync_dist=True) | |
| self.log("val/bias_penalty", bias_penalty, on_step=False, | |
| on_epoch=True, logger=True, sync_dist=True) | |
| self.log("val_huber_loss", huber_loss, on_step=False, on_epoch=True, prog_bar=True, logger=True, | |
| sync_dist=True) | |
| self.log("val_mse_loss", mse_loss, on_step=False, on_epoch=True, prog_bar=True, logger=True, sync_dist=True) | |
| self.log("val_mae_loss", mae_loss, on_step=False, on_epoch=True, prog_bar=True, logger=True, sync_dist=True) | |
| self.log("val_rmse_loss", rmse_loss, on_step=False, on_epoch=True, prog_bar=True, logger=True, | |
| sync_dist=True) | |
| return loss | |
| def training_step(self, batch, batch_idx): | |
| return self._calculate_loss(batch, mode="train") | |
| def validation_step(self, batch, batch_idx): | |
| self._calculate_loss(batch, mode="val") | |
| def test_step(self, batch, batch_idx): | |
| self._calculate_loss(batch, mode="test") | |
| class VisionTransformerLocal(nn.Module): | |
| def __init__( | |
| self, | |
| embed_dim, | |
| hidden_dim, | |
| num_channels, | |
| num_heads, | |
| num_layers, | |
| patch_size, | |
| num_patches, | |
| dropout, | |
| mask_mode='inverted', | |
| local_window=9, | |
| ): | |
| """Vision Transformer that outputs flux contributions per patch. | |
| Args: | |
| embed_dim: Dimensionality of the input feature vectors to the Transformer | |
| hidden_dim: Dimensionality of the hidden layer in the feed-forward networks | |
| within the Transformer | |
| num_channels: Number of channels of the input (3 for RGB) | |
| num_heads: Number of heads to use in the Multi-Head Attention block | |
| num_layers: Number of layers to use in the Transformer | |
| patch_size: Number of pixels that the patches have per dimension | |
| num_patches: Maximum number of patches an image can have | |
| dropout: Amount of dropout to apply in the feed-forward network and | |
| on the input encoding | |
| mask_mode: Self-attention masking. 'inverted' (default) reproduces the | |
| original released model exactly; 'local' is true local | |
| attention; 'none' is full global attention. See | |
| InvertedAttentionBlock. | |
| local_window: Side length (in patches) of the local neighbourhood used | |
| by the 'inverted' and 'local' masks. | |
| """ | |
| super().__init__() | |
| self.patch_size = patch_size | |
| # Layers/Networks | |
| self.input_layer = nn.Linear(num_channels * (patch_size ** 2), embed_dim) | |
| self.mask_mode = mask_mode | |
| self.local_window = local_window | |
| self.transformer_blocks = nn.ModuleList([ | |
| InvertedAttentionBlock(embed_dim, hidden_dim, num_heads, num_patches, | |
| dropout=dropout, local_window=local_window, mask_mode=mask_mode) | |
| for _ in range(num_layers) | |
| ]) | |
| self.mlp_head = nn.Sequential(nn.LayerNorm(embed_dim), nn.Linear(embed_dim, 1)) | |
| self.dropout = nn.Dropout(dropout) | |
| # Parameters/Embeddings - using 2D positional encoding for local attention | |
| # No CLS token needed for local attention architecture | |
| self.grid_h = int(math.sqrt(num_patches)) | |
| self.grid_w = int(math.sqrt(num_patches)) | |
| self.pos_embedding_2d = nn.Parameter(torch.randn(1, self.grid_h, self.grid_w, embed_dim)) | |
| def forward(self, x, sxr_norm, return_attention=False): | |
| # Preprocess input | |
| x = img_to_patch(x, self.patch_size) | |
| x = self.input_layer(x) | |
| # Add positional encoding (no CLS token for local attention) | |
| x = self._add_2d_positional_encoding(x) | |
| # Apply Transformer blocks | |
| x = self.dropout(x) | |
| x = x.transpose(0, 1) # [T, B, embed_dim] | |
| attention_weights = [] | |
| for block in self.transformer_blocks: | |
| if return_attention: | |
| x, attn_weights = block(x, return_attention=True) | |
| attention_weights.append(attn_weights) | |
| else: | |
| x = block(x) | |
| patch_embeddings = x.transpose(0, 1) # [B, num_patches, embed_dim] | |
| patch_logits = self.mlp_head(patch_embeddings).squeeze(-1) # normalized log predictions [B, num_patches] | |
| # --- Convert to raw SXR --- | |
| mean, std = sxr_norm # in log10 space | |
| patch_flux_raw = torch.clamp( | |
| 10 ** (patch_logits * std + mean) - SXR_LOG_OFFSET, | |
| min=1e-15, | |
| max=1 | |
| ) | |
| # Sum over patches for raw global flux | |
| global_flux_raw = patch_flux_raw.sum(dim=1, keepdim=True) | |
| # Ensure global flux is never zero (add small epsilon if needed) | |
| global_flux_raw = torch.clamp(global_flux_raw, min=1e-15) | |
| if return_attention: | |
| return global_flux_raw, attention_weights, patch_flux_raw | |
| else: | |
| return global_flux_raw, patch_flux_raw | |
| def _add_2d_positional_encoding(self, x): | |
| """Add learned 2D positional encoding to patch embeddings""" | |
| B, T, embed_dim = x.shape | |
| num_patches = T # All tokens are patches (no CLS token) | |
| # Reshape patches to 2D grid: [B, grid_h, grid_w, embed_dim] | |
| patch_embeddings = x.reshape(B, self.grid_h, self.grid_w, embed_dim) | |
| # Add learned 2D positional encoding | |
| # Broadcasting: [B, grid_h, grid_w, embed_dim] + [1, grid_h, grid_w, embed_dim] | |
| patch_embeddings = patch_embeddings + self.pos_embedding_2d | |
| # Reshape back to sequence format: [B, num_patches, embed_dim] | |
| x = patch_embeddings.reshape(B, num_patches, embed_dim) | |
| return x | |
| def set_mask_mode(self, mask_mode, local_window=None): | |
| """Override the attention mask in every block (e.g. to ablate a loaded | |
| checkpoint). Normal checkpoint loading keeps each block's saved mask.""" | |
| self.mask_mode = mask_mode | |
| if local_window is not None: | |
| self.local_window = local_window | |
| for block in self.transformer_blocks: | |
| block.set_mask_mode(mask_mode, local_window=local_window) | |
| class InvertedAttentionBlock(nn.Module): | |
| """Transformer block whose self-attention can be masked in three ways. | |
| mask_mode (passed through from the model config): | |
| 'inverted' - ORIGINAL FOXES behaviour and the default, so released | |
| checkpoints reproduce exactly. The boolean mask marks LOCAL | |
| pairs as True, and nn.MultiheadAttention treats True as | |
| "blocked", so nearby patches are blocked and every patch | |
| attends only to DISTANT patches. (This is the flipped | |
| local-attention syntax the model was actually trained with.) | |
| 'local' - Correct local attention: block everything OUTSIDE the local | |
| window, so each patch attends only to its neighbours. | |
| 'none' - No mask at all; full global attention. | |
| The mask is registered as a PERSISTENT buffer, so it travels inside the | |
| checkpoint. Loading restores the exact mask a model was trained with -- the | |
| original inverted mask, or any hand-edited mask from past experiments -- | |
| regardless of the mask_mode passed at construction. mask_mode/local_window | |
| only decide the mask for a *fresh* model; to deliberately change the mask of | |
| an already-loaded checkpoint (e.g. for an ablation), call set_mask_mode(). | |
| """ | |
| VALID_MASK_MODES = ('inverted', 'local', 'none') | |
| def __init__(self, embed_dim, hidden_dim, num_heads, num_patches, dropout=0.0, | |
| local_window=9, mask_mode='inverted'): | |
| super().__init__() | |
| if mask_mode not in self.VALID_MASK_MODES: | |
| raise ValueError('mask_mode must be one of %s, got %r' | |
| % (self.VALID_MASK_MODES, mask_mode)) | |
| self.embed_dim = embed_dim | |
| self.num_heads = num_heads | |
| self.local_window = local_window | |
| self.num_patches = num_patches | |
| self.mask_mode = mask_mode | |
| self.layer_norm_1 = nn.LayerNorm(embed_dim) | |
| self.attn = nn.MultiheadAttention(embed_dim, num_heads, batch_first=False) | |
| self.layer_norm_2 = nn.LayerNorm(embed_dim) | |
| self.linear = nn.Sequential( | |
| nn.Linear(embed_dim, hidden_dim), | |
| nn.GELU(), | |
| nn.Dropout(dropout), | |
| nn.Linear(hidden_dim, embed_dim), | |
| nn.Dropout(dropout), | |
| ) | |
| # Persistent: the mask is saved with the weights so every checkpoint | |
| # reproduces exactly what it trained with. None => no masking ('none'). | |
| self.register_buffer('attention_mask', self._build_attention_mask()) | |
| def _build_attention_mask(self): | |
| """Boolean attn_mask for nn.MultiheadAttention (True == position blocked). | |
| """ | |
| if self.mask_mode == 'none': | |
| return None | |
| grid_size = int(math.sqrt(self.num_patches)) | |
| idx = torch.arange(self.num_patches) | |
| rows = (idx // grid_size).to(torch.int16) | |
| cols = (idx % grid_size).to(torch.int16) | |
| r = self.local_window // 2 | |
| # local[i, j] True when patches i and j are within the local window. | |
| local = (((rows[:, None] - rows[None, :]).abs() <= r) & | |
| ((cols[:, None] - cols[None, :]).abs() <= r)) | |
| # 'inverted' blocks the local neighbourhood (original); 'local' blocks its | |
| # complement so attention stays inside the neighbourhood. | |
| return local if self.mask_mode == 'inverted' else ~local | |
| def set_mask_mode(self, mask_mode, local_window=None): | |
| """Rebuild the attention mask in place, overriding whatever is currently | |
| set (including a mask restored from a checkpoint). | |
| Use this to deliberately change a trained model's mask -- e.g. to ablate a | |
| released checkpoint under 'none' or 'local'. Loading a checkpoint normally | |
| keeps its own saved mask; this is the explicit opt-out. | |
| """ | |
| if mask_mode not in self.VALID_MASK_MODES: | |
| raise ValueError('mask_mode must be one of %s, got %r' | |
| % (self.VALID_MASK_MODES, mask_mode)) | |
| self.mask_mode = mask_mode | |
| if local_window is not None: | |
| self.local_window = local_window | |
| mask = self._build_attention_mask() | |
| if mask is not None: | |
| try: | |
| mask = mask.to(self.attention_mask.device) | |
| except AttributeError: # current buffer is None ('none' mode) | |
| mask = mask.to(next(self.parameters()).device) | |
| self.attention_mask = mask | |
| def forward(self, x, return_attention=False): | |
| inp_x = self.layer_norm_1(x) | |
| if return_attention: | |
| # attention_mask is None for mask_mode='none' -> standard full attention. | |
| attn_output, attn_weights = self.attn( | |
| inp_x, inp_x, inp_x, | |
| attn_mask=self.attention_mask, | |
| average_attn_weights=False | |
| ) | |
| x = x + attn_output | |
| x = x + self.linear(self.layer_norm_2(x)) | |
| return x, attn_weights | |
| else: | |
| # Avoid computing and returning the O(T^2) attention weights on | |
| # the normal training/inference path. Attention-map callers use | |
| # the explicit return_attention=True branch above. | |
| attn_output = self.attn( | |
| inp_x, inp_x, inp_x, | |
| attn_mask=self.attention_mask, | |
| need_weights=False, | |
| )[0] | |
| x = x + attn_output | |
| x = x + self.linear(self.layer_norm_2(x)) | |
| return x | |
| def img_to_patch(x, patch_size, flatten_channels=True): | |
| """ | |
| Args: | |
| x: Tensor representing the image of shape [B, H, W, C] | |
| patch_size: Number of pixels per dimension of the patches (integer) | |
| flatten_channels: If True, the patches will be returned in a flattened format | |
| as a feature vector instead of a image grid. | |
| """ | |
| x = x.permute(0, 3, 1, 2) | |
| B, C, H, W = x.shape | |
| x = x.reshape(B, C, H // patch_size, patch_size, W // patch_size, patch_size) | |
| x = x.permute(0, 2, 4, 1, 3, 5) # [B, H', W', C, p_H, p_W] | |
| x = x.flatten(1, 2) # [B, H'*W', C, p_H, p_W] | |
| if flatten_channels: | |
| x = x.flatten(2, 4) # [B, H'*W', C*p_H*p_W] | |
| return x | |
| class SXRRegressionDynamicLoss: | |
| """ | |
| Huber loss with per-class weights (quiet/C/M/X) that adapt based on each | |
| class's recent vs. overall running error — classes that are currently | |
| performing worse than their own history get up-weighted. | |
| Parameters | |
| ---------- | |
| window_size : int | |
| Max length of each class's rolling error history. | |
| base_weights : dict, optional | |
| Static per-class weight, multiplied by the adaptive multiplier below. | |
| If None, falls back to DEFAULT_BASE_WEIGHTS (fit to the originally | |
| released dataset — pass real weights fit to your own data instead, | |
| e.g. via training/train.py's get_four_class_macro_weights). | |
| adaptive_multipliers : dict, optional | |
| Per-class {max_multiplier, min_multiplier, sensitivity, min_samples, | |
| recent_window} controlling how much recent performance can move a | |
| class's weight. Merged over DEFAULT_ADAPTIVE_MULTIPLIERS. | |
| huber_delta : float | |
| Delta for the underlying Huber loss. | |
| """ | |
| # GOES flare-class flux boundaries (W/m^2) — a fixed physical definition, | |
| # not a training knob, so this is intentionally not a constructor param. | |
| CLASS_THRESHOLDS = {'c': 1e-6, 'm': 1e-5, 'x': 1e-4} | |
| DEFAULT_BASE_WEIGHTS = { | |
| 'quiet': 6.643528005464481, | |
| 'c_class': 1.626986450317832, | |
| 'm_class': 4.724573441010383, | |
| 'x_class': 43.13137472283814, | |
| } | |
| DEFAULT_ADAPTIVE_MULTIPLIERS = { | |
| 'quiet': {'max_multiplier': 1.5, 'min_multiplier': 0.6, 'sensitivity': 0.05, 'min_samples': 2500, 'recent_window': 800}, | |
| 'c_class': {'max_multiplier': 2.0, 'min_multiplier': 0.7, 'sensitivity': 0.08, 'min_samples': 2500, 'recent_window': 800}, | |
| 'm_class': {'max_multiplier': 5.0, 'min_multiplier': 0.8, 'sensitivity': 0.1, 'min_samples': 1500, 'recent_window': 500}, | |
| 'x_class': {'max_multiplier': 8.0, 'min_multiplier': 0.8, 'sensitivity': 0.12, 'min_samples': 1000, 'recent_window': 300}, | |
| } | |
| def __init__(self, window_size=15000, base_weights=None, adaptive_multipliers=None, huber_delta=0.3): | |
| self.c_threshold = self.CLASS_THRESHOLDS['c'] | |
| self.m_threshold = self.CLASS_THRESHOLDS['m'] | |
| self.x_threshold = self.CLASS_THRESHOLDS['x'] | |
| self.huber_delta = huber_delta | |
| self.window_size = window_size | |
| self.quiet_errors = deque(maxlen=window_size) | |
| self.c_errors = deque(maxlen=window_size) | |
| self.m_errors = deque(maxlen=window_size) | |
| self.x_errors = deque(maxlen=window_size) | |
| self.base_weights = base_weights if base_weights is not None else dict(self.DEFAULT_BASE_WEIGHTS) | |
| self.multiplier_params = {**self.DEFAULT_ADAPTIVE_MULTIPLIERS, **(adaptive_multipliers or {})} | |
| def calculate_loss(self, preds_norm, sxr_norm, sxr_un, update_tracking=True): | |
| base_loss = F.huber_loss(preds_norm, sxr_norm, delta=self.huber_delta, reduction='none') | |
| weights = self._get_adaptive_weights(sxr_un) | |
| if update_tracking: | |
| self._update_tracking(sxr_un, sxr_norm, preds_norm) | |
| weighted_loss = base_loss * weights | |
| loss = weighted_loss.mean() | |
| return loss, weights | |
| def _class_weight(self, sxrclass, error_history): | |
| mult = self._get_performance_multiplier(error_history, sxrclass) | |
| return self.base_weights[sxrclass] * mult | |
| def _get_adaptive_weights(self, sxr_un): | |
| device = sxr_un.device | |
| quiet_weight = self._class_weight('quiet', self.quiet_errors) | |
| c_weight = self._class_weight('c_class', self.c_errors) | |
| m_weight = self._class_weight('m_class', self.m_errors) | |
| x_weight = self._class_weight('x_class', self.x_errors) | |
| weights = torch.ones_like(sxr_un, device=device) | |
| weights = torch.where(sxr_un < self.c_threshold, quiet_weight, weights) | |
| weights = torch.where((sxr_un >= self.c_threshold) & (sxr_un < self.m_threshold), c_weight, weights) | |
| weights = torch.where((sxr_un >= self.m_threshold) & (sxr_un < self.x_threshold), m_weight, weights) | |
| weights = torch.where(sxr_un >= self.x_threshold, x_weight, weights) | |
| # Normalize so mean weight ~1.0 (optional, helps stability) | |
| mean_weight = torch.mean(weights) | |
| weights = weights / (mean_weight) | |
| return weights | |
| def _get_performance_multiplier(self, error_history, sxrclass): | |
| """Class-dependent performance multiplier: how much recent error deviates | |
| from this class's overall running error, mapped through an exponential | |
| and clipped to [min_multiplier, max_multiplier].""" | |
| params = self.multiplier_params[sxrclass] | |
| if len(error_history) < params['min_samples']: | |
| return 1.0 | |
| recent = np.mean(list(error_history)[-params['recent_window']:]) | |
| overall = np.mean(list(error_history)) | |
| ratio = recent / overall | |
| multiplier = np.exp(params['sensitivity'] * (ratio - 1)) | |
| return np.clip(multiplier, params['min_multiplier'], params['max_multiplier']) | |
| def _update_tracking(self, sxr_un, sxr_norm, preds_norm): | |
| sxr_un_np = sxr_un.detach().cpu().numpy() | |
| error = F.huber_loss(preds_norm, sxr_norm, delta=self.huber_delta, reduction='none') | |
| error = error.detach().cpu().numpy() | |
| quiet_mask = sxr_un_np < self.c_threshold | |
| if quiet_mask.sum() > 0: | |
| self.quiet_errors.append(float(np.mean(error[quiet_mask]))) | |
| c_mask = (sxr_un_np >= self.c_threshold) & (sxr_un_np < self.m_threshold) | |
| if c_mask.sum() > 0: | |
| self.c_errors.append(float(np.mean(error[c_mask]))) | |
| m_mask = (sxr_un_np >= self.m_threshold) & (sxr_un_np < self.x_threshold) | |
| if m_mask.sum() > 0: | |
| self.m_errors.append(float(np.mean(error[m_mask]))) | |
| x_mask = sxr_un_np >= self.x_threshold | |
| if x_mask.sum() > 0: | |
| self.x_errors.append(float(np.mean(error[x_mask]))) | |
| def get_current_multipliers(self): | |
| """Current per-class multipliers/weights for logging — uses the exact | |
| same _get_performance_multiplier params as the loss itself, so what's | |
| logged always matches what was actually applied.""" | |
| quiet_mult = self._get_performance_multiplier(self.quiet_errors, 'quiet') | |
| c_mult = self._get_performance_multiplier(self.c_errors, 'c_class') | |
| m_mult = self._get_performance_multiplier(self.m_errors, 'm_class') | |
| x_mult = self._get_performance_multiplier(self.x_errors, 'x_class') | |
| return { | |
| 'quiet_mult': quiet_mult, | |
| 'c_mult': c_mult, | |
| 'm_mult': m_mult, | |
| 'x_mult': x_mult, | |
| 'quiet_count': len(self.quiet_errors), | |
| 'c_count': len(self.c_errors), | |
| 'm_count': len(self.m_errors), | |
| 'x_count': len(self.x_errors), | |
| 'quiet_error': np.mean(self.quiet_errors) if self.quiet_errors else 0.0, | |
| 'c_error': np.mean(self.c_errors) if self.c_errors else 0.0, | |
| 'm_error': np.mean(self.m_errors) if self.m_errors else 0.0, | |
| 'x_error': np.mean(self.x_errors) if self.x_errors else 0.0, | |
| 'quiet_weight': self.base_weights['quiet'] * quiet_mult, | |
| 'c_weight': self.base_weights['c_class'] * c_mult, | |
| 'm_weight': self.base_weights['m_class'] * m_mult, | |
| 'x_weight': self.base_weights['x_class'] * x_mult, | |
| } | |
| # Coupled single-query uncertainty model. | |
| class CoupledVisionTransformerLocal(VisionTransformerLocal): | |
| """Predict additive patch flux with local attention and one global query. | |
| Local logits scale a positive image-wide baseline spread over the patches. | |
| A zero-initialized global head adjusts each patch using raw AIA tokens and | |
| shared context. The uncertainty head consumes detached local logits. | |
| """ | |
| def __init__(self, *args, uncertainty_enabled=True, | |
| max_abs_log10_patch_multiplier=8.0, | |
| max_abs_log10_global_adjustment=0.3, | |
| global_attention_heads=None, | |
| global_head_hidden_dim=None, | |
| global_attention_dropout=None, | |
| initial_relative_std=1.0, | |
| min_log10_relative_std=-4.0, | |
| max_log10_relative_std=4.0, | |
| uncertainty_hidden_dim=16, **kwargs): | |
| super().__init__(*args, **kwargs) | |
| self.uncertainty_enabled = bool(uncertainty_enabled) | |
| self.max_abs_log10_patch_multiplier = float( | |
| max_abs_log10_patch_multiplier | |
| ) | |
| self.max_abs_log10_global_adjustment = float( | |
| max_abs_log10_global_adjustment | |
| ) | |
| self.initial_relative_std = float(initial_relative_std) | |
| self.min_log10_relative_std = float(min_log10_relative_std) | |
| self.max_log10_relative_std = float(max_log10_relative_std) | |
| self.uncertainty_hidden_dim = int(uncertainty_hidden_dim) | |
| if self.max_abs_log10_patch_multiplier <= 0: | |
| raise ValueError("max_abs_log10_patch_multiplier must be positive") | |
| if self.max_abs_log10_global_adjustment <= 0: | |
| raise ValueError("max_abs_log10_global_adjustment must be positive") | |
| if self.mask_mode != 'local': | |
| raise ValueError( | |
| "The coupled model requires local patch attention so the " | |
| "spatial head cannot import distant activity" | |
| ) | |
| if self.initial_relative_std <= 0: | |
| raise ValueError("initial_relative_std must be positive") | |
| if self.max_log10_relative_std <= self.min_log10_relative_std: | |
| raise ValueError( | |
| "max_log10_relative_std must exceed " | |
| "min_log10_relative_std" | |
| ) | |
| if self.uncertainty_hidden_dim <= 0: | |
| raise ValueError("uncertainty_hidden_dim must be positive") | |
| self.last_log10_global_patch_adjustment = None | |
| embed_dim = self.mlp_head[-1].in_features | |
| local_heads = self.transformer_blocks[0].num_heads | |
| self.global_attention_heads = ( | |
| local_heads if global_attention_heads is None | |
| else global_attention_heads | |
| ) | |
| self.global_head_hidden_dim = ( | |
| embed_dim // 2 if global_head_hidden_dim is None | |
| else global_head_hidden_dim | |
| ) | |
| self.global_attention_dropout = ( | |
| self.dropout.p if global_attention_dropout is None | |
| else global_attention_dropout | |
| ) | |
| if (isinstance(self.global_attention_heads, bool) | |
| or not isinstance(self.global_attention_heads, int) | |
| or self.global_attention_heads <= 0 | |
| or embed_dim % self.global_attention_heads != 0): | |
| raise ValueError( | |
| 'global_attention_heads must be a positive divisor of embed_dim' | |
| ) | |
| if (isinstance(self.global_head_hidden_dim, bool) | |
| or not isinstance(self.global_head_hidden_dim, int) | |
| or self.global_head_hidden_dim <= 0): | |
| raise ValueError('global_head_hidden_dim must be a positive integer') | |
| if (isinstance(self.global_attention_dropout, bool) | |
| or not isinstance(self.global_attention_dropout, (int, float)) | |
| or not 0 <= self.global_attention_dropout < 1): | |
| raise ValueError('global_attention_dropout must be in [0, 1)') | |
| # A summary query sees every original AIA patch. The per-patch | |
| # correction also receives that patch's raw AIA token and its | |
| # locally encoded feature, so remote activity cannot directly | |
| # replace the local evidence at a quiet patch. | |
| self.global_raw_patch_projection = nn.Linear( | |
| self.input_layer.in_features, embed_dim | |
| ) | |
| self.global_query = nn.Parameter(torch.zeros(1, 1, embed_dim)) | |
| self.global_attention = nn.MultiheadAttention( | |
| embed_dim, | |
| self.global_attention_heads, | |
| dropout=self.global_attention_dropout, | |
| batch_first=True, | |
| ) | |
| self.global_patch_head = nn.Sequential( | |
| nn.LayerNorm(3 * embed_dim), | |
| nn.Linear(3 * embed_dim, self.global_head_hidden_dim), | |
| nn.GELU(), | |
| nn.Linear(self.global_head_hidden_dim, 1), | |
| ) | |
| nn.init.zeros_(self.global_patch_head[-1].weight) | |
| nn.init.zeros_(self.global_patch_head[-1].bias) | |
| self.patch_uncertainty_head = None | |
| self.initial_log10_relative_std = None | |
| if self.uncertainty_enabled: | |
| self.initial_log10_relative_std = float( | |
| torch.log10(torch.tensor(self.initial_relative_std)).item() | |
| ) | |
| if not ( | |
| self.min_log10_relative_std | |
| < self.initial_log10_relative_std | |
| < self.max_log10_relative_std | |
| ): | |
| raise ValueError( | |
| "initial log10 relative standard deviation falls outside " | |
| "the configured numerical bounds" | |
| ) | |
| self.patch_uncertainty_head = nn.Sequential( | |
| nn.Linear(1, self.uncertainty_hidden_dim), | |
| nn.SiLU(), | |
| nn.Linear(self.uncertainty_hidden_dim, 1), | |
| ) | |
| nn.init.zeros_(self.patch_uncertainty_head[-1].weight) | |
| nn.init.constant_( | |
| self.patch_uncertainty_head[-1].bias, | |
| self.initial_log10_relative_std, | |
| ) | |
| def _patch_flux_from_logits(self, patch_logits, sxr_norm): | |
| """Convert patch outputs to nonnegative additive raw flux.""" | |
| typical_global_flux = torch.clamp( | |
| 10 ** sxr_norm[0] - SXR_LOG_OFFSET, | |
| min=torch.finfo(torch.float32).tiny, | |
| ) | |
| # Preserve the original normalized-log head scale and default | |
| # initialization, but assign the whole-image training scale across | |
| # patches before applying each patch's multiplicative adjustment. | |
| log10_adjustment = torch.clamp( | |
| patch_logits.float(), | |
| min=-self.max_abs_log10_patch_multiplier, | |
| max=self.max_abs_log10_patch_multiplier, | |
| ) | |
| patch_multiplier = torch.exp( | |
| log10_adjustment * torch.log(torch.tensor( | |
| 10.0, | |
| device=patch_logits.device, | |
| dtype=torch.float32, | |
| )) | |
| ) | |
| return ( | |
| typical_global_flux.float() | |
| / patch_logits.shape[1] | |
| * patch_multiplier | |
| ) | |
| def _encode_patch_embeddings(self, x, return_attention): | |
| """Encode image patches while preserving the public attention output.""" | |
| x = img_to_patch(x, self.patch_size) | |
| x = self.input_layer(x) | |
| x = self._add_2d_positional_encoding(x) | |
| x = self.dropout(x).transpose(0, 1) | |
| attention_weights = [] | |
| for block in self.transformer_blocks: | |
| if return_attention: | |
| x, weights = block(x, return_attention=True) | |
| attention_weights.append(weights) | |
| else: | |
| x = block(x) | |
| return x.transpose(0, 1), attention_weights | |
| def _patch_mean_from_embeddings(self, patch_embeddings, sxr_norm, images): | |
| """Return logits and nonnegative additive patch means.""" | |
| patch_logits = self.mlp_head(patch_embeddings).squeeze(-1) | |
| patch_flux_raw = self._patch_flux_from_logits(patch_logits, sxr_norm) | |
| raw_tokens = self.global_raw_patch_projection( | |
| img_to_patch(images, self.patch_size) | |
| ) | |
| raw_tokens = raw_tokens + self.pos_embedding_2d.reshape( | |
| 1, -1, raw_tokens.shape[-1] | |
| ) | |
| query = self.global_query.expand(patch_embeddings.shape[0], -1, -1) | |
| context, _ = self.global_attention( | |
| query, raw_tokens, raw_tokens, need_weights=False | |
| ) | |
| context_per_patch = context.expand(-1, patch_embeddings.shape[1], -1) | |
| correction_input = torch.cat( | |
| (patch_embeddings, raw_tokens, context_per_patch), dim=-1 | |
| ) | |
| log10_adjustment = ( | |
| self.max_abs_log10_global_adjustment | |
| * torch.tanh(self.global_patch_head(correction_input).squeeze(-1)) | |
| ) | |
| self.last_log10_global_patch_adjustment = log10_adjustment.detach() | |
| patch_flux_raw = patch_flux_raw * torch.pow( | |
| torch.tensor(10.0, device=patch_flux_raw.device), | |
| log10_adjustment.float(), | |
| ) | |
| return patch_logits, patch_flux_raw | |
| def _patch_variance_from_logits(self, patch_logits, patch_flux_raw): | |
| """Predict raw variance contributions from detached patch logits.""" | |
| if not self.uncertainty_enabled: | |
| raise RuntimeError("uncertainty is disabled for this model") | |
| detached_patch_logits = patch_logits.detach().float() | |
| detached_patch_flux = patch_flux_raw.detach().float() | |
| patch_log10_relative_std = self.patch_uncertainty_head( | |
| detached_patch_logits.unsqueeze(-1) | |
| ).squeeze(-1).float() | |
| patch_log10_relative_std = torch.clamp( | |
| patch_log10_relative_std, | |
| min=self.min_log10_relative_std, | |
| max=self.max_log10_relative_std, | |
| ) | |
| relative_std = torch.pow( | |
| torch.tensor( | |
| 10.0, | |
| device=patch_log10_relative_std.device, | |
| dtype=torch.float32, | |
| ), | |
| patch_log10_relative_std, | |
| ) | |
| patch_std_raw = relative_std * detached_patch_flux | |
| return patch_std_raw.square() | |
| def forward(self, x, sxr_norm, return_attention=False): | |
| """Return corrected predictions and additive patch contributions.""" | |
| patch_embeddings, attention_weights = self._encode_patch_embeddings( | |
| x, return_attention | |
| ) | |
| _, sxr_std = sxr_norm | |
| patch_logits, patch_flux_raw = self._patch_mean_from_embeddings( | |
| patch_embeddings, sxr_norm, images=x, | |
| ) | |
| global_flux_raw = patch_flux_raw.sum(dim=1, keepdim=True) | |
| mean_normalized = normalize_sxr(global_flux_raw.squeeze(-1), sxr_norm) | |
| if not self.uncertainty_enabled: | |
| if return_attention: | |
| outputs = ( | |
| global_flux_raw, mean_normalized, | |
| attention_weights, patch_flux_raw, | |
| ) | |
| else: | |
| outputs = global_flux_raw, mean_normalized, patch_flux_raw | |
| return outputs | |
| patch_variance_raw = self._patch_variance_from_logits( | |
| patch_logits, patch_flux_raw | |
| ) | |
| global_variance_raw = patch_variance_raw.sum(dim=1) | |
| # Delta-method conversion for | |
| # y=(log10(raw_flux + offset)-mean)/std. | |
| # The delta-method scale depends numerically on the predicted global | |
| # flux, but its variance gradient must not flow into the mean branch. | |
| # Without this detach, NLL can move an otherwise-correct mean merely to | |
| # shrink or enlarge its normalized variance. | |
| global_flux_for_variance = global_flux_raw.squeeze(-1).float().detach() | |
| log_derivative = 1.0 / ( | |
| torch.log(torch.tensor( | |
| 10.0, device=global_flux_raw.device, dtype=torch.float32 | |
| )) | |
| * sxr_std.float() | |
| * (global_flux_for_variance + SXR_LOG_OFFSET) | |
| ) | |
| variance_normalized = ( | |
| global_variance_raw * log_derivative.square() | |
| ) | |
| if return_attention: | |
| outputs = ( | |
| global_flux_raw, mean_normalized, variance_normalized, | |
| attention_weights, patch_flux_raw, patch_variance_raw, | |
| ) | |
| else: | |
| outputs = ( | |
| global_flux_raw, mean_normalized, variance_normalized, | |
| patch_flux_raw, patch_variance_raw, | |
| ) | |
| return outputs | |
| class CoupledGaussianViTLocal(pl.LightningModule): | |
| """Train the canonical FOXES patch model with Gaussian uncertainty. | |
| The mean/backbone is optimized only by an optionally class-weighted Huber | |
| loss. A separate Gaussian NLL trains relative patch standard deviations | |
| from detached patch logits, so it cannot alter the spatial mean | |
| prediction. | |
| The final corrected-flux Huber loss updates both local and global layers. | |
| The predicted variance is in normalized log10-SXR units. Use | |
| :meth:`predict_interval` to obtain intervals in physical W/m^2 units. | |
| """ | |
| DEFAULT_SCHEDULER_KWARGS = {'T_max': 250, 'eta_min': 1e-7} | |
| predicts_uncertainty = True | |
| uncertainty_kind = 'gaussian' | |
| uncertainty_parameterization = 'logit_conditioned_relative_log10_patch_std' | |
| NETWORK_UNCERTAINTY_KEYS = ( | |
| 'max_abs_log10_patch_multiplier', | |
| 'initial_relative_std', | |
| 'min_log10_relative_std', | |
| 'max_log10_relative_std', | |
| 'uncertainty_hidden_dim', | |
| ) | |
| def __init__(self, model_kwargs, sxr_norm, | |
| weight_decay=1e-5, scheduler_kwargs=None, | |
| uncertainty_kwargs=None): | |
| super().__init__() | |
| self.lr = model_kwargs.get('learning_rate', model_kwargs.get('lr', 1e-4)) | |
| self.save_hyperparameters() | |
| filtered_kwargs = dict(model_kwargs) | |
| filtered_kwargs.pop('learning_rate', None) | |
| filtered_kwargs.pop('lr', None) | |
| filtered_kwargs.pop('num_classes', None) | |
| # This fixed architecture flag is present in the distributed checkpoint. | |
| if filtered_kwargs.pop('global_patch_adjustment', True) is not True: | |
| raise ValueError('The coupled model always applies global patch adjustment') | |
| uncertainty_kwargs = dict(uncertainty_kwargs or {}) | |
| self.uncertainty_enabled = bool( | |
| uncertainty_kwargs.pop('enabled', True) | |
| ) | |
| configured_start_epoch = uncertainty_kwargs.pop('start_epoch', None) | |
| if configured_start_epoch is None: | |
| self.uncertainty_start_epoch = 0 | |
| elif ( | |
| isinstance(configured_start_epoch, bool) | |
| or not isinstance(configured_start_epoch, int) | |
| or configured_start_epoch < 0 | |
| ): | |
| raise ValueError( | |
| "uncertainty.start_epoch must be a nonnegative integer or null" | |
| ) | |
| else: | |
| self.uncertainty_start_epoch = configured_start_epoch | |
| self.predicts_uncertainty = self.uncertainty_enabled | |
| allowed_uncertainty_keys = set(self.NETWORK_UNCERTAINTY_KEYS) | { | |
| 'huber_delta', | |
| 'class_weighting', | |
| 'class_weights', | |
| 'bias_penalty_weight', | |
| } | |
| unknown_keys = set(uncertainty_kwargs) - allowed_uncertainty_keys | |
| if unknown_keys: | |
| raise ValueError( | |
| f"Unknown uncertainty settings: {sorted(unknown_keys)}" | |
| ) | |
| network_uncertainty_kwargs = { | |
| key: uncertainty_kwargs[key] | |
| for key in self.NETWORK_UNCERTAINTY_KEYS | |
| if key in uncertainty_kwargs | |
| } | |
| self.bias_penalty_weight = validate_bias_penalty_weight( | |
| uncertainty_kwargs.get('bias_penalty_weight', 0.0) | |
| ) | |
| self.model = CoupledVisionTransformerLocal( | |
| **filtered_kwargs, | |
| **network_uncertainty_kwargs, | |
| uncertainty_enabled=self.uncertainty_enabled, | |
| ) | |
| self.hparams['uncertainty_parameterization'] = ( | |
| self.uncertainty_parameterization | |
| ) | |
| self.hparams['initial_log10_relative_std'] = ( | |
| self.model.initial_log10_relative_std | |
| ) | |
| self.register_buffer( | |
| 'sxr_norm', torch.as_tensor(sxr_norm, dtype=torch.float32) | |
| ) | |
| self.weight_decay = weight_decay | |
| self.scheduler_kwargs = { | |
| **self.DEFAULT_SCHEDULER_KWARGS, **(scheduler_kwargs or {}) | |
| } | |
| self.huber_delta = float(uncertainty_kwargs.get('huber_delta', 1.0)) | |
| if self.huber_delta <= 0: | |
| raise ValueError("uncertainty.huber_delta must be positive") | |
| self.class_weighting = str(uncertainty_kwargs.get('class_weighting', 'none')) | |
| if self.class_weighting not in { | |
| 'none', 'inverse_frequency', 'sqrt_inverse_frequency', | |
| 'power_inverse_frequency', | |
| }: | |
| raise ValueError( | |
| "uncertainty.class_weighting must be 'none', " | |
| "'inverse_frequency', 'sqrt_inverse_frequency', " | |
| "or 'power_inverse_frequency'" | |
| ) | |
| class_names = ('quiet', 'c_class', 'm_class', 'x_class') | |
| configured_weights = uncertainty_kwargs.get('class_weights') | |
| if self.class_weighting != 'none': | |
| if not isinstance(configured_weights, dict): | |
| raise ValueError( | |
| "class-weighted mean loss requires training-derived " | |
| "uncertainty.class_weights" | |
| ) | |
| if set(configured_weights) != set(class_names): | |
| raise ValueError( | |
| "uncertainty.class_weights must contain exactly " | |
| f"{list(class_names)}" | |
| ) | |
| weights = torch.tensor( | |
| [configured_weights[name] for name in class_names], | |
| dtype=torch.float32, | |
| ) | |
| if not torch.isfinite(weights).all() or (weights <= 0).any(): | |
| raise ValueError( | |
| "uncertainty.class_weights must be finite and positive" | |
| ) | |
| else: | |
| weights = torch.ones(len(class_names), dtype=torch.float32) | |
| # These weights are derived deterministically from the training split. | |
| self.register_buffer( | |
| 'class_weight_values', weights, persistent=False, | |
| ) | |
| def on_load_checkpoint(self, checkpoint): | |
| checkpoint_hparams = checkpoint.get('hyper_parameters', {}) | |
| checkpoint_uncertainty_parameterization = checkpoint_hparams.get( | |
| 'uncertainty_parameterization' | |
| ) | |
| state_dict = checkpoint.get('state_dict', {}) | |
| has_uncertainty_head = any( | |
| 'patch_uncertainty_head' in key for key in state_dict | |
| ) | |
| if ( | |
| has_uncertainty_head | |
| and checkpoint_uncertainty_parameterization | |
| != self.uncertainty_parameterization | |
| ): | |
| raise RuntimeError( | |
| "Checkpoint uncertainty parameterization is missing or " | |
| "incompatible with the logit-conditioned relative-standard-" | |
| "deviation model." | |
| ) | |
| def forward(self, x, return_attention=False): | |
| """Return Gaussian outputs, or deterministic outputs when disabled.""" | |
| outputs = self.model(x, self.sxr_norm, return_attention=return_attention) | |
| if not self.uncertainty_enabled: | |
| if return_attention: | |
| raw, _, attention, patches = outputs | |
| return raw, attention, patches | |
| raw, _, patches = outputs | |
| return raw, patches | |
| if return_attention: | |
| raw, _, variance, attention, patches, patch_variance = outputs | |
| return raw, variance, attention, patches, patch_variance | |
| raw, _, variance, patches, patch_variance = outputs | |
| return raw, variance, patches, patch_variance | |
| def predict_interval(self, x, z=1.96): | |
| """Return point, lower, and upper physical-flux estimates.""" | |
| if not self.uncertainty_enabled: | |
| raise RuntimeError("predict_interval requires uncertainty.enabled=true") | |
| raw, variance_norm, _, _ = self.forward(x, return_attention=False) | |
| mean_norm = normalize_sxr(raw.squeeze(-1), self.sxr_norm) | |
| sigma = torch.sqrt(variance_norm) | |
| lower = unnormalize_sxr(mean_norm - z * sigma, self.sxr_norm) | |
| upper = unnormalize_sxr(mean_norm + z * sigma, self.sxr_norm) | |
| return raw.squeeze(-1), torch.clamp(lower, min=0), torch.clamp(upper, min=0) | |
| def _weighted_huber_loss(self, mean, target, target_raw): | |
| """Return Huber loss, optionally balanced by the four GOES classes.""" | |
| per_sample = F.huber_loss( | |
| mean, target, delta=self.huber_delta, reduction='none', | |
| ) | |
| if self.class_weighting != 'none': | |
| thresholds = SXRRegressionDynamicLoss.CLASS_THRESHOLDS | |
| class_indices = torch.zeros_like(target_raw, dtype=torch.long) | |
| class_indices = torch.where( | |
| target_raw >= thresholds['c'], 1, class_indices, | |
| ) | |
| class_indices = torch.where( | |
| target_raw >= thresholds['m'], 2, class_indices, | |
| ) | |
| class_indices = torch.where( | |
| target_raw >= thresholds['x'], 3, class_indices, | |
| ) | |
| sample_weights = self.class_weight_values[class_indices] | |
| per_sample = per_sample * sample_weights.reshape_as(per_sample) | |
| return per_sample.mean() | |
| def _uncertainty_nll_loss(mean, target, variance): | |
| """Train variance without allowing NLL gradients into the mean.""" | |
| return F.gaussian_nll_loss( | |
| mean.detach(), target, variance, full=True, reduction='mean', | |
| ) | |
| def _uncertainty_active_for_epoch(self, epoch): | |
| return ( | |
| self.uncertainty_enabled | |
| and epoch >= self.uncertainty_start_epoch | |
| ) | |
| def _uncertainty_training_is_active(self): | |
| trainer = getattr(self, '_trainer', None) | |
| epoch = trainer.current_epoch if trainer is not None else 0 | |
| return self._uncertainty_active_for_epoch(epoch) | |
| def _calculate_loss(self, batch, mode): | |
| images, target_norm = batch | |
| outputs = self.model( | |
| images, self.sxr_norm, return_attention=False, | |
| ) | |
| if self.uncertainty_enabled: | |
| ( | |
| raw_prediction, mean_norm, variance_norm, | |
| _, patch_variance_raw, | |
| ) = outputs | |
| else: | |
| raw_prediction, mean_norm, _ = outputs | |
| variance_norm = patch_variance_raw = None | |
| target_norm = target_norm.reshape_as(mean_norm) | |
| target_raw = unnormalize_sxr(target_norm, self.sxr_norm) | |
| mean_loss = self._weighted_huber_loss( | |
| mean_norm, target_norm, target_raw, | |
| ) | |
| signed_error = (mean_norm - target_norm).mean() | |
| bias_penalty = mean_class_signed_error_penalty( | |
| mean_norm, target_norm, target_raw, self.bias_penalty_weight, | |
| ) | |
| mean_objective = mean_loss + bias_penalty | |
| uncertainty_active = self._uncertainty_training_is_active() | |
| uncertainty_loss = ( | |
| self._uncertainty_nll_loss( | |
| mean_norm, target_norm, variance_norm, | |
| ) | |
| if self.uncertainty_enabled else None | |
| ) | |
| loss = ( | |
| mean_objective + uncertainty_loss | |
| if uncertainty_active else mean_objective | |
| ) | |
| mse = F.mse_loss(mean_norm, target_norm) | |
| mae = F.l1_loss(mean_norm, target_norm) | |
| prefix = mode | |
| on_step = mode == 'train' | |
| if self.uncertainty_enabled: | |
| sigma = torch.sqrt(variance_norm) | |
| sigma_dex = sigma * self.sxr_norm[1] | |
| standardized = torch.abs(target_norm - mean_norm) / sigma | |
| coverage_68 = (standardized <= 1.0).float().mean() | |
| coverage_95 = (standardized <= 1.96).float().mean() | |
| self.log(f'{prefix}/nll', uncertainty_loss, on_step=on_step, | |
| on_epoch=True, | |
| prog_bar=True, logger=True, sync_dist=True) | |
| self.log( | |
| f'{prefix}/uncertainty_nll', uncertainty_loss, | |
| on_step=on_step, on_epoch=True, | |
| logger=True, sync_dist=True, | |
| ) | |
| self.log( | |
| f'{prefix}/uncertainty_active', | |
| float(uncertainty_active), on_step=on_step, on_epoch=True, | |
| logger=True, sync_dist=True, | |
| ) | |
| self.log(f'{prefix}/mean_sigma', sigma.mean(), on_step=on_step, | |
| on_epoch=True, logger=True, sync_dist=True) | |
| self.log( | |
| f'{prefix}/mean_sigma_dex', sigma_dex.mean(), | |
| on_step=on_step, on_epoch=True, logger=True, sync_dist=True, | |
| ) | |
| self.log( | |
| f'{prefix}/mean_sqrt_variance_contribution_raw', | |
| torch.sqrt(patch_variance_raw).mean(), | |
| on_step=on_step, on_epoch=True, logger=True, sync_dist=True, | |
| ) | |
| self.log(f'{prefix}/coverage_68', coverage_68, on_step=False, | |
| on_epoch=True, logger=True, sync_dist=True) | |
| self.log(f'{prefix}/coverage_95', coverage_95, on_step=False, | |
| on_epoch=True, logger=True, sync_dist=True) | |
| self.log( | |
| f'{prefix}/mean_loss', mean_loss, on_step=on_step, on_epoch=True, | |
| logger=True, sync_dist=True, | |
| ) | |
| self.log(f'{prefix}/bias_dex', signed_error * self.sxr_norm[1], | |
| on_step=on_step, on_epoch=True, logger=True, sync_dist=True) | |
| self.log(f'{prefix}/bias_penalty', bias_penalty, on_step=on_step, | |
| on_epoch=True, logger=True, sync_dist=True) | |
| self.log(f'{prefix}/total_loss', loss, on_step=on_step, on_epoch=True, | |
| logger=True, sync_dist=True) | |
| self.log(f'{prefix}/mse', mse, on_step=on_step, on_epoch=True, | |
| logger=True, sync_dist=True) | |
| self.log(f'{prefix}/mae', mae, on_step=on_step, on_epoch=True, | |
| logger=True, sync_dist=True) | |
| self.log( | |
| f'{prefix}/global_patch_adjustment_dex', | |
| self.model.last_log10_global_patch_adjustment.abs().mean(), | |
| on_step=on_step, on_epoch=True, logger=True, sync_dist=True, | |
| ) | |
| if mode == 'val': | |
| self.log('val_total_loss', loss, on_step=False, on_epoch=True, | |
| prog_bar=True, logger=True, sync_dist=True) | |
| if mode == 'train': | |
| learning_rate = self.trainer.optimizers[0].param_groups[0]['lr'] | |
| self.log('train/learning_rate', learning_rate, on_step=True, | |
| on_epoch=False, prog_bar=True, logger=True, sync_dist=True) | |
| if mode == 'val': | |
| result = { | |
| 'loss': loss.detach(), | |
| 'mean_norm': mean_norm.detach(), | |
| 'target_norm': target_norm.detach(), | |
| 'target_raw': target_raw.detach(), | |
| 'prediction_raw': raw_prediction.squeeze(-1).detach(), | |
| } | |
| if self.uncertainty_enabled: | |
| result.update({ | |
| 'nll': uncertainty_loss.detach(), | |
| 'variance_norm': variance_norm.detach(), | |
| }) | |
| return result | |
| return loss | |
| def training_step(self, batch, batch_idx): | |
| return self._calculate_loss(batch, 'train') | |
| def validation_step(self, batch, batch_idx): | |
| return self._calculate_loss(batch, 'val') | |
| def test_step(self, batch, batch_idx): | |
| self._calculate_loss(batch, 'test') | |
| def configure_optimizers(self): | |
| trainable = [p for p in self.parameters() if p.requires_grad] | |
| if not trainable: | |
| raise ValueError('No trainable parameters remain') | |
| optimizer = torch.optim.AdamW( | |
| trainable, lr=self.lr, weight_decay=self.weight_decay | |
| ) | |
| scheduler = CosineAnnealingLR(optimizer, **self.scheduler_kwargs) | |
| return { | |
| 'optimizer': optimizer, | |
| 'lr_scheduler': { | |
| 'scheduler': scheduler, 'interval': 'epoch', 'frequency': 1, | |
| 'name': 'learning_rate', | |
| }, | |
| } | |