complexity-levels-api / src /predictor.py
uzzam2121
Fix build: bundle code in Space repo
fc7db98
Raw
History Blame Contribute Delete
7.77 kB
"""
UI-ready inference wrapper for the two-head complexity model.
"""
from __future__ import annotations
import json
import pickle
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
import numpy as np
import torch
from linguistic_features import extract_linguistic_features
from model_loading import load_model_from_checkpoint
from utils import (
EXPORT_DIR,
HARD_LEVELS,
ID_TO_LEVEL,
ID_TO_REASON,
LEVEL_ORDER,
MODELS,
REASON_ORDER,
difficult_class_probability,
tokenize_lcp_input,
)
@dataclass
class PredictionResult:
complexity_level: str
level_id: int
level_probs: dict[str, float]
difficult_class_prob: float # P(Hard)+P(Very Hard) from 5-class softmax (auxiliary)
reason: str | None
reason_id: int | None
reason_probs: dict[str, float] | None
def to_dict(self) -> dict[str, Any]:
return asdict(self)
class ComplexityPredictor:
"""Load once, call predict() from your UI or API."""
def __init__(
self,
model,
tokenizer,
meta: dict,
max_length: int = 192,
device: str | None = None,
):
self.model = model
self.tokenizer = tokenizer
self.meta = meta
self.model_key = meta["model_key"]
self.encoding = meta.get("encoding", "span_mark")
self.max_length = max_length
self.device = torch.device(device or ("cuda" if torch.cuda.is_available() else "cpu"))
self.model.to(self.device)
self.model.eval()
@classmethod
def from_checkpoint(cls, checkpoint_path: str | Path, device: str | None = None) -> "ComplexityPredictor":
dev = torch.device(device or ("cuda" if torch.cuda.is_available() else "cpu"))
model, tokenizer, meta = load_model_from_checkpoint(checkpoint_path, dev)
return cls(model, tokenizer, meta, device=str(dev))
@classmethod
def load(cls, export_dir: str | Path, device: str | None = None) -> "ComplexityPredictor":
export_dir = Path(export_dir)
with open(export_dir / "config.json") as f:
config = json.load(f)
from transformers import AutoTokenizer
from two_head_model import TwoHeadModel
model_key = config["model_key"]
tokenizer = AutoTokenizer.from_pretrained(export_dir / "tokenizer")
model = TwoHeadModel(
model_name=MODELS[model_key],
pooling_mode=config.get("pooling", "span"),
use_linguistic_features=config.get("use_linguistic_features", False),
)
model.encoder.resize_token_embeddings(len(tokenizer))
weights = torch.load(export_dir / "model_weights.pt", map_location="cpu", weights_only=True)
model.load_state_dict(weights)
model.set_tgt_token_ids(config["tgt_id"], config.get("tgt_end_id"))
meta = {
"model_key": model_key,
"encoding": config.get("encoding", "span_mark"),
"pooling": config.get("pooling", "span"),
"use_linguistic_features": config.get("use_linguistic_features", False),
"tgt_id": config["tgt_id"],
"tgt_end_id": config.get("tgt_end_id"),
"feat_mean": np.array(config["feat_mean"]) if config.get("feat_mean") else None,
"feat_std": np.array(config["feat_std"]) if config.get("feat_std") else None,
}
return cls(model, tokenizer, meta, max_length=config.get("max_length", 192), device=device)
def save(self, export_dir: str | Path) -> Path:
export_dir = Path(export_dir)
export_dir.mkdir(parents=True, exist_ok=True)
config = {
"model_key": self.model_key,
"model_name": MODELS[self.model_key],
"tgt_id": self.meta.get("tgt_id"),
"tgt_end_id": self.meta.get("tgt_end_id"),
"encoding": self.encoding,
"pooling": self.meta.get("pooling", "span"),
"use_linguistic_features": self.meta.get("use_linguistic_features", False),
"feat_mean": self.meta.get("feat_mean").tolist() if self.meta.get("feat_mean") is not None else None,
"feat_std": self.meta.get("feat_std").tolist() if self.meta.get("feat_std") is not None else None,
"max_length": self.max_length,
"level_labels": LEVEL_ORDER,
"reason_labels": REASON_ORDER,
"hard_levels": list(HARD_LEVELS),
"inference_rule": "Reason is returned only when predicted level is Hard or Very Hard.",
"task": "5_class_complexity_prediction",
"outputs": "discrete_level_and_optional_reason_class",
}
with open(export_dir / "config.json", "w") as f:
json.dump(config, f, indent=2)
torch.save(self.model.state_dict(), export_dir / "model_weights.pt")
self.tokenizer.save_pretrained(export_dir / "tokenizer")
return export_dir
def save_pickle(self, pickle_path: str | Path) -> Path:
pickle_path = Path(pickle_path)
pickle_path.parent.mkdir(parents=True, exist_ok=True)
with open(pickle_path, "wb") as f:
pickle.dump(self, f)
return pickle_path
@classmethod
def load_pickle(cls, pickle_path: str | Path, device: str | None = None) -> "ComplexityPredictor":
with open(pickle_path, "rb") as f:
obj = pickle.load(f)
if not isinstance(obj, cls):
raise TypeError(f"Expected ComplexityPredictor, got {type(obj)}")
if device:
obj.device = torch.device(device)
obj.model.to(obj.device)
obj.model.eval()
return obj
@torch.no_grad()
def predict(self, sentence: str, target_word: str, corpus: str | None = None) -> PredictionResult:
enc = tokenize_lcp_input(
self.tokenizer,
sentence,
target_word,
encoding=self.encoding,
max_length=self.max_length,
corpus=corpus,
)
enc = {k: v.to(self.device) for k, v in enc.items()}
kwargs = {"input_ids": enc["input_ids"], "attention_mask": enc["attention_mask"]}
if self.meta.get("use_linguistic_features"):
feats = extract_linguistic_features(sentence, target_word)
if self.meta.get("feat_mean") is not None:
feats = (feats - self.meta["feat_mean"]) / self.meta["feat_std"]
kwargs["linguistic_features"] = torch.tensor(feats, dtype=torch.float32).unsqueeze(0).to(self.device)
out = self.model(**kwargs)
level_probs_t = torch.softmax(out.level_logits, dim=-1)[0].cpu()
reason_probs_t = torch.softmax(out.reason_logits, dim=-1)[0].cpu()
level_id = int(level_probs_t.argmax())
level_label = ID_TO_LEVEL[level_id]
level_probs = {ID_TO_LEVEL[i]: float(level_probs_t[i]) for i in range(5)}
diff_prob = difficult_class_probability(level_probs_t)
reason_label = None
reason_id = None
reason_probs = None
if level_label in HARD_LEVELS:
reason_id = int(reason_probs_t.argmax())
reason_label = ID_TO_REASON[reason_id]
reason_probs = {ID_TO_REASON[i]: float(reason_probs_t[i]) for i in range(3)}
return PredictionResult(
complexity_level=level_label,
level_id=level_id,
level_probs=level_probs,
difficult_class_prob=diff_prob,
reason=reason_label,
reason_id=reason_id,
reason_probs=reason_probs,
)
def predict_batch(self, items: list[dict[str, str]]) -> list[PredictionResult]:
return [self.predict(item["sentence"], item["target_word"], item.get("corpus")) for item in items]