Download inference_fuso.py from textpie/genderize: direct link, hf CLI and curl.
- Browser
- Download file 13.7 kB
-
https://huggingface.co/textpie/genderize/resolve/main/inference_fuso.py
- Command line
-
hf download hf://textpie/genderize/inference_fuso.py
-
curl -L -o inference_fuso.py https://huggingface.co/textpie/genderize/resolve/main/inference_fuso.py
13.7 kB
| """genderize v2 «fuso» — un solo modello, due reti interne (v1 ultra + v2 distillata D2). | |
| Uso: | |
| g = GenderizeFuso('models/genderize_fuso_v2') | |
| g.predict(['Maria Rossi', 'Nguyen Van An']) # -> [{'gender','p_male','country','p_country','top5'}] | |
| Regole (DECISIONI 25/09): p(M) = media delle p(M) calibrate delle due reti; «M» se p(M) >= soglia | |
| (0,60: scelta sui banchi, verificata su 6 insiemi freschi); paese = 0,7·v1 + 0,3·v2 sulle probabilità. | |
| Ogni rete riceve il nome con la propria codifica (v1 byte maxlen 48, v2 codepoint maxlen 32). | |
| T1 — instradamento per scrittura con traslitterazione (DOM-102, 27/09): la scrittura del nome | |
| viene riconosciuta dai blocchi Unicode e si applica la regola misurata in | |
| reports/trainer/g3_traslit_T1.json (scelta solo sulla metà di validazione, nessun parametro | |
| appreso; totale test 85,82 contro 78,11 fuso e 74,31 v1): «nativo» classifica il nome originale, | |
| «anyascii» la sua traslitterazione, «mix» la media delle probabilità delle due forme; le scritture | |
| latine e quelle non mappate restano nativo. Il paese è sempre calcolato sul nome originale. | |
| anyascii è una dipendenza runtime (licenza ISC) richiesta solo quando serve traslitterare. | |
| Disambiguazioni: kana → giapponese (senza kana il solo Han va in cinese); lettere persiane | |
| specifiche (پ چ ژ گ ک ی) distinguono il farsi dall'arabo; lettere kazake specifiche | |
| (ә ғ қ ң ө ұ ү һ) e ucraine (ї є ґ і) distinguono i cirillici, altrimenti russo. | |
| v2.1 (DOM-112, 28/09) — testa paese e selettore per paese: | |
| • logit adjustment β=0,75 della testa paese: log(p_c) − β·log(π), dove p_c è la probabilità | |
| fusa (0,7·v1 + 0,3·v2) sul nome originale e π è la frequenza empirica del corpus v2 letta da | |
| models/genderize_fuso_v2/country_prior.json (accanto ai pesi, SHA nel manifest); β scelto solo | |
| sulla validation in DOM-105. La distribuzione restituita è rinormalizzata; l'argmax decide | |
| il paese. | |
| • selettore per paese: se il paese predetto (dopo l'aggiustamento) è in S {AM GE MM TG TD WS}, | |
| la decisione di genere usa il ramo scelto solo sui banchi VECCHI (residuo 226 v1 + nativo; | |
| mai il banco 226 v2): v1@0,5 oppure fuso a soglia diversa. Per quelle righe il p(M) restituito | |
| è il punteggio del ramo rimappato in modo che il taglio 0,60 riproduca esattamente la | |
| decisione del ramo (p_ramo + 0,60 − soglia_ramo, limitato a [0,1]); per tutte le altre righe | |
| resta fuso@0,60. «gender» e «p_male» restano sempre coerenti con la stessa regola di taglio. | |
| v2.2 (DOM-113, 28/09) — rimedio «tratti + massa» per GE MM TG TD (WS escluso): | |
| • se la decisione v2.1 e v1@0,50 discordano E il nome ha tratti tipici di quei paesi, la | |
| decisione di genere (e il punteggio, rimappato come il selettore: p_v1 + 0,60 − 0,50) viene | |
| dal ramo v1. Ambito: SOLO nomi di scritture latine (la validazione che ha scelto il rimedio | |
| è latina; sulle scritture native il ramo v1 leggerebbe byte fuori distribuzione e il trigger | |
| non è mai stato misurato). Tratti scelti SOLO sul banco di validazione DOM-113 (Wikidata CC0, | |
| congelato; mai il 226 v2) e vincolati a non danneggiare il residuo 226 v1 (−0,023 pp | |
| misurati): | |
| (i) token esatto del lessico del paese (supporto ≥ 2 in validazione, ovunque nel nome); | |
| (ii) onorifico birmano iniziale {Maung, Mg, Saw, Sao, U} con almeno 2 token; | |
| (iii) somma delle p_paese aggiustate su {AM GE MM TG TD WS} ≥ 0,15 con il paese a p massima | |
| tra {GE MM TG TD WS} attivo. Configurazione in models/genderize_fuso_v2/dom113_rimedio.json | |
| (SHA nel manifest del verdetto); se il file manca, il comportamento resta v2.1. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| from pathlib import Path | |
| import torch, torch.nn.functional as F | |
| from inference import NameModel, encode, normalise | |
| try: | |
| from anyascii import anyascii as _anyascii | |
| except ImportError: | |
| _anyascii = None | |
| REGOLA_T1 = { | |
| 'zh': 'nativo', 'ja': 'nativo', 'ko': 'nativo', 'ar': 'nativo', 'he': 'nativo', | |
| 'th': 'nativo', 'el': 'nativo', 'uk': 'nativo', | |
| 'ka': 'anyascii', 'hy': 'anyascii', 'kk': 'anyascii', 'ru': 'anyascii', | |
| 'fa': 'mix', 'bn': 'mix', 'hi': 'mix', | |
| } | |
| _BLOCCHI = ( | |
| ('ka', 0x10A0, 0x10FF), ('hy', 0x0530, 0x058F), ('he', 0x0590, 0x05FF), | |
| ('ar', 0x0600, 0x06FF), ('bn', 0x0980, 0x09FF), ('hi', 0x0900, 0x097F), | |
| ('th', 0x0E00, 0x0E7F), ('el', 0x0370, 0x03FF), ('han', 0x3400, 0x9FFF), | |
| ('hangul', 0xAC00, 0xD7AF), ('hiragana', 0x3040, 0x309F), ('katakana', 0x30A0, 0x30FF), | |
| ('cyrillic', 0x0400, 0x052F), ('latino', 0x0041, 0x024F), | |
| ) | |
| _PERSIANO = frozenset('پچژگکی') | |
| _KAZAKO = frozenset('әғқңөұүһӘҒҚҢӨҰҮҺ') | |
| _UCRAINO = frozenset('їєґіЇЄҐІ') | |
| def script_t1(testo: str): | |
| conteggi: dict[str, int] = {} | |
| for c in testo: | |
| cp = ord(c) | |
| for nome, a, b in _BLOCCHI: | |
| if a <= cp <= b: | |
| conteggi[nome] = conteggi.get(nome, 0) + 1 | |
| break | |
| if not conteggi: | |
| return None | |
| if 'hiragana' in conteggi or 'katakana' in conteggi: | |
| return 'ja' | |
| if 'hangul' in conteggi: | |
| return 'ko' | |
| if 'han' in conteggi: | |
| return 'zh' | |
| dominante = max(conteggi, key=lambda k: (conteggi[k], k != 'latino')) | |
| if dominante == 'latino': | |
| return None | |
| if dominante == 'ar': | |
| return 'fa' if _PERSIANO & set(testo) else 'ar' | |
| if dominante == 'cyrillic': | |
| if _KAZAKO & set(testo): | |
| return 'kk' | |
| if _UCRAINO & set(testo): | |
| return 'uk' | |
| return 'ru' | |
| return dominante | |
| def _cap(s: str) -> str: | |
| return ' '.join(w[:1].upper() + w[1:] for w in s.split()) | |
| def _traslittera(nome: str) -> str: | |
| if _anyascii is None: | |
| raise RuntimeError('anyascii è richiesto per le scritture traslitterate: pip install anyascii (licenza ISC)') | |
| return _cap(_anyascii(nome)) or nome | |
| def varianti_t1(nome: str) -> list[str]: | |
| regola = REGOLA_T1.get(script_t1(nome)) | |
| if regola in (None, 'nativo'): | |
| return [nome] | |
| tr = _traslittera(nome) | |
| return [nome, tr] if regola == 'mix' else [tr] | |
| class GenderizeFuso: | |
| def __init__(self, cartella: str, device: str = 'cpu', batch_size: int = 256): | |
| if batch_size < 1: | |
| raise ValueError('batch_size deve essere positivo') | |
| self.batch_size = batch_size | |
| base = Path(cartella); cfg = json.loads((base / 'fuso.json').read_text()) | |
| self.soglia, self.peso_paese_v1, self.dev = float(cfg['soglia_M']), float(cfg['peso_paese_v1']), device | |
| self.reti = [] | |
| for sub in ('v1', 'v2'): | |
| d = base / sub; c = json.loads((d / 'genderize_ultra.config.json').read_text()) | |
| cal = json.loads((d / 'genderize_ultra.calibration.json').read_text()); mp = json.loads((d / 'maps.json').read_text()) | |
| vc = mp.get('vocab_codepoint') if sub == 'v2' else None | |
| m = NameModel(len(mp['country']), ch=int(c['ch']), vocab_size=len(vc) + 2 if vc else 256) | |
| st = torch.load(d / 'genderize_ultra.pt', map_location='cpu', weights_only=True) | |
| m.load_state_dict(st.get('state_dict', st)); m.eval().to(device) | |
| paesi = mp['country'] if isinstance(mp['country'], list) else [k for k, _ in sorted(mp['country'].items(), key=lambda x: x[1])] | |
| self.reti.append((m, cal, vc, int(c.get('maxlen', mp.get('maxlen', 48))), paesi)) | |
| self.paesi = self.reti[1][4] | |
| self.perm = torch.tensor([self.reti[0][4].index(p) for p in self.paesi]) # v1 -> ordine v2 | |
| pri = json.loads((base / 'country_prior.json').read_text()) | |
| self.beta = float(pri['beta']) | |
| pr = pri['prior']; assert len(pr) == len(self.paesi) and set(pr) == set(self.paesi) | |
| self.log_prior = torch.tensor([float(pr[p]) for p in self.paesi], dtype=torch.float64) | |
| sc = pri['selettore']['scelte'] | |
| self._sel_v1 = torch.zeros(len(self.paesi), dtype=torch.bool) | |
| self._sel_off = torch.zeros(len(self.paesi)) | |
| for c, b in sc.items(): | |
| i = self.paesi.index(c) | |
| self._sel_v1[i] = b['ramo'] == 'v1' | |
| self._sel_off[i] = self.soglia - float(b['soglia']) | |
| # v2.2 (DOM-113): rimedio «tratti + massa»; senza config resta v2.1 | |
| self._rim_lessico: frozenset[str] = frozenset() | |
| self._rim_onorifici: frozenset[str] = frozenset() | |
| self._rim_tau = 1.0 | |
| rp = base / 'dom113_rimedio.json' | |
| if rp.exists(): | |
| rim = json.loads(rp.read_text()) | |
| lessico = set() | |
| for c in rim['paesi_attivi']: | |
| lessico.update(rim['lessico'][c]) | |
| self._rim_lessico = frozenset(lessico) | |
| self._rim_onorifici = frozenset(rim['onorifici_mm']) | |
| self._rim_tau = float(rim['tau_massa']) | |
| s6 = ['AM', 'GE', 'MM', 'TG', 'TD', 'WS'] # somma di massa (come DOM-113) | |
| s5 = ['GE', 'MM', 'TG', 'TD', 'WS'] # argmax di massa (senza AM) | |
| self._rim_s6 = torch.tensor([self.paesi.index(c) for c in s6]) | |
| self._rim_s5 = torch.tensor([self.paesi.index(c) for c in s5]) | |
| self._rim_attivi = torch.zeros(len(self.paesi), dtype=torch.bool) | |
| for c in rim['paesi_attivi']: | |
| self._rim_attivi[self.paesi.index(c)] = True | |
| def probabilita(self, nomi: list[str]): | |
| if not nomi: | |
| return torch.empty(0), torch.empty((0, len(self.paesi))) | |
| # One normalized work list shared by both networks. Only selected rows | |
| # add a transliteration; equal forms and repeated names reuse inference. | |
| piatti, righe, indice = [], [], {} | |
| def aggiungi(nome): | |
| testo = normalise(nome) | |
| if testo not in indice: | |
| indice[testo] = len(piatti) | |
| piatti.append(testo) | |
| return indice[testo] | |
| for n in nomi: | |
| regola = REGOLA_T1.get(script_t1(n)) | |
| a = aggiungi(n) | |
| b = aggiungi(_traslittera(n)) if regola in ('anyascii', 'mix') else None | |
| righe.append((a, b, regola)) | |
| pm_reti, pc_reti = [], [] | |
| for m, cal, vc, ml, _ in self.reti: | |
| lg_all, lc_all = [], [] | |
| for i in range(0, len(piatti), self.batch_size): | |
| lg, lc = m(encode(piatti[i:i + self.batch_size], maxlen=ml, vocab_codepoint=vc).to(self.dev)) | |
| lg_all.append(lg / cal.get('temp_gender', 1.0)); lc_all.append(lc / cal.get('temp_country', 1.0)) | |
| pm_reti.append(F.softmax(torch.cat(lg_all), -1)[:, 0].cpu()) | |
| pc_reti.append(F.softmax(torch.cat(lc_all), -1).cpu()) | |
| a_t = torch.tensor([a for a, _, _ in righe]) | |
| b_t = torch.tensor([b if b is not None else 0 for _, b, _ in righe]) | |
| aa_m = torch.tensor([regola == 'anyascii' for _, _, regola in righe], dtype=torch.bool) | |
| mix_m = torch.tensor([regola == 'mix' for _, _, regola in righe], dtype=torch.bool) | |
| pn = (pm_reti[0][a_t] + pm_reti[1][a_t]) / 2 | |
| pt = (pm_reti[0][b_t] + pm_reti[1][b_t]) / 2 | |
| p_m = torch.where(aa_m, pt, torch.where(mix_m, (pn + pt) / 2, pn)) | |
| p_c = self.peso_paese_v1 * pc_reti[0][a_t][:, self.perm] + (1 - self.peso_paese_v1) * pc_reti[1][a_t] | |
| # v2.1: logit adjustment della testa paese — log(p_c) − β·log(π), float64 come DOM-105; | |
| # la distribuzione restituita è rinormalizzata e l'argmax decide il paese. | |
| lg_c = torch.log(p_c.to(torch.float64)) - self.beta * self.log_prior | |
| paese = lg_c.argmax(-1) | |
| p_c = torch.softmax(lg_c, -1).to(p_m.dtype) | |
| # v2.1: selettore per paese — per i paesi di S il punteggio del ramo scelto è rimappato | |
| # perché il taglio self.soglia (0,60) riproduca esattamente la decisione del ramo. | |
| base = torch.where(self._sel_v1[paese], pm_reti[0][a_t], p_m) | |
| p_m = torch.clamp(base + self._sel_off[paese], 0.0, 1.0) | |
| # v2.2: rimedio DOM-113 — se v2.1 e v1 discordano e il nome ha tratti tipici | |
| # (lessico / onorifico birmano / massa del paese), decide il ramo v1 | |
| # (punteggio rimappato: p_v1 + 0,60 − 0,50), come il selettore DOM-112. | |
| if self._rim_lessico: | |
| s6 = p_c[:, self._rim_s6].sum(-1) | |
| cS = self._rim_s5[p_c[:, self._rim_s5].argmax(-1)] | |
| massa = (s6 >= self._rim_tau) & self._rim_attivi[cS] | |
| # il rimedio vale solo per nomi latini: la validazione DOM-113 che lo ha | |
| # scelto è latina; sulle scritture native il ramo v1 girerebbe sui byte | |
| # originali (fuori distribuzione) e il trigger non è stato mai misurato | |
| tratti = torch.tensor( | |
| [script_t1(n) is None and (bool(massa[j]) or | |
| any(t in self._rim_lessico for t in n.split()) | |
| or (len(n.split()) >= 2 and n.split()[0] in self._rim_onorifici)) | |
| for j, n in enumerate(nomi)], dtype=torch.bool) | |
| pv1 = pm_reti[0][a_t] | |
| discorda = (p_m >= self.soglia) != (pv1 >= 0.5) | |
| p_m = torch.where(tratti & discorda, | |
| torch.clamp(pv1 + self.soglia - 0.50, 0.0, 1.0), p_m) | |
| return p_m, p_c | |
| def predict(self, nomi: list[str]): | |
| out = [] | |
| for i in range(0, len(nomi), self.batch_size): | |
| p_m, p_c = self.probabilita(nomi[i:i + self.batch_size]); top = p_c.topk(5, -1) | |
| for j in range(len(p_m)): | |
| out.append({'gender': 'M' if p_m[j] >= self.soglia else 'F', 'p_male': round(float(p_m[j]), 4), | |
| 'country': self.paesi[int(top.indices[j, 0])], 'p_country': round(float(top.values[j, 0]), 4), | |
| 'top5': [self.paesi[int(k)] for k in top.indices[j]]}) | |
| return out | |