| """ |
| Encoder-only Transformer for predicting circuit summary statistics. |
| |
| Architecture: |
| 1. Each scalar input feature β learned linear projection to d_model |
| 2. Add learned positional + type embeddings (ACh token gets special type) |
| 3. Prepend a [CLS] aggregation token |
| 4. Transformer encoder (N layers, multi-head self-attention) |
| 5. [CLS] output β MLP head β 11 predicted statistics |
| |
| Two configurations: |
| - Model A: 10 input tokens (no ACh), predicts 11 stats |
| - Model B: 11 input tokens (with ACh), predicts 11 stats |
| """ |
|
|
| from __future__ import annotations |
|
|
| import math |
|
|
| import torch |
| import torch.nn as nn |
|
|
|
|
| class FeatureTokenizer(nn.Module): |
| """Project each scalar feature to d_model via per-feature linear layers. |
| |
| Input: (B, n_features) β raw normalized scalars |
| Output: (B, n_features, d_model) β token embeddings |
| """ |
|
|
| def __init__(self, n_features: int, d_model: int): |
| super().__init__() |
| self.projections = nn.ModuleList([ |
| nn.Linear(1, d_model) for _ in range(n_features) |
| ]) |
|
|
| def forward(self, x: torch.Tensor) -> torch.Tensor: |
| |
| tokens = [] |
| for i, proj in enumerate(self.projections): |
| tokens.append(proj(x[:, i : i + 1])) |
| return torch.stack(tokens, dim=1) |
|
|
|
|
| class CircuitTransformer(nn.Module): |
| """Encoder-only transformer for circuit statistics prediction. |
| |
| Args: |
| n_features: Number of input features (10 for Model A, 11 for Model B) |
| n_outputs: Number of output statistics (11) |
| d_model: Embedding dimension |
| n_heads: Number of attention heads |
| n_layers: Number of transformer encoder layers |
| d_ff: Feed-forward hidden dimension |
| dropout: Dropout rate |
| has_ach: Whether ACh is included (for type embedding) |
| """ |
|
|
| def __init__( |
| self, |
| n_features: int, |
| n_outputs: int = 11, |
| d_model: int = 64, |
| n_heads: int = 4, |
| n_layers: int = 4, |
| d_ff: int = 256, |
| dropout: float = 0.1, |
| has_ach: bool = True, |
| ): |
| super().__init__() |
| self.n_features = n_features |
| self.n_outputs = n_outputs |
| self.d_model = d_model |
| self.has_ach = has_ach |
|
|
| |
| self.tokenizer = FeatureTokenizer(n_features, d_model) |
|
|
| |
| self.cls_token = nn.Parameter(torch.randn(1, 1, d_model) * 0.02) |
|
|
| |
| self.pos_embed = nn.Parameter( |
| torch.randn(1, n_features + 1, d_model) * 0.02 |
| ) |
|
|
| |
| self.type_embed = nn.Embedding(3, d_model) |
|
|
| self.embed_dropout = nn.Dropout(dropout) |
| self.embed_norm = nn.LayerNorm(d_model) |
|
|
| |
| encoder_layer = nn.TransformerEncoderLayer( |
| d_model=d_model, |
| nhead=n_heads, |
| dim_feedforward=d_ff, |
| dropout=dropout, |
| activation="gelu", |
| batch_first=True, |
| norm_first=True, |
| ) |
| self.encoder = nn.TransformerEncoder( |
| encoder_layer, num_layers=n_layers |
| ) |
|
|
| |
| self.output_norm = nn.LayerNorm(d_model) |
| self.output_head = nn.Sequential( |
| nn.Linear(d_model, d_ff), |
| nn.GELU(), |
| nn.Dropout(dropout), |
| nn.Linear(d_ff, n_outputs), |
| ) |
|
|
| |
| self.apply(self._init_weights) |
|
|
| def _init_weights(self, module: nn.Module): |
| if isinstance(module, nn.Linear): |
| nn.init.trunc_normal_(module.weight, std=0.02) |
| if module.bias is not None: |
| nn.init.zeros_(module.bias) |
| elif isinstance(module, nn.Embedding): |
| nn.init.trunc_normal_(module.weight, std=0.02) |
| elif isinstance(module, nn.LayerNorm): |
| nn.init.ones_(module.weight) |
| nn.init.zeros_(module.bias) |
|
|
| def _build_type_ids(self, batch_size: int, device: torch.device) -> torch.Tensor: |
| """Build type IDs: [CLS]=2, structural=0, ACh=1.""" |
| |
| type_ids = torch.zeros( |
| batch_size, self.n_features + 1, dtype=torch.long, device=device |
| ) |
| type_ids[:, 0] = 2 |
|
|
| if self.has_ach: |
| |
| type_ids[:, -1] = 1 |
|
|
| return type_ids |
|
|
| def forward(self, x: torch.Tensor) -> torch.Tensor: |
| """ |
| Args: |
| x: (B, n_features) β normalized input features |
| |
| Returns: |
| (B, n_outputs) β predicted statistics (in normalized space) |
| """ |
| B = x.shape[0] |
| device = x.device |
|
|
| |
| tokens = self.tokenizer(x) |
|
|
| |
| cls_expanded = self.cls_token.expand(B, -1, -1) |
| tokens = torch.cat([cls_expanded, tokens], dim=1) |
|
|
| |
| type_ids = self._build_type_ids(B, device) |
| tokens = tokens + self.pos_embed + self.type_embed(type_ids) |
|
|
| |
| tokens = self.embed_norm(tokens) |
| tokens = self.embed_dropout(tokens) |
|
|
| |
| tokens = self.encoder(tokens) |
|
|
| |
| cls_out = tokens[:, 0] |
| cls_out = self.output_norm(cls_out) |
| return self.output_head(cls_out) |
|
|
| def count_params(self) -> int: |
| return sum(p.numel() for p in self.parameters() if p.requires_grad) |
|
|
|
|
| |
|
|
| class CircuitMLP(nn.Module): |
| """Simple MLP baseline for tabular regression. |
| |
| Properly sized for small datasets (~5K-55K samples). |
| Default: 2 layers Γ 64 units = ~5K-10K params. |
| """ |
|
|
| def __init__( |
| self, |
| n_features: int, |
| n_outputs: int = 11, |
| hidden_dims: list[int] | None = None, |
| dropout: float = 0.1, |
| ): |
| super().__init__() |
| self.n_features = n_features |
| self.n_outputs = n_outputs |
| hidden_dims = hidden_dims or [64, 64] |
|
|
| layers = [] |
| in_dim = n_features |
| for h_dim in hidden_dims: |
| layers.extend([ |
| nn.Linear(in_dim, h_dim), |
| nn.GELU(), |
| nn.Dropout(dropout), |
| ]) |
| in_dim = h_dim |
| layers.append(nn.Linear(in_dim, n_outputs)) |
|
|
| self.net = nn.Sequential(*layers) |
| self.apply(self._init_weights) |
|
|
| def _init_weights(self, module: nn.Module): |
| if isinstance(module, nn.Linear): |
| nn.init.kaiming_normal_(module.weight, nonlinearity="linear") |
| if module.bias is not None: |
| nn.init.zeros_(module.bias) |
|
|
| def forward(self, x: torch.Tensor) -> torch.Tensor: |
| return self.net(x) |
|
|
| def count_params(self) -> int: |
| return sum(p.numel() for p in self.parameters() if p.requires_grad) |
|
|
|
|
| |
|
|
| def build_model_a(cfg, arch: str = "transformer") -> nn.Module: |
| """Model A: plain HH (no ACh token).""" |
| if arch == "mlp": |
| return CircuitMLP( |
| n_features=cfg.n_input_features_a, |
| n_outputs=cfg.n_output_stats, |
| hidden_dims=cfg.mlp_hidden, |
| dropout=cfg.mlp_dropout, |
| ) |
| return CircuitTransformer( |
| n_features=cfg.n_input_features_a, |
| n_outputs=cfg.n_output_stats, |
| d_model=cfg.d_model, |
| n_heads=cfg.n_heads, |
| n_layers=cfg.n_layers, |
| d_ff=cfg.d_ff, |
| dropout=cfg.dropout, |
| has_ach=False, |
| ) |
|
|
|
|
| def build_model_b(cfg, arch: str = "transformer") -> nn.Module: |
| """Model B: HH + ACh modulation.""" |
| if arch == "mlp": |
| return CircuitMLP( |
| n_features=cfg.n_input_features_b, |
| n_outputs=cfg.n_output_stats, |
| hidden_dims=cfg.mlp_hidden, |
| dropout=cfg.mlp_dropout, |
| ) |
| return CircuitTransformer( |
| n_features=cfg.n_input_features_b, |
| n_outputs=cfg.n_output_stats, |
| d_model=cfg.d_model, |
| n_heads=cfg.n_heads, |
| n_layers=cfg.n_layers, |
| d_ff=cfg.d_ff, |
| dropout=cfg.dropout, |
| has_ach=True, |
| ) |
|
|