"""FinBERT adapter for financial sentiment classification. Provides a unified interface for FinBERT inference with: - Production mode: loads ProsusAI/finbert and runs real inference - Test mode: deterministic keyword-based mock probabilities Model version is pinned and exposed for lineage tracking. """ from __future__ import annotations import logging logger = logging.getLogger(__name__) # Pinned model configuration FINBERT_MODEL_NAME = "ProsusAI/finbert" FINBERT_MODEL_VERSION = "ProsusAI/finbert@v1.0" # Keywords for deterministic test mode _POSITIVE_KEYWORDS = frozenset({ "growth", "profit", "beat", "raised", "upgrade", "strong", "surge", "gain", "bullish", "outperform", "exceeded", "record", "positive", "optimistic", "rally", "upside", }) _NEGATIVE_KEYWORDS = frozenset({ "loss", "decline", "miss", "cut", "downgrade", "weak", "plunge", "drop", "bearish", "underperform", "fell", "crash", "negative", "pessimistic", "risk", "downside", "slump", }) class FinBERTAdapter: """Adapter for FinBERT financial sentiment classification. Parameters ---------- test_mode When True, uses deterministic keyword-based classification instead of loading the actual FinBERT model. Useful for testing without GPU/large model dependencies. """ def __init__(self, test_mode: bool = True) -> None: self._test_mode = test_mode self._model = None self._tokenizer = None self._model_name = FINBERT_MODEL_NAME self._model_version = FINBERT_MODEL_VERSION if not test_mode: self._load_model() @property def model_version(self) -> str: """Return the pinned model version string.""" return self._model_version @property def model_name(self) -> str: """Return the model name.""" return self._model_name def _load_model(self) -> None: """Load the FinBERT model and tokenizer for production inference.""" try: from transformers import AutoModelForSequenceClassification, AutoTokenizer logger.info("Loading FinBERT model: %s", self._model_name) self._tokenizer = AutoTokenizer.from_pretrained(self._model_name) self._model = AutoModelForSequenceClassification.from_pretrained(self._model_name) self._model.eval() logger.info("FinBERT model loaded successfully") except ImportError: raise RuntimeError( "transformers and torch are required for production FinBERT inference. " "Install with: pip install transformers torch" ) except Exception as e: raise RuntimeError(f"Failed to load FinBERT model: {e}") from e def classify(self, texts: list[str]) -> list[tuple[float, float, float]]: """Classify texts and return probability distributions. Parameters ---------- texts List of text strings to classify. Returns ------- list[tuple[float, float, float]] List of (positive_prob, negative_prob, neutral_prob) tuples. Probabilities sum to 1.0 for each text. """ if not texts: return [] if self._test_mode: return self._classify_test_mode(texts) return self._classify_production(texts) def _classify_test_mode(self, texts: list[str]) -> list[tuple[float, float, float]]: """Deterministic keyword-based classification for testing. Returns consistent probabilities based on keyword presence: - Positive keywords dominant -> (0.75, 0.10, 0.15) - Negative keywords dominant -> (0.10, 0.75, 0.15) - Both present (mixed signals) -> (0.40, 0.40, 0.20) - Neither present -> (0.15, 0.15, 0.70) """ results: list[tuple[float, float, float]] = [] for text in texts: lower_text = text.lower() words = set(lower_text.split()) has_positive = bool(words & _POSITIVE_KEYWORDS) has_negative = bool(words & _NEGATIVE_KEYWORDS) if has_positive and has_negative: # Mixed signals results.append((0.40, 0.40, 0.20)) elif has_positive: results.append((0.75, 0.10, 0.15)) elif has_negative: results.append((0.10, 0.75, 0.15)) else: # Neutral — no sentiment keywords results.append((0.15, 0.15, 0.70)) return results def _classify_production(self, texts: list[str]) -> list[tuple[float, float, float]]: """Run FinBERT inference on texts using the loaded model.""" import torch if self._model is None or self._tokenizer is None: raise RuntimeError("Model not loaded. Initialize with test_mode=False.") results: list[tuple[float, float, float]] = [] # Process in batches to manage memory batch_size = 16 for i in range(0, len(texts), batch_size): batch = texts[i : i + batch_size] inputs = self._tokenizer( batch, padding=True, truncation=True, max_length=512, return_tensors="pt", ) with torch.no_grad(): outputs = self._model(**inputs) # FinBERT output order: positive, negative, neutral probs = torch.softmax(outputs.logits, dim=-1) for prob in probs: pos = float(prob[0]) neg = float(prob[1]) neu = float(prob[2]) results.append((pos, neg, neu)) return results