File size: 8,651 Bytes
7df18eb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
#!/usr/bin/env python
"""Restauration des majuscules et de la ponctuation pour la sortie ASR malgache.

    from punctuate import restore
    restore("lanonana ara-panambadiana iray ahitana karazan'olona mandihy")
    -> "Lanonana ara-panambadiana iray ahitana karazan'olona mandihy."

Garantie : normalize(sortie).split() == normalize(entree).split() (assert).
Les mots ne sont jamais changes ; seules la casse et la ponctuation qui suit
chaque mot sont predites (plus, en option, la remise des lettres accentuees
qu'efface normalize(), ex. "tan na" -> "tanàna", qui respecte la meme garantie).

Modele : XLM-R base (Davlan/xlm-roberta-base-finetuned-malagasy) + une tete de
classification de jetons combinee (4 casses x 7 ponctuations), etiquette lue
sur le premier sous-jeton de chaque mot. Textes longs : fenetres glissantes.

CLI :
  python punctuate.py "texte en minuscules"            # restaure
  python punctuate.py --bench 100 --threads 2          # latence CPU
"""
import argparse
import json
import os
import sys
import time

import numpy as np
import torch

HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
from common import CASES, PUNCTS, normalize, render  # noqa: E402

DEFAULT_MODEL = os.environ.get("MG_PUNCT_DIR", HERE)
NP = len(PUNCTS)


def windows(n, size, step):
    """Fenetres [a, b) couvrant n mots ; chaque mot sera lu dans la fenetre ou il est le plus central."""
    if n <= size:
        return [(0, n)]
    out, a = [], 0
    while True:
        b = min(a + size, n)
        out.append((a, b))
        if b == n:
            return out
        a += step


class Restorer:
    def __init__(self, model_dir=DEFAULT_MODEL, device="cpu", model=None, tokenizer=None, use_lex=True, use_bias=True, quantize=False,
                 win_words=150, win_step=100, max_tokens=512):
        from transformers import AutoModelForTokenClassification, AutoTokenizer

        self.device = device
        self.tok = tokenizer or AutoTokenizer.from_pretrained(model_dir)
        if model is None:
            model = AutoModelForTokenClassification.from_pretrained(model_dir)
            model.to(device).eval()
            if quantize and device == "cpu":
                # int8 dynamique sur les couches lineaires (option, CPU seulement).
                model = torch.ao.quantization.quantize_dynamic(model, {torch.nn.Linear}, dtype=torch.qint8)
        self.model = model
        tables = {"mixed": {}, "lex": {}}
        tpath = os.path.join(model_dir, "tables.json") if model_dir else ""
        if tpath and os.path.exists(tpath):
            tables = json.load(open(tpath))
        self.mixed = tables.get("mixed", {})
        self.lex = tables.get("lex", {}) if use_lex else {}
        self.win_words, self.win_step, self.max_tokens = win_words, win_step, max_tokens
        # Biais additif par signe sur log P(ponct.), regle sur la validation (tune_bias.py).
        self.bias = np.zeros(NP, dtype=np.float32)
        bpath = os.path.join(model_dir, "punct_bias.json") if model_dir else ""
        if use_bias and bpath and os.path.exists(bpath):
            b = json.load(open(bpath))
            self.bias = np.array([b.get(p, 0.0) for p in PUNCTS], dtype=np.float32)

    def predict_words(self, batch_words, batch_size=32):
        """Liste de listes de mots -> liste de (casses, ponctuations). Argmax des marginales."""
        out = []
        for w, (pc, pp, seen) in zip(batch_words, self.predict_probs(batch_words, batch_size)):
            cases = [CASES[i] for i in pc.argmax(1)] if len(w) else []
            puncts = [PUNCTS[i] for i in (np.log(pp + 1e-9) + self.bias).argmax(1)] if len(w) else []
            # Mot jamais vu (tronque) : pas de prediction -> minuscule, sans signe.
            for g in range(len(w)):
                if not seen[g]:
                    cases[g], puncts[g] = "L", "O"
            out.append((cases, puncts))
        return out

    @torch.no_grad()
    def predict_probs(self, batch_words, batch_size=32):
        """Liste de listes de mots -> [(P(casse) [n,4], P(ponct.) [n,7], vu [n])] par exemple."""
        jobs = []  # (index exemple, a, b)
        for k, w in enumerate(batch_words):
            for a, b in windows(len(w), self.win_words, self.win_step):
                jobs.append((k, a, b))
        probs = [np.zeros((len(w), len(CASES) * NP), dtype=np.float32) for w in batch_words]
        best = [np.full(len(w), -1.0) for w in batch_words]
        jobs.sort(key=lambda j: j[2] - j[1])
        for s in range(0, len(jobs), batch_size):
            chunk = jobs[s : s + batch_size]
            enc = self.tok([batch_words[k][a:b] for k, a, b in chunk], is_split_into_words=True,
                           truncation=True, max_length=self.max_tokens, padding=True, return_tensors="pt")
            logits = self.model(input_ids=enc["input_ids"].to(self.device),
                                attention_mask=enc["attention_mask"].to(self.device)).logits.float()
            p = torch.softmax(logits, -1).cpu().numpy()
            for j, (k, a, b) in enumerate(chunk):
                prev = None
                for t, wid in enumerate(enc.word_ids(j)):
                    if wid is None or wid == prev:
                        prev = wid
                        continue
                    prev = wid
                    g = a + wid
                    # centralite : distance au bord de fenetre (les bords de texte ne comptent pas)
                    left = wid if a > 0 else 10**6
                    right = (b - a - 1 - wid) if b < len(batch_words[k]) else 10**6
                    c = min(left, right)
                    if c > best[k][g]:
                        best[k][g] = c
                        probs[k][g] = p[j, t]
        out = []
        for k, w in enumerate(batch_words):
            pr = probs[k].reshape(len(w), len(CASES), NP)
            out.append((pr.sum(2), pr.sum(1), best[k] >= 0))
        return out

    def restore_many(self, texts, batch_size=32):
        words = [normalize(t).split() for t in texts]
        preds = self.predict_words(words, batch_size=batch_size)
        outs = []
        for w, (c, p) in zip(words, preds):
            o = render(w, c, p, self.mixed, self.lex) if w else ""
            assert normalize(o).split() == w, (w, o)
            outs.append(o)
        return outs

    def restore(self, text):
        return self.restore_many([text])[0]


