Skip to content

Commit 485bfbf

Browse files
committed
eroll translit pipe
0 parents  commit 485bfbf

18 files changed

Lines changed: 1066 additions & 0 deletions

.gitignore

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Python
2+
__pycache__/
3+
*.py[cod]
4+
*.egg-info/
5+
.eggs/
6+
build/
7+
dist/
8+
.venv/
9+
.mypy_cache/
10+
.ruff_cache/
11+
.pytest_cache/
12+
htmlcov/
13+
.coverage
14+
coverage.xml
15+
16+
# Build/runtime artifacts (any location)
17+
*.7z
18+
*.parquet
19+
*_tokens.jsonl
20+
*.batchstate.json
21+
22+
# data/: track README + the per-language word-pair corpora (small .csv.gz, <100MB).
23+
# Large raw rolls (>100MB) and derived files stay out of git -> host on Dataverse
24+
# (GitHub hard-rejects files >100MB).
25+
/data/*
26+
!/data/README.md
27+
!/data/*.csv.gz
28+
/data/*_all_clean*.csv.gz
29+
/data/*_train_v2.csv.gz

Makefile

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
.PHONY: help install dev-install test lint format type-check ci
2+
3+
help: ## Show this help message
4+
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " %-14s %s\n", $$1, $$2}'
5+
6+
install: ## Install package
7+
uv sync
8+
9+
dev-install: ## Install with dev dependencies
10+
uv sync --group dev
11+
12+
test: ## Run tests
13+
uv run python -m pytest
14+
15+
lint: ## Run linter
16+
uv run ruff check eroll/ tests/
17+
18+
format: ## Format code
19+
uv run black eroll/ tests/
20+
uv run isort eroll/ tests/
21+
22+
type-check: ## Run type checker
23+
uv run mypy eroll/
24+
25+
ci: lint type-check test ## Run all CI checks

README.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# eroll_transliteration
2+
3+
Reusable pipeline for transliterating Indian **electoral rolls** from native script to
4+
English, building high-quality `(native, english)` training corpora and improved
5+
romanized rolls. The LLM step runs in **batch mode** through the
6+
[`indicate`](https://github.com/in-rolls/indicate) core library; this repo holds the
7+
roll-specific glue (per-state config, streaming IO, join-back, corpus extraction).
8+
9+
**Forward direction only** (Indic → English). Rolls that are already romanized English
10+
(e.g. Andhra) are out of scope — there is no native source side to learn from.
11+
12+
## Install
13+
14+
```bash
15+
uv sync # uses ../indicate as an editable path dependency (see pyproject)
16+
```
17+
18+
Set the chosen provider's API key before any LLM step (batch via litellm — OpenAI by
19+
default; Anthropic is not a batch provider in litellm, use Bedrock for Claude):
20+
21+
```bash
22+
export OPENAI_API_KEY=... # or configure another litellm batch provider
23+
export EROLL_DATA_DIR=/path/to/rolls # defaults to ./data
24+
```
25+
26+
## Usage
27+
28+
```bash
29+
# 1. Profile: unique-token counts + batch-cost estimate (no API calls)
30+
python -m eroll.transliterate_rolls --state assam profile
31+
32+
# 2. Sample-review 300 random tokens before committing to a full run
33+
python -m eroll.transliterate_rolls --state assam sample --sample-n 300
34+
35+
# 3. Full run: batch-transliterate all uniques -> parallel parquet (resumable)
36+
python -m eroll.transliterate_rolls --state assam run
37+
38+
# 4. Extract the (native, english) corpus -> data/assamese.csv.gz
39+
python -m eroll.transliterate_rolls --state assam extract
40+
41+
# Or gated end-to-end:
42+
python -m eroll.transliterate_rolls --state assam all
43+
```
44+
45+
The produced corpus (e.g. `data/assamese.csv.gz`) is handed to `indicate` for model
46+
training; only the trained model ships in the `indicate` wheel.
47+
48+
## Adding a state
49+
50+
Add one `StateConfig` entry to `eroll/states.py` (name, language, native unicode range,
51+
input filename, corpus header). Everything else is state-agnostic.
52+
53+
## Layout
54+
55+
| Module | Role |
56+
|---|---|
57+
| `eroll/states.py` | Per-state config + `STATES` registry |
58+
| `eroll/pipeline.py` | Unique-token extraction + word-map join-back |
59+
| `eroll/extract_pairs.py` | Aligned `(native, english)` pair extraction (modal dedup) |
60+
| `eroll/transliterate_rolls.py` | Orchestrator CLI (profile/sample/run/extract/all) |
61+
| `eroll/pricing.py` | Batch-API cost table for the profile step |

data/README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Roll data
2+
3+
Roll inputs and pipeline artifacts live here (path overridable via `EROLL_DATA_DIR`).
4+
Large files are **not** committed — host them on Dataverse or git-LFS, mirroring the
5+
`indicate` repo's data convention.
6+
7+
## Inputs (place here or point `EROLL_DATA_DIR` at them)
8+
9+
| File | State | Notes |
10+
|---|---|---|
11+
| `assam_all_clean+t13n.csv.gz` | Assam | Native Assamese columns + weak libindic `_t13n` columns. The live forward target. Relocated out of the `indicate` repo. |
12+
| `andhra.7z` | Andhra | **Excluded / not required here** — already romanized English (no native script), so out of scope for a forward Indic→English corpus. Keep wherever you store raw Andhra rolls; the pipeline never reads it. |
13+
| `punjab_transliteration_subset.parquet` | Punjab | GPT-4o parallel parquet (`<field>` + `<field>_transliterated`). Used only by the `extract_pairs` regression test. Currently lives in `indicate/data/`. |
14+
15+
## Outputs (generated; gitignored)
16+
17+
| File | Produced by |
18+
|---|---|
19+
| `<state>_tokens.jsonl` (+ `.batchstate.json`) | batch checkpoint/state |
20+
| `<state>_parallel.parquet` | `run` (join-back) |
21+
| `<language>.csv.gz` (e.g. `assamese.csv.gz`) | `extract` — the corpus handed to `indicate` for training |
22+
| `eval/<state>_sample.tsv` | `sample` — human-review gate |

data/bengali.csv.gz

5.64 MB
Binary file not shown.

data/hindi.csv.gz

2.56 MB
Binary file not shown.

data/punjabi.csv.gz

1.72 MB
Binary file not shown.

eroll/__init__.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"""eroll -- reusable electoral-roll transliteration pipeline.
2+
3+
Turns Indian electoral rolls (native script) into high-quality Indic->English
4+
training corpora and improved romanized rolls, using the ``indicate`` core library's
5+
batch-mode LLM transliteration. Forward direction only (Indic -> English); rolls that
6+
are already romanized English are out of scope.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
__version__ = "0.1.0"
12+
13+
from .states import STATES, StateConfig
14+
15+
__all__ = ["STATES", "StateConfig"]

eroll/extract_pairs.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""Extract clean ``(native, english)`` word pairs from a parallel-script parquet.
2+
3+
Generalized from ``indicate``'s ``training/extract_punjabi.py``. For every row/field
4+
it pulls the native-script runs from ``<field>`` and the Latin runs from the
5+
positionally-aligned ``<field><translit_suffix>``; when their counts match they zip
6+
into word pairs. Ambiguous native words (more than one observed English) collapse to
7+
the **modal** English. Output is a deduped, sorted ``(native, english)`` csv.gz.
8+
9+
Field order and ``batch_size`` are significant for exact reproduction of the original
10+
Punjabi corpus: they fix the ``Counter`` insertion order (hence modal tie-breaking).
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import csv
16+
import gzip
17+
import re
18+
from collections import Counter, defaultdict
19+
from pathlib import Path
20+
21+
import pyarrow.parquet as pq
22+
from tqdm import tqdm
23+
24+
LATIN_RUN = re.compile(r"[A-Za-z]+")
25+
26+
27+
def extract_pairs(
28+
*,
29+
source: str | Path,
30+
out: str | Path,
31+
native_run: re.Pattern[str],
32+
fields: list[str],
33+
header: tuple[str, str],
34+
translit_suffix: str = "_transliterated",
35+
latin_run: re.Pattern[str] = LATIN_RUN,
36+
max_len: int = 40,
37+
batch_size: int = 100_000,
38+
progress: bool = True,
39+
) -> dict[str, int]:
40+
"""Write ``(native, english)`` pairs from ``source`` parquet to ``out`` csv(.gz).
41+
42+
Returns a stats dict: ``rows_seen, aligned, mismatched, pairs, ambiguous``.
43+
"""
44+
source = Path(source)
45+
out = Path(out)
46+
parquet = pq.ParquetFile(source)
47+
columns = [c for f in fields for c in (f, f"{f}{translit_suffix}")]
48+
49+
counts: dict[str, Counter[str]] = defaultdict(Counter)
50+
rows_seen = aligned = mismatched = 0
51+
total = parquet.metadata.num_rows
52+
53+
bar = tqdm(total=total, desc="rows", unit="row", disable=not progress)
54+
for batch in parquet.iter_batches(batch_size=batch_size, columns=columns):
55+
cols = batch.to_pydict()
56+
batch_len = len(cols[fields[0]])
57+
rows_seen += batch_len
58+
for field_name in fields:
59+
src_col = cols[field_name]
60+
tgt_col = cols[f"{field_name}{translit_suffix}"]
61+
for src, tgt in zip(src_col, tgt_col, strict=False):
62+
if not src or not tgt:
63+
continue
64+
native = native_run.findall(src)
65+
if not native:
66+
continue
67+
latin = latin_run.findall(tgt)
68+
if len(native) != len(latin):
69+
mismatched += 1
70+
continue
71+
aligned += 1
72+
for nat, lat in zip(native, latin, strict=False):
73+
eng = lat.lower()
74+
if 0 < len(nat) <= max_len and 0 < len(eng) <= max_len:
75+
counts[nat][eng] += 1
76+
bar.update(batch_len)
77+
bar.close()
78+
79+
ambiguous = 0
80+
pairs: list[tuple[str, str]] = []
81+
for native_word, eng_counts in counts.items():
82+
if len(eng_counts) > 1:
83+
ambiguous += 1
84+
pairs.append((native_word, eng_counts.most_common(1)[0][0]))
85+
pairs.sort()
86+
87+
out.parent.mkdir(parents=True, exist_ok=True)
88+
opener = gzip.open if str(out).endswith(".gz") else open
89+
with opener(out, "wt", encoding="utf-8", newline="") as handle:
90+
writer = csv.writer(handle)
91+
writer.writerow(list(header))
92+
writer.writerows(pairs)
93+
94+
return {
95+
"rows_seen": rows_seen,
96+
"aligned": aligned,
97+
"mismatched": mismatched,
98+
"pairs": len(pairs),
99+
"ambiguous": ambiguous,
100+
}

eroll/pipeline.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""Roll pipeline primitives: unique-token extraction and word-map join-back.
2+
3+
The LLM step itself is delegated to ``indicate.batch.transliterate_tokens_batched``
4+
(batch-mode, checkpointed). This module only does the cheap, deterministic data work:
5+
collecting unique native-script tokens from roll columns, and substituting a resolved
6+
``token -> english`` map back into each cell.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import re
12+
from collections.abc import Iterable
13+
14+
import pandas as pd
15+
16+
17+
def extract_unique_native_tokens(
18+
chunks: Iterable[pd.DataFrame],
19+
columns: Iterable[str],
20+
native_run: re.Pattern[str],
21+
) -> set[str]:
22+
"""Collect every unique native-script run across ``columns`` of all chunks."""
23+
columns = list(columns)
24+
tokens: set[str] = set()
25+
for df in chunks:
26+
for col in columns:
27+
if col not in df.columns:
28+
continue
29+
for value in df[col].dropna().unique():
30+
tokens.update(native_run.findall(str(value)))
31+
return tokens
32+
33+
34+
def join_back(
35+
df: pd.DataFrame,
36+
columns: Iterable[str],
37+
word_map: dict[str, str],
38+
native_run: re.Pattern[str],
39+
suffix: str = "_t13n_llm",
40+
) -> pd.DataFrame:
41+
"""Add ``<col><suffix>`` columns with native runs replaced via ``word_map``.
42+
43+
Mirrors the notebook's fast path: transliterate each column's *unique* values
44+
once, then map back to every row. Unknown runs (not in ``word_map``) are left
45+
unchanged, so the output degrades gracefully. Returns ``df`` (mutated in place).
46+
"""
47+
columns = list(columns)
48+
49+
def _sub(text: object) -> object:
50+
if pd.isna(text):
51+
return text
52+
return native_run.sub(lambda m: word_map.get(m.group(0), m.group(0)), str(text))
53+
54+
for col in columns:
55+
if col not in df.columns:
56+
continue
57+
value_map = {value: _sub(value) for value in df[col].dropna().unique()}
58+
df[f"{col}{suffix}"] = df[col].map(value_map)
59+
return df

0 commit comments

Comments
 (0)