fix(loss): prevent objective-starvation with fixed task weights; target pimt_v3.pt
9ddcf86 unverified | import torch | |
| import torch.nn as nn | |
| class AdaptiveLossBalancer(nn.Module): | |
| """ | |
| Multi-task loss weighting with optional adaptive floor. | |
| Two modes: | |
| * ``fixed_weights``: directly supplied task weights (e.g., priority weighting | |
| for the time-resolved objective head). This is the default in PINO because | |
| the objective head is the only task that consumes the physics trajectory; | |
| letting it be starved removes the model's ability to learn time dynamics. | |
| * ``adaptive`` mode: weights are derived from per-task progress ratios | |
| (current / initial loss) with a per-task floor so that fast-converging | |
| tasks are not dropped to zero. | |
| The computed weights are stored in ``self.last_weights`` as a detached CPU list | |
| so training loops can log them without modifying the loss flow. | |
| """ | |
| def __init__( | |
| self, | |
| num_tasks: int = 4, | |
| fixed_weights: torch.Tensor | None = None, | |
| momentum: float = 0.9, | |
| min_weight: float = 0.20, | |
| ) -> None: | |
| super().__init__() | |
| self.momentum = momentum | |
| self.min_weight = min_weight | |
| self.register_buffer("running_losses", torch.ones(num_tasks)) | |
| self.register_buffer("initial_losses", torch.zeros(num_tasks)) | |
| self.register_buffer("_initialized", torch.tensor(0, dtype=torch.bool)) | |
| if fixed_weights is not None: | |
| fixed = torch.as_tensor(fixed_weights, dtype=torch.float32) | |
| if fixed.dim() == 1 and fixed.numel() == num_tasks: | |
| self.register_buffer("fixed_weights", fixed) | |
| else: | |
| raise ValueError( | |
| f"fixed_weights must be a {num_tasks}-element vector, got shape {fixed.shape}" | |
| ) | |
| else: | |
| self.register_buffer("fixed_weights", torch.zeros(num_tasks)) | |
| def forward(self, loss_dict: dict[str, torch.Tensor]) -> torch.Tensor: | |
| """Combine task losses with either fixed or adaptive weights.""" | |
| losses = torch.stack(list(loss_dict.values())) | |
| num_tasks = float(len(losses)) | |
| if self.fixed_weights.abs().sum().item() > 0: | |
| weights = self.fixed_weights.to(losses.device) | |
| self.last_weights = weights.detach().cpu().tolist() | |
| return torch.sum(losses * weights) | |
| # Adaptive fallback. | |
| if not self._initialized.item() or torch.all(self.initial_losses == 0): | |
| self.initial_losses.copy_(losses.detach().clamp(min=1e-5)) | |
| self.running_losses.copy_(losses.detach().clamp(min=1e-5)) | |
| self._initialized.fill_(True) | |
| self.running_losses.copy_( | |
| self.momentum * self.running_losses + (1 - self.momentum) * losses.detach() | |
| ) | |
| ratios = self.running_losses / self.initial_losses | |
| weights = ratios / (torch.sum(ratios) + 1e-8) | |
| # Apply a floor so that fast-converging tasks are not starved. | |
| weights = torch.clamp(weights, min=self.min_weight) # type: ignore[attr-defined] | |
| weights = weights / (torch.sum(weights) + 1e-8) # type: ignore[attr-defined] | |
| self.last_weights = weights.detach().cpu().tolist() | |
| return torch.sum(losses * weights * num_tasks) | |