| """TWLAT-R(V2)模型(《04 實驗設計》§4)。 |
| |
| 任務:對文本中每個 proposal(字典判定「這裡可能要改」)在候選集中選一個。 |
| 候選 index 0 **永遠是「維持原樣」**,即文本中實際出現的形式。 |
| |
| **核心約束(V2 的全部重點)**: |
| 禁止任何 per-candidate / per-site 的 trainable embedding lookup。 |
| 候選只能由「表面字串 + 數值特徵」動態編碼,且與文本共用同一份 char embedding。 |
| 因此新增字典條目不需要新增任何參數,held-out proposal 也不是隨機向量。 |
| (V1 的 `cand_emb = nn.Embedding(4096, 192)` 佔 13% 參數並阻斷 zero-shot,就是要修掉的。) |
| |
| 結構: |
| |
| char_emb(共用)──┬─→ context encoder ──→ span mean-pool ──→ h_i [B,P,D] |
| │ |
| └─→ 候選字串 mean-pool ──→ proj ──────────→ e_c [B,P,C,D] |
| |
| score = MLP([h_i ⊕ e_c ⊕ (h_i * e_c) ⊕ cand_feat]) → [B,P,C] |
| |
| context encoder 可切換(`TWLATRConfig.encoder`),兩者參數量刻意對齊以便做 scaling curve: |
| - "tcn" :4 層 dilated depthwise separable Conv1d(dilation 1/2/4/8、kernel 5) |
| - "local" :4 層 local-window Transformer(window 半徑 32、RoPE、pre-LN、GELU) |
| |
| batch 欄位見 `TWLATR.forward` docstring。 |
| """ |
|
|
| from __future__ import annotations |
|
|
| from dataclasses import dataclass |
| from typing import Any |
|
|
| import torch |
| import torch.nn as nn |
| import torch.nn.functional as F |
|
|
| |
| W_CAND = 1.0 |
| W_KEEP_BIAS = 0.3 |
| KEEP_MARGIN = 0.5 |
| KEEP_INDEX = 0 |
|
|
| ENCODERS = ("tcn", "local") |
|
|
|
|
| @dataclass |
| class TWLATRConfig: |
| """TWLAT-R 配置。目標參數量 3.0M–4.5M。 |
| |
| 註:d_model=160 只有約 2.5M(低於下限),故預設起跳為 192; |
| tools/param_count_r.py 會印出兩者的對照。 |
| """ |
|
|
| d_model: int = 192 |
| n_heads: int = 4 |
| ffn_dim: int = 768 |
| dropout: float = 0.1 |
|
|
| |
| encoder: str = "local" |
| n_layers: int = 4 |
| local_window: int = 32 |
| conv_kernel: int = 5 |
| conv_dilations: tuple[int, ...] = (1, 2, 4, 8) |
| |
| |
| conv_expansion: int = 2 |
|
|
| |
| han_vocab: int = 4000 |
| n_hash: int = 2 |
| hash_buckets: int = 2048 |
| feat_dim: int = 4 |
| feat_vocab: tuple[int, ...] = (16, 4, 4, 4) |
|
|
| |
| max_props: int = 48 |
| max_cands: int = 8 |
| cand_len: int = 6 |
| cand_feat_dim: int = 12 |
| score_hidden: int = 512 |
| |
| |
| |
| candidate_encoder: str = "dynamic" |
| cand_id_vocab: int = 4096 |
|
|
| seq_len: int = 512 |
| rope_base: float = 10000.0 |
|
|
| def __post_init__(self) -> None: |
| assert self.encoder in ENCODERS, f"encoder 必須是 {ENCODERS}" |
| assert self.d_model % self.n_heads == 0 |
| assert self.d_model % 2 == 0, "hash_dim = d_model // 2,需為偶數" |
| assert len(self.feat_vocab) == self.feat_dim |
| assert self.conv_kernel % 2 == 1, "kernel 需為奇數才能等長 padding" |
|
|
| @property |
| def head_dim(self) -> int: |
| return self.d_model // self.n_heads |
|
|
| @property |
| def hash_dim(self) -> int: |
| return self.d_model // 2 |
|
|
| def dilation_at(self, i: int) -> int: |
| return self.conv_dilations[i % len(self.conv_dilations)] |
|
|
|
|
| |
| |
| |
|
|
|
|
| def build_rope_cache( |
| seq_len: int, head_dim: int, base: float, device, dtype |
| ) -> tuple[torch.Tensor, torch.Tensor]: |
| """回傳 [T, head_dim//2] 的 cos / sin。""" |
| half = head_dim // 2 |
| inv_freq = base ** (-torch.arange(half, device=device, dtype=torch.float32) / half) |
| pos = torch.arange(seq_len, device=device, dtype=torch.float32) |
| freqs = torch.outer(pos, inv_freq) |
| return freqs.cos().to(dtype), freqs.sin().to(dtype) |
|
|
|
|
| def apply_rope(x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor) -> torch.Tensor: |
| """x: [B, H, T, D],對相鄰兩維做旋轉。""" |
| x_even, x_odd = x[..., 0::2], x[..., 1::2] |
| cos = cos[None, None, : x.shape[-2], :] |
| sin = sin[None, None, : x.shape[-2], :] |
| out = torch.stack([x_even * cos - x_odd * sin, x_even * sin + x_odd * cos], dim=-1) |
| return out.flatten(-2) |
|
|
|
|
| |
| |
| |
|
|
| _HASH_MULT = (2654435761, 40503) |
| _HASH_ADD = (0, 987654321) |
|
|
|
|
| class CharEmbedding(nn.Module): |
| """常用字 4000 直接查表;id >= han_vocab 的罕字用 2 組 hash(buckets 2048, dim d/2) |
| 串接後投影。 |
| |
| **文本與候選字串共用這一份**:候選只是一串字元 id,沒有自己的 embedding 表, |
| 所以字典新增條目不會增加任何參數,未見過的字串也落在同一個表徵空間。 |
| """ |
|
|
| def __init__(self, cfg: TWLATRConfig): |
| super().__init__() |
| self.cfg = cfg |
| self.han = nn.Embedding(cfg.han_vocab, cfg.d_model) |
| self.hash = nn.ModuleList( |
| nn.Embedding(cfg.hash_buckets, cfg.hash_dim) for _ in range(cfg.n_hash) |
| ) |
| self.hash_proj = nn.Linear(cfg.n_hash * cfg.hash_dim, cfg.d_model) |
|
|
| def forward(self, ids: torch.Tensor) -> torch.Tensor: |
| """ids: 任意形狀 [...],回傳 [..., d_model]。""" |
| cfg = self.cfg |
| ids = ids.clamp(min=0) |
| rare = ids >= cfg.han_vocab |
| han = self.han(ids.clamp(max=cfg.han_vocab - 1)) |
| parts = [] |
| for i, emb in enumerate(self.hash): |
| m = _HASH_MULT[i % len(_HASH_MULT)] |
| a = _HASH_ADD[i % len(_HASH_ADD)] |
| parts.append(emb((ids * m + a) % cfg.hash_buckets)) |
| rare_vec = self.hash_proj(torch.cat(parts, dim=-1)) |
| return torch.where(rare.unsqueeze(-1), rare_vec, han) |
|
|
|
|
| class TextFeatures(nn.Module): |
| """文本側的 4 個離散特徵;char embedding 由外部傳入,以免共用的表被重複註冊。""" |
|
|
| def __init__(self, cfg: TWLATRConfig): |
| super().__init__() |
| self.cfg = cfg |
| self.feat = nn.ModuleList(nn.Embedding(n, cfg.d_model) for n in cfg.feat_vocab) |
| self.ln = nn.LayerNorm(cfg.d_model) |
| self.drop = nn.Dropout(cfg.dropout) |
|
|
| def forward(self, x: torch.Tensor, feat: torch.Tensor) -> torch.Tensor: |
| for i, emb in enumerate(self.feat): |
| x = x + emb(feat[..., i].clamp(0, self.cfg.feat_vocab[i] - 1)) |
| return self.drop(self.ln(x)) |
|
|
|
|
| |
| |
| |
|
|
|
|
| class LocalAttention(nn.Module): |
| """local-window self-attention + RoPE;|i-j| <= local_window 才可見。""" |
|
|
| def __init__(self, cfg: TWLATRConfig): |
| super().__init__() |
| self.cfg = cfg |
| self.qkv = nn.Linear(cfg.d_model, 3 * cfg.d_model) |
| self.out = nn.Linear(cfg.d_model, cfg.d_model) |
| self.drop = nn.Dropout(cfg.dropout) |
|
|
| def forward(self, x, attn_mask, rope): |
| b, t, _ = x.shape |
| h, d = self.cfg.n_heads, self.cfg.head_dim |
| q, k, v = self.qkv(x).view(b, t, 3, h, d).permute(2, 0, 3, 1, 4).unbind(0) |
| q, k = apply_rope(q, *rope), apply_rope(k, *rope) |
| p = self.cfg.dropout if self.training else 0.0 |
| y = F.scaled_dot_product_attention(q, k, v, attn_mask=attn_mask, dropout_p=p) |
| y = y.transpose(1, 2).reshape(b, t, self.cfg.d_model) |
| return self.drop(self.out(y)) |
|
|
|
|
| class ConvMixer(nn.Module): |
| """dilated depthwise separable Conv1d:depthwise(k, dilation) → pointwise 擴張 → GELU |
| → pointwise 還原。padding 位置先歸零,避免 pad 洩漏進感受野。""" |
|
|
| def __init__(self, cfg: TWLATRConfig, dilation: int): |
| super().__init__() |
| d, k = cfg.d_model, cfg.conv_kernel |
| pad = dilation * (k - 1) // 2 |
| mid = d * cfg.conv_expansion |
| self.dw = nn.Conv1d(d, d, k, padding=pad, dilation=dilation, groups=d) |
| self.pw1 = nn.Conv1d(d, mid, 1) |
| self.pw2 = nn.Conv1d(mid, d, 1) |
| self.drop = nn.Dropout(cfg.dropout) |
|
|
| def forward(self, x, pad_mask, rope=None): |
| z = (x * pad_mask.unsqueeze(-1).to(x.dtype)).transpose(1, 2) |
| z = self.pw2(F.gelu(self.pw1(self.dw(z)))) |
| return self.drop(z.transpose(1, 2)) |
|
|
|
|
| class EncoderBlock(nn.Module): |
| """pre-LN:mixer(local attention 或 dilated conv)+ FFN。""" |
|
|
| def __init__(self, cfg: TWLATRConfig, layer_idx: int): |
| super().__init__() |
| self.ln1 = nn.LayerNorm(cfg.d_model) |
| if cfg.encoder == "local": |
| self.mixer: nn.Module = LocalAttention(cfg) |
| else: |
| self.mixer = ConvMixer(cfg, cfg.dilation_at(layer_idx)) |
| self.ln2 = nn.LayerNorm(cfg.d_model) |
| self.ffn = nn.Sequential( |
| nn.Linear(cfg.d_model, cfg.ffn_dim), |
| nn.GELU(), |
| nn.Dropout(cfg.dropout), |
| nn.Linear(cfg.ffn_dim, cfg.d_model), |
| nn.Dropout(cfg.dropout), |
| ) |
|
|
| def forward(self, x, mix_arg, rope=None): |
| x = x + self.mixer(self.ln1(x), mix_arg, rope) |
| x = x + self.ffn(self.ln2(x)) |
| return x |
|
|
|
|
| |
| |
| |
|
|
|
|
| class TWLATR(nn.Module): |
| def __init__(self, cfg: TWLATRConfig | None = None): |
| super().__init__() |
| self.cfg = cfg = cfg or TWLATRConfig() |
|
|
| self.char_emb = CharEmbedding(cfg) |
| self.text_feat = TextFeatures(cfg) |
|
|
| self.layers = nn.ModuleList( |
| EncoderBlock(cfg, i) for i in range(cfg.n_layers) |
| ) |
| self.enc_ln = nn.LayerNorm(cfg.d_model) |
|
|
| |
| self.cand_ln = nn.LayerNorm(cfg.d_model) |
| self.cand_proj = nn.Linear(cfg.d_model, cfg.d_model) |
| |
| |
| |
| if cfg.candidate_encoder == "id": |
| self.cand_id_emb = nn.Embedding(cfg.cand_id_vocab, cfg.d_model) |
|
|
| score_in = 3 * cfg.d_model + cfg.cand_feat_dim |
| self.score = nn.Sequential( |
| nn.Linear(score_in, cfg.score_hidden), |
| nn.GELU(), |
| nn.Dropout(cfg.dropout), |
| nn.Linear(cfg.score_hidden, 1), |
| ) |
|
|
| self.apply(self._init_weights) |
| self._rope_cache: dict[Any, tuple[torch.Tensor, torch.Tensor]] = {} |
| self._window_cache: dict[Any, torch.Tensor] = {} |
|
|
| @staticmethod |
| def _init_weights(m: nn.Module) -> None: |
| if isinstance(m, nn.Linear): |
| nn.init.normal_(m.weight, std=0.02) |
| if m.bias is not None: |
| nn.init.zeros_(m.bias) |
| elif isinstance(m, nn.Embedding): |
| nn.init.normal_(m.weight, std=0.02) |
| elif isinstance(m, nn.Conv1d): |
| nn.init.normal_(m.weight, std=0.02) |
| if m.bias is not None: |
| nn.init.zeros_(m.bias) |
|
|
| |
|
|
| def _rope(self, t: int, device, dtype): |
| key = (t, str(device), dtype) |
| if key not in self._rope_cache: |
| self._rope_cache[key] = build_rope_cache( |
| t, self.cfg.head_dim, self.cfg.rope_base, device, dtype |
| ) |
| return self._rope_cache[key] |
|
|
| def _window_mask(self, t: int, device) -> torch.Tensor: |
| key = (t, str(device)) |
| if key not in self._window_cache: |
| idx = torch.arange(t, device=device) |
| self._window_cache[key] = (idx[:, None] - idx[None, :]).abs() <= self.cfg.local_window |
| return self._window_cache[key] |
|
|
| |
|
|
| def encode_text(self, input_ids, feat, pad_mask) -> torch.Tensor: |
| """[B,T] → [B,T,D]。""" |
| t, device = input_ids.shape[1], input_ids.device |
| x = self.text_feat(self.char_emb(input_ids), feat) |
| if self.cfg.encoder == "local": |
| ar = torch.arange(t, device=device) |
| eye = ar[:, None] == ar[None, :] |
| mask = ((self._window_mask(t, device) & pad_mask[:, None, :]) | eye).unsqueeze(1) |
| rope = self._rope(t, device, x.dtype) |
| for layer in self.layers: |
| x = layer(x, mask, rope) |
| else: |
| for layer in self.layers: |
| x = layer(x, pad_mask) |
| return self.enc_ln(x) |
|
|
| @staticmethod |
| def span_pool(h: torch.Tensor, spans: torch.Tensor, valid: torch.Tensor) -> torch.Tensor: |
| """對每個 proposal 的 [start,end) 做 mean-pool。h:[B,T,D] spans:[B,P,2] → [B,P,D]。""" |
| t = h.shape[1] |
| pos = torch.arange(t, device=h.device) |
| start = spans[..., 0].clamp(0, t).unsqueeze(-1) |
| end = spans[..., 1].clamp(0, t).unsqueeze(-1) |
| w = ((pos >= start) & (pos < end) & valid).to(h.dtype) |
| return torch.matmul(w, h) / w.sum(-1, keepdim=True).clamp(min=1.0) |
|
|
| def encode_cands(self, cand_tok: torch.Tensor) -> torch.Tensor: |
| """候選表面字串 → 向量。[B,P,C,L] → [B,P,C,D]。 |
| |
| 走的是與文本同一份 char_emb,且只有 mean-pool + proj: |
| 任何未見過的字串都能得到有限且有梯度的表徵(zero-shot 的前提)。 |
| """ |
| if self.cfg.candidate_encoder == "id": |
| |
| |
| mult = torch.tensor([1, 131, 131 ** 2, 131 ** 3, 131 ** 4, 131 ** 5], |
| device=cand_tok.device, dtype=torch.long) |
| mult = mult[: cand_tok.shape[-1]] |
| cid = (cand_tok * mult).sum(-1) % self.cfg.cand_id_vocab |
| return self.cand_id_emb(cid) |
| valid = (cand_tok > 0).unsqueeze(-1) |
| e = self.char_emb(cand_tok) |
| e = e * valid.to(e.dtype) |
| pooled = e.sum(-2) / valid.sum(-2).clamp(min=1).to(e.dtype) |
| return self.cand_proj(self.cand_ln(pooled)) |
|
|
| |
|
|
| def forward(self, batch: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]: |
| """batch: |
| input_ids [B,T] int64 原文字元 id |
| feat [B,T,4] int64 script / 在 proposal span 內 / 保護段 / 詞界 |
| pad_mask [B,T] bool (可省,缺省全 True) |
| prop_spans [B,P,2] int64 proposal 的 [start,end) |
| prop_mask [B,P] bool |
| cand_tok [B,P,C,L] int64 候選表面字串(右側 0 padding) |
| cand_mask [B,P,C] bool |
| cand_feat [B,P,C,K] float |
| 回傳 {"cand_logits": [B,P,C]},padding 候選為 -inf。 |
| """ |
| input_ids = batch["input_ids"] |
| b, t = input_ids.shape |
| device = input_ids.device |
|
|
| pad_mask = batch.get("pad_mask") |
| pad_mask = ( |
| torch.ones(b, t, dtype=torch.bool, device=device) |
| if pad_mask is None |
| else pad_mask.bool() |
| ) |
| prop_mask = batch["prop_mask"].bool() |
| cand_mask = batch["cand_mask"].bool() |
|
|
| h_text = self.encode_text(input_ids, batch["feat"], pad_mask) |
| h = self.span_pool( |
| h_text, batch["prop_spans"], (pad_mask[:, None, :] & prop_mask[..., None]) |
| ) |
| e = self.encode_cands(batch["cand_tok"]) |
|
|
| c = e.shape[2] |
| h_exp = h.unsqueeze(2).expand(-1, -1, c, -1) |
| z = torch.cat([h_exp, e, h_exp * e, batch["cand_feat"].to(e.dtype)], dim=-1) |
| logits = self.score(z).squeeze(-1) |
| return {"cand_logits": logits.masked_fill(~cand_mask, float("-inf"))} |
|
|
| def compute_loss(self, outputs, batch): |
| return compute_loss(outputs, batch) |
|
|
|
|
| |
| |
| |
|
|
|
|
| def compute_loss( |
| outputs: dict[str, torch.Tensor], |
| batch: dict[str, torch.Tensor], |
| weights: dict[str, float] | None = None, |
| ) -> tuple[torch.Tensor, dict[str, torch.Tensor]]: |
| """masked cross-entropy over candidates,外加 keep_bias 正則。 |
| |
| keep_bias:對 gold == 0(維持原樣)的 proposal,任何非 0 候選只要分數不比 |
| 候選 0 低 0.5 以上就受罰 —— 直接壓「改了不該改」(佔 V1 錯誤的 73%)。 |
| """ |
| w = {"cand": W_CAND, "keep_bias": W_KEEP_BIAS} |
| if weights: |
| w.update(weights) |
|
|
| logits = outputs["cand_logits"] |
| prop_mask = batch["prop_mask"].bool() |
| cand_mask = batch["cand_mask"].bool() |
| n_cands = logits.shape[-1] |
| dtype = logits.dtype |
|
|
| |
| neg = torch.finfo(dtype).min |
| safe = torch.where(cand_mask, logits, torch.full_like(logits, neg)) |
| finite = torch.where(cand_mask, logits, torch.zeros_like(logits)) |
|
|
| gold = batch["gold_cand"].clamp(0, n_cands - 1) |
| prop_w = prop_mask.to(dtype) |
| n_prop = prop_w.sum().clamp(min=1.0) |
|
|
| |
| logp = torch.log_softmax(safe, dim=-1) |
| nll = -logp.gather(-1, gold.unsqueeze(-1)).squeeze(-1) |
| l_cand = (nll * prop_w).sum() / n_prop |
|
|
| |
| is_keep = (gold == KEEP_INDEX) & prop_mask |
| s_keep = finite[..., KEEP_INDEX : KEEP_INDEX + 1] |
| other = cand_mask & ( |
| torch.arange(n_cands, device=logits.device)[None, None, :] != KEEP_INDEX |
| ) |
| hinge = F.relu(finite - s_keep + KEEP_MARGIN) * other.to(dtype) |
| n_keep = is_keep.to(dtype).sum().clamp(min=1.0) |
| l_keep = (hinge.sum(-1) * is_keep.to(dtype)).sum() / n_keep |
|
|
| total = w["cand"] * l_cand + w["keep_bias"] * l_keep |
| with torch.no_grad(): |
| acc = ((safe.argmax(-1) == gold).to(dtype) * prop_w).sum() / n_prop |
| return total, { |
| "loss": total.detach(), |
| "cand": l_cand.detach(), |
| "keep_bias": l_keep.detach(), |
| "acc": acc, |
| "n_prop": n_prop.detach(), |
| } |
|
|