| """T2-focused learned residue classifier for modular multiplication. |
| |
| The model deliberately targets Tiers 1 and 2, where p < 256. It uses the same |
| allowed input normalization as the reference neural baselines: each operand is |
| reduced separately modulo p before entering the network. The network then has |
| to choose the output residue from learned parameters. |
| |
| There is no inference-time code path that computes ``(a * b) % p``. The only |
| post-processing is masking classes outside ``[0, p)`` so that the emitted |
| single base-p digit is well-formed under the challenge decoder. |
| """ |
|
|
| from __future__ import annotations |
|
|
| from pathlib import Path |
|
|
| import torch |
| import torch.nn as nn |
|
|
| from modchallenge.interface.base_model import ModularMultiplicationModel |
|
|
| MAX_P = 256 |
| MAX_CLASSES = 256 |
| PAIR_VOCAB = MAX_P * MAX_CLASSES |
|
|
|
|
| class ResidueProductNet(nn.Module): |
| def __init__( |
| self, |
| d_model: int = 128, |
| hidden: int = 512, |
| depth: int = 3, |
| bilinear_dim: int = 64, |
| ): |
| super().__init__() |
| self.in_emb = nn.Embedding(PAIR_VOCAB, d_model) |
| self.p_emb = nn.Embedding(MAX_P, d_model) |
| self.out_emb = nn.Embedding(PAIR_VOCAB, d_model) |
| self.out_bias = nn.Embedding(PAIR_VOCAB, 1) |
| self.left_factor = nn.Embedding(PAIR_VOCAB, bilinear_dim) |
| self.right_factor = nn.Embedding(PAIR_VOCAB, bilinear_dim) |
| self.candidate_factor = nn.Embedding(PAIR_VOCAB, bilinear_dim) |
| self.factor_ln = nn.LayerNorm(bilinear_dim) |
| self.factor_scale = bilinear_dim ** -0.5 |
| nn.init.zeros_(self.candidate_factor.weight) |
|
|
| layers: list[nn.Module] = [] |
| in_dim = 4 * d_model |
| for _ in range(depth): |
| layers.extend( |
| [ |
| nn.Linear(in_dim, hidden), |
| nn.GELU(), |
| nn.LayerNorm(hidden), |
| ] |
| ) |
| in_dim = hidden |
| layers.append(nn.Linear(hidden, d_model)) |
| layers.append(nn.LayerNorm(d_model)) |
| self.net = nn.Sequential(*layers) |
| self.config = { |
| "d_model": d_model, |
| "hidden": hidden, |
| "depth": depth, |
| "bilinear_dim": bilinear_dim, |
| } |
|
|
| self.register_buffer( |
| "classes", torch.arange(MAX_CLASSES, dtype=torch.long), persistent=False |
| ) |
|
|
| def forward(self, a_red: torch.Tensor, b_red: torch.Tensor, p: torch.Tensor) -> torch.Tensor: |
| a_idx = p * MAX_CLASSES + a_red |
| b_idx = p * MAX_CLASSES + b_red |
|
|
| ea = self.in_emb(a_idx) |
| eb = self.in_emb(b_idx) |
| ep = self.p_emb(p) |
| h = self.net(torch.cat([ea, eb, ea * eb, ep], dim=-1)) |
|
|
| candidate_idx = p.unsqueeze(1) * MAX_CLASSES + self.classes.unsqueeze(0) |
| candidate_emb = self.out_emb(candidate_idx) |
| logits = torch.einsum("bd,bkd->bk", h, candidate_emb) |
| logits = logits + self.out_bias(candidate_idx).squeeze(-1) |
|
|
| |
| |
| |
| factor_h = self.factor_ln(self.left_factor(a_idx) * self.right_factor(b_idx)) |
| factor_candidates = self.candidate_factor(candidate_idx) |
| logits = logits + self.factor_scale * torch.einsum( |
| "bd,bkd->bk", factor_h, factor_candidates |
| ) |
|
|
| invalid = self.classes.unsqueeze(0) >= p.unsqueeze(1) |
| return logits.masked_fill(invalid, -1.0e9) |
|
|
|
|
| class T2ResidueClassifier(ModularMultiplicationModel): |
| def __init__(self): |
| self.model: ResidueProductNet | None = None |
| self.device: torch.device | None = None |
|
|
| def load(self, model_dir: str) -> None: |
| if torch.backends.mps.is_available(): |
| self.device = torch.device("mps") |
| elif torch.cuda.is_available(): |
| self.device = torch.device("cuda") |
| else: |
| self.device = torch.device("cpu") |
|
|
| ckpt = torch.load( |
| Path(model_dir) / "weights.pt", |
| map_location=self.device, |
| weights_only=True, |
| ) |
| self.model = ResidueProductNet(**ckpt.get("config", {})) |
| load_result = self.model.load_state_dict(ckpt["state_dict"], strict=False) |
| extra_keys = load_result[1] |
| if extra_keys: |
| raise RuntimeError(f"extra checkpoint keys: {extra_keys}") |
| self.model.to(self.device) |
| self.model.eval() |
|
|
| def preprocess_a(self, a): |
| return a |
|
|
| def preprocess_b(self, b): |
| return b |
|
|
| def preprocess_p(self, p): |
| return p |
|
|
| @torch.no_grad() |
| def predict_digits(self, a_enc, b_enc, p_enc): |
| return self.predict_digits_batch([(a_enc, b_enc, p_enc)])[0] |
|
|
| @torch.no_grad() |
| def predict_digits_batch(self, inputs): |
| assert self.model is not None |
| assert self.device is not None |
|
|
| out: list[list[int] | None] = [None] * len(inputs) |
| a_rows: list[int] = [] |
| b_rows: list[int] = [] |
| p_rows: list[int] = [] |
| idx: list[int] = [] |
|
|
| for i, (a_enc, b_enc, p_enc) in enumerate(inputs): |
| p = int(p_enc) |
| if not (2 <= p < MAX_P): |
| out[i] = [0] |
| continue |
| a_rows.append(int(a_enc) % p) |
| b_rows.append(int(b_enc) % p) |
| p_rows.append(p) |
| idx.append(i) |
|
|
| if idx: |
| a_t = torch.tensor(a_rows, dtype=torch.long, device=self.device) |
| b_t = torch.tensor(b_rows, dtype=torch.long, device=self.device) |
| p_t = torch.tensor(p_rows, dtype=torch.long, device=self.device) |
| preds = self.model(a_t, b_t, p_t).argmax(dim=-1).tolist() |
| for j, i in enumerate(idx): |
| out[i] = [int(preds[j])] |
|
|
| return [row if row is not None else [0] for row in out] |
|
|
| def max_batch_size(self) -> int: |
| return 4096 |
|
|