FALCON / safety.py
JonathanJMK's picture
Upload complete FALCON model and project documentation
c645013 verified
Raw History Blame Contribute Delete
10.3 kB
"""Structured Safety Adapter (SSA) with binary presence supervision."""
from __future__ import annotations
import math
from dataclasses import dataclass
import torch
from torch import Tensor, nn
from torch.nn import functional as F
from .capabilities import ALL_SAFETY_HEADS, SafetyCapabilities
# These indices are the canonical Falcon-X annotation order. The paper's
# "main charge" is represented by the dataset label ``explosive``.
COMPONENT_TYPES = ("detonator", "explosive", "battery")
LINK_PAIRS = ((2, 0), (2, 1), (0, 1))
SAFETY_TOKEN_TYPES = (
"scene_risk",
"presence_detonator",
"presence_explosive",
"presence_battery",
"link_battery_detonator",
"link_battery_explosive",
"link_detonator_explosive",
)
@dataclass
class SafetyOutput:
slots: Tensor
attention: Tensor
presence_logits: Tensor
presence_probabilities: Tensor
link_logits: Tensor
link_probabilities: Tensor
risk_logit: Tensor
risk_probability: Tensor
tokens: Tensor
capabilities: SafetyCapabilities = SafetyCapabilities()
def prediction(self, batch_index: int = 0) -> dict:
"""Serialize unavailable heads as null, never plausible-looking numbers."""
return {
"risk": float(self.risk_probability[batch_index].detach().cpu())
if self.capabilities.risk
else None,
"presence": [
float(value.detach().cpu()) if enabled else None
for value, enabled in zip(
self.presence_probabilities[batch_index],
self.capabilities.presence,
strict=True,
)
],
"links": [
float(value.detach().cpu()) if enabled else None
for value, enabled in zip(
self.link_probabilities[batch_index], self.capabilities.links, strict=True
)
],
"capabilities": self.capabilities.as_dict(),
}
@property
def scalar_values(self) -> Tensor:
"""Values in the seven-token paper order: risk, presence, links."""
return torch.cat(
(
self.risk_probability.unsqueeze(-1),
self.presence_probabilities,
self.link_probabilities,
),
dim=-1,
)
class StructuredSafetyAdapter(nn.Module):
"""Map a variable proposal set to three typed component slots and seven tokens."""
def __init__(
self,
model_dim: int,
token_dim: int,
*,
head_hidden_dim: int | None = None,
capabilities: SafetyCapabilities = ALL_SAFETY_HEADS,
) -> None:
super().__init__()
hidden_dim = head_hidden_dim or model_dim
self.model_dim = model_dim
self.token_dim = token_dim
self.capabilities = capabilities
self.component_queries = nn.Parameter(torch.empty(3, model_dim))
nn.init.normal_(self.component_queries, std=0.02)
self.presence_head = nn.Linear(model_dim, 1)
self.link_heads = nn.ModuleList(
[
nn.Sequential(
nn.Linear(2 * model_dim, hidden_dim),
nn.GELU(),
nn.Linear(hidden_dim, 1),
)
for _ in LINK_PAIRS
]
)
self.risk_head = nn.Sequential(
nn.Linear(3 * model_dim + len(LINK_PAIRS), hidden_dim),
nn.GELU(),
nn.Linear(hidden_dim, 1),
)
self.value_projection = nn.Linear(1, token_dim)
self.type_embedding = nn.Embedding(len(SAFETY_TOKEN_TYPES), token_dim)
self.freeze_unavailable_heads()
def freeze_unavailable_heads(self) -> None:
"""Reapply after a training-stage change has enabled the shared adapter."""
modules = [
(self.presence_head, any(self.capabilities.presence)),
(self.risk_head, self.capabilities.risk),
]
modules.extend(zip(self.link_heads, self.capabilities.links, strict=True))
for module, enabled in modules:
if not enabled:
for parameter in module.parameters():
parameter.requires_grad = False
def _component_slots(
self,
region_embeddings: Tensor,
valid: Tensor,
) -> tuple[Tensor, Tensor]:
batch, proposals, channels = region_embeddings.shape
if channels != self.model_dim:
raise ValueError(f"expected region dimension {self.model_dim}, received {channels}")
if valid.shape != (batch, proposals):
raise ValueError("valid must have shape [batch, proposals]")
if proposals == 0:
slots = region_embeddings.new_zeros((batch, 3, channels))
attention = region_embeddings.new_zeros((batch, 3, 0))
return slots, attention
scores = torch.einsum("cd,bnd->bcn", self.component_queries, region_embeddings)
scores = scores / math.sqrt(float(channels))
expanded_valid = valid[:, None, :].to(dtype=torch.bool)
scores = scores.masked_fill(~expanded_valid, torch.finfo(scores.dtype).min)
attention = scores.softmax(dim=-1) * expanded_valid.to(dtype=scores.dtype)
attention = attention / attention.sum(dim=-1, keepdim=True).clamp_min(1e-12)
slots = torch.einsum("bcn,bnd->bcd", attention, region_embeddings)
return slots, attention
def forward(
self,
region_embeddings: Tensor,
valid: Tensor | None = None,
) -> SafetyOutput:
if region_embeddings.ndim != 3:
raise ValueError("region_embeddings must have shape [batch, proposals, channels]")
if valid is None:
valid = torch.ones(
region_embeddings.shape[:2],
dtype=torch.bool,
device=region_embeddings.device,
)
else:
valid = valid.to(device=region_embeddings.device, dtype=torch.bool)
slots, attention = self._component_slots(region_embeddings, valid)
presence_logits = (
self.presence_head(slots).squeeze(-1)
if any(self.capabilities.presence)
else slots.new_zeros(slots.shape[:2])
)
link_logits = torch.stack(
[
head(torch.cat((slots[:, left], slots[:, right]), dim=-1)).squeeze(-1)
if enabled
else slots.new_zeros(slots.shape[0])
for head, (left, right), enabled in zip(
self.link_heads, LINK_PAIRS, self.capabilities.links, strict=True
)
],
dim=-1,
)
link_probabilities = link_logits.sigmoid() * slots.new_tensor(self.capabilities.links)
# Risk is explicitly conditioned on every slot and the three inferred links.
risk_input = torch.cat((slots.flatten(start_dim=1), link_probabilities), dim=-1)
risk_logit = (
self.risk_head(risk_input).squeeze(-1)
if self.capabilities.risk
else slots.new_zeros(slots.shape[0])
)
risk_probability = risk_logit.sigmoid() * int(self.capabilities.risk)
presence_probabilities = presence_logits.sigmoid() * slots.new_tensor(
self.capabilities.presence
)
scalar_values = torch.cat(
(
risk_probability.unsqueeze(-1),
presence_probabilities,
link_probabilities,
),
dim=-1,
)
type_ids = torch.tensor(
self.capabilities.token_indices, device=slots.device, dtype=torch.long
)
tokens = self.value_projection(scalar_values[:, type_ids].unsqueeze(-1))
tokens = tokens + self.type_embedding(type_ids).unsqueeze(0)
return SafetyOutput(
slots=slots,
attention=attention,
presence_logits=presence_logits,
presence_probabilities=presence_probabilities,
link_logits=link_logits,
link_probabilities=link_probabilities,
risk_logit=risk_logit,
risk_probability=risk_probability,
tokens=tokens,
capabilities=self.capabilities,
)
@staticmethod
def loss(
output: SafetyOutput,
*,
risk: Tensor,
presence: Tensor,
links: Tensor,
) -> dict[str, Tensor]:
"""Stage-2/3 objective: presence BCE with logits and risk/link mean L1."""
risk_target = risk.to(output.risk_probability).reshape_as(output.risk_probability)
presence_logits = output.presence_logits.float()
presence_target = presence.to(
device=presence_logits.device, dtype=torch.float32
).reshape_as(presence_logits)
link_target = links.to(output.link_probabilities).reshape_as(output.link_probabilities)
if not output.capabilities.risk:
risk_target = torch.full_like(risk_target, float("nan"))
presence_target = presence_target.masked_fill(
~torch.tensor(output.capabilities.presence, device=presence_target.device), float("nan")
)
link_target = link_target.masked_fill(
~torch.tensor(output.capabilities.links, device=link_target.device), float("nan")
)
def finite_l1(prediction: Tensor, target: Tensor) -> Tensor:
finite = torch.isfinite(target)
if finite.any():
return F.l1_loss(prediction[finite], target[finite])
# Keep a differentiable zero when a sample has no annotated links.
return prediction.sum() * 0.0
risk_loss = finite_l1(output.risk_probability, risk_target)
observed_presence = torch.isfinite(presence_target)
if observed_presence.any():
presence_loss = F.binary_cross_entropy_with_logits(
presence_logits[observed_presence], presence_target[observed_presence]
)
else:
presence_loss = presence_logits.sum() * 0.0
link_loss = finite_l1(output.link_probabilities, link_target)
return {
"risk": risk_loss,
"presence": presence_loss,
"links": link_loss,
}