Download model-4/Colab/src/text_utils.py from Jyo-K/PoPTard5: direct link, hf CLI and curl.
- Browser
- Download file 9.13 kB
-
https://huggingface.co/Jyo-K/PoPTard5/resolve/main/model-4/Colab/src/text_utils.py
- Command line
-
hf download hf://Jyo-K/PoPTard5/model-4/Colab/src/text_utils.py
-
curl -L -o text_utils.py https://huggingface.co/Jyo-K/PoPTard5/resolve/main/model-4/Colab/src/text_utils.py
9.13 kB
| """Country-agnostic normalisation. No hand-written dictionaries: legal forms are discovered per country | |
| from unlabeled names, abbreviations are matched by a generic prefix/subsequence rule.""" | |
| import math | |
| import re | |
| from collections import Counter | |
| from itertools import combinations | |
| from anyascii import anyascii | |
| from rapidfuzz.distance import JaroWinkler | |
| _PRE = str.maketrans({"°": "o ", "º": "o ","’": "'", "`": "'", " ": " "}) | |
| _DOTTED = re.compile(r"\b(?:[a-z]\.){2,}") | |
| _NONALNUM = re.compile(r"[^a-z0-9]+") | |
| _DOMAIN = re.compile(r"^(?:www\.)?([a-z0-9][a-z0-9\-]*)\.[a-z]{2,4}$") | |
| _JOIN_ALNUM = re.compile(r"(?<=[a-z0-9])[-/](?=[a-z0-9])") | |
| _HOUSE_SEG = re.compile(r"[^a-z0-9#\-/. ]") | |
| _HOUSE = re.compile(r"^(?:#|[a-z]{1,3}\.?)?\s*(\d{1,6})(?:\s*-\s*(\d{1,6})\b|([a-z])\b|\s*/\s*([a-z0-9]{1,3})\b)?") | |
| _DIGITS = re.compile(r"\d+") | |
| _LETTER = re.compile(r"[a-z]") | |
| def is_nonlatin(s): | |
| return any(ord(ch) > 0x24F for ch in s) if s else False | |
| def ascii_lower(s): | |
| if not s: | |
| return "" | |
| return anyascii(s.translate(_PRE)).lower() | |
| def norm(s_ascii_lower): | |
| s = _DOTTED.sub(lambda m: m.group(0).replace(".", ""), s_ascii_lower) | |
| return _NONALNUM.sub(" ", s).strip() | |
| # ----------------------------------------------------------------------------------------------- names | |
| def name_tokens(raw): | |
| a = ascii_lower(raw).strip() | |
| if a and " " not in a: | |
| m = _DOMAIN.match(a) | |
| if m: | |
| a = m.group(1) | |
| return norm(a).split() | |
| def discover_legal_forms(token_lists, min_frac, boundary_ratio, max_cooc, bigram_tight): | |
| """Legal forms = spans that sit at a name boundary, are frequent, and are mutually exclusive with each | |
| other (SARL/SAS/EURL never co-occur, while descriptors like 'groupe' co-occur with a legal form).""" | |
| n = len(token_lists) | |
| bcount, total_uni = Counter(), Counter() | |
| for t in token_lists: | |
| total_uni.update(set(t)) | |
| if len(t) < 2: | |
| continue | |
| spans = {(t[-1],), (t[0],)} | |
| if len(t) > 2: | |
| spans |= {tuple(t[-2:]), tuple(t[:2])} | |
| bcount.update(spans) | |
| cands = [s for s, c in bcount.items() if c >= min_frac * n] | |
| cset = set(cands) | |
| total, occs, nbr = Counter(), [], {} | |
| for t in token_lists: | |
| if len(t) >= 2: | |
| for k in (1, 2): | |
| if len(t) > k: | |
| for s, nb in ((tuple(t[-k:]), t[-k - 1]), (tuple(t[:k]), t[k])): | |
| if s in cset: | |
| nbr.setdefault(s, Counter())[nb] += 1 | |
| occ = [] | |
| for k in (1, 2): | |
| for i in range(len(t) - k + 1): | |
| s = tuple(t[i:i + k]) | |
| if s in cset: | |
| occ.append((s, i, i + k)) | |
| if occ: | |
| total.update({o[0] for o in occ}) | |
| occs.append(occ) | |
| cands = [s for s in cands if bcount[s] >= boundary_ratio * total[s]] | |
| # bigram must be "tight": its first token rarely appears outside it (private->private limited) | |
| cands = [s for s in cands if len(s) == 1 or total[s] >= bigram_tight * total_uni[s[0]]] | |
| # a legal form attaches to many different words; 'notre' is nearly always next to 'sainte' | |
| in_bigram = {x for s in cands if len(s) == 2 for x in s} | |
| def dominated(s): | |
| c = nbr.get(s) | |
| return bool(c) and s[0] not in in_bigram and c.most_common(1)[0][1] > 0.5 * sum(c.values()) | |
| cands = [s for s in cands if not dominated(s)] | |
| keep = set(cands) | |
| co = Counter() | |
| for occ in occs: | |
| occ = [o for o in occ if o[0] in keep] | |
| for (a, ai, aj), (b, bi, bj) in combinations(occ, 2): | |
| if a != b and (aj <= bi or bj <= ai): | |
| co[frozenset((a, b))] += 1 | |
| accepted = [] | |
| for s in sorted(cands, key=lambda x: -bcount[x]): | |
| if all(co[frozenset((s, a))] <= max_cooc * min(total[s], total[a]) for a in accepted): | |
| accepted.append(s) | |
| return sorted(accepted, key=lambda x: (-len(x), x)) | |
| def strip_legal(tokens, forms, long_single): | |
| t, found = list(tokens), [] | |
| changed = True | |
| while changed and len(t) > 1: | |
| changed = False | |
| for f in forms: | |
| k = len(f) | |
| if len(t) > k and tuple(t[-k:]) == f: | |
| found.append(" ".join(f)); del t[-k:]; changed = True; break | |
| if len(t) > k and tuple(t[:k]) == f: | |
| found.append(" ".join(f)); del t[:k]; changed = True; break | |
| if len(t) > 1: | |
| t2 = [x for x in t if x not in long_single] | |
| if t2 and len(t2) < len(t): | |
| found += [x for x in t if x in long_single] | |
| t = t2 | |
| return t, " ".join(sorted(found)) | |
| def process_name(raw, forms, long_single): | |
| toks = name_tokens(raw) | |
| core, legal = strip_legal(toks, forms, long_single) | |
| return {"name_full": " ".join(toks), "name_core": " ".join(core), "legal": legal, | |
| "name_ns": "".join(core), "nonlatin": is_nonlatin(raw)} | |
| # ----------------------------------------------------------------------------------------------- addresses | |
| def parse_address(raw): | |
| raw = raw or "" | |
| segs = [s for s in re.split(r"[,;]", raw) if s.strip()] | |
| house, street_i, street_rest = "", -1, None | |
| for i, seg in enumerate(segs): | |
| s = _HOUSE_SEG.sub(" ", ascii_lower(seg)).strip() | |
| m = _HOUSE.match(s) | |
| if m and _LETTER.search(s[m.end():]): | |
| base, hi, suf1, suf2 = m.group(1), m.group(2), m.group(3), m.group(4) | |
| house = f"{int(base)}|{suf1 or suf2 or ''}|{int(hi) if hi else ''}" | |
| street_i, street_rest = i, s[m.end():] | |
| break | |
| if street_i < 0 and segs: | |
| street_i, street_rest = 0, ascii_lower(segs[0]) | |
| street = norm(street_rest or "") | |
| loc = [norm(ascii_lower(s)) for j, s in enumerate(segs) if j != street_i] | |
| loc = [x for x in loc if x] | |
| al = ascii_lower(raw) | |
| full = norm(al) | |
| nums = _DIGITS.findall(full) | |
| hb = house.split("|")[0] if house else None | |
| postal = sorted({x for x in nums if len(x) in (5, 6) and x != hb}) | |
| alnum = sorted({t for t in norm(_JOIN_ALNUM.sub("", al)).split() if _LETTER.search(t) and any(c.isdigit() for c in t)}) | |
| return {"addr_norm": full, "street": street, "street_ns": street.replace(" ", ""), "loc": "|".join(loc), | |
| "house": house, "nums": " ".join(sorted(set(nums))), "postal": " ".join(postal), | |
| "alnum": " ".join(alnum), "addr_empty": not full} | |
| def house_compare(ha, hb): | |
| """-> (state, log abs diff). 0 both missing, 1 one missing, 2 equal, 3 base equal/suffix differs, | |
| 4 range contains, 5 conflict.""" | |
| if not ha and not hb: | |
| return 0, math.nan | |
| if not ha or not hb: | |
| return 1, math.nan | |
| a, b = ha.split("|"), hb.split("|") | |
| ab, bb = int(a[0]), int(b[0]) | |
| if ab == bb: | |
| return (2 if a[1] == b[1] else 3), 0.0 | |
| if (a[2] and ab <= bb <= int(a[2])) or (b[2] and bb <= ab <= int(b[2])): | |
| return 4, 0.0 | |
| return 5, math.log1p(abs(ab - bb)) | |
| def set_state(a, b): | |
| """0 both missing, 1 one missing, 2 share, 3 both present disjoint same lengths, 4 disjoint + length differs.""" | |
| if not a and not b: | |
| return 0 | |
| if not a or not b: | |
| return 1 | |
| if a & b: | |
| return 2 | |
| return 4 if {len(x) for x in a}.isdisjoint({len(x) for x in b}) else 3 | |
| # ----------------------------------------------------------------------------------------------- token matching | |
| def is_subseq(short, long): | |
| it = iter(long) | |
| return all(c in it for c in short) | |
| def abbrev_compat(a, b): | |
| if a == b: | |
| return True | |
| s, l = (a, b) if len(a) <= len(b) else (b, a) | |
| return len(s) <= 4 and len(l) > len(s) and s[0] == l[0] and not s.isdigit() and is_subseq(s, l) | |
| def tok_sim(a, b): | |
| if a == b: | |
| return 1.0 | |
| if a.isdigit() or b.isdigit(): | |
| return 0.0 | |
| if abbrev_compat(a, b): | |
| return 0.9 | |
| if len(a) >= 4 and len(b) >= 4: | |
| s = JaroWinkler.similarity(a, b) | |
| if s >= 0.9: | |
| return 0.9 * s | |
| return 0.0 | |
| def soft_cov(ta, tb): | |
| """Fraction of ta covered by tb (exact / abbreviation / typo tolerant).""" | |
| if not ta: | |
| return math.nan | |
| if not tb: | |
| return 0.0 | |
| sb = set(tb) | |
| tot = 0.0 | |
| for x in ta: | |
| if x in sb: | |
| tot += 1.0 | |
| continue | |
| best = 0.0 | |
| for y in tb: | |
| s = tok_sim(x, y) | |
| if s > best: | |
| best = s | |
| tot += best | |
| return tot / len(ta) | |
| def contains_seq(a, b): | |
| k = len(a) | |
| if k == 0 or k > len(b): | |
| return False | |
| return any(b[i:i + k] == a for i in range(len(b) - k + 1)) | |
| def acronym_match(ta, tb): | |
| if len(ta) >= 2: | |
| ini = "".join(t[0] for t in ta) | |
| if ini in tb or ini == "".join(tb): | |
| return True | |
| return False | |
| def legal_state(la, lb): | |
| if not la and not lb: | |
| return 0 | |
| if not la or not lb: | |
| return 1 | |
| if la == lb: | |
| return 2 | |
| ta, tb = la.split(), lb.split() | |
| if len(ta) == len(tb) and all(abbrev_compat(x, y) for x, y in zip(ta, tb)): | |
| return 3 | |
| if abbrev_compat(la.replace(" ", ""), lb.replace(" ", "")): | |
| return 3 | |
| return 4 | |