peyterho commited on
Commit
efce272
Β·
verified Β·
1 Parent(s): ecfd1e5

v0.3.0: Add multilingual head (XLM-RoBERTa) with language-aware routing

Browse files
macro_sentiment/transformers_ensemble.py CHANGED
@@ -2,10 +2,11 @@
2
  Multi-head transformer ensemble for macroeconomic sentiment.
3
 
4
  Domain-specific heads:
5
- 1. FinBERT β€” financial news sentiment
6
- 2. Financial-RoBERTa-large β€” policy/formal text sentiment
7
- 3. ClimateBERT β€” climate risk/opportunity
8
- 4. Keyword-based Topic Router β€” routes to appropriate head
 
9
 
10
  All heads output standardized scores in [-1, +1].
11
 
@@ -13,25 +14,27 @@ Fine-tuned models (v0.2.0):
13
  - FinBERT: peyterho/finbert-macro-sentiment
14
  - RoBERTa: peyterho/financial-roberta-large-macro-sentiment
15
  - ClimateBERT: peyterho/climatebert-macro-sentiment
 
 
 
16
  """
17
 
18
  import re
19
  import numpy as np
20
  from typing import Dict, List, Optional, Tuple
21
  from transformers import (
22
- AutoTokenizer,
23
  AutoModelForSequenceClassification,
24
  pipeline,
25
  )
26
 
27
 
28
  # ─── Default model names ─────────────────────────────────────────
29
- # Fine-tuned on 20K combined financial sentiment corpus (v0.2.0)
30
  DEFAULT_FINBERT = "peyterho/finbert-macro-sentiment"
31
  DEFAULT_ROBERTA = "peyterho/financial-roberta-large-macro-sentiment"
32
  DEFAULT_CLIMATEBERT = "peyterho/climatebert-macro-sentiment"
 
33
 
34
- # Original off-the-shelf models (v0.1.0)
35
  ORIGINAL_FINBERT = "ProsusAI/finbert"
36
  ORIGINAL_ROBERTA = "soleimanian/financial-roberta-large-sentiment"
37
  ORIGINAL_CLIMATEBERT = "climatebert/distilroberta-base-climate-sentiment"
@@ -39,7 +42,7 @@ ORIGINAL_CLIMATEBERT = "climatebert/distilroberta-base-climate-sentiment"
39
 
40
  class SentimentHead:
41
  """Base class for a transformer sentiment scoring head."""
42
-
43
  def __init__(self, model_name, label_map, score_map, device="cpu", max_length=512):
44
  self.model_name = model_name
45
  self.label_map = label_map
@@ -47,7 +50,7 @@ class SentimentHead:
47
  self.device = device
48
  self.max_length = max_length
49
  self._pipeline = None
50
-
51
  def load(self):
52
  if self._pipeline is None:
53
  tokenizer = AutoTokenizer.from_pretrained(self.model_name, model_max_length=self.max_length)
@@ -58,13 +61,13 @@ class SentimentHead:
58
  truncation=True, max_length=self.max_length, top_k=None,
59
  )
60
  return self
61
-
62
  def score(self, text):
63
  self.load()
64
  outputs = self._pipeline(text)
65
  if outputs and isinstance(outputs[0], list):
66
  outputs = outputs[0]
67
-
68
  result = {}
69
  weighted_score = 0.0
70
  for item in outputs:
@@ -84,13 +87,7 @@ class SentimentHead:
84
 
85
 
86
  class FinBERTHead(SentimentHead):
87
- """FinBERT for financial news sentiment.
88
-
89
- Args:
90
- model_name: HF model ID. Default: fine-tuned 'peyterho/finbert-macro-sentiment'.
91
- Use 'ProsusAI/finbert' for the original off-the-shelf model.
92
- device: 'cpu' or 'cuda:0'.
93
- """
94
  def __init__(self, model_name=DEFAULT_FINBERT, device="cpu"):
95
  super().__init__(
96
  model_name=model_name,
@@ -101,13 +98,7 @@ class FinBERTHead(SentimentHead):
101
 
102
 
103
  class FinancialRoBERTaHead(SentimentHead):
104
- """Financial-RoBERTa-Large for policy/formal text sentiment.
105
-
106
- Args:
107
- model_name: HF model ID. Default: fine-tuned 'peyterho/financial-roberta-large-macro-sentiment'.
108
- Use 'soleimanian/financial-roberta-large-sentiment' for the original.
109
- device: 'cpu' or 'cuda:0'.
110
- """
111
  def __init__(self, model_name=DEFAULT_ROBERTA, device="cpu"):
112
  super().__init__(
113
  model_name=model_name,
@@ -118,13 +109,7 @@ class FinancialRoBERTaHead(SentimentHead):
118
 
119
 
120
  class ClimateBERTHead(SentimentHead):
121
- """ClimateBERT for climate risk/opportunity sentiment.
122
-
123
- Args:
124
- model_name: HF model ID. Default: fine-tuned 'peyterho/climatebert-macro-sentiment'.
125
- Use 'climatebert/distilroberta-base-climate-sentiment' for the original.
126
- device: 'cpu' or 'cuda:0'.
127
- """
128
  def __init__(self, model_name=DEFAULT_CLIMATEBERT, device="cpu"):
129
  super().__init__(
130
  model_name=model_name,
@@ -134,9 +119,93 @@ class ClimateBERTHead(SentimentHead):
134
  )
135
 
136
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  class TopicRouter:
138
- """Keyword-based topic router for macroeconomic text."""
139
-
 
 
 
 
 
 
 
 
 
140
  POLICY_KEYWORDS = {
141
  "federal reserve", "fed ", "fomc", "central bank", "ecb", "boe",
142
  "bank of japan", "boj", "bank of england", "rba", "pboc",
@@ -156,7 +225,7 @@ class TopicRouter:
156
  "deflation", "stagflation", "ppi", "economic growth",
157
  "recession", "economic outlook", "macro", "macroeconomic",
158
  }
159
-
160
  CLIMATE_KEYWORDS = {
161
  "climate", "carbon", "emission", "emissions", "renewable",
162
  "sustainability", "sustainable", "esg", "green bond",
@@ -167,74 +236,83 @@ class TopicRouter:
167
  "environmental", "carbon tax", "carbon price",
168
  "wind energy", "solar energy", "electric vehicle",
169
  }
170
-
171
  SOCIAL_INDICATORS = {
172
  "$", "#", "@", "imo", "imho", "lol", "lmao", "tbh",
173
  "bullish af", "bearish af", "moon", "to the moon",
174
  "diamond hands", "paper hands", "hodl", "fomo", "yolo",
175
  }
176
-
177
- def __init__(self, device="cpu"):
178
- pass
179
-
180
  def load(self):
181
  return self
182
-
183
  def classify(self, text):
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  text_lower = text.lower()
185
  policy_hits = sum(1 for kw in self.POLICY_KEYWORDS if kw in text_lower)
186
  climate_hits = sum(1 for kw in self.CLIMATE_KEYWORDS if kw in text_lower)
187
  social_hits = sum(1 for ind in self.SOCIAL_INDICATORS if ind in text)
188
-
189
  has_cashtag = bool(re.search(r'\$[A-Z]{1,5}\b', text))
190
  is_short = len(text.split()) < 40
191
  if has_cashtag: social_hits += 3
192
  if is_short: social_hits += 1
193
-
194
  scores = {"policy": policy_hits, "climate": climate_hits, "social": social_hits}
195
  best_domain = max(scores, key=scores.get)
196
  best_score = scores[best_domain]
197
-
 
 
198
  if best_score < 2:
199
- return {"topic": "Financial News", "topic_confidence": 0.5, "recommended_head": "finbert", "all_topics": scores}
200
  elif best_domain == "policy":
201
- return {"topic": "Macro/Policy", "topic_confidence": min(1.0, policy_hits / 6), "recommended_head": "policy", "all_topics": scores}
202
  elif best_domain == "climate":
203
- return {"topic": "Climate/ESG", "topic_confidence": min(1.0, climate_hits / 4), "recommended_head": "climate", "all_topics": scores}
204
  else:
205
- return {"topic": "Social/Tweet", "topic_confidence": min(1.0, social_hits / 5), "recommended_head": "finbert", "all_topics": scores}
 
 
206
 
207
 
208
  class TransformerEnsemble:
209
- """Multi-head transformer ensemble with domain routing.
210
-
211
  Args:
