Spaces:
Running
Running
File size: 9,167 Bytes
d0622ef | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 | """Transformer-based hawkish/dovish/neutral classifier.
Default model: "tim9510019/FOMC-RoBERTa", a public mirror of
gtfintechlab/FOMC-RoBERTa -- a RoBERTa-large model fine-tuned specifically
to classify hawkish/dovish/neutral monetary-policy stance in FOMC
communication (from academic work on FOMC sentence-level stance
classification). The original gtfintechlab checkpoint is access-gated on
HuggingFace (requires a logged-in, approved account); this mirror has the
identical weights/config but is publicly downloadable, so the tool works
out of the box with no HF token.
Its config ships with generic labels (LABEL_0/1/2, since the mirror
dropped the original's label names), so this module hard-codes the
correct mapping for that model rather than guessing. That mapping was
verified empirically against the model card's own example sentences plus
several hand-written hawkish/dovish/neutral test sentences (see
tests/test_model.py) -- LABEL_0=dovish, LABEL_1=hawkish, LABEL_2=neutral.
Any other model can be used via `model_name`: if it exposes label
strings containing "hawk"/"dov"/"neutral" they're read directly; general
sentiment models (e.g. "yiyanghkust/finbert-tone", Positive/Negative/
Neutral) fall back to a positive->hawkish/negative->dovish heuristic --
but that's only a rough proxy (general financial tone isn't the same as
policy stance) so prefer the default model unless you have a reason not
to.
`transformers`/`torch` are optional dependencies: importing this module
without them installed raises a clear ImportError only when you actually
try to load a model, so the rest of the package still works.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from functools import lru_cache
# Naive sentence splitter: break on ./!/? followed by whitespace and a
# capital letter or opening quote. Good enough for FOMC-style prose;
# doesn't need a full NLP sentence tokenizer dependency for this.
_SENTENCE_SPLIT_RE = re.compile(r'(?<=[.!?])\s+(?=[A-Z"“])')
DEFAULT_MODEL_NAME = "tim9510019/FOMC-RoBERTa"
# Hard-coded label mappings for models whose config.json doesn't expose
# meaningful label strings (e.g. LABEL_0/1/2). Keyed by model name;
# values are {label_index: "hawkish" | "dovish" | "neutral"}.
_KNOWN_LABEL_MAPS: dict[str, dict[int, str]] = {
"tim9510019/FOMC-RoBERTa": {0: "dovish", 1: "hawkish", 2: "neutral"},
"gtfintechlab/FOMC-RoBERTa": {0: "dovish", 1: "hawkish", 2: "neutral"},
}
# Best-effort mapping from a model's own label strings to our 3-way scale,
# used when the model isn't in _KNOWN_LABEL_MAPS above. Checked
# case-insensitively against substrings, in order.
_LABEL_HEURISTICS: list[tuple[str, str]] = [
("hawk", "hawkish"),
("dov", "dovish"),
("positive", "hawkish"), # general-sentiment-model proxy -- see module docstring
("negative", "dovish"), # general-sentiment-model proxy -- see module docstring
("neutral", "neutral"),
]
@dataclass
class ModelResult:
label: str # "hawkish" | "dovish" | "neutral"
score: float # confidence in [-1, 1], positive=hawkish, negative=dovish
raw_label: str
raw_score: float
sentence: str | None = None # populated when produced via score_document()
@dataclass
class DocumentModelResult:
"""Aggregate of per-sentence ModelResults for a longer piece of text."""
label: str
score: float # mean signed score across sentences, [-1, 1]
hawkish_count: int
dovish_count: int
neutral_count: int
sentences: list[ModelResult] = field(default_factory=list)
def split_sentences(text: str) -> list[str]:
text = " ".join(text.split()) # collapse whitespace/newlines
if not text:
return []
return [s.strip() for s in _SENTENCE_SPLIT_RE.split(text) if s.strip()]
def _map_label(raw_label: str) -> str:
lowered = raw_label.lower()
for needle, mapped in _LABEL_HEURISTICS:
if needle in lowered:
return mapped
return "neutral"
class TransformerScorer:
"""Loads a HF sequence-classification model once and scores text with it.
Raises ImportError at construction time if transformers/torch aren't
installed -- callers should catch this and fall back to the lexicon
scorer (see pipeline.py) if they want the tool to work without these
heavier dependencies.
"""
def __init__(self, model_name: str = DEFAULT_MODEL_NAME, device: str = "cpu"):
try:
from transformers import AutoModelForSequenceClassification, AutoTokenizer
except ImportError as exc:
raise ImportError(
"transformers/torch are required for TransformerScorer. "
"Install them (see requirements.txt) or use the lexicon-only "
"path in src/sentiment/pipeline.py."
) from exc
self.model_name = model_name
self.tokenizer = AutoTokenizer.from_pretrained(model_name)
self.model = AutoModelForSequenceClassification.from_pretrained(model_name)
self.model.to(device)
self.device = device
self.model.eval()
known_map = _KNOWN_LABEL_MAPS.get(model_name)
if known_map is not None:
self._label_map = known_map
self.id2label = {idx: known_map[idx] for idx in known_map}
else:
raw_id2label = self.model.config.id2label
self.id2label = raw_id2label
self._label_map = {int(idx): _map_label(label) for idx, label in raw_id2label.items()}
def score(self, text: str, max_length: int = 512) -> ModelResult:
import torch
inputs = self.tokenizer(
text,
return_tensors="pt",
truncation=True,
max_length=max_length,
).to(self.device)
with torch.no_grad():
logits = self.model(**inputs).logits
probs = torch.softmax(logits, dim=-1)[0]
top_idx = int(torch.argmax(probs).item())
raw_label = str(self.id2label[top_idx])
raw_score = float(probs[top_idx].item())
mapped_label = self._label_map[top_idx]
hawkish_p = sum(float(probs[idx].item()) for idx, lab in self._label_map.items() if lab == "hawkish")
dovish_p = sum(float(probs[idx].item()) for idx, lab in self._label_map.items() if lab == "dovish")
if hawkish_p or dovish_p:
signed_score = hawkish_p - dovish_p
else:
signed_score = raw_score if mapped_label == "hawkish" else -raw_score if mapped_label == "dovish" else 0.0
return ModelResult(
label=mapped_label,
score=round(signed_score, 4),
raw_label=raw_label,
raw_score=round(raw_score, 4),
)
def score_document(self, text: str, threshold: float = 0.15, max_sentences: int = 150) -> DocumentModelResult:
"""Score arbitrary-length text by classifying it sentence-by-sentence
and aggregating -- this model was trained on individual sentences,
so a single `score()` call on a long document truncates to its
first ~512 tokens and effectively ignores the rest. For a single
short sentence (the common case for this repo's live analyzer),
this reduces to one `score()` call.
`max_sentences` bounds runtime on very long documents (a full FOMC
minutes can run 200+ sentences, and this model runs one forward
pass per sentence on CPU) by evenly sampling across the document
rather than just taking the first N, so the aggregate score still
reflects the whole document's arc, not just its opening.
"""
sentences = split_sentences(text)
if not sentences:
sentences = [text.strip()] if text.strip() else []
if not sentences:
return DocumentModelResult(label="neutral", score=0.0, hawkish_count=0, dovish_count=0, neutral_count=0)
if len(sentences) > max_sentences:
step = len(sentences) / max_sentences
sentences = [sentences[int(i * step)] for i in range(max_sentences)]
results = []
for s in sentences:
r = self.score(s)
r.sentence = s
results.append(r)
mean_score = sum(r.score for r in results) / len(results)
hawkish_count = sum(1 for r in results if r.label == "hawkish")
dovish_count = sum(1 for r in results if r.label == "dovish")
neutral_count = sum(1 for r in results if r.label == "neutral")
if mean_score > threshold:
label = "hawkish"
elif mean_score < -threshold:
label = "dovish"
else:
label = "neutral"
return DocumentModelResult(
label=label,
score=round(mean_score, 4),
hawkish_count=hawkish_count,
dovish_count=dovish_count,
neutral_count=neutral_count,
sentences=results,
)
@lru_cache(maxsize=4)
def get_scorer(model_name: str = DEFAULT_MODEL_NAME) -> TransformerScorer:
"""Cache scorers by model name so repeated calls don't reload weights."""
return TransformerScorer(model_name=model_name)
|