from __future__ import annotations """Modern-language word popularity (Zipf + within-language rank). Primary: Robyn Speer's ``wordfreq`` (Wikipedia, subtitles, news, books, web, social). Fallback: HermitDave FrequencyWords (OpenSubtitles) for languages wordfreq lacks — practical stand-in for popular usage when FastText crawl ``.bin`` models (multi-GB each) cannot ship in a Space image. Optional ``FASTTEXT_FREQ_DIR/{lang}.txt`` ranked vocab files are also consulted when present (one word per line, most frequent first). """ import math import os import re from functools import lru_cache from pathlib import Path ROOT = Path(__file__).resolve().parents[1] FREQ_DIR = ROOT / "data" / "freq" FASTTEXT_DIR = Path(os.environ.get("FASTTEXT_FREQ_DIR", str(FREQ_DIR / "fasttext"))) # Atlas lang key / ISO 639-3 → wordfreq / FrequencyWords codes. LANG_TO_CODE: dict[str, str] = { "english": "en", "spanish": "es", "french": "fr", "german": "de", "italian": "it", "portuguese": "pt", "dutch": "nl", "russian": "ru", "polish": "pl", "swedish": "sv", "norwegian": "nb", "danish": "da", "finnish": "fi", "hungarian": "hu", "czech": "cs", "slovak": "sk", "romanian": "ro", "bulgarian": "bg", "greek": "el", "turkish": "tr", "arabic": "ar", "hebrew": "he", "hindi": "hi", "bengali": "bn", "indonesian": "id", "malay": "ms", "vietnamese": "vi", "thai": "th", "chinese": "zh", "japanese": "ja", "korean": "ko", "ukrainian": "uk", "catalan": "ca", "croatian": "hr", "serbian": "sr", "slovenian": "sl", "lithuanian": "lt", "latvian": "lv", "estonian": "et", "persian": "fa", "urdu": "ur", "tamil": "ta", "tagalog": "tl", "filipino": "tl", "icelandic": "is", "basque": "eu", "galician": "gl", "eng": "en", "spa": "es", "fra": "fr", "fre": "fr", "deu": "de", "ger": "de", "ita": "it", "por": "pt", "nld": "nl", "dut": "nl", "rus": "ru", "pol": "pl", "swe": "sv", "nor": "nb", "nob": "nb", "dan": "da", "fin": "fi", "hun": "hu", "ces": "cs", "cze": "cs", "slk": "sk", "ron": "ro", "rum": "ro", "bul": "bg", "ell": "el", "gre": "el", "tur": "tr", "arb": "ar", "heb": "he", "hin": "hi", "ben": "bn", "ind": "id", "msa": "ms", "vie": "vi", "tha": "th", "cmn": "zh", "zho": "zh", "jpn": "ja", "kor": "ko", "ukr": "uk", "cat": "ca", "hrv": "hr", "srp": "sr", "slv": "sl", "lit": "lt", "lav": "lv", "est": "et", "fas": "fa", "pes": "fa", "urd": "ur", "tam": "ta", "tgl": "tl", "fil": "tl", "isl": "is", "eus": "eu", "glg": "gl", } # FrequencyWords filename stem on HermitDave/FrequencyWords content/2018/ FW_FILES: dict[str, str] = { "en": "en_50k.txt", "es": "es_50k.txt", "fr": "fr_50k.txt", "de": "de_50k.txt", "it": "it_50k.txt", "pt": "pt_50k.txt", "nl": "nl_50k.txt", "ru": "ru_50k.txt", "pl": "pl_50k.txt", "sv": "sv_50k.txt", "cs": "cs_50k.txt", "ro": "ro_50k.txt", "hu": "hu_50k.txt", "tr": "tr_50k.txt", "uk": "uk_50k.txt", "fi": "fi_50k.txt", "da": "da_50k.txt", "el": "el_50k.txt", "bg": "bg_50k.txt", "hr": "hr_50k.txt", "sk": "sk_50k.txt", "nb": "no_50k.txt", "ca": "ca_50k.txt", "id": "id_50k.txt", "vi": "vi_50k.txt", "ar": "ar_50k.txt", "he": "he_50k.txt", "hi": "hi_50k.txt", "ko": "ko_50k.txt", "ja": "ja_50k.txt", "zh": "zh_50k.txt", "fa": "fa_50k.txt", "tl": "tl_50k.txt", } FW_BASE = "https://raw.githubusercontent.com/hermitdave/FrequencyWords/master/content/2018/" _WORD_RE = re.compile(r"[^\W\d_]+", re.UNICODE) def freq_code_for(lang: str, iso_639_3: str | None = None) -> str | None: """Map atlas language → frequency code. Only known modern codes (no bare 2-letter guess).""" key = (lang or "").strip().casefold() if key in LANG_TO_CODE: return LANG_TO_CODE[key] iso = (iso_639_3 or "").strip().casefold() if iso in LANG_TO_CODE: return LANG_TO_CODE[iso] return None def _norm_term(term: str) -> str: t = (term or "").strip().casefold() if t.startswith("*"): t = t[1:] return t @lru_cache(maxsize=64) def _wordfreq_ranks(code: str) -> dict[str, tuple[float, int]] | None: try: from wordfreq import get_frequency_dict, available_languages except ImportError: return None langs = available_languages(wordlist="best") if code not in langs: langs = available_languages(wordlist="large") wordlist = "large" if code in langs else None if wordlist is None: return None else: wordlist = "best" freq_dict = get_frequency_dict(code, wordlist=wordlist) # Zipf = log10(occurrences per billion words) == log10(proportion) + 9 ranked = sorted(freq_dict.items(), key=lambda kv: -kv[1]) out: dict[str, tuple[float, int]] = {} for i, (w, f) in enumerate(ranked, start=1): if f <= 0: continue z = math.log10(f) + 9.0 out[w] = (z, i) if i >= 1_000_000: break return out @lru_cache(maxsize=64) def _ranked_file_map(path: str) -> dict[str, tuple[float, int]]: """Ranked word list → (pseudo-zipf, rank). Zipf ≈ 7.5 - log10(rank).""" out: dict[str, tuple[float, int]] = {} p = Path(path) if not p.exists(): return out with p.open(encoding="utf-8", errors="ignore") as fh: rank = 0 for line in fh: line = line.strip() if not line: continue # FrequencyWords: "word count" ; fasttext vocab dump: "word" or "word freq" parts = line.split() if not parts: continue w = parts[0].casefold() if not w or w.startswith("#"): continue rank += 1 zipf = max(0.0, 7.5 - math.log10(rank)) out.setdefault(w, (zipf, rank)) if rank >= 1_000_000: break return out def ensure_frequencywords(code: str) -> Path | None: fname = FW_FILES.get(code) if not fname: return None FREQ_DIR.mkdir(parents=True, exist_ok=True) dest = FREQ_DIR / fname if dest.exists() and dest.stat().st_size > 0: return dest url = FW_BASE + fname try: import httpx with httpx.Client(timeout=60.0, follow_redirects=True) as client: r = client.get(url) r.raise_for_status() dest.write_bytes(r.content) return dest except Exception: return None def _fasttext_path(code: str) -> Path | None: for name in (f"{code}.txt", f"{code}_freq.txt", f"cc.{code}.txt"): p = FASTTEXT_DIR / name if p.exists(): return p return None def _lookup_in_map(mp: dict[str, tuple[float, int]], norm: str) -> tuple[float, int] | None: """Exact form first; single-token fallback only (never first word of a phrase).""" hit = mp.get(norm) if hit is not None: return hit # Avoid "Cape Verde" → "cape" false popularity. if any(ch.isspace() for ch in norm) or "-" in norm: return None m = _WORD_RE.fullmatch(norm) or _WORD_RE.search(norm) if m and m.group(0) != norm: return mp.get(m.group(0)) return None def lookup_frequency(term: str, lang: str, iso_639_3: str | None = None) -> dict: """Return zipf, rank (1=most frequent), and source. Unknown → zipf 0, rank 0.""" code = freq_code_for(lang, iso_639_3) norm = _norm_term(term) empty = {"zipf": 0.0, "rank": 0, "source": None, "code": code} if not code or not norm: return empty # 1) wordfreq wf = _wordfreq_ranks(code) if wf is not None: hit = _lookup_in_map(wf, norm) if hit is not None: return {"zipf": hit[0], "rank": hit[1], "source": "wordfreq", "code": code} # 2) optional FastText ranked vocab dir ft = _fasttext_path(code) if ft is not None: mp = _ranked_file_map(str(ft)) hit = _lookup_in_map(mp, norm) if hit is not None: return {"zipf": hit[0], "rank": hit[1], "source": "fasttext", "code": code} # 3) FrequencyWords (OpenSubtitles) popular-usage fallback fw = ensure_frequencywords(code) if fw is not None: mp = _ranked_file_map(str(fw)) hit = _lookup_in_map(mp, norm) if hit is not None: return {"zipf": hit[0], "rank": hit[1], "source": "frequencywords", "code": code} return empty def _wordfreq_hits_for(code: str, needed: set[str]) -> dict[str, tuple[float, int]] | None: """Rank + Zipf for ``needed`` forms only (avoids multi-100k dicts in Docker builds).""" if not needed: return {} try: from wordfreq import get_frequency_dict, available_languages except ImportError: return None langs = available_languages(wordlist="best") if code not in langs: langs = available_languages(wordlist="large") wordlist = "large" if code in langs else None if wordlist is None: return None else: wordlist = "best" freq_dict = get_frequency_dict(code, wordlist=wordlist) remaining = set(needed) out: dict[str, tuple[float, int]] = {} # Iterate in descending frequency so enumerate position is the rank. for i, (w, f) in enumerate(sorted(freq_dict.items(), key=lambda kv: -kv[1]), start=1): if w in remaining and f > 0: out[w] = (math.log10(f) + 9.0, i) remaining.discard(w) if not remaining: break if i >= 1_000_000: break return out def _file_hits_for(path: Path, needed: set[str]) -> dict[str, tuple[float, int]]: if not needed or not path.exists(): return {} out: dict[str, tuple[float, int]] = {} remaining = set(needed) with path.open(encoding="utf-8", errors="ignore") as fh: rank = 0 for line in fh: line = line.strip() if not line: continue parts = line.split() if not parts: continue w = parts[0].casefold() if not w or w.startswith("#"): continue rank += 1 if w in remaining: out[w] = (max(0.0, 7.5 - math.log10(rank)), rank) remaining.discard(w) if not remaining: break if rank >= 1_000_000: break return out def _map_for_needed(code: str, needed: set[str]) -> tuple[dict[str, tuple[float, int]], str | None]: hits = _wordfreq_hits_for(code, needed) if hits is not None: return hits, "wordfreq" ft = _fasttext_path(code) if ft is not None: return _file_hits_for(ft, needed), "fasttext" fw = ensure_frequencywords(code) if fw is not None: return _file_hits_for(fw, needed), "frequencywords" return {}, None def _map_for_code(code: str) -> tuple[dict[str, tuple[float, int]], str | None]: """Load the best available rank map for a language code once (runtime lookups).""" wf = _wordfreq_ranks(code) if wf is not None: return wf, "wordfreq" ft = _fasttext_path(code) if ft is not None: return _ranked_file_map(str(ft)), "fasttext" fw = ensure_frequencywords(code) if fw is not None: return _ranked_file_map(str(fw)), "frequencywords" return {}, None def _needed_forms(terms: list[str], idxs: list[int]) -> set[str]: needed: set[str] = set() for i in idxs: norm = _norm_term(terms[i]) if not norm: continue needed.add(norm) if any(ch.isspace() for ch in norm) or "-" in norm: continue m = _WORD_RE.search(norm) if m and m.group(0) != norm: needed.add(m.group(0)) return needed def annotate_nodes( terms: list[str], langs: list[str], iso_by_lang: dict[str, str | None], ) -> tuple["object", "object"]: """Build per-node zipf (float32) and rank (uint32) arrays.""" import numpy as np n = len(terms) zipf = np.zeros(n, dtype=np.float32) rank = np.zeros(n, dtype=np.uint32) by_code: dict[str, list[int]] = {} for i, lang in enumerate(langs): code = freq_code_for(lang, iso_by_lang.get(lang)) if not code: continue by_code.setdefault(code, []).append(i) for code, idxs in sorted(by_code.items(), key=lambda kv: -len(kv[1])): needed = _needed_forms(terms, idxs) mp, source = _map_for_needed(code, needed) print(f" {code}: {len(idxs):,} nodes via {source or 'none'} ({len(mp):,} hits / {len(needed):,} forms)") if mp: for i in idxs: norm = _norm_term(terms[i]) if not norm: continue hit = _lookup_in_map(mp, norm) if hit is not None: zipf[i] = hit[0] rank[i] = hit[1] _wordfreq_ranks.cache_clear() _ranked_file_map.cache_clear() del mp, needed return zipf, rank