_DEFAULT = None


def restore(text: str) -> str:
    """Restaure majuscules et ponctuation (CPU). Le modele est charge au premier appel."""
    global _DEFAULT
    if _DEFAULT is None:
        _DEFAULT = Restorer()
    return _DEFAULT.restore(text)


def bench(n, threads, model_dir, quantize=False):
    """Latence CPU, une phrase a la fois, sur n hypotheses ASR de la VALIDATION."""
    torch.set_num_threads(threads)
    r = Restorer(model_dir, quantize=quantize)
    path = os.path.join(HERE, "asr_valid", "valid_lm_transcripts.jsonl")
    if os.path.exists(path):
        texts = [json.loads(l)["hyp"] for l in open(path)]
        import random
        texts = random.Random(0).sample(texts, min(n, len(texts)))  # le fichier est trie par duree
    else:
        texts = [" ".join(json.loads(l)["words"]) for l in open(os.path.join(HERE, "data", "valid.jsonl"))][:n]
    for t in texts[:5]:
        r.restore(t)
    lat = []
    for t in texts:
        t0 = time.perf_counter()
        r.restore(t)
        lat.append(1000 * (time.perf_counter() - t0))
    lat = np.array(lat)
    words = np.mean([len(t.split()) for t in texts])
    res = {"n": len(texts), "threads": threads, "int8": quantize, "mots_moyens": round(float(words), 1),
           "median_ms": round(float(np.median(lat)), 1), "p90_ms": round(float(np.percentile(lat, 90)), 1),
           "max_ms": round(float(lat.max()), 1), "moyenne_ms": round(float(lat.mean()), 1)}
    print(json.dumps(res, ensure_ascii=False))
    return res


if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("text", nargs="?")
    ap.add_argument("--model", default=DEFAULT_MODEL)
    ap.add_argument("--bench", type=int, default=0)
    ap.add_argument("--threads", type=int, default=2)
    ap.add_argument("--out", default=None)
    ap.add_argument("--int8", action="store_true", help="quantification dynamique int8 (CPU)")
    a = ap.parse_args()
    if a.bench:
        res = bench(a.bench, a.threads, a.model, a.int8)
        if a.out:
            json.dump(res, open(a.out, "w"), ensure_ascii=False, indent=2)
    else:
        torch.set_num_threads(a.threads)
        print(Restorer(a.model, quantize=a.int8).restore(a.text or sys.stdin.read()))