212
  device: 'cpu' or 'cuda:0'.
213
  use_router: Enable keyword-based topic routing.
214
  finbert_model: HF model ID for the FinBERT head.
215
  roberta_model: HF model ID for the Financial-RoBERTa head.
216
  climatebert_model: HF model ID for the ClimateBERT head.
217
-
218
- Default models are fine-tuned on 20K combined financial corpus.
219
- Pass the ORIGINAL_* constants to use off-the-shelf models.
220
-
221
  Examples:
222
- # Use fine-tuned models (default, recommended)
223
  ensemble = TransformerEnsemble(device="cpu")
224
-
225
- # Use original off-the-shelf models
226
- from macro_sentiment.transformers_ensemble import ORIGINAL_FINBERT, ORIGINAL_ROBERTA, ORIGINAL_CLIMATEBERT
227
- ensemble = TransformerEnsemble(
228
- finbert_model=ORIGINAL_FINBERT,
229
- roberta_model=ORIGINAL_ROBERTA,
230
- climatebert_model=ORIGINAL_CLIMATEBERT,
231
- )
232
-
233
- # Mix and match
234
- ensemble = TransformerEnsemble(
235
- finbert_model="peyterho/finbert-macro-sentiment",
236
- roberta_model="soleimanian/financial-roberta-large-sentiment",
237
- )
238
  """
239
  def __init__(
240
  self,
@@ -243,6 +321,8 @@ class TransformerEnsemble:
243
  finbert_model=DEFAULT_FINBERT,
244
  roberta_model=DEFAULT_ROBERTA,
245
  climatebert_model=DEFAULT_CLIMATEBERT,
 
 
246
  ):
247
  self.device = device
248
  self.use_router = use_router
@@ -251,23 +331,29 @@ class TransformerEnsemble:
251
  "policy": FinancialRoBERTaHead(model_name=roberta_model, device=device),
252
  "climate": ClimateBERTHead(model_name=climatebert_model, device=device),
253
  }
254
- self.router = TopicRouter(device) if use_router else None
255
-
 
 
256
  def score_routed(self, text):
257
  if not self.router:
258
  raise ValueError("Router not enabled.")
259
  topic_info = self.router.classify(text)
260
  head_name = topic_info["recommended_head"]
261
  if head_name == "tweet": head_name = "finbert"
 
 
 
262
  sentiment = self.heads[head_name].score(text)
263
  return {
264
  "head_used": head_name,
265
  "topic": topic_info["topic"],
266
  "topic_confidence": topic_info["topic_confidence"],
 
267
  "sentiment_score": sentiment["composite_score"],
268
  **{f"{head_name}_{k}": v for k, v in sentiment.items()},
269
  }
270
-
271
  def score_all(self, text):
272
  result = {}
273
  for name, head in self.heads.items():
@@ -276,7 +362,7 @@ class TransformerEnsemble:
276
  composites = [result.get(f"{name}_composite_score", 0.0) for name in self.heads]
277
  result["ensemble_mean"] = np.mean(composites)
278
  return result
279
-
280
  def load_all(self):
281
  for head in self.heads.values():
282
  head.load()
 
2
  Multi-head transformer ensemble for macroeconomic sentiment.
3
 
4
  Domain-specific heads:
5
+ 1. FinBERT β€” financial news sentiment (English)
6
+ 2. Financial-RoBERTa-large β€” policy/formal text sentiment (English)
7
+ 3. ClimateBERT β€” climate risk/opportunity (English)
8
+ 4. XLM-RoBERTa β€” multilingual sentiment (8+ languages)
9
+ 5. Keyword-based Topic Router β€” routes to appropriate head
10
 
11
  All heads output standardized scores in [-1, +1].
12
 
 
14
  - FinBERT: peyterho/finbert-macro-sentiment
15
  - RoBERTa: peyterho/financial-roberta-large-macro-sentiment
16
  - ClimateBERT: peyterho/climatebert-macro-sentiment
17
+
18
+ Multilingual (v0.3.0):
19
+ - XLM-RoBERTa: cardiffnlp/twitter-xlm-roberta-base-sentiment-multilingual
20
  """
