Multi-stage evidence-grounded inference architecture replacing the monolithic 9B model extraction pipeline. CPU-first specialist services handle routine extraction while the 9B vLLM model is preserved for semantic adjudication of ambiguous cases. Key components: - Capability-aware inference gateway (OpenAI-compatible + Ollama) - Endpoint registry with DB migrations and REST API - Sentence-aware document segmenter (property tests) - Deterministic financial parsing with offset integrity - Symbol resolution with ambiguity detection - Specialist service (GLiNER2, dynamic batching, K8s deployment) - Company-specific sentiment (FinBERT, calibration) - Retrieval-based novelty and duplicate detection - Confidence calibration pipeline - Deterministic routing engine (property tests) - 9B adjudication layer with VRAM gating - Stock-specific impact model (features, labels, baseline, trained) - Pipeline orchestrator (state machine, queues, leases, feature flags) - Bounded parallelism (async workers, semaphore, load shedding) - Observability (tracing, metrics, alerts) - Compatibility adapter (v3→v2 golden mapping tests) - Shadow/canary promotion framework - Active learning and fine-tuning pipeline Test results: 1,161 tests pass, ruff lint clean. All 282 spec tasks completed.
168 lines
5.6 KiB
Python
168 lines
5.6 KiB
Python
"""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
|