Text Classification
Transformers
Joblib
Safetensors
bert
sentiment-analysis
finance
macroeconomics
climate
esg
policy
ensemble
dictionary
finbert
Eval Results (legacy)
text-embeddings-inference
Instructions to use peyterho/macro-sentiment-finbert with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use peyterho/macro-sentiment-finbert with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("text-classification", model="peyterho/macro-sentiment-finbert")# Load model directly from transformers import AutoTokenizer, AutoModelForSequenceClassification tokenizer = AutoTokenizer.from_pretrained("peyterho/macro-sentiment-finbert") model = AutoModelForSequenceClassification.from_pretrained("peyterho/macro-sentiment-finbert", device_map="auto") - Notebooks
- Google Colab
- Kaggle
| """ | |
| Multi-head transformer ensemble for macroeconomic sentiment. | |
| Domain-specific heads: | |
| 1. FinBERT — financial news sentiment (English) | |
| 2. Financial-RoBERTa-large — policy/formal text sentiment (English) | |
| 3. ClimateBERT — climate risk/opportunity (English) | |
| 4. XLM-RoBERTa — multilingual sentiment (8+ languages) | |
| 5. Keyword-based Topic Router — routes to appropriate head | |
| All heads output standardized scores in [-1, +1]. | |
| Fine-tuned models (v0.2.0): | |
| - FinBERT: peyterho/finbert-macro-sentiment | |
| - RoBERTa: peyterho/financial-roberta-large-macro-sentiment | |
| - ClimateBERT: peyterho/climatebert-macro-sentiment | |
| Multilingual (v0.3.0): | |
| - XLM-RoBERTa: cardiffnlp/twitter-xlm-roberta-base-sentiment-multilingual | |
| """ | |
| import re | |
| import numpy as np | |
| from typing import Dict, List, Optional, Tuple | |
| from transformers import ( | |
| AutoTokenizer, | |
| AutoModelForSequenceClassification, | |
| pipeline, | |
| ) | |
| # ─── Default model names ───────────────────────────────────────── | |
| DEFAULT_FINBERT = "peyterho/finbert-macro-sentiment" | |
| DEFAULT_ROBERTA = "peyterho/financial-roberta-large-macro-sentiment" | |
| DEFAULT_CLIMATEBERT = "peyterho/climatebert-macro-sentiment" | |
| DEFAULT_MULTILINGUAL = "cardiffnlp/twitter-xlm-roberta-base-sentiment-multilingual" | |
| ORIGINAL_FINBERT = "ProsusAI/finbert" | |
| ORIGINAL_ROBERTA = "soleimanian/financial-roberta-large-sentiment" | |
| ORIGINAL_CLIMATEBERT = "climatebert/distilroberta-base-climate-sentiment" | |
| class SentimentHead: | |
| """Base class for a transformer sentiment scoring head.""" | |
| def __init__(self, model_name, label_map, score_map, device="cpu", max_length=512): | |
| self.model_name = model_name | |
| self.label_map = label_map | |
| self.score_map = score_map | |
| self.device = device | |
| self.max_length = max_length | |
| self._pipeline = None | |
| def load(self): | |
| if self._pipeline is None: | |
| tokenizer = AutoTokenizer.from_pretrained(self.model_name, model_max_length=self.max_length) | |
| model = AutoModelForSequenceClassification.from_pretrained(self.model_name) | |
| self._pipeline = pipeline( | |
| "text-classification", model=model, tokenizer=tokenizer, | |
| device=self.device if self.device == "cpu" else int(self.device.split(":")[-1]), | |
| truncation=True, max_length=self.max_length, top_k=None, | |
| ) | |
| return self | |
| def score(self, text): | |
| self.load() | |
| outputs = self._pipeline(text) | |
| if outputs and isinstance(outputs[0], list): | |
| outputs = outputs[0] | |
| result = {} | |
| weighted_score = 0.0 | |
| for item in outputs: | |
| label = item["label"] | |
| prob = item["score"] | |
| result[f"prob_{label}"] = prob | |
| if label in self.score_map: | |
| weighted_score += prob * self.score_map[label] | |
| else: | |
| for idx, name in self.label_map.items(): | |
| if label == f"LABEL_{idx}" or label == name: | |
| weighted_score += prob * self.score_map.get(name, 0.0) | |
| result[f"prob_{name}"] = prob | |
| break | |
| result["composite_score"] = weighted_score | |
| return result | |
| class FinBERTHead(SentimentHead): | |
| """FinBERT for financial news sentiment (English).""" | |
| def __init__(self, model_name=DEFAULT_FINBERT, device="cpu"): | |
| super().__init__( | |
| model_name=model_name, | |
| label_map={0: "positive", 1: "negative", 2: "neutral"}, | |
| score_map={"positive": 1.0, "negative": -1.0, "neutral": 0.0}, | |
| device=device, | |
| ) | |
| class FinancialRoBERTaHead(SentimentHead): | |
| """Financial-RoBERTa-Large for policy/formal text (English).""" | |
| def __init__(self, model_name=DEFAULT_ROBERTA, device="cpu"): | |
| super().__init__( | |
| model_name=model_name, | |
| label_map={0: "negative", 1: "neutral", 2: "positive"}, | |
| score_map={"positive": 1.0, "negative": -1.0, "neutral": 0.0}, | |
| device=device, max_length=512, | |
| ) | |
| class ClimateBERTHead(SentimentHead): | |
| """ClimateBERT for climate risk/opportunity (English).""" | |
| def __init__(self, model_name=DEFAULT_CLIMATEBERT, device="cpu"): | |
| super().__init__( | |
| model_name=model_name, | |
| label_map={0: "risk", 1: "neutral", 2: "opportunity"}, | |
| score_map={"risk": -1.0, "neutral": 0.0, "opportunity": 1.0}, | |
| device=device, max_length=512, | |
| ) | |
| class MultilingualHead(SentimentHead): | |
| """XLM-RoBERTa for multilingual sentiment (8+ languages). | |
| Supports: English, Arabic, French, German, Hindi, Italian, Portuguese, Spanish | |
| and performs reasonably on many other languages. | |
| Uses cardiffnlp/twitter-xlm-roberta-base-sentiment-multilingual by default. | |
| Args: | |
| model_name: HF model ID for the multilingual model. | |
| device: 'cpu' or 'cuda:0'. | |
| """ | |
| def __init__(self, model_name=DEFAULT_MULTILINGUAL, device="cpu"): | |
| super().__init__( | |
| model_name=model_name, | |
| label_map={0: "negative", 1: "neutral", 2: "positive"}, | |
| score_map={"positive": 1.0, "negative": -1.0, "neutral": 0.0}, | |
| device=device, max_length=512, | |
| ) | |
| # ─── Language Detection ────────────────────────────────────────── | |
| def detect_language(text): | |
| """Simple heuristic language detection. Returns 'en' or 'other'. | |
| Uses Unicode script analysis — no external dependencies. | |
| For production use, install langdetect or fasttext for better accuracy. | |
| """ | |
| # Try to import langdetect if available | |
| try: | |
| from langdetect import detect | |
| lang = detect(text) | |
| return lang | |
| except ImportError: | |
| pass | |
| # Fallback: heuristic based on character ranges | |
| ascii_chars = sum(1 for c in text if ord(c) < 128) | |
| total_chars = max(len(text), 1) | |
| # If >85% ASCII, likely English (or another Latin-script language) | |
| # Check for common non-English Latin indicators | |
| text_lower = text.lower() | |
| # German indicators | |
| if any(c in text for c in "äöüß") or any(w in text_lower for w in ["und", "der", "die", "das"]): | |
| return "de" | |
| # French indicators | |
| if any(c in text for c in "éèêëàâùûçœ") or any(w in text_lower for w in [" le ", " la ", " les ", " des "]): | |
| return "fr" | |
| # Spanish indicators | |
| if any(c in text for c in "ñáéíóú¿¡") or any(w in text_lower for w in [" el ", " los ", " las "]): | |
| return "es" | |
| # Portuguese | |
| if any(c in text for c in "ãõç") or any(w in text_lower for w in [" os ", " das ", " nos "]): | |
| return "pt" | |
| # Japanese/Chinese (CJK characters) | |
| if any('\u4e00' <= c <= '\u9fff' or '\u3040' <= c <= '\u30ff' for c in text): | |
| return "ja" # could be zh too | |
| # Arabic | |
| if any('\u0600' <= c <= '\u06ff' for c in text): | |
| return "ar" | |
| # Hindi/Devanagari | |
| if any('\u0900' <= c <= '\u097f' for c in text): | |
| return "hi" | |
| if ascii_chars / total_chars > 0.85: | |
| return "en" | |
| return "other" | |
| # ─── Topic Router ──────────────────────────────────────────────── | |
| class TopicRouter: | |
| """Keyword-based topic router with language-aware multilingual fallback. | |
| Routing logic: | |
| 1. Detect language — if non-English, route to multilingual head | |
| 2. For English text, use keyword matching: | |
| - Policy/macro keywords → RoBERTa-Large (policy head) | |
| - Climate/ESG keywords → ClimateBERT | |
| - Social indicators ($cashtags, slang) → FinBERT | |
| - Default → FinBERT | |
| """ | |
| POLICY_KEYWORDS = { | |
| "federal reserve", "fed ", "fomc", "central bank", "ecb", "boe", | |
| "bank of japan", "boj", "bank of england", "rba", "pboc", | |
| "bank of canada", "riksbank", "snb", "reserve bank", | |
| "lagarde", "powell", "yellen", "draghi", "bernanke", | |
| "interest rate", "rate hike", "rate cut", "monetary policy", | |
| "quantitative easing", "quantitative tightening", "tapering", | |
| "policy rate", "discount rate", "fed funds", "federal funds", | |
| "basis points", "bps", "hawkish", "dovish", "accommodative", | |
| "tightening", "easing", "forward guidance", "yield curve", | |
| "rates unchanged", "data-dependent", "data dependent", | |
| "policy stance", "policy decision", "rate decision", | |
| "fiscal policy", "government spending", "treasury", "sovereign", | |
| "debt ceiling", "fiscal stimulus", "budget deficit", "gdp", | |
| "unemployment", "labor market", "jobs report", "nonfarm", | |
| "payrolls", "consumer price index", "cpi", "pce", "inflation", | |
| "deflation", "stagflation", "ppi", "economic growth", | |
| "recession", "economic outlook", "macro", "macroeconomic", | |
| } | |
| CLIMATE_KEYWORDS = { | |
| "climate", "carbon", "emission", "emissions", "renewable", | |
| "sustainability", "sustainable", "esg", "green bond", | |
| "net zero", "net-zero", "paris agreement", "clean energy", | |
| "fossil fuel", "decarbonization", "decarbonisation", | |
| "tcfd", "sfdr", "eu taxonomy", "climate risk", | |
| "climate change", "global warming", "greenhouse", | |
| "environmental", "carbon tax", "carbon price", | |
| "wind energy", "solar energy", "electric vehicle", | |
| } | |
| SOCIAL_INDICATORS = { | |
| "$", "#", "@", "imo", "imho", "lol", "lmao", "tbh", | |
| "bullish af", "bearish af", "moon", "to the moon", | |
| "diamond hands", "paper hands", "hodl", "fomo", "yolo", | |
| } | |
| def __init__(self, device="cpu", enable_multilingual=True): | |
| self.enable_multilingual = enable_multilingual | |
| def load(self): | |
| return self | |
| def classify(self, text): | |
| # Step 1: Language detection | |
| lang = detect_language(text) if self.enable_multilingual else "en" | |
| if lang != "en" and self.enable_multilingual: | |
| return { | |
| "topic": f"Multilingual ({lang})", | |
| "topic_confidence": 0.8, | |
| "recommended_head": "multilingual", | |
| "detected_language": lang, | |
| "all_topics": {"multilingual": 10}, | |
| } | |
| # Step 2: English keyword routing | |
| text_lower = text.lower() | |
| policy_hits = sum(1 for kw in self.POLICY_KEYWORDS if kw in text_lower) | |
| climate_hits = sum(1 for kw in self.CLIMATE_KEYWORDS if kw in text_lower) | |
| social_hits = sum(1 for ind in self.SOCIAL_INDICATORS if ind in text) | |
| has_cashtag = bool(re.search(r'\$[A-Z]{1,5}\b', text)) | |
| is_short = len(text.split()) < 40 | |
| if has_cashtag: social_hits += 3 | |
| if is_short: social_hits += 1 | |
| scores = {"policy": policy_hits, "climate": climate_hits, "social": social_hits} | |
| best_domain = max(scores, key=scores.get) | |
| best_score = scores[best_domain] | |
| result = {"detected_language": lang, "all_topics": scores} | |
| if best_score < 2: | |
| result.update({"topic": "Financial News", "topic_confidence": 0.5, "recommended_head": "finbert"}) | |
| elif best_domain == "policy": | |
| result.update({"topic": "Macro/Policy", "topic_confidence": min(1.0, policy_hits / 6), "recommended_head": "policy"}) | |
| elif best_domain == "climate": | |
| result.update({"topic": "Climate/ESG", "topic_confidence": min(1.0, climate_hits / 4), "recommended_head": "climate"}) | |
| else: | |
| result.update({"topic": "Social/Tweet", "topic_confidence": min(1.0, social_hits / 5), "recommended_head": "finbert"}) | |
| return result | |
| class TransformerEnsemble: | |
| """Multi-head transformer ensemble with domain and language routing. | |
| Args: | |
| device: 'cpu' or 'cuda:0'. | |
| use_router: Enable keyword-based topic routing. | |
| finbert_model: HF model ID for the FinBERT head. | |
| roberta_model: HF model ID for the Financial-RoBERTa head. | |
| climatebert_model: HF model ID for the ClimateBERT head. | |
| multilingual_model: HF model ID for the multilingual head. Set to None to disable. | |
| enable_multilingual: Auto-route non-English text to multilingual head. | |
| Examples: | |
| # All heads including multilingual (default) | |
| ensemble = TransformerEnsemble(device="cpu") | |
| # English-only (no multilingual head, v0.2.0 behavior) | |
| ensemble = TransformerEnsemble(multilingual_model=None) | |
| # Score German text — auto-routed to multilingual head | |
| result = ensemble.score_routed("EZB signalisiert Geduld bei Zinssenkungen.") | |
| # result["head_used"] = "multilingual" | |
| """ | |
| def __init__( | |
| self, | |
| device="cpu", | |
| use_router=True, | |
| finbert_model=DEFAULT_FINBERT, | |
| roberta_model=DEFAULT_ROBERTA, | |
| climatebert_model=DEFAULT_CLIMATEBERT, | |
| multilingual_model=DEFAULT_MULTILINGUAL, | |
| enable_multilingual=True, | |
| ): | |
| self.device = device | |
| self.use_router = use_router | |
| self.heads = { | |
| "finbert": FinBERTHead(model_name=finbert_model, device=device), | |
| "policy": FinancialRoBERTaHead(model_name=roberta_model, device=device), | |
| "climate": ClimateBERTHead(model_name=climatebert_model, device=device), | |
| } | |
| if multilingual_model: | |
| self.heads["multilingual"] = MultilingualHead(model_name=multilingual_model, device=device) | |
| self.router = TopicRouter(device, enable_multilingual=enable_multilingual and multilingual_model is not None) if use_router else None | |
| def score_routed(self, text): | |
| if not self.router: | |
| raise ValueError("Router not enabled.") | |
| topic_info = self.router.classify(text) | |
| head_name = topic_info["recommended_head"] | |
| if head_name == "tweet": head_name = "finbert" | |
| # Fallback if multilingual head not loaded | |
| if head_name == "multilingual" and "multilingual" not in self.heads: | |
| head_name = "finbert" | |
| sentiment = self.heads[head_name].score(text) | |
| return { | |
| "head_used": head_name, | |
| "topic": topic_info["topic"], | |
| "topic_confidence": topic_info["topic_confidence"], | |
| "detected_language": topic_info.get("detected_language", "en"), | |
| "sentiment_score": sentiment["composite_score"], | |
| **{f"{head_name}_{k}": v for k, v in sentiment.items()}, | |
| } | |
| def score_all(self, text): | |
| result = {} | |
| for name, head in self.heads.items(): | |
| for k, v in head.score(text).items(): | |
| result[f"{name}_{k}"] = v | |
| composites = [result.get(f"{name}_composite_score", 0.0) for name in self.heads] | |
| result["ensemble_mean"] = np.mean(composites) | |
| return result | |
| def load_all(self): | |
| for head in self.heads.values(): | |
| head.load() | |
| if self.router: self.router.load() | |
| return self | |