Download src/sigir/models/decoder_head.py from inflaton/pakdd_mrd: direct link, hf CLI and curl.
- Browser
- Download file 7.44 kB
-
https://huggingface.co/spaces/inflaton/pakdd_mrd/resolve/main/src/sigir/models/decoder_head.py
- Command line
-
hf download hf://spaces/inflaton/pakdd_mrd/src/sigir/models/decoder_head.py
-
curl -L -o decoder_head.py https://huggingface.co/spaces/inflaton/pakdd_mrd/resolve/main/src/sigir/models/decoder_head.py
7.44 kB
| """Direct decision head on a small decoder (plan §6: 'same family with a direct decision head'). | |
| A causal LM (1-4B, LoRA-adapted) reads the serialized query + candidate list with symbols A, B, C, ... | |
| and NONE. The decision is read from the next-token logits at the final position, restricted to the | |
| symbol tokens. Single-pass; no autoregressive generation. Tokenization of every symbol is verified to be | |
| exactly one token (plan §6: a decimal integer is not necessarily one token). | |
| prompt_format "chat" (Nov 8, 2026; post hoc arm) wraps the same prompt in the backbone's chat template. For | |
| hybrid-thinking models (Qwen3) the thinking-off template ends in an empty "<think>\n\n</think>\n\n" block, so | |
| training and thinking-off scoring read the symbol right after it. Thinking-on scoring (decoder_trainer. | |
| predict_decoder_thinking) lets the model write its own reasoning inside that block first, then reads the symbol | |
| logits at the same position after "</think>\n\n". | |
| """ | |
| 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 | |
| from ..data.serialization import NONE_SYMBOL, SYMBOLS, serialize_record | |
| class DecoderHeadConfig: | |
| backbone: str = "Qwen/Qwen2.5-1.5B-Instruct" | |
| max_len: int = 2048 | |
| serialization: str = "compact_v1" | |
| lora_r: int = 16 | |
| lora_alpha: int = 32 | |
| lora_dropout: float = 0.05 | |
| lora_targets: tuple[str, ...] = ("q_proj", "k_proj", "v_proj", "o_proj") | |
| dtype: str = "bfloat16" | |
| symbol_prefix: str = " " # token form tried first: " A" (leading space), then "A" | |
| gradient_checkpointing: bool = True | |
| prompt_format: str = "raw" # raw: completion prompt ending in "Answer:" | chat: chat template (see module doc) | |
| max_think_tokens: int = 1024 # thinking-on scoring: reasoning budget before "</think>" is forced | |
| def to_dict(self) -> dict[str, Any]: | |
| d = self.__dict__.copy() | |
| d["lora_targets"] = list(self.lora_targets) | |
| return d | |
| PROMPT_HEADER = ( | |
| "You are matching a query record to at most one catalog record.\n" | |
| "Identity policy: {policy}\n" | |
| "Query: {query}\n" | |
| "Candidates:\n{candidates}\n" | |
| "Answer with the single symbol of the matching candidate, or {none} if no candidate is the same entity.\n" | |
| "Answer:" | |
| ) | |
| def render_prompt(query_fields: dict[str, Any], candidates: list[dict[str, Any]], policy: str, serialization: str, field_order: list[str] | None = None, compact_fields: list[str] | None = None) -> str: | |
| q = serialize_record(query_fields, field_order, serialization, compact_fields) | |
| lines = [f"{SYMBOLS[j]}. {serialize_record(c, field_order, serialization, compact_fields)}" for j, c in enumerate(candidates)] | |
| return PROMPT_HEADER.format(policy=policy, query=q, candidates="\n".join(lines), none=NONE_SYMBOL) | |
| def verify_symbol_tokens(tokenizer, k_max: int, prefix_candidates: tuple[str, ...] = (" ", "")) -> tuple[str, list[int], int]: | |
| """Return (prefix, candidate_token_ids[k_max], none_token_id) such that every symbol is ONE token. | |
| Raises if no prefix variant makes all symbols single tokens. | |
| """ | |
| problems = {} | |
| for prefix in prefix_candidates: | |
| ids = [] | |
| ok = True | |
| for s in SYMBOLS[:k_max] + [NONE_SYMBOL]: | |
| toks = tokenizer.encode(prefix + s, add_special_tokens=False) | |
| if len(toks) != 1: | |
| ok = False | |
| problems[prefix + s] = toks | |
| break | |
| ids.append(toks[0]) | |
| if ok and len(set(ids)) == len(ids): | |
| return prefix, ids[:-1], ids[-1] | |
| raise ValueError(f"no single-token symbol encoding for K={k_max}; offending: {problems}. Reduce K or change symbols.") | |
| class DecoderDecisionHead(nn.Module): | |
| def __init__(self, cfg: DecoderHeadConfig, k_max: int, use_lora: bool = True): | |
| super().__init__() | |
| from transformers import AutoModelForCausalLM, AutoTokenizer | |
| self.cfg = cfg | |
| self.tokenizer = AutoTokenizer.from_pretrained(cfg.backbone) | |
| if self.tokenizer.pad_token is None: | |
| self.tokenizer.pad_token = self.tokenizer.eos_token | |
| self.tokenizer.padding_side = "left" # last position = decision position | |
| dtype = getattr(torch, cfg.dtype) | |
| self.lm = AutoModelForCausalLM.from_pretrained(cfg.backbone, torch_dtype=dtype, attn_implementation="sdpa") | |
| if cfg.gradient_checkpointing: | |
| self.lm.gradient_checkpointing_enable() | |
| self.lm.config.use_cache = False | |
| if use_lora: | |
| from peft import LoraConfig, get_peft_model | |
| self.lm = get_peft_model(self.lm, LoraConfig(r=cfg.lora_r, lora_alpha=cfg.lora_alpha, lora_dropout=cfg.lora_dropout, target_modules=list(cfg.lora_targets), task_type="CAUSAL_LM")) | |
| self.prefix, cand_ids, none_id = verify_symbol_tokens(self.tokenizer, k_max, (cfg.symbol_prefix, "" if cfg.symbol_prefix else " ")) | |
| self.register_buffer("symbol_ids", torch.tensor(cand_ids + [none_id], dtype=torch.long), persistent=False) | |
| self.k_max = k_max | |
| def forward(self, input_ids: torch.Tensor, attention_mask: torch.Tensor, slot_mask: torch.Tensor) -> torch.Tensor: | |
| """Return log-probs [B, k_max+1] over symbols (padded slots -inf). Left-padded inputs.""" | |
| # logits_to_keep=1: only the decision position's vocabulary logits (same values; avoids [B, L, V] at long L) | |
| out = self.lm(input_ids=input_ids, attention_mask=attention_mask, logits_to_keep=1) | |
| last = out.logits[:, -1, :].float() # [B, V] | |
| sym = last[:, self.symbol_ids] # [B, k_max+1] | |
| full_mask = torch.cat([slot_mask, torch.ones(slot_mask.shape[0], 1, dtype=torch.bool, device=slot_mask.device)], 1) | |
| sym = sym.masked_fill(~full_mask, float("-inf")) | |
| return F.log_softmax(sym, dim=-1) | |
| def format_prompts(self, prompts: list[str], thinking: bool = False) -> list[str]: | |
| """Model input text for rendered prompts: unchanged for "raw"; chat template for "chat".""" | |
| if self.cfg.prompt_format == "raw": | |
| if thinking: | |
| raise ValueError("thinking needs prompt_format: chat") | |
| return prompts | |
| if self.cfg.prompt_format != "chat": | |
| raise ValueError(self.cfg.prompt_format) | |
| suffix = "\nAnswer:" | |
| return [self.tokenizer.apply_chat_template([{"role": "user", "content": p.removesuffix(suffix)}], tokenize=False, | |
| add_generation_prompt=True, enable_thinking=thinking) for p in prompts] | |
| def tokenize_prompts(self, prompts: list[str], formatted: bool = False, max_len: int | None = None) -> dict[str, torch.Tensor]: | |
| texts = prompts if formatted else self.format_prompts(prompts) | |
| enc = self.tokenizer(texts, return_tensors="pt", padding=True, truncation=False, add_special_tokens=self.cfg.prompt_format == "raw") | |
| limit = max_len or self.cfg.max_len | |
| if enc["input_ids"].shape[1] > limit: | |
| # right truncation would cut the decision position (the last token); never truncate silently | |
| raise ValueError(f"prompt of {enc['input_ids'].shape[1]} tokens exceeds max_len {limit}") | |
| return {"input_ids": enc["input_ids"], "attention_mask": enc["attention_mask"]} | |
| def save_adapter(self, path: str) -> None: | |
| self.lm.save_pretrained(path) | |
| self.tokenizer.save_pretrained(path) | |