fugu-lite / src /fugu_lite /model.py
tahsinsoyak's picture
Upload private Fugu-Lite V1 snapshot
88e15cd verified
Raw
History Blame
6.56 kB
from __future__ import annotations
import json
from contextlib import nullcontext
from pathlib import Path
import torch
from safetensors.torch import load_file, save_file
from torch import nn
from transformers import AutoModel, AutoTokenizer
from .config import ModelConfig
def resolve_torch_dtype(name: str, device: torch.device) -> torch.dtype:
if name == "float32":
return torch.float32
if name == "float16":
return torch.float16
if name == "bfloat16":
return torch.bfloat16
if device.type != "cuda":
return torch.float32
if torch.cuda.is_bf16_supported():
return torch.bfloat16
return torch.float16
class RouterModel(nn.Module):
"""Frozen language-model encoder plus a small worker-selection head."""
def __init__(
self,
config: ModelConfig,
worker_ids: list[str],
device: torch.device,
backbone_source: str | Path | None = None,
):
super().__init__()
self.router_config = config
self.worker_ids = list(worker_ids)
self.device_ref = device
dtype = resolve_torch_dtype(config.dtype, device)
source = str(backbone_source or config.base_model)
self.tokenizer = AutoTokenizer.from_pretrained(source, use_fast=True)
if self.tokenizer.pad_token_id is None:
self.tokenizer.pad_token = self.tokenizer.eos_token
self.tokenizer.padding_side = "right"
self.backbone = AutoModel.from_pretrained(source, torch_dtype=dtype)
hidden_size = getattr(self.backbone.config, "hidden_size", None)
if hidden_size is None:
hidden_size = getattr(self.backbone.config, "d_model", None)
if hidden_size is None:
raise ValueError("Could not infer backbone hidden size")
self.norm = nn.LayerNorm(hidden_size, dtype=torch.float32)
self.dropout = nn.Dropout(config.dropout)
self.classifier = nn.Linear(hidden_size, len(worker_ids), dtype=torch.float32)
self.backbone.requires_grad_(not config.freeze_backbone)
self.to(device)
if config.freeze_backbone:
self.backbone.eval()
def train(self, mode: bool = True):
super().train(mode)
if self.router_config.freeze_backbone:
self.backbone.eval()
return self
def _pool(self, hidden: torch.Tensor, attention_mask: torch.Tensor) -> torch.Tensor:
if self.router_config.pooling == "mean":
mask = attention_mask.unsqueeze(-1).to(hidden.dtype)
return (hidden * mask).sum(dim=1) / mask.sum(dim=1).clamp_min(1)
positions = torch.arange(hidden.shape[1], device=hidden.device).unsqueeze(0)
last_indices = positions.masked_fill(attention_mask == 0, -1).max(dim=1).values
batch_indices = torch.arange(hidden.shape[0], device=hidden.device)
return hidden[batch_indices, last_indices]
def encode_features(self, input_ids: torch.Tensor, attention_mask: torch.Tensor) -> torch.Tensor:
context = torch.no_grad() if self.router_config.freeze_backbone else nullcontext()
with context:
output = self.backbone(input_ids=input_ids, attention_mask=attention_mask)
pooled = self._pool(output.last_hidden_state, attention_mask)
return self.norm(pooled.float())
def forward(self, input_ids: torch.Tensor, attention_mask: torch.Tensor) -> torch.Tensor:
features = self.encode_features(input_ids, attention_mask)
return self.classifier(self.dropout(features))
def head_state_dict(self) -> dict[str, torch.Tensor]:
state = {}
for prefix, module in (("norm", self.norm), ("classifier", self.classifier)):
for name, value in module.state_dict().items():
state[f"{prefix}.{name}"] = value.detach().cpu().contiguous()
return state
def load_head_state_dict(self, state: dict[str, torch.Tensor]) -> None:
self.norm.load_state_dict(
{key.removeprefix("norm."): value for key, value in state.items() if key.startswith("norm.")}
)
self.classifier.load_state_dict(
{
key.removeprefix("classifier."): value
for key, value in state.items()
if key.startswith("classifier.")
}
)
def save_checkpoint(self, path: str | Path, metadata: dict | None = None) -> None:
destination = Path(path)
destination.mkdir(parents=True, exist_ok=True)
payload = {
"format_version": 1,
"model": self.router_config.model_dump(),
"worker_ids": self.worker_ids,
"metadata": metadata or {},
}
(destination / "router_config.json").write_text(
json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
)
save_file(self.head_state_dict(), destination / "router_head.safetensors")
self.tokenizer.save_pretrained(destination / "tokenizer")
if not self.router_config.freeze_backbone:
self.backbone.save_pretrained(destination / "backbone", safe_serialization=True)
@classmethod
def from_checkpoint(
cls, path: str | Path, device: torch.device | None = None
) -> RouterModel:
source = Path(path)
payload = json.loads((source / "router_config.json").read_text(encoding="utf-8"))
config = ModelConfig.model_validate(payload["model"])
target_device = device or torch.device("cuda" if torch.cuda.is_available() else "cpu")
backbone_path = source / "backbone"
model = cls(
config,
payload["worker_ids"],
target_device,
backbone_source=backbone_path if backbone_path.exists() else None,
)
tokenizer_path = source / "tokenizer"
if tokenizer_path.exists():
model.tokenizer = AutoTokenizer.from_pretrained(tokenizer_path, use_fast=True)
if model.tokenizer.pad_token_id is None:
model.tokenizer.pad_token = model.tokenizer.eos_token
model.tokenizer.padding_side = "right"
state = load_file(source / "router_head.safetensors", device="cpu")
model.load_head_state_dict(state)
return model
def trainable_parameter_counts(self) -> tuple[int, int]:
trainable = sum(parameter.numel() for parameter in self.parameters() if parameter.requires_grad)
total = sum(parameter.numel() for parameter in self.parameters())
return trainable, total