feat: Intelligence Pipeline v3 — full implementation
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.
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user