Spaces:
Sleeping
Sleeping
| """ | |
| 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, | |
| ) | |
| 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() | |
| 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)) | |
| 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 | |
| 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 | |
| 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] | |