genderize / inference_fuso.py
textpie's picture
Genderize v2: fused two-network model (v2.2), script-aware routing, country prior — honest model card; v1 preserved in tag v1
5e6f6db verified
Raw History Blame Contribute Delete
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
@torch.no_grad()
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