sakha-normalizer / scripts /build_lexicon.py
kurdga5as's picture
v0.1.1: compact lexicon (min freq 5), ~120 MB RAM, same accuracy
b11f431 verified
Raw History Blame Contribute Delete
5.3 kB
"""Собирает словари словоформ для нормализатора из открытых корпусов.
Источники (только открытые лицензии):
- ailabykt/sakha-corpus-mono (CC BY 4.0) -> якутский словарь
- Википедия на якутском, дамп 2026-09-01 (CC BY-SA 4.0) -> якутский словарь
- ailabykt/sakha-russian-parallel, колонка ru (CC BY 4.0) -> русский словарь
0,3% документов откладываются (по хешу) и в словарь не идут: из них собирается
открытый тестовый набор (scripts/make_testset.py).
Запуск (нужен pyarrow только для сборки, сам нормализатор работает без него):
python scripts/build_lexicon.py --root "../" # корень папки sakha language
"""
import argparse, collections, glob, gzip, hashlib, json, os, re, sys
VARIANTS = str.maketrans({'ғ': 'ҕ', 'ң': 'ҥ', 'ӈ': 'ҥ', 'ɵ': 'ө', 'ӧ': 'ө', 'ӱ': 'ү', 'ё': 'ё'})
TOKEN = re.compile(r'[^\W\d_]+')
CYR = re.compile(r'^[а-яёҕҥөһү]+$')
SAH_LETTERS = set('ҕҥөһү')
def held_out(key: str) -> bool:
return int(hashlib.md5(key.encode('utf-8')).hexdigest(), 16) % 1000 < 3
def tokens(text):
for t in TOKEN.findall(text.lower().translate(VARIANTS)):
if CYR.match(t):
yield t
def sakha_lines(text, min_share=0.025):
"""Строки, набранные с якутской раскладкой (доля ҕҥөһү среди букв >= 2,5%).
В вебе и OCR есть якутский текст без якутских букв («кун» вместо «күн»):
если считать его, «грязные» формы попадут в словарь как правильные.
Русские строки тоже отсекаются: для них отдельный словарь.
"""
for line in text.split('\n'):
low = line.lower()
letters = sum(c.isalpha() for c in low)
if not letters:
continue
if sum(c in 'ҕҥөһү' for c in low) / letters >= min_share:
yield line
def sah_tokens(text):
for line in sakha_lines(text):
yield from tokens(line)
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--root', required=True, help='папка sakha language')
ap.add_argument('--out', default=os.path.join(os.path.dirname(__file__), '..', 'sakha_normalizer', 'data'))
ap.add_argument('--holdout', default=os.path.join(os.path.dirname(__file__), '..', 'eval', 'holdout_docs.jsonl'))
ap.add_argument('--min-freq', type=int, default=5)
a = ap.parse_args()
import pyarrow.parquet as pq
sah = collections.Counter()
ru = collections.Counter()
stats = collections.Counter()
hold = open(a.holdout, 'w', encoding='utf-8')
for path in sorted(glob.glob(os.path.join(a.root, 'raw', 'ailabykt_mono', 'data', '*.parquet'))):
pf = pq.ParquetFile(path)
for batch in pf.iter_batches(batch_size=2000, columns=['text', 'source']):
for text, src in zip(batch.column('text').to_pylist(), batch.column('source').to_pylist()):
if not text:
continue
key = 'ailabykt:' + hashlib.md5(text[:2000].encode('utf-8')).hexdigest()
if held_out(key):
hold.write(json.dumps({'source': 'ailabykt/sakha-corpus-mono:' + src, 'text': text}, ensure_ascii=False) + '\n')
stats['held_docs'] += 1
continue
sah.update(sah_tokens(text))
stats['docs_ailabykt'] += 1
print(path, len(sah), file=sys.stderr)
for line in open(os.path.join(a.root, 'data', 'sahwiki.jsonl'), encoding='utf-8'):
r = json.loads(line)
if held_out('wiki:' + r['title']):
hold.write(json.dumps({'source': 'sah.wikipedia:' + r['title'], 'text': r['text']}, ensure_ascii=False) + '\n')
stats['held_docs'] += 1
continue
sah.update(sah_tokens(r['text']))
stats['docs_wiki'] += 1
hold.close()
pf = pq.ParquetFile(os.path.join(a.root, 'raw', 'ailabykt_parallel', 'sakha-russian.parquet'))
for batch in pf.iter_batches(batch_size=5000, columns=['ru']):
for text in batch.column('ru').to_pylist():
if text:
ru.update(t for t in tokens(text) if not (set(t) & SAH_LETTERS))
os.makedirs(a.out, exist_ok=True)
for name, cnt, mf in (('sah_lexicon.tsv.gz', sah, a.min_freq), ('ru_lexicon.tsv.gz', ru, 2)):
kept = [(w, n) for w, n in cnt.items() if n >= mf]
kept.sort(key=lambda x: (-x[1], x[0]))
with gzip.open(os.path.join(a.out, name), 'wt', encoding='utf-8', compresslevel=9) as f:
for w, n in kept:
f.write(f'{w}\t{n}\n')
stats[name + ':forms'] = len(kept)
stats[name + ':tokens'] = sum(n for _, n in kept)
print(json.dumps(stats, ensure_ascii=False, indent=1))
json.dump(stats, open(os.path.join(a.out, 'lexicon_stats.json'), 'w'), ensure_ascii=False, indent=1)
if __name__ == '__main__':
main()