21
 
22
  import re
23
  import numpy as np
24
  from typing import Dict, List, Optional, Tuple
25
  from transformers import (
26
+ AutoTokenizer,
27
  AutoModelForSequenceClassification,
28
  pipeline,
29
  )
30
 
31
 
32
  # ─── Default model names ─────────────────────────────────────────
 
33
  DEFAULT_FINBERT = "peyterho/finbert-macro-sentiment"
34
  DEFAULT_ROBERTA = "peyterho/financial-roberta-large-macro-sentiment"
35
  DEFAULT_CLIMATEBERT = "peyterho/climatebert-macro-sentiment"
36
+ DEFAULT_MULTILINGUAL = "cardiffnlp/twitter-xlm-roberta-base-sentiment-multilingual"
37
 
 
38
  ORIGINAL_FINBERT = "ProsusAI/finbert"
39
  ORIGINAL_ROBERTA = "soleimanian/financial-roberta-large-sentiment"
40
  ORIGINAL_CLIMATEBERT = "climatebert/distilroberta-base-climate-sentiment"
 
42
 
43
  class SentimentHead:
44
  """Base class for a transformer sentiment scoring head."""
45
+
46
  def __init__(self, model_name, label_map, score_map, device="cpu", max_length=512):
47
  self.model_name = model_name
