""" Debatra — Worker 3: Stance Tracker Inference ============================================= Base model: cross-encoder/nli-deberta-v3-small Adapter: LoRA r=16 trained on ibm/claim_stance + ibm/argq_30k + climate_fever Task: 3-class sequence classification Labels: FAVOR=0 AGAINST=1 NONE=2 Test F1: 0.848 Input: (text: str, topic: str) Internally formatted as: "[TOPIC] {topic} [ARGUMENT] {text}" Output: { "label": 0, "stance": "FAVOR", "confidence": 0.91, "score": 9.0, # 0-10 (high = clear committed stance) "uncertain": False, } """ import torch from transformers import AutoTokenizer, AutoModelForSequenceClassification from peft import PeftModel LABEL2ID = {"FAVOR":0, "AGAINST":1, "NONE":2} ID2LABEL = {v: k for k, v in LABEL2ID.items()} BASE_MODEL = "cross-encoder/nli-deberta-v3-small" MAX_LENGTH = 256 class StanceTrackerWorker: def __init__(self, model_path: str, confidence_threshold: float = 0.60): self.model_path = model_path self.confidence_threshold = confidence_threshold self._loaded = False self._load() def _load(self): try: try: self.tokenizer = AutoTokenizer.from_pretrained( self.model_path, use_fast=True ) except Exception: # Some LoRA export folders omit config.json; fall back to base tokenizer. self.tokenizer = AutoTokenizer.from_pretrained( BASE_MODEL, use_fast=True ) base = AutoModelForSequenceClassification.from_pretrained( BASE_MODEL, num_labels=len(LABEL2ID), ignore_mismatched_sizes=True, ) self.model = PeftModel.from_pretrained(base, self.model_path) self.model.eval() if torch.cuda.is_available(): self.model = self.model.cuda() self._loaded = True except Exception as e: raise RuntimeError(f"Stance Tracker failed to load from {self.model_path}: {e}") def status(self) -> str: device = "cuda" if torch.cuda.is_available() else "cpu" return f"loaded ({device})" if self._loaded else "not loaded" def predict(self, text: str, topic: str = "") -> dict: """ Format input as "[TOPIC] {topic} [ARGUMENT] {text}" matching the training format from data_prep_v3.py. """ if topic: formatted = f"[TOPIC] {topic} [ARGUMENT] {text}" else: formatted = text enc = self.tokenizer( formatted, truncation=True, max_length=MAX_LENGTH, return_tensors="pt", ) device = next(self.model.parameters()).device enc = {k: v.to(device) for k, v in enc.items()} with torch.no_grad(): outputs = self.model(**enc) probs = torch.softmax(outputs.logits[0], dim=-1) label_id = torch.argmax(probs).item() confidence = probs[label_id].item() stance = ID2LABEL[label_id] uncertain = confidence < self.confidence_threshold # Score: clear FAVOR or AGAINST = high, NONE = mid, uncertain = low if stance == "NONE": score = 4.0 else: score = round(confidence * 10.0, 2) # max 10 for 100% conf if uncertain: score = round(score * 0.6, 2) return { "label": label_id, "stance": stance if not uncertain else "NONE", "confidence": round(confidence, 3), "score": min(score, 10.0), "uncertain": uncertain, }