scrubdata / training /vocab.py
OpenAI Codex
deploy: add sponsor:openai tag (Best Use of Codex) + Codex-hardened build
16dc556
Raw
History Blame Contribute Delete
15.3 kB
"""Real-world vocabularies + a corruption engine for hard, self-verifiable canonicalization.
The toy pools (4 countries, 3 category sets) taught the model nothing the heuristic
didn't already do. Here we back categorical columns with hundreds of REAL canonical
entities (countries, US states, currencies, cities, departments, job titles, statuses)
and corrupt each into realistic surface forms (aliases, codes, casing, punctuation,
typos) — recording the exact surface→canonical mapping so the example still round-trips
through scrubdata.executor (self-verified).
Design rule that keeps it learnable AND verifiable:
- Each generated column draws only a FEW canonicals (low cardinality) so every dirty
surface appears in the profile sample the model sees.
- No outer whitespace on categorical cells (executor's canonicalize strips only outer
space and would otherwise leave canonical-valued cells untrimmed) — casing/punct/typo
noise is what creates the mapping entries.
"""
from __future__ import annotations
import random
import string
import pycountry
# --- generic surface corruption ---------------------------------------------
_KEEP = set(string.ascii_letters + string.digits + " ")
def _strip_punct(s: str) -> str:
return "".join(ch for ch in s if ch in _KEEP).strip()
def _one_typo(rng: random.Random, s: str) -> str:
if len(s) < 4:
return s
i = rng.randrange(1, len(s) - 1)
mode = rng.random()
if mode < 0.28: # drop a char
return s[:i] + s[i + 1:]
if mode < 0.52: # swap adjacent
return s[:i] + s[i + 1] + s[i] + s[i + 2:]
if mode < 0.72: # duplicate a char
return s[:i] + s[i] + s[i:]
# substitute a char (the classic 'birminghxm' corruption)
repl = rng.choice(string.ascii_uppercase if s[i].isupper() else string.ascii_lowercase)
return s[:i] + repl + s[i + 1:]
def make_surface(rng: random.Random, canonical: str, aliases: list[str],
typo_p: float = 0.13) -> str:
"""Produce one realistic dirty surface for a canonical value.
`typo_p` is the per-cell probability of injecting a single-char typo. The
high-cardinality path raises this (column-level parameter) so a column reliably
carries a long tail of single-char typos rather than a thin sprinkle.
"""
s = rng.choice([canonical, *aliases]) if aliases else canonical
r = rng.random()
if r < 0.28:
s = s.lower()
elif r < 0.42:
s = s.upper()
elif r < 0.52:
s = s.title()
if rng.random() < 0.15:
s = _strip_punct(s)
if rng.random() < typo_p:
s = _one_typo(rng, s)
return s.strip()
def make_substitution_typo(rng: random.Random, canonical: str) -> str:
"""Guaranteed single-char SUBSTITUTION typo of the canonical (birminghxm regime).
Returns the canonical unchanged only when too short to safely corrupt; callers
that require a real typo should check `surface != canonical`.
"""
if len(canonical) < 4:
return canonical
i = rng.randrange(1, len(canonical) - 1)
ch = canonical[i]
if not ch.isalpha():
# find any interior alpha char to substitute, else give up
alpha_idx = [j for j in range(1, len(canonical) - 1) if canonical[j].isalpha()]
if not alpha_idx:
return canonical
i = rng.choice(alpha_idx)
ch = canonical[i]
pool = string.ascii_uppercase if ch.isupper() else string.ascii_lowercase
repl = rng.choice([c for c in pool if c != ch.lower() and c != ch.upper()])
return canonical[:i] + repl + canonical[i + 1:]
# --- vocabularies (canonical -> aliases) ------------------------------------
# Curated common aliases for high-frequency countries (codes are added automatically).
_COUNTRY_ALIASES = {
"United States": ["USA", "U.S.A.", "U.S.", "America", "the US", "United States of America"],
"United Kingdom": ["UK", "U.K.", "Britain", "Great Britain", "England"],
"United Arab Emirates": ["UAE", "U.A.E."],
"South Korea": ["Korea", "Republic of Korea"],
"Russian Federation": ["Russia"],
"Czechia": ["Czech Republic"],
"Netherlands": ["Holland", "The Netherlands"],
}
def _country_entries(limit: int | None = None) -> dict[str, list[str]]:
out: dict[str, list[str]] = {}
for c in pycountry.countries:
name = c.name
aliases = {c.alpha_2, c.alpha_3}
official = getattr(c, "official_name", None)
if official and official != name:
aliases.add(official)
aliases.update(_COUNTRY_ALIASES.get(name, []))
out[name] = sorted(a for a in aliases if a and a != name)
if limit and len(out) >= limit:
break
return out
# USPS-style: pycountry subdivision code is "US-CA"; the bare code "CA" is the alias.
def _subdivision_entries(country_code: str = "US") -> dict[str, list[str]]:
out: dict[str, list[str]] = {}
for s in pycountry.subdivisions.get(country_code=country_code) or []:
bare = s.code.split("-")[-1]
out[s.name] = [bare]
return out
def _currency_entries(limit: int = 60) -> dict[str, list[str]]:
# Canonical = ISO code (USD); aliases = the long name and a few symbols.
symbols = {"USD": ["$", "US$"], "EUR": ["€"], "GBP": ["£"], "JPY": ["¥"], "INR": ["₹"]}
out: dict[str, list[str]] = {}
for c in pycountry.currencies:
code = c.alpha_3
out[code] = [c.name, *symbols.get(code, [])]
if len(out) >= limit:
break
return out
_CITY_ALIASES = {
"New York": ["NYC", "New York City", "NY"], "Los Angeles": ["LA", "L.A."],
"San Francisco": ["SF", "San Fran"], "Las Vegas": ["Vegas"],
"Washington": ["Washington D.C.", "DC", "Washington DC"], "Chicago": ["Chi-town"],
"London": ["LDN"], "Mexico City": ["CDMX", "Ciudad de Mexico"],
"Sao Paulo": ["São Paulo", "SP"], "Tokyo": ["Tōkyō"], "Mumbai": ["Bombay"],
"Bengaluru": ["Bangalore"], "Istanbul": ["Constantinople"], "Beijing": ["Peking"],
"Philadelphia": ["Philly"], "New Orleans": ["NOLA"], "Hong Kong": ["HK"],
"Rio de Janeiro": ["Rio"], "Buenos Aires": ["BA"], "Amsterdam": ["A'dam"],
}
def _department_entries() -> dict[str, list[str]]:
return {
"Engineering": ["Eng", "Eng.", "R&D", "Dev"], "Sales": ["Biz Dev"],
"Marketing": ["Mktg", "Mkt", "Growth"], "Human Resources": ["HR", "People", "People Ops"],
"Finance": ["Fin", "Accounting"], "Operations": ["Ops"],
"Customer Support": ["Support", "CS", "Cust Support"], "Legal": ["Legal & Compliance"],
"Product": ["Prod", "PM"], "Information Technology": ["IT", "I.T."],
}
def _job_title_entries() -> dict[str, list[str]]:
return {
"Senior Engineer": ["Sr. Engineer", "Sr Engineer", "Senior Eng", "Snr Engineer"],
"Engineering Manager": ["Eng Manager", "Eng Mgr", "Engineering Mgr"],
"Chief Executive Officer": ["CEO", "C.E.O."],
"Chief Technology Officer": ["CTO", "C.T.O."],
"Vice President": ["VP", "V.P.", "Vice Pres"],
"Account Executive": ["AE", "Acct Exec"], "Sales Representative": ["Sales Rep", "Rep"],
"Product Manager": ["PM", "Prod Manager", "Prod Mgr"],
"Administrative Assistant": ["Admin Assistant", "Admin Asst", "Admin"],
"Director": ["Dir", "Dir."],
}
CITIES = [
"New York", "Los Angeles", "Chicago", "Houston", "Phoenix", "Philadelphia",
"San Antonio", "San Diego", "Dallas", "San Francisco", "Austin", "Seattle",
"Denver", "Boston", "Washington", "Las Vegas", "Portland", "Miami", "Atlanta",
"New Orleans", "London", "Paris", "Berlin", "Madrid", "Rome", "Amsterdam",
"Lisbon", "Dublin", "Vienna", "Prague", "Warsaw", "Stockholm", "Oslo",
"Tokyo", "Osaka", "Seoul", "Beijing", "Shanghai", "Mumbai", "Delhi",
"Bengaluru", "Singapore", "Hong Kong", "Bangkok", "Jakarta", "Manila",
"Sydney", "Melbourne", "Toronto", "Vancouver", "Montreal", "Mexico City",
"Sao Paulo", "Rio de Janeiro", "Buenos Aires", "Bogota", "Lima", "Santiago",
"Cairo", "Lagos", "Nairobi", "Johannesburg", "Dubai", "Istanbul",
]
# Lazily-built, cached vocab dicts (pycountry iteration is a touch slow).
_CACHE: dict[str, dict[str, list[str]]] = {}
def _cached(key: str, builder) -> dict[str, list[str]]:
if key not in _CACHE:
_CACHE[key] = builder()
return _CACHE[key]
def country_vocab() -> dict[str, list[str]]:
return _cached("country", _country_entries)
def state_vocab() -> dict[str, list[str]]:
return _cached("state", lambda: _subdivision_entries("US"))
def currency_vocab() -> dict[str, list[str]]:
return _cached("currency", lambda: _currency_entries(60))
def _learnable_alias(alias: str, canon: str) -> bool:
"""Keep only aliases the model can LEARN as a string skill, not memorize as a lookup:
variants (edit-similar), substrings/abbreviations, or acronyms. MEASURED: unfiltered
arbitrary-knowledge aliases (BIA SAIGON->Sabeco) dilute training (-0.18 canon_f1)."""
import difflib
na = "".join(c.lower() for c in alias if c.isalnum())
nc = "".join(c.lower() for c in canon if c.isalnum())
if not na or not nc:
return False
if na in nc or nc in na: # substring/abbreviation
return True
initials = "".join(w[0].lower() for w in canon.split() if w and w[0].isalpha())
if len(na) >= 2 and na == initials: # acronym
return True
return difflib.SequenceMatcher(None, na, nc).ratio() >= 0.6 # variant
def _alias_file(name: str, limit: int, seed: int = 3,
learnable_only: bool = True) -> dict[str, list[str]]:
"""Load a harvested {canonical, aliases} jsonl (GeoNames/Wikidata/ROR) if present,
gated to learnable aliases by default."""
import json as _json
from pathlib import Path
p = Path(__file__).resolve().parent.parent / "data" / name
if not p.exists():
return {}
rows = [_json.loads(l) for l in p.open(encoding="utf-8")]
rng = random.Random(seed)
rng.shuffle(rows)
out: dict[str, list[str]] = {}
for r in rows:
canon = r["canonical"].strip()
aliases = [a for a in r.get("aliases", []) if a and a != canon]
if learnable_only:
aliases = [a for a in aliases if _learnable_alias(a, canon)]
if aliases and 3 <= len(canon) <= 60:
out[canon] = aliases[:4]
if len(out) >= limit:
break
return out
def _city_entries() -> dict[str, list[str]]:
# Curated cities carry valuable aliases (NYC->New York); the fetched open-data
# list (training/cities.txt) adds breadth; harvested GeoNames English aliases
# (data/geonames_city_aliases.jsonl, 80k cities / 152k aliases) add REAL variety.
from pathlib import Path
cities = list(CITIES)
extra = Path(__file__).parent / "cities.txt"
if extra.exists():
cities += [c.strip() for c in extra.read_text(encoding="utf-8").splitlines() if c.strip()]
out = {c: list(_CITY_ALIASES.get(c, [])) for c in dict.fromkeys(cities)}
for canon, aliases in _alias_file("geonames_city_aliases.jsonl", limit=4000).items():
merged = list(dict.fromkeys(out.get(canon, []) + aliases))
out[canon] = merged[:5]
return out
def company_vocab() -> dict[str, list[str]]:
"""Real company alias->canonical pairs (Wikidata skos:altLabel, CC0)."""
return _cached("company", lambda: _alias_file("wikidata_company_aliases.jsonl", limit=2500))
def nickname_vocab() -> dict[str, list[str]]:
"""Unambiguous nickname->formal first-name pairs (carltonnorthern/nicknames, Apache-2.0)."""
return _cached("nickname", lambda: _alias_file("nickname_aliases.jsonl", limit=600))
def city_vocab() -> dict[str, list[str]]:
return _cached("city", _city_entries)
def department_vocab() -> dict[str, list[str]]:
return _cached("department", _department_entries)
def _org_entries(limit: int = 2500, seed: int = 3) -> dict[str, list[str]]:
"""Real organization alias->canonical pairs harvested from the ROR registry
(data/ror_aliases.jsonl, CC0; 72k orgs with aliases/acronyms): 'RMIT' ->
'RMIT University'. Empty dict (archetype skipped) when the harvest is absent."""
import json as _json
from pathlib import Path
p = Path(__file__).resolve().parent.parent / "data" / "ror_aliases.jsonl"
if not p.exists():
return {}
rows = [_json.loads(l) for l in p.open(encoding="utf-8")]
rng = random.Random(seed)
rng.shuffle(rows)
out: dict[str, list[str]] = {}
for r in rows:
canon = r["canonical"].strip()
aliases = [a.strip() for a in r.get("aliases", []) if a and a.strip() != canon]
if not aliases or not (4 <= len(canon) <= 60):
continue
if sum(c.isascii() for c in canon) < 0.9 * len(canon): # keep learnable/ascii-ish
continue
out[canon] = aliases[:4]
if len(out) >= limit:
break
return out
def org_vocab() -> dict[str, list[str]]:
return _cached("org", _org_entries)
def job_title_vocab() -> dict[str, list[str]]:
return _cached("job_title", _job_title_entries)
def _industry_entries() -> dict[str, list[str]]:
return {
"Technology": ["Tech", "IT", "Software", "tech"],
"Healthcare": ["Health Care", "Medical", "Health"],
"Financial Services": ["Finance", "FinServ", "Banking", "Fintech"],
"Retail": ["E-commerce", "Retail & E-commerce", "retail"],
"Manufacturing": ["Mfg", "Industrial"],
"Education": ["EdTech", "Ed", "education"],
"Real Estate": ["RealEstate", "Property", "PropTech"],
"Hospitality": ["Hotels & Travel", "Travel", "Tourism"],
"Energy": ["Oil & Gas", "Utilities", "energy"],
"Telecommunications": ["Telecom", "Telco", "Comms"],
}
def _unit_entries() -> dict[str, list[str]]:
return {
"kg": ["kilogram", "kilograms", "Kg", "KG"],
"lb": ["lbs", "pound", "pounds", "Lb"],
"cm": ["centimeter", "centimeters", "Cm"],
"in": ["inch", "inches", "\""],
"L": ["liter", "litre", "liters", "l"],
"mL": ["milliliter", "ml", "mls"],
"km": ["kilometer", "kilometers", "Km"],
"mi": ["mile", "miles", "Mi"],
}
def industry_vocab() -> dict[str, list[str]]:
return _cached("industry", _industry_entries)
def unit_vocab() -> dict[str, list[str]]:
return _cached("unit", _unit_entries)
# Status / categorical value sets (canonical -> messy variants).
_STATUS_SETS = [
{"Won": ["won", "WON", "Closed Won", "closed-won"], "Lost": ["lost", "Closed Lost"],
"In Progress": ["in progress", "in-progress", "WIP", "ongoing"], "Open": ["open", "new"]},
{"High": ["high", "HIGH", "H", "P1"], "Medium": ["medium", "med", "M", "P2"],
"Low": ["low", "L", "P3"]},
{"Active": ["active", "ACTIVE"], "Churned": ["churned", "cancelled", "canceled"],
"Trial": ["trial", "TRIAL", "free trial"], "Paused": ["paused", "on hold"]},
{"Paid": ["paid", "PAID"], "Pending": ["pending", "unpaid", "due"],
"Refunded": ["refunded", "refund"], "Overdue": ["overdue", "late"]},
]