48
  self.label_map = label_map
 
50
  self.device = device
51
  self.max_length = max_length
52
  self._pipeline = None
53
+
54
  def load(self):
55
  if self._pipeline is None:
56
  tokenizer = AutoTokenizer.from_pretrained(self.model_name, model_max_length=self.max_length)
 
61
  truncation=True, max_length=self.max_length, top_k=None,
62
  )
63
  return self
64
+
65
  def score(self, text):
66
  self.load()
67
  outputs = self._pipeline(text)
68
  if outputs and isinstance(outputs[0], list):
69
  outputs = outputs[0]
70
+
71
  result = {}
72
  weighted_score = 0.0
73
  for item in outputs:
 
87
 
88
 
89
  class FinBERTHead(SentimentHead):
90
+ """FinBERT for financial news sentiment (English)."""
 
 
 
 
 
 
91
  def __init__(self, model_name=DEFAULT_FINBERT, device="cpu"):
92
  super().__init__(
93
  model_name=model_name,
 
98
 
99
 
100
  class FinancialRoBERTaHead(SentimentHead):
101
+ """Financial-RoBERTa-Large for policy/formal text (English)."""
 
 
 
 
 
 
102
  def __init__(self, model_name=DEFAULT_ROBERTA, device="cpu"):
103
  super().__init__(
104
  model_name=model_name,
 
109
 
110
 
111
  class ClimateBERTHead(SentimentHead):
112
+ """ClimateBERT for climate risk/opportunity (English)."""
 
 
 
 
 
 
113
  def __init__(self, model_name=DEFAULT_CLIMATEBERT, device="cpu"):
114
  super().__init__(
115
  model_name=model_name,
 
119
  )
120
 
121
 
122
+ class MultilingualHead(SentimentHead):
123
+ """XLM-RoBERTa for multilingual sentiment (8+ languages).
124
+
125
+ Supports: English, Arabic, French, German, Hindi, Italian, Portuguese, Spanish
126
+ and performs reasonably on many other languages.
127
+
128
+ Uses cardiffnlp/twitter-xlm-roberta-base-sentiment-multilingual by default.
129
+
130
+ Args:
131
+ model_name: HF model ID for the multilingual model.
132
+ device: 'cpu' or 'cuda:0'.
133
+ """
134
+ def __init__(self, model_name=DEFAULT_MULTILINGUAL, device="cpu"):
135
+ super().__init__(
136
+ model_name=model_name,
137
+ label_map={0: "negative", 1: "neutral", 2: "positive"},
138
+ score_map={"positive": 1.0, "negative": -1.0, "neutral": 0.0},
139
+ device=device, max_length=512,
140
+ )
141
+
142
+
143
+ # ─── Language Detection ──────────────────────────────────────────
144
+
145
+ def detect_language(text):
146
+ """Simple heuristic language detection. Returns 'en' or 'other'.
147
+
148
+ Uses Unicode script analysis β€” no external dependencies.
149
+ For production use, install langdetect or fasttext for better accuracy.
150
+ """
151
+ # Try to import langdetect if available
152
+ try:
153
+ from langdetect import detect
154
+ lang = detect(text)
155
+ return lang
156
+ except ImportError:
157
+ pass
158
+
159
+ # Fallback: heuristic based on character ranges
160
+ ascii_chars = sum(1 for c in text if ord(c) < 128)
161
+ total_chars = max(len(text), 1)
162
+
163
+ # If >85% ASCII, likely English (or another Latin-script language)
164
+ # Check for common non-English Latin indicators
165
+ text_lower = text.lower()
166
+
167
+ # German indicators
168
+ if any(c in text for c in "Àâüß") or any(w in text_lower for w in ["und", "der", "die", "das"]):
169
+ return "de"
170
+ # French indicators
171
+ if any(c in text for c in "éèΓͺëàÒùûçœ") or any(w in text_lower for w in [" le ", " la ", " les ", " des "]):
172
+ return "fr"
173
+ # Spanish indicators
174
+ if any(c in text for c in "ñÑéíóú¿‘") or any(w in text_lower for w in [" el ", " los ", " las "]):
175
+ return "es"
176
+ # Portuguese
177
+ if any(c in text for c in "ãáç") or any(w in text_lower for w in [" os ", " das ", " nos "]):
178
+ return "pt"
179
+ # Japanese/Chinese (CJK characters)
180
+ if any('\u4e00' <= c <= '\u9fff' or '\u3040' <= c <= '\u30ff' for c in text):
181
+ return "ja" # could be zh too
182
+ # Arabic
183
+ if any('\u0600' <= c <= '\u06ff' for c in text):
184
+ return "ar"
185
+ # Hindi/Devanagari
186
+ if any('\u0900' <= c <= '\u097f' for c in text):
187
+ return "hi"
188
+
189
+ if ascii_chars / total_chars > 0.85:
190
+ return "en"
191
+
192
+ return "other"
193
+
194
+
195
+ # ─── Topic Router ────────────────────────────────────────────────
196
+
197
  class TopicRouter:
198
+ """Keyword-based topic router with language-aware multilingual fallback.
199
+
200
+ Routing logic:
201
+ 1. Detect language β€” if non-English, route to multilingual head
202
+ 2. For English text, use keyword matching:
203
+ - Policy/macro keywords β†’ RoBERTa-Large (policy head)
204
+ - Climate/ESG keywords β†’ ClimateBERT
205
+ - Social indicators ($cashtags, slang) β†’ FinBERT
206
+ - Default β†’ FinBERT
207
+ """
208
+
209
  POLICY_KEYWORDS = {
210
  "federal reserve", "fed ", "fomc", "central bank", "ecb", "boe",
211
  "bank of japan", "boj", "bank of england", "rba", "pboc",
 
225
  "deflation", "stagflation", "ppi", "economic growth",
226
  "recession", "economic outlook", "macro", "macroeconomic",
227
  }
228
+
229
  CLIMATE_KEYWORDS = {
230
  "climate", "carbon", "emission", "emissions", "renewable",
231
  "sustainability", "sustainable", "esg", "green bond",
 
236
  "environmental", "carbon tax", "carbon price",
237
  "wind energy", "solar energy", "electric vehicle",
238
  }
239
+
240
  SOCIAL_INDICATORS = {
241
  "$", "#", "@", "imo", "imho", "lol", "lmao", "tbh",
242
  "bullish af", "bearish af", "moon", "to the moon",
243
  "diamond hands", "paper hands", "hodl", "fomo", "yolo",
244
  }
245
+
246
+ def __init__(self, device="cpu", enable_multilingual=True):
247
+ self.enable_multilingual = enable_multilingual
248
+
249
  def load(self):
250
  return self
251
+
252
  def classify(self, text):
253
+ # Step 1: Language detection
254
+ lang = detect_language(text) if self.enable_multilingual else "en"
255
+
256
+ if lang != "en" and self.enable_multilingual:
257
+ return {
258
+ "topic": f"Multilingual ({lang})",
259
+ "topic_confidence": 0.8,
260
+ "recommended_head": "multilingual",
261
+ "detected_language": lang,
262
+ "all_topics": {"multilingual": 10},
263
+ }
264
+
265
+ # Step 2: English keyword routing
266
  text_lower = text.lower()
267
  policy_hits = sum(1 for kw in self.POLICY_KEYWORDS if kw in text_lower)
268
  climate_hits = sum(1 for kw in self.CLIMATE_KEYWORDS if kw in text_lower)
269
  social_hits = sum(1 for ind in self.SOCIAL_INDICATORS if ind in text)
270
+
271
  has_cashtag = bool(re.search(r'\$[A-Z]{1,5}\b', text))
272
  is_short = len(text.split()) < 40
273
  if has_cashtag: social_hits += 3
274
  if is_short: social_hits += 1
275
+
276
  scores = {"policy": policy_hits, "climate": climate_hits, "social": social_hits}
277
  best_domain = max(scores, key=scores.get)
278
  best_score = scores[best_domain]
279
+
280
+ result = {"detected_language": lang, "all_topics": scores}
281
+
282
  if best_score < 2:
283
+ result.update({"topic": "Financial News", "topic_confidence": 0.5, "recommended_head": "finbert"})
284
  elif best_domain == "policy":
285
+ result.update({"topic": "Macro/Policy", "topic_confidence": min(1.0, policy_hits / 6), "recommended_head": "policy"})
286
  elif best_domain == "climate":
287
+ result.update({"topic": "Climate/ESG", "topic_confidence": min(1.0, climate_hits / 4), "recommended_head": "climate"})
288
  else:
289
+ result.update({"topic": "Social/Tweet", "topic_confidence": min(1.0, social_hits / 5), "recommended_head": "finbert"})
290
+
291
+ return result
292
 
293
 
294
  class TransformerEnsemble:
295
+ """Multi-head transformer ensemble with domain and language routing.
296
+
297
  Args:
298
  device: 'cpu' or 'cuda:0'.
299
  use_router: Enable keyword-based topic routing.
300
  finbert_model: HF model ID for the FinBERT head.
301
  roberta_model: HF model ID for the Financial-RoBERTa head.
302
  climatebert_model: HF model ID for the ClimateBERT head.
303
+ multilingual_model: HF model ID for the multilingual head. Set to None to disable.
304
+ enable_multilingual: Auto-route non-English text to multilingual head.
305
+
 
306
  Examples:
307
+ # All heads including multilingual (default)
308
  ensemble = TransformerEnsemble(device="cpu")
309
+
310
+ # English-only (no multilingual head, v0.2.0 behavior)
311
+ ensemble = TransformerEnsemble(multilingual_model=None)
312
+
313
+ # Score German text β€” auto-routed to multilingual head
314
+ result = ensemble.score_routed("EZB signalisiert Geduld bei Zinssenkungen.")
315
+ # result["head_used"] = "multilingual"
 
 
 
 
 
 
 
316
  """
317
  def __init__(
318
  self,
 
321
  finbert_model=DEFAULT_FINBERT,
322
  roberta_model=DEFAULT_ROBERTA,
323
  climatebert_model=DEFAULT_CLIMATEBERT,
324
+ multilingual_model=DEFAULT_MULTILINGUAL,
325
+ enable_multilingual=True,
326
  ):
327
  self.device = device
328
  self.use_router = use_router
 
331
  "policy": FinancialRoBERTaHead(model_name=roberta_model, device=device),
332
  "climate": ClimateBERTHead(model_name=climatebert_model, device=device),
333
  }
334
+ if multilingual_model:
335
+ self.heads["multilingual"] = MultilingualHead(model_name=multilingual_model, device=device)
336
+ self.router = TopicRouter(device, enable_multilingual=enable_multilingual and multilingual_model is not None) if use_router else None
337
+
338
  def score_routed(self, text):
339
  if not self.router:
340
  raise ValueError("Router not enabled.")
341
  topic_info = self.router.classify(text)
342
  head_name = topic_info["recommended_head"]
343
  if head_name == "tweet": head_name = "finbert"
344
+ # Fallback if multilingual head not loaded
345
+ if head_name == "multilingual" and "multilingual" not in self.heads:
346
+ head_name = "finbert"
347
  sentiment = self.heads[head_name].score(text)
348
  return {
349
  "head_used": head_name,
350
  "topic": topic_info["topic"],
351
  "topic_confidence": topic_info["topic_confidence"],
352
+ "detected_language": topic_info.get("detected_language", "en"),
353
  "sentiment_score": sentiment["composite_score"],
354
  **{f"{head_name}_{k}": v for k, v in sentiment.items()},
355
  }
356
+
357
  def score_all(self, text):
358
  result = {}
359
  for name, head in self.heads.items():
 
362
  composites = [result.get(f"{name}_composite_score", 0.0) for name in self.heads]
363
  result["ensemble_mean"] = np.mean(composites)
364
  return result
365
+
366
  def load_all(self):
367
  for head in self.heads.values():
368
  head.load()