Sage-SciPerception / modeling_projector.py
yuhangzang's picture
feat(projectors): release nine GLM scientific projectors
fd0cd31 verified
Raw History Blame Contribute Delete
16.7 kB
"""Shared MLP with learned slot identities; state keys match the exported weights."""
import math
import torch
from torch import nn
class OutputNorm(nn.Module):
"""Parameter-free RMSNorm then one learned scale, kept in log space so decay pulls toward 1."""
def __init__(self, scale, eps=1e-12):
super().__init__()
self.eps = eps
self.log_scale = nn.Parameter(torch.tensor(math.log(scale)))
def forward(self, values):
# Initial outputs have RMS ~1e-2 (last-layer std 0.001), so a usual 1e-6 eps would shrink them.
values = values * torch.rsqrt(values.pow(2).mean(-1, keepdim=True) + self.eps)
# RMS norm of a d-dim vector is sqrt(d), so divide to make `scale` the target L2 norm.
return values * (self.log_scale.exp() / math.sqrt(values.shape[-1]))
class ResidualBlock(nn.Module):
"""Pre-LN MLP block; the zero-initialized exit makes extra depth start as the identity."""
def __init__(self, hidden_dim, mult):
super().__init__()
self.norm = nn.LayerNorm(hidden_dim)
self.up = nn.Linear(hidden_dim, hidden_dim * mult)
self.down = nn.Linear(hidden_dim * mult, hidden_dim)
nn.init.zeros_(self.down.weight)
nn.init.zeros_(self.down.bias)
def forward(self, values):
return values + self.down(nn.functional.gelu(self.up(self.norm(values))))
class ContextResidual(nn.Module):
"""A zero-exit global readout within each scientific object, before translation to GLM."""
def __init__(self, hidden_dim, model_dim, layers, heads):
super().__init__()
self.input = nn.Sequential(nn.LayerNorm(hidden_dim), nn.Linear(hidden_dim, model_dim))
layer = nn.TransformerEncoderLayer(model_dim, heads, model_dim * 2, dropout=0.0,
activation='gelu', batch_first=True, norm_first=True)
self.encoder = nn.TransformerEncoder(layer, layers, norm=nn.LayerNorm(model_dim),
enable_nested_tensor=False)
self.output = nn.Linear(model_dim, hidden_dim)
nn.init.zeros_(self.output.weight)
nn.init.zeros_(self.output.bias)
def forward(self, hidden, counts):
if not len(hidden):
return hidden + sum(parameter.sum() * 0 for parameter in self.parameters())
values = self.input(hidden)
chunks = values.split(counts.tolist())
padded = nn.utils.rnn.pad_sequence(chunks, batch_first=True)
valid = torch.arange(padded.shape[1], device=hidden.device)[None, :] < counts[:, None]
encoded = self.encoder(padded, src_key_padding_mask=~valid)
return hidden + self.output(encoded[valid])
class ClassificationReadout(nn.Module):
"""A pretrained scientific classifier followed by a trainable translation to GLM space."""
def __init__(self, feature_dim, output_dim, classes, side_dim=0):
super().__init__()
self.per_atom = classes == 1 # A single output is the per-residue binary task.
self.side_dim = side_dim
width = (feature_dim + side_dim) * (1 if self.per_atom else 5) + (0 if self.per_atom else 1)
self.register_buffer('feature_mean', torch.zeros(width))
self.register_buffer('feature_std', torch.ones(width))
self.classifier = (nn.Sequential(nn.LayerNorm(width),nn.Linear(width,256),nn.GELU(),nn.Linear(256,1))
if self.per_atom else nn.Sequential(nn.Linear(width,256),nn.GELU(),nn.Dropout(0.),nn.Linear(256,classes)))
self.bridge = nn.Sequential(nn.Linear(classes,128),nn.GELU(),nn.Linear(128,output_dim))
nn.init.zeros_(self.bridge[-1].weight)
nn.init.zeros_(self.bridge[-1].bias)
self.classes = classes
def probabilities(self, values, counts, side=None):
if not len(values):
return values.new_empty((0,self.classes))
if self.side_dim:
values = torch.cat([values,side.float()],dim=-1)
if self.per_atom:
normalized = (values-self.feature_mean)/self.feature_std
return self.classifier(normalized).sigmoid()
pooled = []
for item in values.split(counts.tolist()):
if self.side_dim:
# The lightcurve teacher pools joint states and measurements at floor-spaced edges.
edges = [i*len(item)//4 for i in range(5)]
regions = [item[left:right].mean(0) if right>left else item[min(left,len(item)-1)]
for left,right in zip(edges[:-1],edges[1:])]
else:
regions = [chunk.mean(0) if len(chunk) else item[-1] for chunk in item.tensor_split(4)]
pooled.append(torch.cat([item.mean(0),*regions,item.new_tensor([math.log1p(len(item))])]))
normalized = (torch.stack(pooled)-self.feature_mean)/self.feature_std
return self.classifier(normalized).softmax(-1)
def forward(self, values, counts, side=None):
translated = self.bridge(self.probabilities(values,counts,side))
return translated if self.per_atom else translated.repeat_interleave(counts,dim=0)
class BondResidualBlock(nn.Module):
"""Mean bond messages; a zero exit initially preserves the frozen node states."""
def __init__(self, feature_dim, hidden_dim):
super().__init__()
self.norm = nn.LayerNorm(feature_dim)
self.up = nn.Linear(feature_dim, hidden_dim)
self.bond = nn.Embedding(4, hidden_dim)
self.down = nn.Linear(hidden_dim, feature_dim)
nn.init.normal_(self.bond.weight, std=0.02)
nn.init.zeros_(self.down.weight)
nn.init.zeros_(self.down.bias)
def forward(self, values, edge_index, bond_types):
source, target = edge_index
messages = nn.functional.gelu(self.up(self.norm(values)[source]) + self.bond(bond_types))
aggregate = values.new_zeros((len(values), messages.shape[-1])).index_add_(0, target, messages)
degree = values.new_zeros(len(values)).index_add_(0, target, values.new_ones(len(target)))
return values + self.down(aggregate / degree.clamp_min(1)[:, None])
class LeadProjector(nn.Module):
def __init__(self, feature_dim, tokens, hidden_dim, output_dim, output_scale=None):
super().__init__()
self.payload_tokens = tokens
self.feature_dim = feature_dim
self.output_dim = output_dim
self.identity = nn.Parameter(torch.empty(tokens, feature_dim))
nn.init.normal_(self.identity, std=0.02)
self.layers = nn.Sequential(nn.LayerNorm(feature_dim), nn.Linear(feature_dim, hidden_dim),
nn.GELU(), nn.Linear(hidden_dim, output_dim))
nn.init.normal_(self.layers[-1].weight, std=0.001)
nn.init.zeros_(self.layers[-1].bias)
self.output_norm = OutputNorm(output_scale) if output_scale else nn.Identity()
def forward(self, values):
return self.output_norm(self.layers(values.float() + self.identity))
class AtomProjector(nn.Module):
def __init__(self, feature_dim, index_dim, max_atoms, hidden_dim, output_dim, output_scale=None,
discrete_sizes=(), discrete_dim=0, depth=0, block_mult=2, block_exit_norm=False,
graph_layers=0, graph_hidden_dim=128, side_dim=0, side_hidden_dim=128,
context_layers=0, context_dim=256, context_heads=8, readout_classes=0,
readout_uses_side=True):
super().__init__()
self.graph = nn.ModuleList(BondResidualBlock(feature_dim, graph_hidden_dim) for _ in range(graph_layers))
self.needs_graph = bool(graph_layers)
self.atom_index = nn.Embedding(max_atoms, index_dim)
# The frozen GIN mixes its neighbourhood into every state; these embeddings give the
# projector each atom's own discrete properties unmixed, in the exported column order.
self.discrete = nn.ModuleList([nn.Embedding(size, discrete_dim) for size in discrete_sizes])
width = feature_dim + index_dim + discrete_dim * len(self.discrete)
# depth 0 keeps the original four-element Sequential, so earlier state keys still load.
blocks = [ResidualBlock(hidden_dim, block_mult) for _ in range(depth)]
# A pre-LN residual stack normalizes each block's input but never its own output, so the
# stream reaches the exit projection unbounded: e1-stm-num-deep (depth 2, no exit norm)
# trained to payload norms 250,000x the vocabulary rows and scored near zero. Keep the
# flag so that run stays reproducible from its manifest; set it on every new deep arm.
exit_norm = [nn.LayerNorm(hidden_dim)] if depth and block_exit_norm else []
self.layers = nn.Sequential(nn.LayerNorm(width), nn.Linear(width, hidden_dim), nn.GELU(),
*blocks, *exit_norm, nn.Linear(hidden_dim, output_dim))
for embedding in (self.atom_index, *self.discrete):
nn.init.normal_(embedding.weight, std=0.02)
nn.init.normal_(self.layers[-1].weight, std=0.001)
nn.init.zeros_(self.layers[-1].bias)
self.needs_context = bool(context_layers)
self.needs_counts = bool(context_layers or readout_classes > 1)
if context_layers:
self.context = ContextResidual(hidden_dim, context_dim, context_layers, context_heads)
self.output_norm = OutputNorm(output_scale) if output_scale else nn.Identity()
self.needs_side = bool(side_dim)
if side_dim:
self.side = nn.Sequential(nn.Linear(side_dim, side_hidden_dim, bias=False), nn.GELU(),
nn.Linear(side_hidden_dim, output_dim, bias=False))
nn.init.zeros_(self.side[-1].weight)
if readout_classes:
self.readout = ClassificationReadout(feature_dim,output_dim,readout_classes,
side_dim if readout_uses_side else 0)
def forward(self, values, atom_indices, discrete=None, *, edge_index=None, bond_types=None, side=None, counts=None):
values = values.float()
if self.needs_graph:
if edge_index is None or bond_types is None:
raise ValueError('bond residual projector requires original graph edges and bond types')
for block in self.graph:
values = block(values, edge_index, bond_types)
parts = [values, self.atom_index(atom_indices)]
if len(self.discrete) != (0 if discrete is None else discrete.shape[-1]):
raise ValueError('discrete atom feature columns and embeddings disagree')
parts += [embedding(discrete[:, column]) for column, embedding in enumerate(self.discrete)]
hidden = self.layers[:-1](torch.cat(parts, dim=-1))
if self.needs_context:
hidden = self.context(hidden, counts)
output = self.layers[-1](hidden)
if self.needs_side:
output = output + self.side(side.float())
if hasattr(self,'readout'):
output = output + self.readout(values,counts,side)
return self.output_norm(output)
class AtomSetProjector(nn.Module):
"""Atoms of one molecule attend to each other; no graph structure, no cross-molecule leak.
The flat per-atom batch is regrouped into `[molecules, longest, model_dim]` with a key
padding mask, so a molecule's output cannot depend on what else shares the batch.
"""
needs_counts = True
def __init__(self, feature_dim, index_dim, max_atoms, model_dim, layers, heads, ffn_dim,
output_dim, output_scale=None):
super().__init__()
self.atom_index = nn.Embedding(max_atoms, index_dim)
nn.init.normal_(self.atom_index.weight, std=0.02)
width = feature_dim + index_dim
self.input = nn.Sequential(nn.LayerNorm(width), nn.Linear(width, model_dim))
layer = nn.TransformerEncoderLayer(model_dim, heads, ffn_dim, dropout=0.0,
activation='gelu', batch_first=True, norm_first=True)
self.encoder = nn.TransformerEncoder(layer, layers, norm=nn.LayerNorm(model_dim),
enable_nested_tensor=False)
self.output = nn.Linear(model_dim, output_dim)
nn.init.normal_(self.output.weight, std=0.001)
nn.init.zeros_(self.output.bias)
self.output_norm = OutputNorm(output_scale) if output_scale else nn.Identity()
def forward(self, values, atom_indices, counts):
hidden = self.input(torch.cat([values.float(), self.atom_index(atom_indices)], dim=-1))
if hidden.shape[0] == 0:
return self.output_norm(self.output(hidden))
# Wrong boundaries would mix molecules without ever failing, so check them here.
if int(counts.sum()) != hidden.shape[0] or int(counts.min()) < 1:
raise ValueError('molecule atom counts do not partition the payload rows')
longest = int(counts.max())
valid = torch.arange(longest, device=hidden.device)[None, :] < counts.to(hidden.device)[:, None]
padded = hidden.new_zeros((len(counts), longest, hidden.shape[-1]))
padded[valid] = hidden
encoded = self.encoder(padded, src_key_padding_mask=~valid)
return self.output_norm(self.output(encoded[valid]))
def build(spec, output_dim, *, train_core=False, train_side_only=False):
# output_scale is the initial L2 norm of each payload vector; absent keeps the old unnormalized output.
scale = spec.get("output_scale")
if spec.get('graph_layers') and spec.get('kind') != 'atom':
raise ValueError('bond residual layers require the atom projector')
if spec.get('side_dim') and spec.get('kind') != 'atom':
raise ValueError('side measurements require the atom projector')
if spec.get('context_layers') and spec.get('kind') != 'atom':
raise ValueError('context residual requires the atom projector')
if spec.get('readout_classes') and spec.get('kind') != 'atom':
raise ValueError('classification readout requires the atom projector')
if spec.get("kind") == "atom-set":
model = AtomSetProjector(spec["feature_dim"], spec["index_dim"], spec["max_atoms"],
spec["model_dim"], spec["layers"], spec["heads"],
spec["ffn_dim"], output_dim, scale)
elif spec.get("kind") == "atom":
model = AtomProjector(spec["feature_dim"], spec["index_dim"], spec["max_atoms"],
spec["hidden_dim"], output_dim, scale,
spec.get("discrete_sizes") or (), spec.get("discrete_dim", 0),
spec.get("depth", 0), spec.get("block_mult", 2),
spec.get("block_exit_norm", False),
spec.get("graph_layers", 0), spec.get("graph_hidden_dim", 128),
spec.get('side_dim', 0), spec.get('side_hidden_dim', 128),
spec.get('context_layers', 0), spec.get('context_dim', 256),
spec.get('context_heads', 8), spec.get('readout_classes', 0),
spec.get('readout_uses_side', True))
else:
model = LeadProjector(spec["feature_dim"], spec["tokens"], spec["hidden_dim"], output_dim, scale)
if spec.get('task_head_outputs'):
model.task_head = nn.Sequential(nn.LayerNorm(output_dim), nn.Linear(output_dim, spec['task_head_outputs']))
if spec.get('readout_classes'):
model.requires_grad_(train_core)
model.readout.requires_grad_(False)
model.readout.bridge.requires_grad_(True)
if hasattr(model,'task_head'):
model.task_head.requires_grad_(False)
if train_side_only:
if not spec.get('side_dim'):
raise ValueError('side-only training requires a scientific readout side channel')
model.requires_grad_(False)
model.side.requires_grad_(True)
return model
def load_initial(model, state):
"""An original atom checkpoint may initialize only the complete non-graph core."""
loaded = model.load_state_dict(state, strict=False)
missing = set(loaded.missing_keys)
for prefix in ('graph.', 'task_head.', 'side.', 'context.', 'readout.'):
keys = {key for key in model.state_dict() if key.startswith(prefix)}
if missing & keys and not keys <= missing:
raise ValueError(f'incomplete projector initialization: {loaded}')
missing -= keys
if loaded.unexpected_keys or missing:
raise ValueError(f'incomplete projector initialization: {loaded}')