File size: 13,731 Bytes
5e6f6db
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
"""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