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 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
@@ -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
+ )