fliprate 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fliprate/__init__.py +132 -0
- fliprate/adapters.py +143 -0
- fliprate/aksharantar.py +297 -0
- fliprate/api.py +135 -0
- fliprate/baselines.py +28 -0
- fliprate/cli.py +137 -0
- fliprate/dakshina.py +341 -0
- fliprate/data/sample_support.csv +41 -0
- fliprate/dataset.py +320 -0
- fliprate/demo.py +51 -0
- fliprate/evaluation.py +344 -0
- fliprate/events.py +28 -0
- fliprate/experiment.py +73 -0
- fliprate/gating.py +148 -0
- fliprate/hf_models.py +13 -0
- fliprate/models.py +82 -0
- fliprate/normalization.py +94 -0
- fliprate/perturbation.py +101 -0
- fliprate/py.typed +0 -0
- fliprate/rules.py +1159 -0
- fliprate/service.py +154 -0
- fliprate/web/index.html +564 -0
- fliprate-0.2.1.dist-info/METADATA +331 -0
- fliprate-0.2.1.dist-info/RECORD +28 -0
- fliprate-0.2.1.dist-info/WHEEL +5 -0
- fliprate-0.2.1.dist-info/entry_points.txt +2 -0
- fliprate-0.2.1.dist-info/licenses/LICENSE +21 -0
- fliprate-0.2.1.dist-info/top_level.txt +1 -0
fliprate/__init__.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""FlipRate: measure and reduce how often an NLP model changes its answer
|
|
2
|
+
on messy romanized Hindi / Marathi / Tamil / Telugu and other Indian-language text.
|
|
3
|
+
|
|
4
|
+
import fliprate as fr
|
|
5
|
+
|
|
6
|
+
# Test robustness
|
|
7
|
+
report = fr.test(my_model, "data/test.csv")
|
|
8
|
+
print(report.summary())
|
|
9
|
+
|
|
10
|
+
# Fix it
|
|
11
|
+
safe_model = fr.protect(my_model)
|
|
12
|
+
report2 = fr.test(safe_model, "data/test.csv")
|
|
13
|
+
|
|
14
|
+
# Real-time gate
|
|
15
|
+
result = fr.gate(my_model, "mala product avadlaaa nahi")
|
|
16
|
+
print(result.decision) # PASS / TRANSFORM / REVIEW
|
|
17
|
+
|
|
18
|
+
# Multi-language rules from Aksharantar (428K groups, 21 languages)
|
|
19
|
+
rules = fr.aksharantar_rules("tam", "tel")
|
|
20
|
+
report = fr.test(my_model, "data/test.csv", rules=rules)
|
|
21
|
+
|
|
22
|
+
Author: Shiv Supane
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
# ── Core: most users only need these ──────────────────────────────────────────
|
|
27
|
+
from .evaluation import (
|
|
28
|
+
Report,
|
|
29
|
+
iter_test,
|
|
30
|
+
load_dataset,
|
|
31
|
+
macro_f1,
|
|
32
|
+
paired_difference,
|
|
33
|
+
read_csv_text,
|
|
34
|
+
test,
|
|
35
|
+
)
|
|
36
|
+
from .gating import GateResult, gate, iter_gate
|
|
37
|
+
from .models import Predictor, ProtectedModel, protect
|
|
38
|
+
from .normalization import NormalizeResult, normalize
|
|
39
|
+
from .perturbation import KINDS, Variant, perturb
|
|
40
|
+
from .rules import DEFAULT_RULES, LANGUAGE_GROUPS, RuleSet, is_safe_variant
|
|
41
|
+
|
|
42
|
+
# ── Model adapters ─────────────────────────────────────────────────────────────
|
|
43
|
+
from .adapters import (
|
|
44
|
+
HFTextClassifier,
|
|
45
|
+
LLMClassifier,
|
|
46
|
+
from_sklearn,
|
|
47
|
+
llm_classifier,
|
|
48
|
+
twitter_preprocess,
|
|
49
|
+
with_retries,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# ── Experiment runner ──────────────────────────────────────────────────────────
|
|
53
|
+
from .experiment import ExperimentResult, run_experiment
|
|
54
|
+
|
|
55
|
+
# ── Multi-language dataset loaders ────────────────────────────────────────────
|
|
56
|
+
from .dakshina import (
|
|
57
|
+
SUPPORTED_LANGUAGES as DAKSHINA_LANGUAGES,
|
|
58
|
+
dakshina_rules,
|
|
59
|
+
load_dakshina_groups,
|
|
60
|
+
)
|
|
61
|
+
from .aksharantar import (
|
|
62
|
+
SUPPORTED_LANGUAGES as AKSHARANTAR_LANGUAGES,
|
|
63
|
+
aksharantar_rules,
|
|
64
|
+
load_aksharantar_groups,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# ── Streaming helpers (for building custom event consumers) ───────────────────
|
|
68
|
+
from .events import drain, make_event, to_sse
|
|
69
|
+
|
|
70
|
+
__version__ = "0.2.1"
|
|
71
|
+
__author__ = "Shiv Supane"
|
|
72
|
+
|
|
73
|
+
__all__ = [
|
|
74
|
+
# ── Test & measure ──────────────────────────────────────────────────────
|
|
75
|
+
"test",
|
|
76
|
+
"iter_test",
|
|
77
|
+
"gate",
|
|
78
|
+
"iter_gate",
|
|
79
|
+
"Report",
|
|
80
|
+
"GateResult",
|
|
81
|
+
"paired_difference",
|
|
82
|
+
|
|
83
|
+
# ── Fix & protect ────────────────────────────────────────────────────────
|
|
84
|
+
"protect",
|
|
85
|
+
"normalize",
|
|
86
|
+
"perturb",
|
|
87
|
+
"is_safe_variant",
|
|
88
|
+
|
|
89
|
+
# ── Rules ────────────────────────────────────────────────────────────────
|
|
90
|
+
"RuleSet",
|
|
91
|
+
"DEFAULT_RULES",
|
|
92
|
+
"LANGUAGE_GROUPS",
|
|
93
|
+
|
|
94
|
+
# ── Multi-language datasets ──────────────────────────────────────────────
|
|
95
|
+
"dakshina_rules",
|
|
96
|
+
"load_dakshina_groups",
|
|
97
|
+
"DAKSHINA_LANGUAGES",
|
|
98
|
+
"aksharantar_rules",
|
|
99
|
+
"load_aksharantar_groups",
|
|
100
|
+
"AKSHARANTAR_LANGUAGES",
|
|
101
|
+
|
|
102
|
+
# ── Model adapters ────────────────────────────────────────────────────────
|
|
103
|
+
"HFTextClassifier",
|
|
104
|
+
"LLMClassifier",
|
|
105
|
+
"llm_classifier",
|
|
106
|
+
"from_sklearn",
|
|
107
|
+
"twitter_preprocess",
|
|
108
|
+
"with_retries",
|
|
109
|
+
"ProtectedModel",
|
|
110
|
+
"Predictor",
|
|
111
|
+
|
|
112
|
+
# ── Experiment ───────────────────────────────────────────────────────────
|
|
113
|
+
"run_experiment",
|
|
114
|
+
"ExperimentResult",
|
|
115
|
+
|
|
116
|
+
# ── Data loading ─────────────────────────────────────────────────────────
|
|
117
|
+
"load_dataset",
|
|
118
|
+
"read_csv_text",
|
|
119
|
+
|
|
120
|
+
# ── Types & helpers ──────────────────────────────────────────────────────
|
|
121
|
+
"NormalizeResult",
|
|
122
|
+
"Variant",
|
|
123
|
+
"KINDS",
|
|
124
|
+
"macro_f1",
|
|
125
|
+
"drain",
|
|
126
|
+
"make_event",
|
|
127
|
+
"to_sse",
|
|
128
|
+
|
|
129
|
+
# ── Version ──────────────────────────────────────────────────────────────
|
|
130
|
+
"__version__",
|
|
131
|
+
"__author__",
|
|
132
|
+
]
|
fliprate/adapters.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Adapters that turn real models into FlipRate models (text -> label, with fast `predict_batch`).
|
|
2
|
+
|
|
3
|
+
Heavy libraries (transformers, scikit-learn) are imported lazily, only when you use the adapter.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any, Callable, Dict, List, Optional, Sequence
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def twitter_preprocess(text: str) -> str:
|
|
13
|
+
"""The @user / http placeholders that the cardiffnlp Twitter models were trained with."""
|
|
14
|
+
out = []
|
|
15
|
+
for t in text.split(" "):
|
|
16
|
+
t = "@user" if t.startswith("@") and len(t) > 1 else t
|
|
17
|
+
t = "http" if t.startswith("http") else t
|
|
18
|
+
out.append(t)
|
|
19
|
+
return " ".join(out)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class HFTextClassifier:
|
|
23
|
+
"""Hugging Face text-classification model, batched.
|
|
24
|
+
|
|
25
|
+
clf = HFTextClassifier("cardiffnlp/twitter-xlm-roberta-base-sentiment",
|
|
26
|
+
lowercase_labels=True, preprocess=twitter_preprocess)
|
|
27
|
+
fr.test(clf, rows)
|
|
28
|
+
|
|
29
|
+
`label_map` renames model labels to your dataset's labels. `lowercase_labels=True` maps
|
|
30
|
+
"Positive" -> "positive". `pipeline_factory` lets tests inject a fake pipeline.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, model_name: str, device: Any = None, batch_size: int = 32, max_length: int = 256,
|
|
34
|
+
label_map: Optional[Dict[str, str]] = None, lowercase_labels: bool = False,
|
|
35
|
+
preprocess: Optional[Callable[[str], str]] = None,
|
|
36
|
+
pipeline_factory: Optional[Callable[[], Callable]] = None):
|
|
37
|
+
self.name = model_name
|
|
38
|
+
self.device, self.batch_size, self.max_length = device, batch_size, max_length
|
|
39
|
+
self.label_map, self.lowercase_labels, self.preprocess = label_map or {}, lowercase_labels, preprocess
|
|
40
|
+
self._factory = pipeline_factory
|
|
41
|
+
self._pipe: Optional[Callable] = None
|
|
42
|
+
|
|
43
|
+
def _get(self) -> Callable:
|
|
44
|
+
if self._pipe is None:
|
|
45
|
+
if self._factory:
|
|
46
|
+
self._pipe = self._factory()
|
|
47
|
+
else:
|
|
48
|
+
try:
|
|
49
|
+
from transformers import pipeline
|
|
50
|
+
except ImportError as exc:
|
|
51
|
+
raise ImportError("HFTextClassifier needs `pip install transformers torch`") from exc
|
|
52
|
+
kwargs = {"model": self.name, "tokenizer": self.name}
|
|
53
|
+
if self.device is not None:
|
|
54
|
+
kwargs["device"] = self.device
|
|
55
|
+
self._pipe = pipeline("text-classification", **kwargs)
|
|
56
|
+
return self._pipe
|
|
57
|
+
|
|
58
|
+
def warmup(self) -> None:
|
|
59
|
+
self.predict_batch(["warmup"])
|
|
60
|
+
|
|
61
|
+
def predict_batch(self, texts: Sequence[str]) -> List[str]:
|
|
62
|
+
texts = [self.preprocess(t) if self.preprocess else t for t in texts]
|
|
63
|
+
outs = self._get()(texts, batch_size=self.batch_size, truncation=True, max_length=self.max_length)
|
|
64
|
+
labels = []
|
|
65
|
+
for o in outs:
|
|
66
|
+
lab = o["label"]
|
|
67
|
+
lab = self.label_map.get(lab, lab)
|
|
68
|
+
labels.append(lab.lower() if self.lowercase_labels else lab)
|
|
69
|
+
return labels
|
|
70
|
+
|
|
71
|
+
def __call__(self, text: str) -> str:
|
|
72
|
+
return self.predict_batch([text])[0]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class SklearnAdapter:
|
|
76
|
+
"""Wrap a fitted scikit-learn Pipeline (e.g. TfidfVectorizer + LogisticRegression)."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, pipeline: Any, name: str = "sklearn"):
|
|
79
|
+
self.pipeline, self.name = pipeline, name
|
|
80
|
+
|
|
81
|
+
def predict_batch(self, texts: Sequence[str]) -> List[Any]:
|
|
82
|
+
out = self.pipeline.predict(list(texts))
|
|
83
|
+
return out.tolist() if hasattr(out, "tolist") else list(out)
|
|
84
|
+
|
|
85
|
+
def __call__(self, text: str):
|
|
86
|
+
return self.predict_batch([text])[0]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def from_sklearn(pipeline: Any, name: str = "sklearn") -> SklearnAdapter:
|
|
90
|
+
return SklearnAdapter(pipeline, name)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class LLMClassifier:
|
|
94
|
+
"""Classify with any LLM through a function `complete(prompt) -> str` that you provide.
|
|
95
|
+
|
|
96
|
+
The reply is accepted only if it clearly names exactly one allowed label; otherwise it is recorded as
|
|
97
|
+
`invalid_label`. Use temperature 0 so results are repeatable. `n_invalid` counts unusable replies.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def __init__(self, complete: Callable[[str], str], labels: Sequence[str], instruction: Optional[str] = None,
|
|
101
|
+
name: str = "llm", invalid_label: str = "__invalid__"):
|
|
102
|
+
self.complete, self.labels, self.name, self.invalid_label = complete, list(labels), name, invalid_label
|
|
103
|
+
self.instruction = instruction or "Classify the customer message into exactly one label."
|
|
104
|
+
self.n_invalid = 0
|
|
105
|
+
|
|
106
|
+
def _prompt(self, text: str) -> str:
|
|
107
|
+
return (f"{self.instruction}\nAllowed labels: {', '.join(self.labels)}\n"
|
|
108
|
+
f"Message: {text}\nAnswer with the label only.")
|
|
109
|
+
|
|
110
|
+
def parse(self, reply: str) -> str:
|
|
111
|
+
reply = (reply or "").strip().lower()
|
|
112
|
+
if reply in self.labels:
|
|
113
|
+
return reply
|
|
114
|
+
found = [l for l in self.labels if re.search(rf"(?<![a-z_]){re.escape(l.lower())}(?![a-z_])", reply)]
|
|
115
|
+
if len(found) == 1:
|
|
116
|
+
return found[0]
|
|
117
|
+
self.n_invalid += 1
|
|
118
|
+
return self.invalid_label
|
|
119
|
+
|
|
120
|
+
def __call__(self, text: str) -> str:
|
|
121
|
+
return self.parse(self.complete(self._prompt(text)))
|
|
122
|
+
|
|
123
|
+
def predict_batch(self, texts: Sequence[str]) -> List[str]:
|
|
124
|
+
return [self(t) for t in texts]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def llm_classifier(complete: Callable[[str], str], labels: Sequence[str], **kw) -> LLMClassifier:
|
|
128
|
+
return LLMClassifier(complete, labels, **kw)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def with_retries(fn: Callable, retries: int = 3, backoff: float = 0.5,
|
|
132
|
+
exceptions: tuple = (Exception,)) -> Callable:
|
|
133
|
+
"""Retry flaky calls (network/LLM APIs) with exponential backoff; the last error is re-raised."""
|
|
134
|
+
def wrapped(*args, **kwargs):
|
|
135
|
+
for attempt in range(retries + 1):
|
|
136
|
+
try:
|
|
137
|
+
return fn(*args, **kwargs)
|
|
138
|
+
except exceptions:
|
|
139
|
+
if attempt == retries:
|
|
140
|
+
raise
|
|
141
|
+
time.sleep(backoff * (2 ** attempt))
|
|
142
|
+
wrapped.__name__ = getattr(fn, "__name__", "wrapped")
|
|
143
|
+
return wrapped
|
fliprate/aksharantar.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Loader for the Aksharantar dataset spelling variant groups.
|
|
3
|
+
|
|
4
|
+
The Aksharantar dataset (AI4Bharat, 2022, CC BY 4.0) contains 24M+ native-script
|
|
5
|
+
→ romanization word pairs for 21 Indian languages, sourced from IndicCorp,
|
|
6
|
+
Samanantar, Dakshina, and other curated Indian language corpora.
|
|
7
|
+
|
|
8
|
+
Reference
|
|
9
|
+
---------
|
|
10
|
+
Mittal et al. (2022) "Aksharantar: Towards Building Open Transliteration Tools
|
|
11
|
+
for the Next Billion Users." https://arxiv.org/abs/2205.03018
|
|
12
|
+
|
|
13
|
+
Dataset location
|
|
14
|
+
----------------
|
|
15
|
+
The loader looks for language directories (hin/, tam/, etc.) relative to the
|
|
16
|
+
current working directory first, then next to the fliprate package source.
|
|
17
|
+
Override with FLIPRATE_AKSHARANTAR_PATH environment variable.
|
|
18
|
+
|
|
19
|
+
Usage
|
|
20
|
+
-----
|
|
21
|
+
from fliprate.aksharantar import aksharantar_rules, load_aksharantar_groups
|
|
22
|
+
|
|
23
|
+
# Single language
|
|
24
|
+
rules = aksharantar_rules("ta")
|
|
25
|
+
|
|
26
|
+
# Multiple languages
|
|
27
|
+
rules = aksharantar_rules("ta", "kn", "te")
|
|
28
|
+
|
|
29
|
+
# All 21 languages
|
|
30
|
+
rules = aksharantar_rules()
|
|
31
|
+
|
|
32
|
+
# Just the groups (for manual inspection or further filtering)
|
|
33
|
+
groups = load_aksharantar_groups("hi")
|
|
34
|
+
print(groups[:5])
|
|
35
|
+
"""
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import json
|
|
39
|
+
import os
|
|
40
|
+
import re
|
|
41
|
+
import sys
|
|
42
|
+
from collections import defaultdict
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
from typing import Dict, List, Optional, Tuple
|
|
45
|
+
|
|
46
|
+
from .rules import RuleSet, DEFAULT_RULES, word_key
|
|
47
|
+
|
|
48
|
+
SUPPORTED_LANGUAGES = {
|
|
49
|
+
"hin": "Hindi",
|
|
50
|
+
"guj": "Gujarati",
|
|
51
|
+
"tam": "Tamil",
|
|
52
|
+
"tel": "Telugu",
|
|
53
|
+
"kan": "Kannada",
|
|
54
|
+
"mal": "Malayalam",
|
|
55
|
+
"mar": "Marathi",
|
|
56
|
+
"pan": "Punjabi",
|
|
57
|
+
"ben": "Bengali",
|
|
58
|
+
"urd": "Urdu",
|
|
59
|
+
"ori": "Odia",
|
|
60
|
+
"asm": "Assamese",
|
|
61
|
+
"kas": "Kashmiri",
|
|
62
|
+
"kok": "Konkani",
|
|
63
|
+
"mai": "Maithili",
|
|
64
|
+
"mni": "Manipuri",
|
|
65
|
+
"nep": "Nepali",
|
|
66
|
+
"san": "Sanskrit",
|
|
67
|
+
"brx": "Bodo",
|
|
68
|
+
"sid": "Sindhi",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
_RUN_RE = re.compile(r"(.)\1+")
|
|
72
|
+
_PURE_ALPHA = re.compile(r"^[a-z]{2,30}$")
|
|
73
|
+
_NEGATION_KEYS = frozenset({
|
|
74
|
+
"nahi", "nhi", "nahin", "nahee", "nako", "nko", "mat", "maat",
|
|
75
|
+
"not", "no", "never",
|
|
76
|
+
"ila", "ilai", "beda", "ledu", "vadu", "kadu", "venda", "ala",
|
|
77
|
+
"noy", "nei", "nathi",
|
|
78
|
+
})
|
|
79
|
+
_SKIP = frozenset({
|
|
80
|
+
"na", "nai", "he", "to", "ho", "re", "ki", "ko", "po",
|
|
81
|
+
"la", "le", "se", "ka", "ke", "sa", "ne", "pe", "me",
|
|
82
|
+
"aa", "ee", "oo", "ha", "hi", "hu",
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _find_root() -> Optional[Path]:
|
|
87
|
+
env = os.environ.get("FLIPRATE_AKSHARANTAR_PATH")
|
|
88
|
+
if env:
|
|
89
|
+
p = Path(env)
|
|
90
|
+
if p.exists():
|
|
91
|
+
return p
|
|
92
|
+
candidates = [
|
|
93
|
+
# New consolidated folder first
|
|
94
|
+
Path.cwd() / "Aksharantar_All_Languages",
|
|
95
|
+
Path(__file__).parent.parent.parent / "Aksharantar_All_Languages",
|
|
96
|
+
Path(__file__).parent.parent.parent.parent / "Aksharantar_All_Languages",
|
|
97
|
+
# Legacy: language folders directly in cwd
|
|
98
|
+
Path.cwd(),
|
|
99
|
+
Path(__file__).parent.parent.parent,
|
|
100
|
+
Path(__file__).parent.parent.parent.parent,
|
|
101
|
+
]
|
|
102
|
+
for c in candidates:
|
|
103
|
+
if (c / "hin").exists() and (c / "tam").exists():
|
|
104
|
+
return c
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _load_lang(root: Path, lang: str) -> Dict[str, Dict[str, int]]:
|
|
109
|
+
data: Dict[str, Dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
|
110
|
+
for split in ("train", "valid", "test"):
|
|
111
|
+
f = root / lang / f"{lang}_{split}.json"
|
|
112
|
+
if not f.exists():
|
|
113
|
+
continue
|
|
114
|
+
with open(f, encoding="utf-8") as fh:
|
|
115
|
+
for line in fh:
|
|
116
|
+
line = line.strip()
|
|
117
|
+
if not line:
|
|
118
|
+
continue
|
|
119
|
+
try:
|
|
120
|
+
d = json.loads(line)
|
|
121
|
+
except json.JSONDecodeError:
|
|
122
|
+
continue
|
|
123
|
+
native = d.get("native word", "").strip()
|
|
124
|
+
roman = d.get("english word", "").strip().lower()
|
|
125
|
+
if native and roman:
|
|
126
|
+
data[native][roman] += 1
|
|
127
|
+
return data
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _extract(
|
|
131
|
+
lang_data: Dict[str, Dict[str, int]],
|
|
132
|
+
min_variants: int = 2,
|
|
133
|
+
) -> List[Tuple[str, ...]]:
|
|
134
|
+
groups: List[Tuple[str, ...]] = []
|
|
135
|
+
registered: Dict[str, int] = {}
|
|
136
|
+
|
|
137
|
+
for native, roman_counts in lang_data.items():
|
|
138
|
+
clean = {r: c for r, c in roman_counts.items()
|
|
139
|
+
if _PURE_ALPHA.match(r) and r not in _SKIP}
|
|
140
|
+
if len(clean) < min_variants:
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
sorted_r = sorted(clean.items(), key=lambda x: (-x[1], x[0]))
|
|
144
|
+
members = [r for r, _ in sorted_r]
|
|
145
|
+
|
|
146
|
+
seen: Dict[str, bool] = {}
|
|
147
|
+
deduped = []
|
|
148
|
+
for r in members:
|
|
149
|
+
k = word_key(r)
|
|
150
|
+
if k not in seen:
|
|
151
|
+
seen[k] = True
|
|
152
|
+
deduped.append(r)
|
|
153
|
+
|
|
154
|
+
if len(deduped) < min_variants:
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
group = tuple(deduped)
|
|
158
|
+
group_keys = {word_key(r) for r in group}
|
|
159
|
+
|
|
160
|
+
# Skip negation groups entirely (conservative)
|
|
161
|
+
if group_keys & _NEGATION_KEYS:
|
|
162
|
+
continue
|
|
163
|
+
|
|
164
|
+
# Cross-native-word key conflict — first-wins
|
|
165
|
+
gid = len(groups)
|
|
166
|
+
existing_gid = None
|
|
167
|
+
conflict = False
|
|
168
|
+
for k in group_keys:
|
|
169
|
+
existing = registered.get(k)
|
|
170
|
+
if existing is not None:
|
|
171
|
+
if existing_gid is None:
|
|
172
|
+
existing_gid = existing
|
|
173
|
+
elif existing_gid != existing:
|
|
174
|
+
conflict = True
|
|
175
|
+
break
|
|
176
|
+
if conflict:
|
|
177
|
+
continue
|
|
178
|
+
|
|
179
|
+
for k in group_keys:
|
|
180
|
+
registered[k] = gid
|
|
181
|
+
groups.append(group)
|
|
182
|
+
|
|
183
|
+
return groups
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# Cache
|
|
187
|
+
_cache: Dict[str, List[Tuple[str, ...]]] = {}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def load_aksharantar_groups(
|
|
191
|
+
*languages: str,
|
|
192
|
+
min_variants: int = 2,
|
|
193
|
+
aksharantar_path: Optional[str] = None,
|
|
194
|
+
) -> List[Tuple[str, ...]]:
|
|
195
|
+
"""
|
|
196
|
+
Load Aksharantar spelling variant groups for the given languages.
|
|
197
|
+
|
|
198
|
+
Parameters
|
|
199
|
+
----------
|
|
200
|
+
*languages : str
|
|
201
|
+
Language codes (e.g. "hin", "tam", "tel").
|
|
202
|
+
Pass no arguments to load all 21 supported languages.
|
|
203
|
+
min_variants : int
|
|
204
|
+
Minimum number of distinct romanizations per group (default 2).
|
|
205
|
+
aksharantar_path : str, optional
|
|
206
|
+
Override the auto-detected root directory.
|
|
207
|
+
|
|
208
|
+
Returns
|
|
209
|
+
-------
|
|
210
|
+
List of tuples — ready to pass to RuleSet() or dakshina_rules().
|
|
211
|
+
|
|
212
|
+
Raises
|
|
213
|
+
------
|
|
214
|
+
FileNotFoundError
|
|
215
|
+
If the Aksharantar language directories cannot be found.
|
|
216
|
+
"""
|
|
217
|
+
langs = list(languages) if languages else list(SUPPORTED_LANGUAGES.keys())
|
|
218
|
+
unknown = [l for l in langs if l not in SUPPORTED_LANGUAGES]
|
|
219
|
+
if unknown:
|
|
220
|
+
raise ValueError(f"Unknown language codes: {unknown}. "
|
|
221
|
+
f"Supported: {list(SUPPORTED_LANGUAGES.keys())}")
|
|
222
|
+
|
|
223
|
+
root = Path(aksharantar_path) if aksharantar_path else _find_root()
|
|
224
|
+
if root is None:
|
|
225
|
+
raise FileNotFoundError(
|
|
226
|
+
"Aksharantar language directories not found. "
|
|
227
|
+
"Set FLIPRATE_AKSHARANTAR_PATH to the folder containing hin/, tam/, etc."
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
all_groups: List[Tuple[str, ...]] = []
|
|
231
|
+
for lang in langs:
|
|
232
|
+
cache_key = f"aksh:{lang}:{root}:{min_variants}"
|
|
233
|
+
if cache_key not in _cache:
|
|
234
|
+
lang_dir = root / lang
|
|
235
|
+
if not lang_dir.exists():
|
|
236
|
+
print(f"[fliprate.aksharantar] Warning: {lang_dir} not found, skipping",
|
|
237
|
+
file=sys.stderr)
|
|
238
|
+
_cache[cache_key] = []
|
|
239
|
+
continue
|
|
240
|
+
data = _load_lang(root, lang)
|
|
241
|
+
_cache[cache_key] = _extract(data, min_variants)
|
|
242
|
+
all_groups.extend(_cache[cache_key])
|
|
243
|
+
|
|
244
|
+
return all_groups
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def aksharantar_rules(
|
|
248
|
+
*languages: str,
|
|
249
|
+
base: Optional[RuleSet] = None,
|
|
250
|
+
min_variants: int = 2,
|
|
251
|
+
aksharantar_path: Optional[str] = None,
|
|
252
|
+
) -> RuleSet:
|
|
253
|
+
"""
|
|
254
|
+
Build a RuleSet combining `base` with Aksharantar groups.
|
|
255
|
+
|
|
256
|
+
Parameters
|
|
257
|
+
----------
|
|
258
|
+
*languages : str
|
|
259
|
+
Language codes. Pass none for all 21 languages.
|
|
260
|
+
base : RuleSet, optional
|
|
261
|
+
Base rules to combine with (default: DEFAULT_RULES).
|
|
262
|
+
min_variants : int
|
|
263
|
+
Minimum distinct romanizations per group (default 2).
|
|
264
|
+
aksharantar_path : str, optional
|
|
265
|
+
Override auto-detected root path.
|
|
266
|
+
|
|
267
|
+
Returns
|
|
268
|
+
-------
|
|
269
|
+
RuleSet
|
|
270
|
+
|
|
271
|
+
Examples
|
|
272
|
+
--------
|
|
273
|
+
>>> from fliprate.aksharantar import aksharantar_rules
|
|
274
|
+
>>> rules = aksharantar_rules("tam", "kn")
|
|
275
|
+
>>> fr.test(my_model, dataset, rules=rules)
|
|
276
|
+
|
|
277
|
+
Notes
|
|
278
|
+
-----
|
|
279
|
+
For the best coverage use both datasets:
|
|
280
|
+
>>> from fliprate.dakshina import dakshina_rules
|
|
281
|
+
>>> rules = dakshina_rules("ta").extended(
|
|
282
|
+
... groups=load_aksharantar_groups("tam")
|
|
283
|
+
... )
|
|
284
|
+
"""
|
|
285
|
+
groups = load_aksharantar_groups(
|
|
286
|
+
*languages,
|
|
287
|
+
min_variants=min_variants,
|
|
288
|
+
aksharantar_path=aksharantar_path,
|
|
289
|
+
)
|
|
290
|
+
b = base if base is not None else DEFAULT_RULES
|
|
291
|
+
combined = list(b.groups) + groups
|
|
292
|
+
return RuleSet(
|
|
293
|
+
groups=combined,
|
|
294
|
+
lexicon=list(b.lexicon),
|
|
295
|
+
negations=b.negations,
|
|
296
|
+
ambiguous=dict(b.ambiguous),
|
|
297
|
+
)
|