harsh8i commited on
Commit
b025863
·
1 Parent(s): 5a00d50

initial deploy

Browse files
README.md CHANGED
@@ -1,12 +1,48 @@
1
  ---
2
- title: Scriptt
3
- emoji: 🔥
4
- colorFrom: purple
5
- colorTo: yellow
6
  sdk: gradio
7
- sdk_version: 6.13.0
8
  app_file: app.py
9
  pinned: false
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Indic Language Identifier
3
+ emoji: 🔤
4
+ colorFrom: cyan
5
+ colorTo: blue
6
  sdk: gradio
7
+ sdk_version: "4.24.0"
8
  app_file: app.py
9
  pinned: false
10
  ---
11
 
12
+ # 🔤 Indic Language Identifier
13
+
14
+ Detects language from text across **12 Indic languages + English** using a Unicode heuristic + CharNgram ensemble.
15
+
16
+ ## Supported Languages
17
+ Bengali · English · Gujarati · Hindi · Kannada · Malayalam · Marathi · Odia · Punjabi · Tamil · Telugu · Urdu
18
+
19
+ ## Run Locally
20
+
21
+ ```bash
22
+ pip install -r requirements.txt
23
+ python app.py
24
+ ```
25
+
26
+ ## Deploy on Hugging Face Spaces
27
+
28
+ 1. Create a new Space at https://huggingface.co/spaces
29
+ 2. Choose **Gradio** as the SDK
30
+ 3. Upload these files:
31
+ - `app.py`
32
+ - `requirements.txt`
33
+ - `src/` folder (entire directory)
34
+ - `artifacts/models/` folder (if you have trained `.pkl` files)
35
+
36
+ 4. HF Spaces will auto-install dependencies and launch `app.py`
37
+
38
+ ## Model Priority
39
+ The app loads the best available model in this order:
40
+ 1. `artifacts/models/ensemble.pkl` — full ensemble (CharNgram + FastText + Heuristic)
41
+ 2. `artifacts/models/ngram_model.pkl` — CharNgram only
42
+ 3. Unicode Heuristic fallback — no file needed, works out of the box
43
+
44
+ ## Train Your Own Models
45
+ ```bash
46
+ python train_pipeline.py --model all --max_per_lang 30000
47
+ ```
48
+ Requires the AI4Bharat/Pralekha dataset (auto-downloaded from HuggingFace Hub).
app.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Hugging Face Space — Indic Language Identifier
3
+ Supports 12 Indic languages + English.
4
+ Uses best available model: Ensemble > CharNgram > Unicode Heuristic fallback.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ sys.path.insert(0, str(Path(__file__).parent))
12
+
13
+ import gradio as gr
14
+ from src.utils.languages import LANGUAGE_REGISTRY, ALL_LANGUAGE_CODES
15
+ from src.data.preprocessor import IndicTextPreprocessor
16
+ from src.models.ensemble_model import EnsembleLIDModel, UnicodeHeuristicModel
17
+
18
+ # ── Model Loading ─────────────────────────────────────────────────────────────
19
+
20
+ preprocessor = IndicTextPreprocessor()
21
+ model = None
22
+
23
+ for mpath in ["./artifacts/models/ensemble.pkl", "./artifacts/models/ngram_model.pkl"]:
24
+ if Path(mpath).exists():
25
+ try:
26
+ import pickle
27
+ with open(mpath, "rb") as f:
28
+ model = pickle.load(f)
29
+ print(f"✓ Loaded: {mpath}")
30
+ break
31
+ except Exception as e:
32
+ print(f"Could not load {mpath}: {e}")
33
+
34
+ if model is None:
35
+ model = EnsembleLIDModel()
36
+ print("⚠ No trained model found — using Unicode heuristic fallback")
37
+
38
+
39
+ # ── Inference ─────────────────────────────────────────────────────────────────
40
+
41
+ def predict(text: str) -> str:
42
+ """Clean inference function: text in → language name out."""
43
+ if not text or not text.strip():
44
+ return "Please enter some text."
45
+ processed = preprocessor.preprocess(text)
46
+ if hasattr(model, "predict_with_confidence"):
47
+ result = model.predict_with_confidence([processed])[0]
48
+ lang = result.get("predicted_lang", "?")
49
+ conf = result.get("confidence", 0.0)
50
+ elif hasattr(model, "predict"):
51
+ lang = model.predict([processed])[0]
52
+ conf = 0.9
53
+ else:
54
+ lang, conf = "eng", 0.5
55
+
56
+ info = LANGUAGE_REGISTRY.get(lang)
57
+ name = info.name if info else lang
58
+ native = info.native_name if info else ""
59
+ script = info.script if info else "?"
60
+ direction = info.direction if info else "LTR"
61
+ confidence_icon = "🟢" if conf > 0.8 else "🟡" if conf > 0.6 else "🔴"
62
+
63
+ return (
64
+ f"**{name}** ({native})\n\n"
65
+ f"Code: `{lang}` | Script: {script} | Direction: {direction}\n\n"
66
+ f"Confidence: {conf:.2%} {confidence_icon}"
67
+ )
68
+
69
+
70
+ def predict_detailed(text: str) -> tuple:
71
+ """Returns (result_markdown, top5_table) for the full UI."""
72
+ if not text or not text.strip():
73
+ return "Please enter some text.", None
74
+
75
+ processed = preprocessor.preprocess(text)
76
+
77
+ if hasattr(model, "predict_with_confidence"):
78
+ result = model.predict_with_confidence([processed])[0]
79
+ lang = result.get("predicted_lang", "?")
80
+ conf = result.get("confidence", 0.0)
81
+ top5 = result.get("top_5", [])
82
+ elif hasattr(model, "predict"):
83
+ lang = model.predict([processed])[0]
84
+ conf = 0.9
85
+ top5 = [{"lang": lang, "probability": conf}]
86
+ else:
87
+ lang, conf = "eng", 0.5
88
+ top5 = []
89
+
90
+ info = LANGUAGE_REGISTRY.get(lang)
91
+ confidence_icon = "🟢" if conf > 0.8 else "🟡" if conf > 0.6 else "🔴"
92
+
93
+ result_md = f"""### 🎯 {info.name if info else lang} ({info.native_name if info else ''})
94
+
95
+ | | |
96
+ |---|---|
97
+ | Code | `{lang}` |
98
+ | Script | {info.script if info else '?'} |
99
+ | Direction | {info.direction if info else 'LTR'} |
100
+ | Family | {info.family if info else '?'} |
101
+ | Confidence | {conf:.2%} {confidence_icon} |
102
+ """
103
+
104
+ table = None
105
+ if top5:
106
+ import pandas as pd
107
+ rows = []
108
+ for t in top5:
109
+ i = LANGUAGE_REGISTRY.get(t["lang"])
110
+ rows.append({
111
+ "Language": i.name if i else t["lang"],
112
+ "Code": t["lang"],
113
+ "Probability": f"{t['probability']:.2%}",
114
+ })
115
+ table = pd.DataFrame(rows)
116
+
117
+ return result_md, table
118
+
119
+
120
+ # ── Sample Texts ──────────────────────────────────────────────────────────────
121
+
122
+ SAMPLES = [
123
+ ["भारत एक विशाल देश है जहाँ अनेक भाषाएँ बोली जाती हैं।"],
124
+ ["বাংলাদেশ এবং পশ্চিমবঙ্গে বাংলা ভাষা প্রচলিত।"],
125
+ ["தமிழ் மொழி உலகின் மிகவும் பழமையான மொழிகளில் ஒன்றாகும்."],
126
+ ["India is a diverse country with hundreds of languages."],
127
+ ["اردو پاکستان کی قومی زبان ہے۔"],
128
+ ["ಕನ್ನಡ ಭಾಷೆ ಕರ್ನಾಟಕ ರಾಜ್ಯದ ಅಧ��ಕೃತ ಭಾಷೆ."],
129
+ ]
130
+
131
+ # ── Gradio UI ─────────────────────────────────────────────────────────────────
132
+
133
+ with gr.Blocks(title="Indic Language Identifier") as demo:
134
+ gr.Markdown("""
135
+ # 🔤 Indic Language Identifier
136
+ Detects language from text across **12 Indic languages + English**.
137
+ Powered by Unicode heuristics + CharNgram ensemble.
138
+ """)
139
+
140
+ with gr.Row():
141
+ with gr.Column(scale=2):
142
+ text_input = gr.Textbox(
143
+ label="Input Text",
144
+ placeholder="Type or paste text in any supported language...",
145
+ lines=5,
146
+ )
147
+ with gr.Row():
148
+ clear_btn = gr.Button("Clear", variant="secondary")
149
+ detect_btn = gr.Button("🔍 Detect Language", variant="primary")
150
+
151
+ gr.Examples(examples=SAMPLES, inputs=text_input, label="Try a sample")
152
+
153
+ with gr.Column(scale=2):
154
+ result_md = gr.Markdown(label="Result")
155
+ top5_table = gr.Dataframe(label="Top Predictions", visible=True)
156
+
157
+ detect_btn.click(fn=predict_detailed, inputs=text_input, outputs=[result_md, top5_table])
158
+ clear_btn.click(fn=lambda: ("", None), outputs=[result_md, top5_table])
159
+ text_input.submit(fn=predict_detailed, inputs=text_input, outputs=[result_md, top5_table])
160
+
161
+ gr.Markdown("""---
162
+ **Supported languages:** Bengali · English · Gujarati · Hindi · Kannada · Malayalam · Marathi · Odia · Punjabi · Tamil · Telugu · Urdu
163
+ """)
164
+
165
+ if __name__ == "__main__":
166
+ demo.launch()
requirements.txt ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Core
2
+ numpy>=1.26.0
3
+ pandas>=2.2.0
4
+ scikit-learn>=1.4.0
5
+ scipy>=1.12.0
6
+
7
+ # NLP / Models
8
+ fasttext-wheel>=0.9.2
9
+
10
+ # Unicode & text processing
11
+ regex>=2023.12.25
12
+
13
+ # API & UI
14
+ gradio>=4.24.0
15
+ plotly>=5.20.0
16
+
17
+ # Utilities
18
+ loguru>=0.7.2
19
+ tqdm>=4.66.0
src/__init__.py ADDED
File without changes
src/analysis/__init__.py ADDED
File without changes
src/analysis/script_analyzer.py ADDED
@@ -0,0 +1,462 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Advanced Script Analysis Engine
3
+ Deep Unicode-based analysis: script distribution, mixing, directionality,
4
+ transliteration detection, noise analysis, and linguistic features.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ import unicodedata
10
+ from collections import Counter, defaultdict
11
+ from dataclasses import dataclass, field
12
+ from typing import Dict, List, Optional, Set, Tuple
13
+
14
+ import numpy as np
15
+ from loguru import logger
16
+
17
+ from src.utils.languages import (
18
+ SCRIPT_UNICODE_BLOCKS,
19
+ SCRIPT_TO_DIRECTION,
20
+ LANGUAGE_REGISTRY,
21
+ )
22
+
23
+
24
+ # ─────────────────────────────────────────────
25
+ # Unicode Block Database
26
+ # ─────────────────────────────────────────────
27
+
28
+ UNICODE_BLOCK_NAMES: List[Tuple[int, int, str]] = [
29
+ (0x0000, 0x007F, "Basic Latin"),
30
+ (0x0080, 0x00FF, "Latin-1 Supplement"),
31
+ (0x0100, 0x017F, "Latin Extended-A"),
32
+ (0x0180, 0x024F, "Latin Extended-B"),
33
+ (0x0250, 0x02AF, "IPA Extensions"),
34
+ (0x0300, 0x036F, "Combining Diacritical Marks"),
35
+ (0x0370, 0x03FF, "Greek"),
36
+ (0x0400, 0x04FF, "Cyrillic"),
37
+ (0x0500, 0x052F, "Cyrillic Supplement"),
38
+ (0x0600, 0x06FF, "Arabic"),
39
+ (0x0700, 0x074F, "Syriac"),
40
+ (0x0750, 0x077F, "Arabic Supplement"),
41
+ (0x0900, 0x097F, "Devanagari"),
42
+ (0x0980, 0x09FF, "Bengali"),
43
+ (0x0A00, 0x0A7F, "Gurmukhi"),
44
+ (0x0A80, 0x0AFF, "Gujarati"),
45
+ (0x0B00, 0x0B7F, "Oriya"),
46
+ (0x0B80, 0x0BFF, "Tamil"),
47
+ (0x0C00, 0x0C7F, "Telugu"),
48
+ (0x0C80, 0x0CFF, "Kannada"),
49
+ (0x0D00, 0x0D7F, "Malayalam"),
50
+ (0x0D80, 0x0DFF, "Sinhala"),
51
+ (0x0E00, 0x0E7F, "Thai"),
52
+ (0x0F00, 0x0FFF, "Tibetan"),
53
+ (0x1000, 0x109F, "Myanmar"),
54
+ (0x10A0, 0x10FF, "Georgian"),
55
+ (0x1100, 0x11FF, "Hangul Jamo"),
56
+ (0x1E00, 0x1EFF, "Latin Extended Additional"),
57
+ (0x2000, 0x206F, "General Punctuation"),
58
+ (0x20A0, 0x20CF, "Currency Symbols"),
59
+ (0x2100, 0x214F, "Letterlike Symbols"),
60
+ (0x2200, 0x22FF, "Mathematical Operators"),
61
+ (0x3000, 0x303F, "CJK Symbols and Punctuation"),
62
+ (0x3040, 0x309F, "Hiragana"),
63
+ (0x30A0, 0x30FF, "Katakana"),
64
+ (0x4E00, 0x9FFF, "CJK Unified Ideographs"),
65
+ (0xFB00, 0xFB4F, "Alphabetic Presentation Forms"),
66
+ (0xFB50, 0xFDFF, "Arabic Presentation Forms-A"),
67
+ (0xFE70, 0xFEFF, "Arabic Presentation Forms-B"),
68
+ (0xFF00, 0xFFEF, "Halfwidth and Fullwidth Forms"),
69
+ ]
70
+
71
+
72
+ def get_unicode_block(cp: int) -> str:
73
+ for lo, hi, name in UNICODE_BLOCK_NAMES:
74
+ if lo <= cp <= hi:
75
+ return name
76
+ return f"U+{cp:04X} (unknown block)"
77
+
78
+
79
+ # ─────────────────────────────────────────────
80
+ # Data Structures
81
+ # ─────────────────────────────────────────────
82
+
83
+ @dataclass
84
+ class CharacterAnalysis:
85
+ char: str
86
+ codepoint: int
87
+ unicode_name: str
88
+ unicode_category: str
89
+ unicode_block: str
90
+ script: Optional[str]
91
+ is_alpha: bool
92
+ is_digit: bool
93
+ is_punct: bool
94
+ is_combining: bool
95
+
96
+
97
+ @dataclass
98
+ class ScriptSegment:
99
+ script: str
100
+ start: int
101
+ end: int
102
+ text: str
103
+ char_count: int
104
+
105
+
106
+ @dataclass
107
+ class ScriptAnalysisResult:
108
+ text: str
109
+ total_chars: int
110
+ alpha_chars: int
111
+ digit_chars: int
112
+ punct_chars: int
113
+ space_chars: int
114
+ other_chars: int
115
+
116
+ # Script distribution
117
+ script_distribution: Dict[str, float]
118
+ dominant_script: str
119
+ dominant_script_ratio: float
120
+
121
+ # Mixing analysis
122
+ is_mixed_script: bool
123
+ mixing_score: float # 0=pure, 1=maximally mixed
124
+ script_segments: List[ScriptSegment]
125
+ script_transitions: int
126
+
127
+ # Directionality
128
+ primary_direction: str
129
+ has_bidi: bool
130
+ rtl_ratio: float
131
+
132
+ # Noise indicators
133
+ noise_score: float
134
+ has_zero_width: bool
135
+ has_control_chars: bool
136
+ has_excessive_punct: bool
137
+ url_count: int
138
+ emoji_count: int
139
+ latin_ratio: float # in otherwise Indic text → potential transliteration
140
+
141
+ # Unicode statistics
142
+ unicode_block_distribution: Dict[str, float]
143
+ unique_chars: int
144
+ char_entropy: float
145
+
146
+ # Language hints
147
+ script_to_lang_hints: Dict[str, List[str]]
148
+
149
+ def to_dict(self) -> Dict:
150
+ return {
151
+ "total_chars": self.total_chars,
152
+ "alpha_chars": self.alpha_chars,
153
+ "alpha_ratio": self.alpha_chars / max(self.total_chars, 1),
154
+ "dominant_script": self.dominant_script,
155
+ "dominant_script_ratio": self.dominant_script_ratio,
156
+ "is_mixed_script": self.is_mixed_script,
157
+ "mixing_score": self.mixing_score,
158
+ "script_transitions": self.script_transitions,
159
+ "primary_direction": self.primary_direction,
160
+ "has_bidi": self.has_bidi,
161
+ "noise_score": self.noise_score,
162
+ "char_entropy": self.char_entropy,
163
+ "script_distribution": self.script_distribution,
164
+ "script_to_lang_hints": self.script_to_lang_hints,
165
+ }
166
+
167
+
168
+ # ─────────────────────────────────────────────
169
+ # Script Analyser
170
+ # ─────────────────────────────────────────────
171
+
172
+ class ScriptAnalyzer:
173
+ """
174
+ Deep script and Unicode analysis for multilingual text.
175
+ Designed for Indic languages with mixed-script handling.
176
+ """
177
+
178
+ _URL_RE = re.compile(r"https?://\S+|www\.\S+")
179
+ _EMOJI_RE = re.compile(
180
+ "["
181
+ "\U0001F600-\U0001F64F"
182
+ "\U0001F300-\U0001F5FF"
183
+ "\U0001F680-\U0001F6FF"
184
+ "\U0001F1E0-\U0001F1FF"
185
+ "\U00002702-\U000027B0"
186
+ "\U000024C2-\U0001F251"
187
+ "]+",
188
+ re.UNICODE
189
+ )
190
+ _ZW_RE = re.compile(r"[\u200b-\u200f\u202a-\u202e\ufeff\u00ad]")
191
+ _CTRL_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]")
192
+
193
+ # Script → language hints
194
+ SCRIPT_LANG_HINTS: Dict[str, List[str]] = {
195
+ "Devanagari": ["hin", "mar"],
196
+ "Bengali": ["ben"],
197
+ "Gurmukhi": ["pan"],
198
+ "Gujarati": ["guj"],
199
+ "Oriya": ["ori"],
200
+ "Tamil": ["tam"],
201
+ "Telugu": ["tel"],
202
+ "Kannada": ["kan"],
203
+ "Malayalam": ["mal"],
204
+ "Arabic": ["urd"],
205
+ "Latin": ["eng"],
206
+ }
207
+
208
+ def analyze(self, text: str) -> ScriptAnalysisResult:
209
+ """Comprehensive script analysis of input text."""
210
+ if not text:
211
+ return self._empty_result(text)
212
+
213
+ chars = list(text)
214
+ n = len(chars)
215
+
216
+ # Character category counts
217
+ alpha = sum(c.isalpha() for c in chars)
218
+ digits = sum(c.isdigit() for c in chars)
219
+ spaces = sum(c.isspace() for c in chars)
220
+ puncts = sum(unicodedata.category(c).startswith("P") for c in chars)
221
+ other = n - alpha - digits - spaces - puncts
222
+
223
+ # Script assignment per alpha char
224
+ script_of: List[Optional[str]] = [self._assign_script(c) for c in chars]
225
+ alpha_scripts = [s for c, s in zip(chars, script_of) if c.isalpha() and s]
226
+
227
+ # Script distribution
228
+ script_counter = Counter(alpha_scripts)
229
+ total_alpha = max(len(alpha_scripts), 1)
230
+ script_dist = {s: c / total_alpha for s, c in script_counter.most_common()}
231
+
232
+ dominant = script_counter.most_common(1)[0][0] if script_counter else "Unknown"
233
+ dom_ratio = script_dist.get(dominant, 0.0)
234
+
235
+ # Segment extraction
236
+ segments = self._extract_segments(chars, script_of)
237
+ transitions = max(0, len(segments) - 1)
238
+
239
+ # Mixing score: entropy-based
240
+ mixing_score = self._script_entropy(script_dist)
241
+
242
+ # Directionality
243
+ rtl_langs = {"Arabic", "Hebrew"}
244
+ rtl_chars = sum(1 for s in alpha_scripts if s in rtl_langs)
245
+ rtl_ratio = rtl_chars / max(total_alpha, 1)
246
+ primary_direction = "RTL" if rtl_ratio > 0.5 else "LTR"
247
+ has_bidi = 0.05 < rtl_ratio < 0.95
248
+
249
+ # Unicode block distribution
250
+ block_counter: Counter = Counter()
251
+ for c in chars:
252
+ block = get_unicode_block(ord(c))
253
+ block_counter[block] += 1
254
+ block_dist = {b: c / n for b, c in block_counter.most_common(10)}
255
+
256
+ # Noise analysis
257
+ url_count = len(self._URL_RE.findall(text))
258
+ emoji_count = len(self._EMOJI_RE.findall(text))
259
+ has_zero_width = bool(self._ZW_RE.search(text))
260
+ has_control = bool(self._CTRL_RE.search(text))
261
+ has_excessive_punct = (puncts / max(n, 1)) > 0.15
262
+ latin_ratio = script_dist.get("Basic Latin", 0.0) + script_dist.get("Latin", 0.0)
263
+
264
+ noise_components = [
265
+ 0.3 * min(1.0, url_count * 0.5),
266
+ 0.2 * int(has_zero_width),
267
+ 0.2 * int(has_control),
268
+ 0.15 * int(has_excessive_punct),
269
+ 0.15 * min(1.0, emoji_count * 0.2),
270
+ ]
271
+ noise_score = sum(noise_components)
272
+
273
+ # Char entropy
274
+ char_freq = Counter(text)
275
+ total_f = sum(char_freq.values())
276
+ entropy = -sum(
277
+ (c / total_f) * np.log2(c / total_f)
278
+ for c in char_freq.values() if c > 0
279
+ )
280
+
281
+ # Lang hints based on detected scripts
282
+ lang_hints: Dict[str, List[str]] = {
283
+ s: self.SCRIPT_LANG_HINTS.get(s, [])
284
+ for s in script_dist
285
+ }
286
+
287
+ return ScriptAnalysisResult(
288
+ text=text,
289
+ total_chars=n,
290
+ alpha_chars=alpha,
291
+ digit_chars=digits,
292
+ punct_chars=puncts,
293
+ space_chars=spaces,
294
+ other_chars=other,
295
+ script_distribution=script_dist,
296
+ dominant_script=dominant,
297
+ dominant_script_ratio=dom_ratio,
298
+ is_mixed_script=len(script_dist) > 1 and dom_ratio < 0.95,
299
+ mixing_score=mixing_score,
300
+ script_segments=segments,
301
+ script_transitions=transitions,
302
+ primary_direction=primary_direction,
303
+ has_bidi=has_bidi,
304
+ rtl_ratio=rtl_ratio,
305
+ noise_score=noise_score,
306
+ has_zero_width=has_zero_width,
307
+ has_control_chars=has_control,
308
+ has_excessive_punct=has_excessive_punct,
309
+ url_count=url_count,
310
+ emoji_count=emoji_count,
311
+ latin_ratio=latin_ratio,
312
+ unicode_block_distribution=block_dist,
313
+ unique_chars=len(set(text)),
314
+ char_entropy=float(entropy),
315
+ script_to_lang_hints=lang_hints,
316
+ )
317
+
318
+ def analyze_batch(self, texts: List[str]) -> List[ScriptAnalysisResult]:
319
+ return [self.analyze(t) for t in texts]
320
+
321
+ def _assign_script(self, char: str) -> Optional[str]:
322
+ if not char.isalpha():
323
+ return None
324
+ cp = ord(char)
325
+ for block_start, block_end, block_name in UNICODE_BLOCK_NAMES:
326
+ if block_start <= cp <= block_end:
327
+ return block_name
328
+ return "Unknown"
329
+
330
+ def _extract_segments(
331
+ self, chars: List[str], scripts: List[Optional[str]]
332
+ ) -> List[ScriptSegment]:
333
+ """Extract contiguous script segments from text."""
334
+ segments = []
335
+ if not chars:
336
+ return segments
337
+
338
+ current_script = None
339
+ start = 0
340
+ seg_chars = []
341
+
342
+ for i, (c, s) in enumerate(zip(chars, scripts)):
343
+ if not c.isalpha():
344
+ seg_chars.append(c)
345
+ continue
346
+ if s != current_script:
347
+ if current_script is not None and seg_chars:
348
+ segments.append(ScriptSegment(
349
+ script=current_script,
350
+ start=start,
351
+ end=i,
352
+ text="".join(seg_chars),
353
+ char_count=sum(1 for ch in seg_chars if ch.isalpha()),
354
+ ))
355
+ current_script = s
356
+ start = i
357
+ seg_chars = [c]
358
+ else:
359
+ seg_chars.append(c)
360
+
361
+ if current_script and seg_chars:
362
+ segments.append(ScriptSegment(
363
+ script=current_script,
364
+ start=start,
365
+ end=len(chars),
366
+ text="".join(seg_chars),
367
+ char_count=sum(1 for ch in seg_chars if ch.isalpha()),
368
+ ))
369
+
370
+ return [s for s in segments if s.char_count >= 3]
371
+
372
+ def _script_entropy(self, dist: Dict[str, float]) -> float:
373
+ """Shannon entropy over script distribution (normalized to [0,1])."""
374
+ if len(dist) <= 1:
375
+ return 0.0
376
+ probs = list(dist.values())
377
+ entropy = -sum(p * np.log2(p) for p in probs if p > 0)
378
+ max_entropy = np.log2(len(probs))
379
+ return float(entropy / max_entropy) if max_entropy > 0 else 0.0
380
+
381
+ def _empty_result(self, text: str) -> ScriptAnalysisResult:
382
+ return ScriptAnalysisResult(
383
+ text=text, total_chars=0, alpha_chars=0, digit_chars=0,
384
+ punct_chars=0, space_chars=0, other_chars=0,
385
+ script_distribution={}, dominant_script="Unknown",
386
+ dominant_script_ratio=0.0, is_mixed_script=False,
387
+ mixing_score=0.0, script_segments=[], script_transitions=0,
388
+ primary_direction="LTR", has_bidi=False, rtl_ratio=0.0,
389
+ noise_score=0.0, has_zero_width=False, has_control_chars=False,
390
+ has_excessive_punct=False, url_count=0, emoji_count=0,
391
+ latin_ratio=0.0, unicode_block_distribution={},
392
+ unique_chars=0, char_entropy=0.0, script_to_lang_hints={},
393
+ )
394
+
395
+ def compare_texts(self, text1: str, text2: str) -> Dict:
396
+ """Compare script profiles of two texts."""
397
+ r1 = self.analyze(text1)
398
+ r2 = self.analyze(text2)
399
+
400
+ scripts1 = set(r1.script_distribution.keys())
401
+ scripts2 = set(r2.script_distribution.keys())
402
+ shared = scripts1 & scripts2
403
+
404
+ jaccard = len(shared) / max(len(scripts1 | scripts2), 1)
405
+
406
+ return {
407
+ "script_overlap": list(shared),
408
+ "jaccard_similarity": jaccard,
409
+ "dominant_scripts_match": r1.dominant_script == r2.dominant_script,
410
+ "text1_dominant": r1.dominant_script,
411
+ "text2_dominant": r2.dominant_script,
412
+ "text1_mixing_score": r1.mixing_score,
413
+ "text2_mixing_score": r2.mixing_score,
414
+ }
415
+
416
+ def detect_transliteration(self, text: str) -> Dict:
417
+ """
418
+ Detect if text is a transliteration of an Indic language
419
+ written in Latin script (Romanized Indic).
420
+ """
421
+ result = self.analyze(text)
422
+
423
+ # If predominantly Latin but contains Indic vocabulary patterns
424
+ latin_dominated = result.dominant_script in ("Basic Latin", "Latin Extended-A")
425
+ has_indic_patterns = self._has_indic_romanization_patterns(text)
426
+
427
+ # Common Romanization markers
428
+ has_aspirates = bool(re.search(r"\b\w*(kh|gh|ch|jh|th|dh|ph|bh)\w*\b", text, re.I))
429
+ has_retroflex = bool(re.search(r"\b\w*[ṭḍṇṣ]\w*\b", text))
430
+ has_nasal_markers = bool(re.search(r"\b\w*(ng|ny|ñ|ṅ)\w*\b", text, re.I))
431
+
432
+ is_likely_transliteration = (
433
+ latin_dominated and (has_indic_patterns or has_aspirates)
434
+ )
435
+
436
+ return {
437
+ "is_transliteration": is_likely_transliteration,
438
+ "confidence": 0.8 if (is_likely_transliteration and has_aspirates) else 0.5,
439
+ "evidence": {
440
+ "latin_dominated": latin_dominated,
441
+ "has_indic_patterns": has_indic_patterns,
442
+ "has_aspirates": has_aspirates,
443
+ "has_retroflex": has_retroflex,
444
+ "has_nasal_markers": has_nasal_markers,
445
+ }
446
+ }
447
+
448
+ def _has_indic_romanization_patterns(self, text: str) -> bool:
449
+ """Common romanization patterns across Indic languages."""
450
+ patterns = [
451
+ r"\b(hai|hain|nahin|nahi|aur|yeh|woh|kya|kaun|kahan|kyun|mera|tera|apna)\b", # Hindi
452
+ r"\b(amma|appa|anna|akka|vanakkam|nandri|romba|paaru|paar)\b", # Tamil
453
+ r"\b(ahe|aahe|nahi|tumhi|amhi|tya|tyanche|mhanje)\b", # Marathi
454
+ ]
455
+ for p in patterns:
456
+ if re.search(p, text, re.I):
457
+ return True
458
+ return False
459
+
460
+
461
+ # Singleton for reuse
462
+ default_analyzer = ScriptAnalyzer()
src/data/__init__.py ADDED
File without changes
src/data/preprocessor.py ADDED
@@ -0,0 +1,268 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Advanced Text Preprocessing for Multilingual Indic Text
3
+ Handles Unicode normalization, noise removal, script detection preprocessing.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import re
8
+ import unicodedata
9
+ from dataclasses import dataclass
10
+ from typing import Dict, List, Optional, Tuple
11
+
12
+ import numpy as np
13
+ from loguru import logger
14
+
15
+ from src.utils.languages import SCRIPT_UNICODE_BLOCKS, LANGUAGE_REGISTRY
16
+
17
+
18
+ # ─────────────────────────────────────────────
19
+ # Unicode Script Detection
20
+ # ─────────────────────────────────────────────
21
+
22
+ def get_char_script(char: str) -> str:
23
+ """Get Unicode script name for a single character."""
24
+ try:
25
+ cat = unicodedata.category(char)
26
+ name = unicodedata.name(char, "")
27
+ # Extract script from Unicode name (e.g. "DEVANAGARI LETTER A" → "Devanagari")
28
+ parts = name.split()
29
+ if not parts:
30
+ return "Unknown"
31
+ first = parts[0].capitalize()
32
+ # Multi-word scripts
33
+ for script in SCRIPT_UNICODE_BLOCKS:
34
+ if script.upper() in name.upper():
35
+ return script
36
+ return first
37
+ except (ValueError, TypeError):
38
+ return "Unknown"
39
+
40
+
41
+ def get_dominant_script(text: str) -> Tuple[str, Dict[str, float]]:
42
+ """
43
+ Detect the dominant script in text.
44
+ Returns (dominant_script, script_distribution).
45
+ """
46
+ script_counts: Dict[str, int] = {}
47
+ total_alpha = 0
48
+
49
+ for char in text:
50
+ if not char.isalpha():
51
+ continue
52
+ total_alpha += 1
53
+ cp = ord(char)
54
+
55
+ matched = False
56
+ for script, ranges in SCRIPT_UNICODE_BLOCKS.items():
57
+ for lo, hi in ranges:
58
+ if lo <= cp <= hi:
59
+ script_counts[script] = script_counts.get(script, 0) + 1
60
+ matched = True
61
+ break
62
+ if matched:
63
+ break
64
+ if not matched:
65
+ script_counts["Other"] = script_counts.get("Other", 0) + 1
66
+
67
+ if total_alpha == 0:
68
+ return "Unknown", {}
69
+
70
+ dist = {s: c / total_alpha for s, c in script_counts.items()}
71
+ dominant = max(dist, key=dist.get) if dist else "Unknown"
72
+ return dominant, dist
73
+
74
+
75
+ # ─────────────────────────────────────────────
76
+ # Preprocessor Config
77
+ # ─────────────────────────────────────────────
78
+
79
+ @dataclass
80
+ class PreprocessorConfig:
81
+ normalize_unicode: bool = True
82
+ nfc_form: str = "NFC"
83
+ remove_urls: bool = True
84
+ remove_emails: bool = True
85
+ remove_html_tags: bool = True
86
+ normalize_whitespace: bool = True
87
+ remove_zero_width: bool = True
88
+ handle_mixed_numerals: bool = True
89
+ preserve_script_punctuation: bool = True
90
+ lowercase_latin: bool = False
91
+ max_length: Optional[int] = 2000
92
+ min_length: int = 10
93
+
94
+
95
+ # ─────────────────────────────────────────────
96
+ # Main Preprocessor
97
+ # ─────────────────────────────────────────────
98
+
99
+ class IndicTextPreprocessor:
100
+ """
101
+ Comprehensive text preprocessor for Indic multilingual corpora.
102
+ Handles Unicode normalization, noise removal, and script-aware cleaning.
103
+ """
104
+
105
+ # Regex patterns
106
+ _URL_RE = re.compile(
107
+ r"https?://\S+|www\.\S+|ftp://\S+", re.UNICODE
108
+ )
109
+ _EMAIL_RE = re.compile(
110
+ r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b"
111
+ )
112
+ _HTML_RE = re.compile(r"<[^>]+>")
113
+ _WHITESPACE_RE = re.compile(r"\s+", re.UNICODE)
114
+ _ZERO_WIDTH_RE = re.compile(
115
+ r"[\u200b\u200c\u200d\u200e\u200f\u202a-\u202e\ufeff\u00ad]"
116
+ )
117
+ _MULTI_PUNCT_RE = re.compile(r"([!?।॥,;.]){2,}")
118
+
119
+ # Indic numeral mappings to ASCII (for normalization)
120
+ _NUMERAL_MAP = str.maketrans(
121
+ "".join([
122
+ "०१२३४५६७८९", # Devanagari
123
+ "০১২৩৪৫৬৭৮৯", # Bengali
124
+ "੦੧੨੩੪੫੬੭੮੯", # Gurmukhi
125
+ "૦૧૨૩૪૫૬૭૮૯", # Gujarati
126
+ "୦୧୨୩୪୫୬୭୮୯", # Odia
127
+ "௦௧௨௩௪௫௬௭௮௯", # Tamil
128
+ "౦౧౨౩౪౫౬౭౮౯", # Telugu
129
+ "೦೧೨೩೪೫೬೭೮೯", # Kannada
130
+ "൦൧൨൩൪൫൬൭൮൯", # Malayalam
131
+ ]),
132
+ "0123456789" * 9
133
+ )
134
+
135
+ def __init__(self, config: Optional[PreprocessorConfig] = None):
136
+ self.config = config or PreprocessorConfig()
137
+
138
+ def preprocess(self, text: str) -> str:
139
+ """Full preprocessing pipeline."""
140
+ if not text or not isinstance(text, str):
141
+ return ""
142
+
143
+ cfg = self.config
144
+
145
+ # 1. Unicode normalization
146
+ if cfg.normalize_unicode:
147
+ text = unicodedata.normalize(cfg.nfc_form, text)
148
+
149
+ # 2. Remove zero-width and invisible characters
150
+ if cfg.remove_zero_width:
151
+ text = self._ZERO_WIDTH_RE.sub("", text)
152
+
153
+ # 3. Remove HTML tags
154
+ if cfg.remove_html_tags:
155
+ text = self._HTML_RE.sub(" ", text)
156
+
157
+ # 4. Remove URLs
158
+ if cfg.remove_urls:
159
+ text = self._URL_RE.sub(" ", text)
160
+
161
+ # 5. Remove emails
162
+ if cfg.remove_emails:
163
+ text = self._EMAIL_RE.sub(" ", text)
164
+
165
+ # 6. Numeral normalization (optional)
166
+ if cfg.handle_mixed_numerals:
167
+ text = text.translate(self._NUMERAL_MAP)
168
+
169
+ # 7. Collapse multiple punctuation
170
+ text = self._MULTI_PUNCT_RE.sub(r"\1", text)
171
+
172
+ # 8. Normalize whitespace
173
+ if cfg.normalize_whitespace:
174
+ text = self._WHITESPACE_RE.sub(" ", text).strip()
175
+
176
+ # 9. Lowercase Latin (optional, e.g. for English)
177
+ if cfg.lowercase_latin:
178
+ text = self._selective_lowercase(text)
179
+
180
+ # 10. Length truncation
181
+ if cfg.max_length and len(text) > cfg.max_length:
182
+ text = text[:cfg.max_length]
183
+
184
+ return text
185
+
186
+ def _selective_lowercase(self, text: str) -> str:
187
+ """Lowercase only Latin characters, preserve others."""
188
+ result = []
189
+ for ch in text:
190
+ cp = ord(ch)
191
+ # Latin Basic + Extended
192
+ if 0x0041 <= cp <= 0x005A or 0x00C0 <= cp <= 0x00D6:
193
+ result.append(ch.lower())
194
+ else:
195
+ result.append(ch)
196
+ return "".join(result)
197
+
198
+ def batch_preprocess(
199
+ self, texts: List[str], show_progress: bool = True
200
+ ) -> List[str]:
201
+ """Preprocess a batch of texts."""
202
+ if show_progress:
203
+ from tqdm.auto import tqdm
204
+ return [self.preprocess(t) for t in tqdm(texts, desc="Preprocessing")]
205
+ return [self.preprocess(t) for t in texts]
206
+
207
+ def quality_score(self, text: str) -> float:
208
+ """
209
+ Returns a text quality score in [0, 1].
210
+ Considers: alpha ratio, script consistency, length adequacy.
211
+ """
212
+ if not text:
213
+ return 0.0
214
+
215
+ alpha = sum(c.isalpha() for c in text)
216
+ total = len(text)
217
+ if total == 0:
218
+ return 0.0
219
+
220
+ alpha_ratio = alpha / total
221
+
222
+ _, script_dist = get_dominant_script(text)
223
+ script_concentration = max(script_dist.values()) if script_dist else 0.0
224
+
225
+ length_score = min(1.0, len(text) / 100)
226
+
227
+ return 0.3 * alpha_ratio + 0.4 * script_concentration + 0.3 * length_score
228
+
229
+ def compute_text_features(self, text: str) -> Dict:
230
+ """Rich feature set for a single text (used in analysis)."""
231
+ dominant, dist = get_dominant_script(text)
232
+ n_chars = len(text)
233
+ n_alpha = sum(c.isalpha() for c in text)
234
+ n_digits = sum(c.isdigit() for c in text)
235
+ n_punct = sum(unicodedata.category(c).startswith("P") for c in text)
236
+ n_spaces = sum(c.isspace() for c in text)
237
+ unique_chars = len(set(text))
238
+ type_token_ratio = unique_chars / max(n_chars, 1)
239
+
240
+ # Character entropy
241
+ from collections import Counter as _Counter
242
+ char_counts = _Counter(text)
243
+ total_f = sum(char_counts.values())
244
+ char_entropy = -sum(
245
+ (c / total_f) * np.log2(c / total_f)
246
+ for c in char_counts.values() if c > 0
247
+ ) if total_f > 0 else 0.0
248
+
249
+ return {
250
+ "n_chars": n_chars,
251
+ "n_alpha": n_alpha,
252
+ "n_digits": n_digits,
253
+ "n_punct": n_punct,
254
+ "n_spaces": n_spaces,
255
+ "alpha_ratio": n_alpha / max(n_chars, 1),
256
+ "digit_ratio": n_digits / max(n_chars, 1),
257
+ "unique_char_ratio": type_token_ratio,
258
+ "dominant_script": dominant,
259
+ "script_distribution": dist,
260
+ "script_count": len(dist),
261
+ "is_mixed_script": len(dist) > 1 and max(dist.values()) < 0.95,
262
+ "quality_score": self.quality_score(text),
263
+ "n_words": len(text.split()),
264
+ "avg_word_len": (
265
+ np.mean([len(w) for w in text.split()]) if text.split() else 0
266
+ ),
267
+ "char_entropy": float(char_entropy),
268
+ }
src/models/__init__.py ADDED
File without changes
src/models/ensemble_model.py ADDED
@@ -0,0 +1,344 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Ensemble Language Identification Model
3
+ Combines CharNgram + FastText + Unicode heuristics for maximum accuracy.
4
+ Strategies: weighted soft voting, stacking, confidence-based routing.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import pickle
10
+ from pathlib import Path
11
+ from typing import Any, Dict, List, Optional, Tuple
12
+
13
+ import numpy as np
14
+ import pandas as pd
15
+ from loguru import logger
16
+ from sklearn.metrics import f1_score, accuracy_score, classification_report
17
+
18
+ from src.utils.languages import (
19
+ LANGUAGE_REGISTRY,
20
+ ALL_LANGUAGE_CODES,
21
+ CODE_TO_LABEL,
22
+ LABEL_TO_CODE,
23
+ CONFUSABLE_PAIRS,
24
+ RTL_LANGUAGES,
25
+ DEVANAGARI_LANGUAGES,
26
+ )
27
+ from src.data.preprocessor import get_dominant_script
28
+
29
+
30
+ # ─────────────────────────────────────────────
31
+ # Unicode Heuristic Model (Rule-based)
32
+ # ─────────────────────────────────────────────
33
+
34
+ class UnicodeHeuristicModel:
35
+ """
36
+ Fast rule-based language identifier using Unicode script analysis.
37
+ Used as a strong prior for high-confidence cases.
38
+ """
39
+
40
+ # Unique codepoints per language for disambiguation
41
+ DISTINCTIVE_CHARS = {
42
+ # Telugu distinctive vowel signs
43
+ "tel": set(range(0x0C00, 0x0C7F)),
44
+ # Kannada
45
+ "kan": set(range(0x0C80, 0x0CFF)),
46
+ # Malayalam
47
+ "mal": set(range(0x0D00, 0x0D7F)),
48
+ # Tamil — note: no voiced stops
49
+ "tam": set(range(0x0B80, 0x0BFF)),
50
+ # Odia
51
+ "ori": set(range(0x0B00, 0x0B7F)),
52
+ # Gurmukhi (Punjabi)
53
+ "pan": set(range(0x0A00, 0x0A7F)),
54
+ # Gujarati
55
+ "guj": set(range(0x0A80, 0x0AFF)),
56
+ # Bengali
57
+ "ben": set(range(0x0980, 0x09FF)),
58
+ # Arabic/Urdu
59
+ "urd": set(range(0x0600, 0x06FF)) | set(range(0xFB50, 0xFDFF)),
60
+ }
61
+
62
+ # Devanagari — needs lexical disambiguation between Hindi/Marathi
63
+ DEVANAGARI_RANGE = set(range(0x0900, 0x097F))
64
+ LATIN_RANGE = set(range(0x0041, 0x007B))
65
+
66
+ def predict_single(self, text: str) -> Tuple[Optional[str], float]:
67
+ """
68
+ Returns (predicted_lang, confidence).
69
+ confidence=0.0 → uncertain (pass to ML model).
70
+ """
71
+ char_codepoints = [ord(c) for c in text if c.isalpha()]
72
+ if not char_codepoints:
73
+ return None, 0.0
74
+
75
+ total = len(char_codepoints)
76
+
77
+ # Check unique scripts with unambiguous mapping
78
+ for lang, codepoints in self.DISTINCTIVE_CHARS.items():
79
+ matches = sum(cp in codepoints for cp in char_codepoints)
80
+ ratio = matches / total
81
+ if ratio > 0.75:
82
+ return lang, min(0.98, 0.7 + ratio * 0.28)
83
+
84
+ # Devanagari → Hindi/Marathi ambiguous, return low confidence
85
+ deva_matches = sum(cp in self.DEVANAGARI_RANGE for cp in char_codepoints)
86
+ if deva_matches / total > 0.75:
87
+ return "hin", 0.55 # slight prior toward Hindi (larger corpus)
88
+
89
+ # Latin → likely English
90
+ latin_matches = sum(cp in self.LATIN_RANGE for cp in char_codepoints)
91
+ if latin_matches / total > 0.85:
92
+ return "eng", 0.90
93
+
94
+ return None, 0.0
95
+
96
+ def predict(self, texts: List[str]) -> List[Tuple[Optional[str], float]]:
97
+ return [self.predict_single(t) for t in texts]
98
+
99
+
100
+ # ─────────────────────────────────────────────
101
+ # Ensemble Model
102
+ # ─────────────────────────────────────────────
103
+
104
+ class EnsembleLIDModel:
105
+ """
106
+ Multi-model ensemble for language identification.
107
+
108
+ Architecture:
109
+ 1. Unicode heuristic: fast pre-filter, handles 40-60% of inputs
110
+ 2. CharNgram model: strong char-level baseline
111
+ 3. FastText model: fast neural model with subword features
112
+ 4. Weighted voting: combine model probabilities
113
+
114
+ Calibration:
115
+ - Per-language confidence thresholds
116
+ - Devanagari disambiguation module (Hindi vs Marathi)
117
+ - Confusable pair re-ranking
118
+ """
119
+
120
+ SUPPORTED_STRATEGIES = ("weighted_vote", "stacking", "router")
121
+
122
+ def __init__(
123
+ self,
124
+ ngram_model=None,
125
+ fasttext_model=None,
126
+ transformer_model=None,
127
+ weights: Optional[Dict[str, float]] = None,
128
+ strategy: str = "weighted_vote",
129
+ ):
130
+ self.ngram_model = ngram_model
131
+ self.fasttext_model = fasttext_model
132
+ self.transformer_model = transformer_model
133
+ self.heuristic_model = UnicodeHeuristicModel()
134
+ self.weights = weights or {"ngram": 0.35, "fasttext": 0.50, "transformer": 0.15}
135
+ self.strategy = strategy
136
+ self.all_langs = ALL_LANGUAGE_CODES
137
+ self.stacking_meta: Optional[Any] = None
138
+ self.is_fitted = False
139
+
140
+ @property
141
+ def active_models(self) -> Dict[str, Any]:
142
+ models = {}
143
+ if self.ngram_model and getattr(self.ngram_model, "is_fitted", False):
144
+ models["ngram"] = self.ngram_model
145
+ if self.fasttext_model and getattr(self.fasttext_model, "is_fitted", False):
146
+ models["fasttext"] = self.fasttext_model
147
+ if self.transformer_model and getattr(self.transformer_model, "is_fitted", False):
148
+ models["transformer"] = self.transformer_model
149
+ return models
150
+
151
+ def fit_stacking(self, val_df: pd.DataFrame):
152
+ """
153
+ Fit meta-learner (stacking) on validation set predictions.
154
+ """
155
+ from sklearn.linear_model import LogisticRegression
156
+
157
+ logger.info("Fitting stacking meta-learner on validation set ...")
158
+ stacked_features = self._get_stacked_features(val_df["text"].tolist())
159
+ labels = val_df["lang"].tolist()
160
+
161
+ meta = LogisticRegression(C=1.0, max_iter=500, solver="lbfgs", multi_class="multinomial")
162
+ meta.fit(stacked_features, labels)
163
+ self.stacking_meta = meta
164
+ logger.success("Stacking meta-learner fitted.")
165
+
166
+ def _get_stacked_features(self, texts: List[str]) -> np.ndarray:
167
+ """Concatenate all model probability vectors."""
168
+ features = []
169
+ for name, model in self.active_models.items():
170
+ if hasattr(model, "predict_proba"):
171
+ probs = model.predict_proba(texts)
172
+ if isinstance(probs, tuple):
173
+ probs, langs = probs
174
+ # Reindex to self.all_langs
175
+ full = np.zeros((len(texts), len(self.all_langs)))
176
+ for j, lang in enumerate(langs):
177
+ if lang in self.all_langs:
178
+ k = self.all_langs.index(lang)
179
+ full[:, k] = probs[:, j]
180
+ probs = full
181
+ features.append(probs)
182
+ if not features:
183
+ raise RuntimeError("No active models for stacking")
184
+ return np.hstack(features)
185
+
186
+ def predict(self, texts: List[str]) -> List[str]:
187
+ results = self.predict_with_confidence(texts)
188
+ return [r["predicted_lang"] for r in results]
189
+
190
+ def predict_with_confidence(self, texts: List[str]) -> List[Dict]:
191
+ """Full ensemble inference with detailed outputs."""
192
+ results = []
193
+ heuristic_preds = self.heuristic_model.predict(texts)
194
+
195
+ # Get model probabilities
196
+ model_probs = {}
197
+ for name, model in self.active_models.items():
198
+ if hasattr(model, "predict_proba"):
199
+ try:
200
+ probs = model.predict_proba(texts)
201
+ if isinstance(probs, tuple):
202
+ prob_matrix, langs = probs
203
+ model_probs[name] = (prob_matrix, langs)
204
+ else:
205
+ # ngram model returns matrix with .classes_
206
+ classes = model.classes_ if hasattr(model, "classes_") else self.all_langs
207
+ model_probs[name] = (probs, list(classes))
208
+ except Exception as e:
209
+ logger.warning(f"Model {name} failed: {e}")
210
+
211
+ for i, text in enumerate(texts):
212
+ h_lang, h_conf = heuristic_preds[i]
213
+
214
+ # High-confidence heuristic → use directly
215
+ if h_lang and h_conf >= 0.92 and h_lang not in DEVANAGARI_LANGUAGES:
216
+ results.append(self._build_result(text, h_lang, h_conf, {}, "heuristic"))
217
+ continue
218
+
219
+ # Weighted voting across ML models
220
+ combined = np.zeros(len(self.all_langs))
221
+ total_weight = 0.0
222
+
223
+ for name, (prob_matrix, langs) in model_probs.items():
224
+ w = self.weights.get(name, 1.0)
225
+ total_weight += w
226
+ prob_row = prob_matrix[i]
227
+ for j, lang in enumerate(langs):
228
+ if lang in self.all_langs:
229
+ k = self.all_langs.index(lang)
230
+ combined[k] += w * (prob_row[j] if j < len(prob_row) else 0)
231
+
232
+ if total_weight > 0:
233
+ combined /= total_weight
234
+
235
+ # Blend in heuristic prior if partially confident
236
+ if h_lang and h_conf >= 0.55:
237
+ k = self.all_langs.index(h_lang) if h_lang in self.all_langs else -1
238
+ if k >= 0:
239
+ alpha = 0.3
240
+ combined[k] = (1 - alpha) * combined[k] + alpha * h_conf
241
+
242
+ best_idx = int(np.argmax(combined))
243
+ pred_lang = self.all_langs[best_idx]
244
+ confidence = float(combined[best_idx])
245
+
246
+ # Devanagari disambiguation (Hindi vs Marathi)
247
+ if pred_lang in DEVANAGARI_LANGUAGES:
248
+ pred_lang, confidence = self._disambiguate_devanagari(
249
+ text, combined, pred_lang, confidence
250
+ )
251
+
252
+ source_contributions = {
253
+ name: self._get_model_top_pred(name, model_probs, i)
254
+ for name in model_probs
255
+ }
256
+ results.append(
257
+ self._build_result(text, pred_lang, confidence, source_contributions, "ensemble")
258
+ )
259
+
260
+ return results
261
+
262
+ def _disambiguate_devanagari(
263
+ self, text: str, probs: np.ndarray, pred: str, conf: float
264
+ ) -> Tuple[str, float]:
265
+ """
266
+ Use lexical markers to disambiguate Hindi vs Marathi.
267
+ Both use Devanagari, so char-level features alone aren't enough.
268
+ """
269
+ # Marathi-specific words/patterns
270
+ marathi_markers = ["आहे", "आहेत", "होते", "केले", "त्यांनी", "आणि", "मराठी"]
271
+ hindi_markers = ["है", "हैं", "था", "थे", "में", "और", "से", "हिंदी"]
272
+
273
+ mar_score = sum(1 for m in marathi_markers if m in text)
274
+ hin_score = sum(1 for m in hindi_markers if m in text)
275
+
276
+ if mar_score > hin_score + 1:
277
+ k = self.all_langs.index("mar") if "mar" in self.all_langs else -1
278
+ return "mar", min(0.95, conf + 0.1 * mar_score)
279
+ elif hin_score > mar_score + 1:
280
+ k = self.all_langs.index("hin") if "hin" in self.all_langs else -1
281
+ return "hin", min(0.95, conf + 0.05 * hin_score)
282
+ return pred, conf
283
+
284
+ def _get_model_top_pred(self, name: str, model_probs: Dict, idx: int) -> Dict:
285
+ if name not in model_probs:
286
+ return {}
287
+ prob_matrix, langs = model_probs[name]
288
+ prob_row = prob_matrix[idx]
289
+ best = int(np.argmax(prob_row))
290
+ return {
291
+ "lang": langs[best] if best < len(langs) else "?",
292
+ "confidence": float(prob_row[best]),
293
+ }
294
+
295
+ def _build_result(
296
+ self, text: str, lang: str, conf: float,
297
+ contributions: Dict, source: str
298
+ ) -> Dict:
299
+ info = LANGUAGE_REGISTRY.get(lang)
300
+ return {
301
+ "text_preview": text[:80] + "..." if len(text) > 80 else text,
302
+ "predicted_lang": lang,
303
+ "predicted_lang_name": info.name if info else lang,
304
+ "script": info.script if info else "Unknown",
305
+ "direction": info.direction if info else "LTR",
306
+ "confidence": round(conf, 4),
307
+ "is_confident": conf >= 0.75,
308
+ "source": source,
309
+ "model_contributions": contributions,
310
+ }
311
+
312
+ def evaluate(self, test_df: pd.DataFrame) -> Dict:
313
+ texts = test_df["text"].tolist()
314
+ true_labels = test_df["lang"].tolist()
315
+ results = self.predict_with_confidence(texts)
316
+ pred_labels = [r["predicted_lang"] for r in results]
317
+
318
+ accuracy = accuracy_score(true_labels, pred_labels)
319
+ macro_f1 = f1_score(true_labels, pred_labels, average="macro", zero_division=0)
320
+ report = classification_report(true_labels, pred_labels, output_dict=True, zero_division=0)
321
+
322
+ logger.info(f"\n{classification_report(true_labels, pred_labels, zero_division=0)}")
323
+ logger.success(f"Ensemble → Accuracy: {accuracy:.4f} | Macro-F1: {macro_f1:.4f}")
324
+
325
+ return {
326
+ "accuracy": accuracy,
327
+ "macro_f1": macro_f1,
328
+ "per_language": report,
329
+ "predictions": pred_labels,
330
+ "true_labels": true_labels,
331
+ "detailed_results": results,
332
+ }
333
+
334
+ def save(self, path: str):
335
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
336
+ data = {
337
+ "weights": self.weights,
338
+ "strategy": self.strategy,
339
+ "stacking_meta": self.stacking_meta,
340
+ "all_langs": self.all_langs,
341
+ }
342
+ with open(path, "wb") as f:
343
+ pickle.dump(data, f)
344
+ logger.success(f"Ensemble config saved: {path}")
src/models/fasttext_model.py ADDED
@@ -0,0 +1,257 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastText-based Language Identification
3
+ Uses Facebook's fastText for ultra-fast, high-accuracy language detection.
4
+ Two modes:
5
+ 1. Pretrained lid.176.ftz (off-the-shelf, 176 languages)
6
+ 2. Custom-trained on Pralekha (domain-specific, higher accuracy on Indic)
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import re
12
+ import tempfile
13
+ import urllib.request
14
+ from pathlib import Path
15
+ from typing import Dict, List, Optional, Tuple
16
+
17
+ import numpy as np
18
+ import pandas as pd
19
+ from loguru import logger
20
+ from tqdm.auto import tqdm
21
+
22
+ try:
23
+ import fasttext
24
+ FASTTEXT_AVAILABLE = True
25
+ except ImportError:
26
+ FASTTEXT_AVAILABLE = False
27
+ logger.warning("fasttext not installed. `pip install fasttext-wheel`")
28
+
29
+ from src.utils.languages import LANGUAGE_REGISTRY, CODE_TO_LABEL
30
+
31
+
32
+ PRETRAINED_MODEL_URL = "https://dl.fbaipublicfiles.com/fasttext/supervised-models/lid.176.ftz"
33
+ LANG_PREFIX = "__label__"
34
+
35
+ # Pralekha lang codes → fastText ISO codes (for pretrained model)
36
+ PRALEKHA_TO_FT = {
37
+ "ben": "bn", "eng": "en", "guj": "gu", "hin": "hi",
38
+ "kan": "kn", "mal": "ml", "mar": "mr", "ori": "or",
39
+ "pan": "pa", "tam": "ta", "tel": "te", "urd": "ur",
40
+ }
41
+ FT_TO_PRALEKHA = {v: k for k, v in PRALEKHA_TO_FT.items()}
42
+
43
+
44
+ class FastTextLIDModel:
45
+ """
46
+ FastText-based Language Identification with two modes:
47
+ - mode='pretrained': Use Facebook's lid.176.ftz (quick, general)
48
+ - mode='custom' : Train from scratch on Pralekha (best Indic accuracy)
49
+ """
50
+
51
+ def __init__(
52
+ self,
53
+ mode: str = "pretrained", # "pretrained" | "custom"
54
+ model_dir: str = "./artifacts/fasttext",
55
+ dim: int = 100,
56
+ epoch: int = 25,
57
+ lr: float = 0.1,
58
+ word_ngrams: int = 3,
59
+ min_count: int = 5,
60
+ thread: int = 8,
61
+ ):
62
+ assert mode in ("pretrained", "custom")
63
+ self.mode = mode
64
+ self.model_dir = Path(model_dir)
65
+ self.model_dir.mkdir(parents=True, exist_ok=True)
66
+ self.dim = dim
67
+ self.epoch = epoch
68
+ self.lr = lr
69
+ self.word_ngrams = word_ngrams
70
+ self.min_count = min_count
71
+ self.thread = thread
72
+ self.model = None
73
+ self.is_fitted = False
74
+ self._target_langs = list(PRALEKHA_TO_FT.keys())
75
+
76
+ # ── Pretrained Mode ───────────────────────────────────────────────────────
77
+
78
+ def load_pretrained(self) -> "FastTextLIDModel":
79
+ """Download and load Facebook's lid.176.ftz pretrained model."""
80
+ assert FASTTEXT_AVAILABLE, "fasttext not installed"
81
+ model_path = self.model_dir / "lid.176.ftz"
82
+
83
+ if not model_path.exists():
84
+ logger.info(f"Downloading pretrained FastText LID model ...")
85
+ urllib.request.urlretrieve(
86
+ PRETRAINED_MODEL_URL,
87
+ str(model_path),
88
+ reporthook=self._download_progress,
89
+ )
90
+ logger.success("Download complete.")
91
+
92
+ self.model = fasttext.load_model(str(model_path))
93
+ self.is_fitted = True
94
+ self.mode = "pretrained"
95
+ logger.success("Pretrained FastText model loaded.")
96
+ return self
97
+
98
+ def _download_progress(self, block_num, block_size, total_size):
99
+ downloaded = block_num * block_size
100
+ pct = min(100, downloaded * 100 / total_size)
101
+ if block_num % 50 == 0:
102
+ logger.debug(f"Download: {pct:.1f}%")
103
+
104
+ # ── Custom Training Mode ──────────────────────────────────────────────────
105
+
106
+ def fit(
107
+ self,
108
+ train_df: pd.DataFrame,
109
+ val_df: Optional[pd.DataFrame] = None,
110
+ ) -> "FastTextLIDModel":
111
+ """Train custom FastText model on Pralekha data."""
112
+ assert FASTTEXT_AVAILABLE, "fasttext not installed"
113
+
114
+ logger.info(f"Preparing FastText training data ({len(train_df):,} samples) ...")
115
+ train_file = self.model_dir / "train.txt"
116
+ self._write_fasttext_format(train_df, train_file)
117
+
118
+ logger.info(f"Training FastText | dim={self.dim} | epoch={self.epoch} | lr={self.lr}")
119
+ self.model = fasttext.train_supervised(
120
+ input=str(train_file),
121
+ dim=self.dim,
122
+ epoch=self.epoch,
123
+ lr=self.lr,
124
+ wordNgrams=self.word_ngrams,
125
+ minCount=self.min_count,
126
+ thread=self.thread,
127
+ verbose=2,
128
+ loss="softmax",
129
+ )
130
+
131
+ model_path = self.model_dir / "custom_lid.bin"
132
+ self.model.save_model(str(model_path))
133
+ self.is_fitted = True
134
+ self.mode = "custom"
135
+
136
+ if val_df is not None:
137
+ results = self.evaluate(val_df)
138
+ logger.success(f"Val Accuracy: {results['accuracy']:.4f} | F1: {results['macro_f1']:.4f}")
139
+
140
+ return self
141
+
142
+ def _write_fasttext_format(self, df: pd.DataFrame, path: Path):
143
+ """Write data in fastText supervised format: __label__LANG text"""
144
+ with open(path, "w", encoding="utf-8") as f:
145
+ for _, row in tqdm(df.iterrows(), total=len(df), desc="Writing FT data"):
146
+ text = self._clean_for_fasttext(row["text"])
147
+ lang = row["lang"]
148
+ label = f"{LANG_PREFIX}{lang}"
149
+ f.write(f"{label} {text}\n")
150
+
151
+ @staticmethod
152
+ def _clean_for_fasttext(text: str) -> str:
153
+ """Minimal cleaning for fastText (preserve Unicode)."""
154
+ text = re.sub(r"\s+", " ", text).strip()
155
+ text = re.sub(r"[\r\n\t]", " ", text)
156
+ return text
157
+
158
+ # ── Inference ─────────────────────────────────────────────────────────────
159
+
160
+ def predict(self, texts: List[str]) -> List[str]:
161
+ """Predict language codes."""
162
+ assert self.is_fitted
163
+ results = []
164
+ for text in texts:
165
+ pred = self._predict_single(text)
166
+ results.append(pred)
167
+ return results
168
+
169
+ def _predict_single(self, text: str) -> str:
170
+ text = self._clean_for_fasttext(text)
171
+ labels, probs = self.model.predict(text, k=1)
172
+ raw_label = labels[0].replace(LANG_PREFIX, "")
173
+
174
+ if self.mode == "pretrained":
175
+ return FT_TO_PRALEKHA.get(raw_label, raw_label)
176
+ return raw_label
177
+
178
+ def predict_proba(self, texts: List[str]) -> Tuple[np.ndarray, List[str]]:
179
+ """
180
+ Returns (prob_matrix [n × n_classes], class_names).
181
+ """
182
+ assert self.is_fitted
183
+ all_labels = self._target_langs
184
+ n = len(texts)
185
+ n_classes = len(all_labels)
186
+ prob_matrix = np.zeros((n, n_classes))
187
+
188
+ for i, text in enumerate(texts):
189
+ text_clean = self._clean_for_fasttext(text)
190
+ labels, probs = self.model.predict(text_clean, k=-1)
191
+ for label, prob in zip(labels, probs):
192
+ raw = label.replace(LANG_PREFIX, "")
193
+ mapped = FT_TO_PRALEKHA.get(raw, raw) if self.mode == "pretrained" else raw
194
+ if mapped in all_labels:
195
+ j = all_labels.index(mapped)
196
+ prob_matrix[i, j] = prob
197
+
198
+ # Normalize
199
+ row_sums = prob_matrix.sum(axis=1, keepdims=True)
200
+ prob_matrix = np.where(row_sums > 0, prob_matrix / row_sums, prob_matrix)
201
+
202
+ return prob_matrix, all_labels
203
+
204
+ def predict_with_confidence(self, texts: List[str]) -> List[Dict]:
205
+ """Rich predictions with top-K confidences."""
206
+ results = []
207
+ for text in texts:
208
+ text_clean = self._clean_for_fasttext(text)
209
+ labels, probs = self.model.predict(text_clean, k=5)
210
+ top_k = []
211
+ for label, prob in zip(labels, probs):
212
+ raw = label.replace(LANG_PREFIX, "")
213
+ mapped = FT_TO_PRALEKHA.get(raw, raw) if self.mode == "pretrained" else raw
214
+ info = LANGUAGE_REGISTRY.get(mapped)
215
+ top_k.append({
216
+ "lang": mapped,
217
+ "name": info.name if info else mapped,
218
+ "probability": float(prob),
219
+ })
220
+
221
+ results.append({
222
+ "text": text[:100],
223
+ "predicted_lang": top_k[0]["lang"],
224
+ "confidence": top_k[0]["probability"],
225
+ "is_confident": top_k[0]["probability"] >= 0.7,
226
+ "top_5": top_k,
227
+ })
228
+ return results
229
+
230
+ def evaluate(self, test_df: pd.DataFrame) -> Dict:
231
+ """Full evaluation."""
232
+ from sklearn.metrics import classification_report, f1_score, accuracy_score
233
+
234
+ texts = test_df["text"].tolist()
235
+ true_labels = test_df["lang"].tolist()
236
+ pred_labels = self.predict(texts)
237
+
238
+ accuracy = accuracy_score(true_labels, pred_labels)
239
+ macro_f1 = f1_score(true_labels, pred_labels, average="macro", zero_division=0)
240
+ report = classification_report(true_labels, pred_labels, output_dict=True, zero_division=0)
241
+
242
+ logger.info(f"\n{classification_report(true_labels, pred_labels, zero_division=0)}")
243
+ logger.success(f"Accuracy: {accuracy:.4f} | Macro-F1: {macro_f1:.4f}")
244
+
245
+ return {
246
+ "accuracy": accuracy,
247
+ "macro_f1": macro_f1,
248
+ "per_language": report,
249
+ "predictions": pred_labels,
250
+ "true_labels": true_labels,
251
+ }
252
+
253
+ def load(self, path: str) -> "FastTextLIDModel":
254
+ assert FASTTEXT_AVAILABLE
255
+ self.model = fasttext.load_model(path)
256
+ self.is_fitted = True
257
+ return self
src/models/ngram_model.py ADDED
@@ -0,0 +1,284 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Character N-gram Language Identification Model
3
+ High-accuracy baseline using TF-IDF character n-grams + Logistic Regression.
4
+ Achieves ~98%+ accuracy on clean Indic text.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import pickle
9
+ from pathlib import Path
10
+ from typing import Dict, List, Optional, Tuple
11
+
12
+ import numpy as np
13
+ import pandas as pd
14
+ from loguru import logger
15
+ from sklearn.calibration import CalibratedClassifierCV
16
+ from sklearn.feature_extraction.text import TfidfVectorizer
17
+ from sklearn.linear_model import LogisticRegression
18
+ from sklearn.metrics import classification_report, f1_score
19
+ from sklearn.pipeline import Pipeline
20
+ from sklearn.preprocessing import LabelEncoder
21
+
22
+ from src.utils.languages import LANGUAGE_REGISTRY, LABEL_TO_CODE, CODE_TO_LABEL
23
+
24
+
25
+ class CharNgramLIDModel:
26
+ """
27
+ Character N-gram Language Identification using TF-IDF + LogReg.
28
+
29
+ Key design choices:
30
+ - analyzer='char_wb': treats word boundaries specially
31
+ - ngram_range=(2,4): captures morpheme-level patterns
32
+ - sublinear_tf=True: dampens frequency explosion
33
+ - Calibration: isotonic regression for reliable probabilities
34
+ - Per-language confidence thresholds for rejection
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ ngram_range: Tuple[int, int] = (2, 4),
40
+ max_features: int = 150_000,
41
+ C: float = 5.0,
42
+ max_iter: int = 1000,
43
+ calibrate: bool = True,
44
+ n_jobs: int = -1,
45
+ ):
46
+ self.ngram_range = ngram_range
47
+ self.max_features = max_features
48
+ self.C = C
49
+ self.max_iter = max_iter
50
+ self.calibrate = calibrate
51
+ self.n_jobs = n_jobs
52
+ self.pipeline: Optional[Pipeline] = None
53
+ self.label_encoder = LabelEncoder()
54
+ self.classes_: Optional[np.ndarray] = None
55
+ self.confidence_thresholds: Dict[str, float] = {}
56
+ self.is_fitted = False
57
+
58
+ def _build_pipeline(self) -> Pipeline:
59
+ vectorizer = TfidfVectorizer(
60
+ analyzer="char_wb",
61
+ ngram_range=self.ngram_range,
62
+ max_features=self.max_features,
63
+ sublinear_tf=True,
64
+ strip_accents=None, # preserve Indic diacritics!
65
+ min_df=2,
66
+ max_df=0.95,
67
+ dtype=np.float32,
68
+ )
69
+ base_clf = LogisticRegression(
70
+ C=self.C,
71
+ max_iter=self.max_iter,
72
+ solver="saga",
73
+ multi_class="multinomial",
74
+ n_jobs=self.n_jobs,
75
+ verbose=0,
76
+ )
77
+ if self.calibrate:
78
+ clf = CalibratedClassifierCV(base_clf, method="isotonic", cv=3)
79
+ else:
80
+ clf = base_clf
81
+
82
+ return Pipeline([
83
+ ("tfidf", vectorizer),
84
+ ("clf", clf),
85
+ ])
86
+
87
+ def fit(
88
+ self,
89
+ train_df: pd.DataFrame,
90
+ val_df: Optional[pd.DataFrame] = None,
91
+ ) -> "CharNgramLIDModel":
92
+ logger.info(
93
+ f"Training CharNgramLID | n_gram={self.ngram_range} | "
94
+ f"features={self.max_features} | C={self.C}"
95
+ )
96
+ texts = train_df["text"].tolist()
97
+ labels = self.label_encoder.fit_transform(train_df["lang"].tolist())
98
+ self.classes_ = self.label_encoder.classes_
99
+
100
+ self.pipeline = self._build_pipeline()
101
+ self.pipeline.fit(texts, labels)
102
+ self.is_fitted = True
103
+
104
+ if val_df is not None:
105
+ self._compute_confidence_thresholds(val_df)
106
+ val_preds = self.predict(val_df["text"].tolist())
107
+ f1 = f1_score(val_df["lang"].tolist(), val_preds, average="macro")
108
+ logger.success(f"Val Macro-F1: {f1:.4f}")
109
+
110
+ return self
111
+
112
+ def predict(self, texts: List[str]) -> List[str]:
113
+ assert self.is_fitted, "Model not fitted!"
114
+ label_ids = self.pipeline.predict(texts)
115
+ return self.label_encoder.inverse_transform(label_ids).tolist()
116
+
117
+ def predict_proba(self, texts: List[str]) -> np.ndarray:
118
+ """Returns probability matrix [n_samples × n_classes]."""
119
+ assert self.is_fitted
120
+ return self.pipeline.predict_proba(texts)
121
+
122
+ def predict_with_confidence(
123
+ self, texts: List[str]
124
+ ) -> List[Dict]:
125
+ """
126
+ Returns per-sample predictions with confidence and
127
+ top-K alternatives.
128
+ """
129
+ probs = self.predict_proba(texts)
130
+ results = []
131
+ for i, prob_row in enumerate(probs):
132
+ top_k_idx = np.argsort(prob_row)[::-1][:5]
133
+ top_k = [
134
+ {
135
+ "lang": self.classes_[j],
136
+ "name": LANGUAGE_REGISTRY.get(self.classes_[j], None),
137
+ "probability": float(prob_row[j]),
138
+ }
139
+ for j in top_k_idx
140
+ ]
141
+ best = top_k[0]
142
+ threshold = self.confidence_thresholds.get(best["lang"], 0.5)
143
+ results.append({
144
+ "text": texts[i][:100] + "..." if len(texts[i]) > 100 else texts[i],
145
+ "predicted_lang": best["lang"],
146
+ "confidence": best["probability"],
147
+ "is_confident": best["probability"] >= threshold,
148
+ "top_5": top_k,
149
+ })
150
+ return results
151
+
152
+ def _compute_confidence_thresholds(
153
+ self, val_df: pd.DataFrame, target_precision: float = 0.95
154
+ ):
155
+ """
156
+ Compute per-language confidence thresholds that achieve
157
+ target_precision on the validation set.
158
+ """
159
+ probs = self.predict_proba(val_df["text"].tolist())
160
+ true_langs = val_df["lang"].tolist()
161
+ pred_langs = self.predict(val_df["text"].tolist())
162
+
163
+ for lang in self.classes_:
164
+ lang_idx = np.where(self.classes_ == lang)[0]
165
+ if len(lang_idx) == 0:
166
+ continue
167
+ idx = lang_idx[0]
168
+ lang_probs = probs[:, idx]
169
+ is_correct = np.array([p == lang for p in pred_langs])
170
+ is_predicted = np.array([p == lang for p in pred_langs])
171
+
172
+ # Find threshold where precision ≥ target
173
+ thresholds = np.linspace(0.3, 0.99, 50)
174
+ best_t = 0.5
175
+ for t in thresholds:
176
+ mask = (lang_probs >= t) & is_predicted
177
+ if mask.sum() == 0:
178
+ break
179
+ prec = is_correct[mask].mean()
180
+ if prec >= target_precision:
181
+ best_t = t
182
+ break
183
+ self.confidence_thresholds[lang] = best_t
184
+
185
+ logger.debug(f"Confidence thresholds: {self.confidence_thresholds}")
186
+
187
+ def evaluate(self, test_df: pd.DataFrame) -> Dict:
188
+ """Full evaluation with per-language metrics."""
189
+ texts = test_df["text"].tolist()
190
+ true_labels = test_df["lang"].tolist()
191
+ pred_labels = self.predict(texts)
192
+ probs = self.predict_proba(texts)
193
+
194
+ overall_f1 = f1_score(true_labels, pred_labels, average="macro")
195
+ report = classification_report(
196
+ true_labels, pred_labels,
197
+ target_names=self.classes_,
198
+ output_dict=True,
199
+ )
200
+
201
+ # Confidence calibration: ECE (Expected Calibration Error)
202
+ confidences = probs.max(axis=1)
203
+ correct = np.array(true_labels) == np.array(pred_labels)
204
+ ece = self._compute_ece(confidences, correct)
205
+
206
+ logger.info(f"\n{classification_report(true_labels, pred_labels)}")
207
+ logger.success(f"Macro F1: {overall_f1:.4f} | ECE: {ece:.4f}")
208
+
209
+ return {
210
+ "macro_f1": overall_f1,
211
+ "ece": ece,
212
+ "per_language": report,
213
+ "predictions": pred_labels,
214
+ "true_labels": true_labels,
215
+ "confidences": confidences.tolist(),
216
+ }
217
+
218
+ @staticmethod
219
+ def _compute_ece(confidences: np.ndarray, correct: np.ndarray, n_bins: int = 10) -> float:
220
+ bins = np.linspace(0, 1, n_bins + 1)
221
+ ece = 0.0
222
+ for lo, hi in zip(bins[:-1], bins[1:]):
223
+ mask = (confidences >= lo) & (confidences < hi)
224
+ if mask.sum() == 0:
225
+ continue
226
+ avg_conf = confidences[mask].mean()
227
+ avg_acc = correct[mask].mean()
228
+ ece += mask.mean() * abs(avg_conf - avg_acc)
229
+ return float(ece)
230
+
231
+ def save(self, path: str):
232
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
233
+ with open(path, "wb") as f:
234
+ pickle.dump({
235
+ "pipeline": self.pipeline,
236
+ "label_encoder": self.label_encoder,
237
+ "classes_": self.classes_,
238
+ "confidence_thresholds": self.confidence_thresholds,
239
+ "config": {
240
+ "ngram_range": self.ngram_range,
241
+ "max_features": self.max_features,
242
+ "C": self.C,
243
+ },
244
+ }, f)
245
+ logger.success(f"Model saved: {path}")
246
+
247
+ @classmethod
248
+ def load(cls, path: str) -> "CharNgramLIDModel":
249
+ with open(path, "rb") as f:
250
+ data = pickle.load(f)
251
+ cfg = data.get("config", {})
252
+ model = cls(
253
+ ngram_range=cfg.get("ngram_range", (2, 4)),
254
+ max_features=cfg.get("max_features", 150_000),
255
+ C=cfg.get("C", 5.0),
256
+ )
257
+ model.pipeline = data["pipeline"]
258
+ model.label_encoder = data["label_encoder"]
259
+ model.classes_ = data["classes_"]
260
+ model.confidence_thresholds = data.get("confidence_thresholds", {})
261
+ model.is_fitted = True
262
+ logger.success(f"Model loaded: {path}")
263
+ return model
264
+
265
+ def get_top_features_per_language(self, n: int = 20) -> Dict[str, List[str]]:
266
+ """Extract most discriminative n-grams per language."""
267
+ assert self.is_fitted
268
+ tfidf = self.pipeline.named_steps["tfidf"]
269
+ clf = self.pipeline.named_steps["clf"]
270
+ feature_names = np.array(tfidf.get_feature_names_out())
271
+
272
+ # Get the underlying LR if calibrated
273
+ if hasattr(clf, "calibrated_classifiers_"):
274
+ coef = np.mean(
275
+ [c.estimator.coef_ for c in clf.calibrated_classifiers_], axis=0
276
+ )
277
+ else:
278
+ coef = clf.coef_
279
+
280
+ result = {}
281
+ for i, lang in enumerate(self.classes_):
282
+ top_idx = np.argsort(coef[i])[::-1][:n]
283
+ result[lang] = feature_names[top_idx].tolist()
284
+ return result
src/models/transformer_model.py ADDED
@@ -0,0 +1,303 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Transformer-based Language Identification Model
3
+ Uses Google MuRIL (Multilingual Universal Representations for Indian Languages)
4
+ or XLM-RoBERTa for high-accuracy sequence classification.
5
+
6
+ Requires GPU for practical training. Set enabled: true in config.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from pathlib import Path
12
+ from typing import Dict, List, Optional, Tuple
13
+
14
+ import numpy as np
15
+ import pandas as pd
16
+ from loguru import logger
17
+
18
+ try:
19
+ import torch
20
+ from torch.utils.data import Dataset, DataLoader
21
+ from transformers import (
22
+ AutoTokenizer,
23
+ AutoModelForSequenceClassification,
24
+ TrainingArguments,
25
+ Trainer,
26
+ EarlyStoppingCallback,
27
+ )
28
+ from transformers import DataCollatorWithPadding
29
+ TRANSFORMERS_AVAILABLE = True
30
+ except ImportError:
31
+ TRANSFORMERS_AVAILABLE = False
32
+ logger.warning("transformers/torch not installed. Transformer model unavailable.")
33
+
34
+ from src.utils.languages import (
35
+ LANGUAGE_REGISTRY,
36
+ CODE_TO_LABEL,
37
+ LABEL_TO_CODE,
38
+ NUM_CLASSES,
39
+ ALL_LANGUAGE_CODES,
40
+ )
41
+
42
+
43
+ # ─────────────────────────────────────────────
44
+ # Dataset Wrapper
45
+ # ─────────────────────────────────────────────
46
+
47
+ class IndicLIDDataset:
48
+ """Wraps a pandas DataFrame for HuggingFace Trainer."""
49
+
50
+ def __init__(self, df: pd.DataFrame, tokenizer, max_length: int = 128):
51
+ self.texts = df["text"].tolist()
52
+ self.labels = df["label"].tolist()
53
+ self.tokenizer = tokenizer
54
+ self.max_length = max_length
55
+
56
+ def __len__(self):
57
+ return len(self.texts)
58
+
59
+ def __getitem__(self, idx):
60
+ encoding = self.tokenizer(
61
+ self.texts[idx],
62
+ max_length=self.max_length,
63
+ truncation=True,
64
+ padding="max_length",
65
+ return_tensors="pt",
66
+ )
67
+ return {
68
+ "input_ids": encoding["input_ids"].squeeze(),
69
+ "attention_mask": encoding["attention_mask"].squeeze(),
70
+ "labels": torch.tensor(self.labels[idx], dtype=torch.long),
71
+ }
72
+
73
+
74
+ # ─────────────────────────────────────────────
75
+ # Transformer LID Model
76
+ # ─────────────────────────────────────────────
77
+
78
+ class TransformerLIDModel:
79
+ """
80
+ Fine-tuned transformer for language identification.
81
+ Defaults to MuRIL (designed for Indic languages).
82
+
83
+ MuRIL advantages over XLM-R for this task:
84
+ - Pretrained on 17 Indian languages + transliterated text
85
+ - Better subword coverage for Indic scripts
86
+ - Transliteration-aware pretraining
87
+ """
88
+
89
+ SUPPORTED_MODELS = {
90
+ "muril": "google/muril-base-cased",
91
+ "xlmr": "xlm-roberta-base",
92
+ "xlmr-large": "xlm-roberta-large",
93
+ "indicbert": "ai4bharat/indic-bert",
94
+ }
95
+
96
+ def __init__(
97
+ self,
98
+ base_model: str = "muril",
99
+ max_length: int = 128,
100
+ batch_size: int = 32,
101
+ learning_rate: float = 2e-5,
102
+ num_epochs: int = 3,
103
+ warmup_ratio: float = 0.1,
104
+ weight_decay: float = 0.01,
105
+ output_dir: str = "./artifacts/models/transformer",
106
+ fp16: bool = True,
107
+ ):
108
+ assert TRANSFORMERS_AVAILABLE, "transformers + torch required"
109
+ self.model_name = self.SUPPORTED_MODELS.get(base_model, base_model)
110
+ self.max_length = max_length
111
+ self.batch_size = batch_size
112
+ self.learning_rate = learning_rate
113
+ self.num_epochs = num_epochs
114
+ self.warmup_ratio = warmup_ratio
115
+ self.weight_decay = weight_decay
116
+ self.output_dir = Path(output_dir)
117
+ self.output_dir.mkdir(parents=True, exist_ok=True)
118
+ self.fp16 = fp16 and torch.cuda.is_available()
119
+
120
+ self.tokenizer = None
121
+ self.model = None
122
+ self.trainer = None
123
+ self.is_fitted = False
124
+
125
+ # Label mapping
126
+ self.id2label = {i: l for i, l in LABEL_TO_CODE.items()}
127
+ self.label2id = {l: i for i, l in self.id2label.items()}
128
+
129
+ def _load_base_model(self):
130
+ logger.info(f"Loading tokenizer and model: {self.model_name}")
131
+ self.tokenizer = AutoTokenizer.from_pretrained(
132
+ self.model_name,
133
+ use_fast=True,
134
+ )
135
+ self.model = AutoModelForSequenceClassification.from_pretrained(
136
+ self.model_name,
137
+ num_labels=NUM_CLASSES,
138
+ id2label=self.id2label,
139
+ label2id=self.label2id,
140
+ ignore_mismatched_sizes=True,
141
+ )
142
+ device = "cuda" if torch.cuda.is_available() else "cpu"
143
+ logger.info(f"Using device: {device}")
144
+ self.model.to(device)
145
+
146
+ def fit(
147
+ self,
148
+ train_df: pd.DataFrame,
149
+ val_df: Optional[pd.DataFrame] = None,
150
+ ) -> "TransformerLIDModel":
151
+ self._load_base_model()
152
+
153
+ train_dataset = IndicLIDDataset(train_df, self.tokenizer, self.max_length)
154
+ val_dataset = IndicLIDDataset(val_df, self.tokenizer, self.max_length) if val_df is not None else None
155
+
156
+ training_args = TrainingArguments(
157
+ output_dir=str(self.output_dir),
158
+ num_train_epochs=self.num_epochs,
159
+ per_device_train_batch_size=self.batch_size,
160
+ per_device_eval_batch_size=self.batch_size * 2,
161
+ learning_rate=self.learning_rate,
162
+ warmup_ratio=self.warmup_ratio,
163
+ weight_decay=self.weight_decay,
164
+ evaluation_strategy="epoch" if val_dataset else "no",
165
+ save_strategy="epoch",
166
+ load_best_model_at_end=val_dataset is not None,
167
+ metric_for_best_model="eval_loss",
168
+ fp16=self.fp16,
169
+ logging_steps=100,
170
+ logging_dir=str(self.output_dir / "logs"),
171
+ dataloader_num_workers=4,
172
+ seed=42,
173
+ report_to="none", # Disable wandb/mlflow unless configured
174
+ )
175
+
176
+ self.trainer = Trainer(
177
+ model=self.model,
178
+ args=training_args,
179
+ train_dataset=train_dataset,
180
+ eval_dataset=val_dataset,
181
+ tokenizer=self.tokenizer,
182
+ callbacks=[EarlyStoppingCallback(early_stopping_patience=2)] if val_dataset else [],
183
+ compute_metrics=self._compute_metrics,
184
+ )
185
+
186
+ logger.info("Starting transformer training ...")
187
+ self.trainer.train()
188
+ self.is_fitted = True
189
+ logger.success("Training complete!")
190
+ return self
191
+
192
+ def _compute_metrics(self, eval_pred):
193
+ from sklearn.metrics import f1_score, accuracy_score
194
+ logits, labels = eval_pred
195
+ predictions = np.argmax(logits, axis=-1)
196
+ acc = accuracy_score(labels, predictions)
197
+ f1 = f1_score(labels, predictions, average="macro", zero_division=0)
198
+ return {"accuracy": acc, "macro_f1": f1}
199
+
200
+ def predict(self, texts: List[str]) -> List[str]:
201
+ assert self.is_fitted
202
+ probs, _ = self.predict_proba(texts)
203
+ label_ids = np.argmax(probs, axis=1)
204
+ return [self.id2label[i] for i in label_ids]
205
+
206
+ def predict_proba(self, texts: List[str]) -> Tuple[np.ndarray, List[str]]:
207
+ assert self.is_fitted
208
+ import torch
209
+ from torch.nn.functional import softmax
210
+
211
+ device = next(self.model.parameters()).device
212
+ all_probs = []
213
+
214
+ for i in range(0, len(texts), self.batch_size):
215
+ batch_texts = texts[i:i + self.batch_size]
216
+ encodings = self.tokenizer(
217
+ batch_texts,
218
+ max_length=self.max_length,
219
+ truncation=True,
220
+ padding=True,
221
+ return_tensors="pt",
222
+ ).to(device)
223
+
224
+ with torch.no_grad():
225
+ outputs = self.model(**encodings)
226
+ probs = softmax(outputs.logits, dim=-1).cpu().numpy()
227
+ all_probs.append(probs)
228
+
229
+ prob_matrix = np.vstack(all_probs)
230
+ class_names = [self.id2label[i] for i in range(NUM_CLASSES)]
231
+ return prob_matrix, class_names
232
+
233
+ def predict_with_confidence(self, texts: List[str]) -> List[Dict]:
234
+ probs, class_names = self.predict_proba(texts)
235
+ results = []
236
+ for i, prob_row in enumerate(probs):
237
+ top_k_idx = np.argsort(prob_row)[::-1][:5]
238
+ top_k = [
239
+ {
240
+ "lang": class_names[j],
241
+ "name": LANGUAGE_REGISTRY.get(class_names[j], None) and LANGUAGE_REGISTRY[class_names[j]].name,
242
+ "probability": float(prob_row[j]),
243
+ }
244
+ for j in top_k_idx
245
+ ]
246
+ best = top_k[0]
247
+ info = LANGUAGE_REGISTRY.get(best["lang"])
248
+ results.append({
249
+ "text_preview": texts[i][:80],
250
+ "predicted_lang": best["lang"],
251
+ "predicted_lang_name": info.name if info else best["lang"],
252
+ "script": info.script if info else "?",
253
+ "direction": info.direction if info else "LTR",
254
+ "confidence": best["probability"],
255
+ "is_confident": best["probability"] >= 0.75,
256
+ "top_5": top_k,
257
+ })
258
+ return results
259
+
260
+ def evaluate(self, test_df: pd.DataFrame) -> Dict:
261
+ from sklearn.metrics import classification_report, f1_score, accuracy_score
262
+ texts = test_df["text"].tolist()
263
+ true_labels = test_df["lang"].tolist()
264
+ pred_labels = self.predict(texts)
265
+ probs, _ = self.predict_proba(texts)
266
+
267
+ accuracy = accuracy_score(true_labels, pred_labels)
268
+ macro_f1 = f1_score(true_labels, pred_labels, average="macro", zero_division=0)
269
+ report = classification_report(true_labels, pred_labels, output_dict=True, zero_division=0)
270
+
271
+ logger.info(f"\n{classification_report(true_labels, pred_labels, zero_division=0)}")
272
+ logger.success(f"Accuracy: {accuracy:.4f} | Macro-F1: {macro_f1:.4f}")
273
+
274
+ return {
275
+ "accuracy": accuracy,
276
+ "macro_f1": macro_f1,
277
+ "per_language": report,
278
+ "predictions": pred_labels,
279
+ "true_labels": true_labels,
280
+ "confidences": probs.max(axis=1).tolist(),
281
+ }
282
+
283
+ def save(self, path: str):
284
+ self.model.save_pretrained(path)
285
+ self.tokenizer.save_pretrained(path)
286
+ logger.success(f"Transformer model saved: {path}")
287
+
288
+ @classmethod
289
+ def load(cls, path: str) -> "TransformerLIDModel":
290
+ assert TRANSFORMERS_AVAILABLE
291
+ obj = cls.__new__(cls)
292
+ obj.output_dir = Path(path)
293
+ obj.tokenizer = AutoTokenizer.from_pretrained(path)
294
+ obj.model = AutoModelForSequenceClassification.from_pretrained(path)
295
+ obj.is_fitted = True
296
+ obj.id2label = {i: l for i, l in LABEL_TO_CODE.items()}
297
+ obj.batch_size = 32
298
+ obj.max_length = 128
299
+ obj.fp16 = False
300
+ device = "cuda" if torch.cuda.is_available() else "cpu"
301
+ obj.model.to(device)
302
+ logger.success(f"Transformer model loaded: {path}")
303
+ return obj
src/utils/__init__.py ADDED
File without changes
src/utils/languages.py ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Language Registry & Constants for AI4Bharat Pralekha Dataset
3
+ Covers 12 Indic languages + English with full Unicode metadata.
4
+ """
5
+ from __future__ import annotations
6
+ from dataclasses import dataclass, field
7
+ from typing import Dict, List, Optional, Tuple
8
+ import unicodedata
9
+
10
+
11
+ # ─────────────────────────────────────────────
12
+ # Script Unicode Block Definitions
13
+ # ─────────────────────────────────────────────
14
+ SCRIPT_UNICODE_BLOCKS: Dict[str, List[Tuple[int, int]]] = {
15
+ "Devanagari": [(0x0900, 0x097F), (0xA8E0, 0xA8FF)],
16
+ "Bengali": [(0x0980, 0x09FF)],
17
+ "Gurmukhi": [(0x0A00, 0x0A7F)],
18
+ "Gujarati": [(0x0A80, 0x0AFF)],
19
+ "Odia": [(0x0B00, 0x0B7F)],
20
+ "Tamil": [(0x0B80, 0x0BFF)],
21
+ "Telugu": [(0x0C00, 0x0C7F)],
22
+ "Kannada": [(0x0C80, 0x0CFF)],
23
+ "Malayalam": [(0x0D00, 0x0D7F)],
24
+ "Sinhala": [(0x0D80, 0x0DFF)],
25
+ "Arabic": [(0x0600, 0x06FF), (0x0750, 0x077F), (0xFB50, 0xFDFF), (0xFE70, 0xFEFF)],
26
+ "Latin": [(0x0000, 0x007F), (0x0080, 0x00FF), (0x0100, 0x017F)],
27
+ "Cyrillic": [(0x0400, 0x04FF)],
28
+ "CJK": [(0x4E00, 0x9FFF), (0x3400, 0x4DBF)],
29
+ }
30
+
31
+ SCRIPT_TO_DIRECTION: Dict[str, str] = {
32
+ "Devanagari": "LTR",
33
+ "Bengali": "LTR",
34
+ "Gurmukhi": "LTR",
35
+ "Gujarati": "LTR",
36
+ "Odia": "LTR",
37
+ "Tamil": "LTR",
38
+ "Telugu": "LTR",
39
+ "Kannada": "LTR",
40
+ "Malayalam": "LTR",
41
+ "Arabic": "RTL",
42
+ "Latin": "LTR",
43
+ }
44
+
45
+
46
+ @dataclass
47
+ class LanguageInfo:
48
+ code: str # Pralekha subset code (ben, hin, ...)
49
+ name: str # Full English name
50
+ script: str # Primary script name
51
+ iso639_1: Optional[str] # ISO 639-1 two-letter code
52
+ iso639_3: str # ISO 639-3 three-letter code
53
+ label_id: int # Numeric label for ML models
54
+ unicode_ranges: List[Tuple[int, int]] = field(default_factory=list)
55
+ direction: str = "LTR"
56
+ family: str = "Indo-Aryan"
57
+ native_name: str = ""
58
+ romanization_scheme: Optional[str] = None # IAST, ISO 15919, etc.
59
+ shares_script_with: List[str] = field(default_factory=list)
60
+
61
+ def char_in_script(self, char: str) -> bool:
62
+ cp = ord(char)
63
+ return any(lo <= cp <= hi for lo, hi in self.unicode_ranges)
64
+
65
+
66
+ # ─────────────────────────────────────────────
67
+ # Language Registry (12 Indic + English)
68
+ # ─────────────────────────────────────────────
69
+ LANGUAGE_REGISTRY: Dict[str, LanguageInfo] = {
70
+ "ben": LanguageInfo(
71
+ code="ben", name="Bengali", script="Bengali",
72
+ iso639_1="bn", iso639_3="ben", label_id=0,
73
+ unicode_ranges=SCRIPT_UNICODE_BLOCKS["Bengali"],
74
+ direction="LTR", family="Indo-Aryan",
75
+ native_name="বাংলা",
76
+ romanization_scheme="ISO 15919",
77
+ ),
78
+ "eng": LanguageInfo(
79
+ code="eng", name="English", script="Latin",
80
+ iso639_1="en", iso639_3="eng", label_id=1,
81
+ unicode_ranges=SCRIPT_UNICODE_BLOCKS["Latin"],
82
+ direction="LTR", family="Germanic",
83
+ native_name="English",
84
+ ),
85
+ "guj": LanguageInfo(
86
+ code="guj", name="Gujarati", script="Gujarati",
87
+ iso639_1="gu", iso639_3="guj", label_id=2,
88
+ unicode_ranges=SCRIPT_UNICODE_BLOCKS["Gujarati"],
89
+ direction="LTR", family="Indo-Aryan",
90
+ native_name="ગુજરાતી",
91
+ romanization_scheme="ISO 15919",
92
+ ),
93
+ "hin": LanguageInfo(
94
+ code="hin", name="Hindi", script="Devanagari",
95
+ iso639_1="hi", iso639_3="hin", label_id=3,
96
+ unicode_ranges=SCRIPT_UNICODE_BLOCKS["Devanagari"],
97
+ direction="LTR", family="Indo-Aryan",
98
+ native_name="हिन्दी",
99
+ romanization_scheme="IAST",
100
+ shares_script_with=["mar"],
101
+ ),
102
+ "kan": LanguageInfo(
103
+ code="kan", name="Kannada", script="Kannada",
104
+ iso639_1="kn", iso639_3="kan", label_id=4,
105
+ unicode_ranges=SCRIPT_UNICODE_BLOCKS["Kannada"],
106
+ direction="LTR", family="Dravidian",
107
+ native_name="ಕನ್ನಡ",
108
+ ),
109
+ "mal": LanguageInfo(
110
+ code="mal", name="Malayalam", script="Malayalam",
111
+ iso639_1="ml", iso639_3="mal", label_id=5,
112
+ unicode_ranges=SCRIPT_UNICODE_BLOCKS["Malayalam"],
113
+ direction="LTR", family="Dravidian",
114
+ native_name="മലയാളം",
115
+ ),
116
+ "mar": LanguageInfo(
117
+ code="mar", name="Marathi", script="Devanagari",
118
+ iso639_1="mr", iso639_3="mar", label_id=6,
119
+ unicode_ranges=SCRIPT_UNICODE_BLOCKS["Devanagari"],
120
+ direction="LTR", family="Indo-Aryan",
121
+ native_name="मराठी",
122
+ romanization_scheme="IAST",
123
+ shares_script_with=["hin"],
124
+ ),
125
+ "ori": LanguageInfo(
126
+ code="ori", name="Odia", script="Odia",
127
+ iso639_1="or", iso639_3="ori", label_id=7,
128
+ unicode_ranges=SCRIPT_UNICODE_BLOCKS["Odia"],
129
+ direction="LTR", family="Indo-Aryan",
130
+ native_name="ଓଡ଼ିଆ",
131
+ ),
132
+ "pan": LanguageInfo(
133
+ code="pan", name="Punjabi", script="Gurmukhi",
134
+ iso639_1="pa", iso639_3="pan", label_id=8,
135
+ unicode_ranges=SCRIPT_UNICODE_BLOCKS["Gurmukhi"],
136
+ direction="LTR", family="Indo-Aryan",
137
+ native_name="ਪੰਜਾਬੀ",
138
+ ),
139
+ "tam": LanguageInfo(
140
+ code="tam", name="Tamil", script="Tamil",
141
+ iso639_1="ta", iso639_3="tam", label_id=9,
142
+ unicode_ranges=SCRIPT_UNICODE_BLOCKS["Tamil"],
143
+ direction="LTR", family="Dravidian",
144
+ native_name="தமிழ்",
145
+ ),
146
+ "tel": LanguageInfo(
147
+ code="tel", name="Telugu", script="Telugu",
148
+ iso639_1="te", iso639_3="tel", label_id=10,
149
+ unicode_ranges=SCRIPT_UNICODE_BLOCKS["Telugu"],
150
+ direction="LTR", family="Dravidian",
151
+ native_name="తెలుగు",
152
+ ),
153
+ "urd": LanguageInfo(
154
+ code="urd", name="Urdu", script="Arabic",
155
+ iso639_1="ur", iso639_3="urd", label_id=11,
156
+ unicode_ranges=SCRIPT_UNICODE_BLOCKS["Arabic"],
157
+ direction="RTL", family="Indo-Aryan",
158
+ native_name="اردو",
159
+ romanization_scheme="ALA-LC",
160
+ ),
161
+ }
162
+
163
+ # Derived mappings
164
+ LABEL_TO_CODE: Dict[int, str] = {v.label_id: k for k, v in LANGUAGE_REGISTRY.items()}
165
+ CODE_TO_LABEL: Dict[str, int] = {k: v.label_id for k, v in LANGUAGE_REGISTRY.items()}
166
+ ALL_LANGUAGE_CODES: List[str] = sorted(LANGUAGE_REGISTRY.keys())
167
+ NUM_CLASSES: int = len(LANGUAGE_REGISTRY)
168
+
169
+ # Script families for analysis
170
+ DRAVIDIAN_LANGUAGES = {"kan", "mal", "tam", "tel"}
171
+ INDO_ARYAN_LANGUAGES = {"ben", "guj", "hin", "mar", "ori", "pan", "urd"}
172
+ DEVANAGARI_LANGUAGES = {"hin", "mar"}
173
+ RTL_LANGUAGES = {"urd"}
174
+
175
+ # Confusable pairs (share script or similar features)
176
+ CONFUSABLE_PAIRS = [
177
+ ("hin", "mar"), # Both Devanagari
178
+ ("tam", "mal"), # Related Dravidian scripts
179
+ ("kan", "tel"), # Related Dravidian scripts
180
+ ("hin", "urd"), # Same spoken language, different scripts
181
+ ("ben", "ori"), # Visually similar scripts
182
+ ]