Kimyayd commited on
Commit
ef8b648
·
verified ·
1 Parent(s): e55f38a

evaluateur: la taille de l echantillon entre dans la cle du cache de corpus

Browse files
Files changed (1) hide show
  1. evaluator.py +653 -648
evaluator.py CHANGED
@@ -1,648 +1,653 @@
1
- """FonBench — évaluateur de fond du Space.
2
-
3
- Un unique thread démon : il prend une demande dans la file Supabase,
4
- charge le corpus de test PRIVÉ, transcrit par tranches et republie les
5
- scores. Aucun GPU : tout se fait sur le CPU du Space.
6
-
7
- Trois précautions valent d'être expliquées, parce qu'elles dictent la
8
- structure du fichier.
9
-
10
- 1. Le corpus est chargé AVANT le modèle, et le token qui y donne accès est
11
- retiré de l'environnement dès l'import. Le Space charge des modèles
12
- écrits par des inconnus : aucun ne doit tourner dans un processus où
13
- traîne encore de quoi lire le corpus. S'y ajoute `trust_remote_code=False`
14
- sans exception — c'est ce qui empêche le code personnalisé d'un dépôt de
15
- s'exécuter.
16
-
17
- 2. On reprend après redémarrage. Un Space gratuit redémarre souvent et une
18
- évaluation dure des heures : sans reprise, les gros modèles ne
19
- finiraient jamais. Ce qu'on sauvegarde à chaque tranche, ce sont des
20
- COMPTEURS d'erreurs, jamais des transcriptions — sinon on recopierait le
21
- corpus privé dans la base. La somme des compteurs redonne exactement les
22
- mêmes scores (voir fonbench_eval).
23
-
24
- 3. Le Space n'a pas la clé service de la base. Il écrit via quatre
25
- procédures protégées par un jeton dédié : au pire, un jeton volé permet
26
- de polluer la file publique, pas de toucher aux comptes ni aux scores.
27
- """
28
-
29
- from __future__ import annotations
30
-
31
- import os
32
- import threading
33
- import time
34
- import traceback
35
-
36
- import requests
37
-
38
- import fonbench_eval as fe
39
-
40
- # ZeroGPU : le GPU n'est attaché que le temps d'une fonction décorée. En
41
- # dehors d'un Space ZeroGPU (tests locaux), le décorateur devient neutre.
42
- try:
43
- import spaces
44
-
45
- GPU = spaces.GPU
46
- ON_ZEROGPU = True
47
- except Exception: # noqa: BLE001
48
- ON_ZEROGPU = False
49
-
50
- def GPU(*args, **kwargs): # noqa: D103
51
- def deco(fn):
52
- return fn
53
- return deco(args[0]) if args and callable(args[0]) else deco
54
-
55
- # --- configuration ------------------------------------------------------
56
-
57
- def env(name: str, default: str = "") -> str:
58
- """Variable d'environnement nettoyée.
59
-
60
- Un secret collé dans l'interface du Space embarque volontiers un retour
61
- à la ligne, ce qui rend l'en-tête HTTP invalide et fait échouer tous les
62
- appels — sans indice évident dans les logs. On tranche à la source.
63
- """
64
- return (os.environ.get(name) or default).strip()
65
-
66
-
67
- SUPABASE_URL = env(
68
- "SUPABASE_URL", "https://cqdimvcnmhrsdcoobkmd.supabase.co"
69
- ).rstrip("/")
70
- # Publique par conception : la Row Level Security fait le tri, pas le secret.
71
- ANON_KEY = env(
72
- "SUPABASE_ANON_KEY", "sb_publishable_MapYll-_Y0hNoLYOfaDR3w_tsTEHyiz"
73
- )
74
- SPACE_TOKEN = env("SPACE_TOKEN")
75
-
76
- # Sorti de l'environnement immédiatement : un modèle inconnu chargé plus
77
- # tard ne doit pas pouvoir le lire dans os.environ.
78
- _DATA_TOKEN = (os.environ.pop("HF_DATA_TOKEN", None)
79
- or os.environ.pop("HF_TOKEN", None) or "").strip() or None
80
-
81
- REST = f"{SUPABASE_URL}/rest/v1"
82
- HEADERS = {"apikey": ANON_KEY, "Content-Type": "application/json"}
83
-
84
- WORKER_VERSION = "space-1.0"
85
- POLL_SECONDS = 60
86
-
87
- # Une tranche = un créneau GPU. ZeroGPU plafonne la durée d'un créneau, et
88
- # plus on en demande, plus on attend : on vise ~10 s de calcul réel.
89
- # Sur A10G, un CTC tourne autour de 100× temps réel (100 énoncés ≈ 700 s
90
- # d'audio ≈ 7 s), un Whisper autour de 5× (20 énoncés ≈ 140 s ≈ 30 s).
91
- GPU_DURATION = 120
92
- CHUNK_CTC = 100
93
- CHUNK_SEQ2SEQ = 20
94
- BATCH_CTC = 8
95
- # Quota journalier épuisé : on attend au lieu d'abandonner. Le point de
96
- # reprise est déjà enregistré, rien n'est perdu.
97
- QUOTA_WAIT_SECONDS = 1800
98
-
99
- # Architectures autorégressives : pipeline + découpage à 30 s, sinon les
100
- # énoncés longs font échouer Whisper.
101
- SEQ2SEQ_TYPES = {
102
- "whisper", "speech_to_text", "speech-encoder-decoder",
103
- "speech_encoder_decoder", "seamless_m4t", "seamless_m4t_v2",
104
- }
105
-
106
- # EXCEPTION AU « AUCUN CODE DE DÉPÔT » — liste blanche explicite.
107
- # Un modèle dont la tête CTC prédit des syllabes ne se décode pas avec les
108
- # classes transformers standard : il faut son propre paquet. Ce paquet n'est
109
- # exécuté QUE pour les dépôts listés ici, et QU'À LA RÉVISION figée ci-dessous
110
- # (code relu avant l'ajout) : une modification ultérieure du dépôt n'est
111
- # jamais exécutée. Aucun autre modèle ne passe par ce chemin.
112
- TRUSTED_RUNNERS = {
113
- "asrDIL/fonglab-asr-w2vbert": {
114
- "revision": "0550598179ed0535ca2c6802a8bfcb09a940d580", # fonasr 0.8.0
115
- "tag": "kenlm-a0.3-b1.0-nlm", # affiché : le système complet, LM + rescorage
116
- "chunk": 50, # le décodage avec LM tourne sur CPU dans le même créneau
117
- },
118
- }
119
-
120
- _state = {"message": "démarrage…", "model": None, "done": 0, "total": 0}
121
-
122
-
123
- def log(msg: str) -> None:
124
- print(f"[evaluator] {msg}", flush=True)
125
-
126
-
127
- def status() -> dict:
128
- return dict(_state)
129
-
130
-
131
- # --- accès à la file ----------------------------------------------------
132
-
133
- def rpc(name: str, payload: dict):
134
- r = requests.post(
135
- f"{REST}/rpc/{name}", headers=HEADERS, json=payload, timeout=60
136
- )
137
- if r.status_code >= 400:
138
- raise RuntimeError(f"{name}: {r.status_code} {r.text[:200]}")
139
- return r.json() if r.text.strip() else None
140
-
141
-
142
- # --- audio --------------------------------------------------------------
143
-
144
- def decode_audio(cell, target_sr: int = 16000):
145
- """Décode un audio (WAV/FLAC/OGG/Opus/WebM/MP3…) en mono float32 16 kHz.
146
-
147
- PyAV plutôt que soundfile : le corpus contient des conteneurs que
148
- libsndfile ne sait pas ouvrir (WebM, Opus) et des fichiers dont
149
- l'en-tête le fait échouer (« array is too big »).
150
- """
151
- import io
152
-
153
- import av
154
- import numpy as np
155
-
156
- raw = cell["bytes"] if isinstance(cell, dict) else cell
157
- if raw is None and isinstance(cell, dict) and cell.get("path"):
158
- with open(cell["path"], "rb") as f:
159
- raw = f.read()
160
-
161
- with av.open(io.BytesIO(raw)) as container:
162
- stream = container.streams.audio[0]
163
- resampler = av.audio.resampler.AudioResampler(
164
- format="flt", layout="mono", rate=target_sr
165
- )
166
- chunks: list = []
167
-
168
- def _emit(frame):
169
- res = resampler.resample(frame)
170
- for rf in res if isinstance(res, list) else ([res] if res else []):
171
- chunks.append(rf.to_ndarray().reshape(-1))
172
-
173
- for frame in container.decode(stream):
174
- _emit(frame)
175
- _emit(None) # flush du resampler
176
-
177
- if not chunks:
178
- return np.zeros(1, dtype="float32")
179
- return np.concatenate(chunks).astype("float32")
180
-
181
-
182
- # --- corpus -------------------------------------------------------------
183
-
184
- _dataset_cache: dict = {}
185
-
186
-
187
- def load_corpus(bench: dict):
188
- """Charge le corpus de test. Gardé en mémoire entre deux évaluations."""
189
- from datasets import Audio, load_dataset
190
-
191
- key = (bench["hf_dataset_id"], bench.get("hf_revision"), bench["hf_split"])
192
- if key in _dataset_cache:
193
- return _dataset_cache[key]
194
-
195
- ds = load_dataset(
196
- bench["hf_dataset_id"],
197
- split=bench["hf_split"],
198
- revision=bench.get("hf_revision") or None,
199
- token=_DATA_TOKEN,
200
- )
201
- if bench.get("sample_size"):
202
- ds = ds.shuffle(seed=bench.get("sample_seed") or 42)
203
- ds = ds.select(range(min(bench["sample_size"], len(ds))))
204
- ds = ds.cast_column("audio", Audio(decode=False))
205
- _dataset_cache.clear() # 16 Go de RAM : un seul corpus à la fois
206
- _dataset_cache[key] = ds
207
- return ds
208
-
209
-
210
- # --- modèle -------------------------------------------------------------
211
-
212
- class Transcriber:
213
- """Charge un modèle du Hub et transcrit des tableaux 16 kHz."""
214
-
215
- def __init__(self, model_id: str, revision: str):
216
- import torch
217
- from transformers import AutoConfig
218
-
219
- torch.set_num_threads(max(1, (os.cpu_count() or 2)))
220
- self.torch = torch
221
- self.model_id = model_id
222
-
223
- cfg = AutoConfig.from_pretrained(
224
- model_id, revision=revision, trust_remote_code=False
225
- )
226
- self.architecture = cfg.model_type
227
- self.seq2seq = cfg.model_type in SEQ2SEQ_TYPES or any(
228
- "ConditionalGeneration" in a or "Seq2Seq" in a
229
- for a in (getattr(cfg, "architectures", None) or [])
230
- )
231
- self.chunk_size = CHUNK_SEQ2SEQ if self.seq2seq else CHUNK_CTC
232
- self.decoder_type = "encoder-decoder" if self.seq2seq else "ctc"
233
-
234
- if self.seq2seq:
235
- self._load_seq2seq(model_id, revision)
236
- else:
237
- self._load_ctc(model_id, revision)
238
-
239
- self.model_params = sum(p.numel() for p in self.model.parameters())
240
-
241
- def _load_seq2seq(self, model_id, revision):
242
- from transformers import (
243
- AutoModelForSpeechSeq2Seq,
244
- AutoProcessor,
245
- pipeline,
246
- )
247
-
248
- self.processor = AutoProcessor.from_pretrained(
249
- model_id, revision=revision, trust_remote_code=False
250
- )
251
- self.model = AutoModelForSpeechSeq2Seq.from_pretrained(
252
- model_id, revision=revision, trust_remote_code=False,
253
- ).eval()
254
- self.pipe = pipeline(
255
- "automatic-speech-recognition",
256
- model=self.model,
257
- tokenizer=self.processor.tokenizer,
258
- feature_extractor=self.processor.feature_extractor,
259
- # Sans ce découpage, tout énoncé de plus de 30 s fait échouer
260
- # Whisper sur une incompatibilité de dimensions.
261
- chunk_length_s=30,
262
- )
263
-
264
- def _load_ctc(self, model_id, revision):
265
- from transformers import AutoModelForCTC, AutoProcessor
266
-
267
- self.processor = AutoProcessor.from_pretrained(
268
- model_id, revision=revision, trust_remote_code=False
269
- )
270
- self.model = AutoModelForCTC.from_pretrained(
271
- model_id, revision=revision, trust_remote_code=False,
272
- ).eval()
273
- self.pipe = None
274
-
275
- # MMS multilingue : les poids fongbe vivent dans un adaptateur séparé,
276
- # sans quoi le modèle transcrit dans une tout autre langue.
277
- try:
278
- if getattr(self.model.config, "adapter_attn_dim", None):
279
- self.model.load_adapter("fon")
280
- self.processor.tokenizer.set_target_lang("fon")
281
- log("adaptateur MMS « fon » chargé")
282
- except Exception as exc: # noqa: BLE001 — modèle déjà spécialisé
283
- log(f"pas d'adaptateur fon ({type(exc).__name__}) — poids tels quels")
284
-
285
- def transcribe(self, arrays: list) -> list[str]:
286
- """Transcrit une tranche. À n'appeler que depuis un créneau GPU."""
287
- torch = self.torch
288
- dev = "cuda" if torch.cuda.is_available() else "cpu"
289
- self.model.to(dev)
290
- if self.pipe is not None:
291
- self.pipe.device = torch.device(dev)
292
-
293
- if self.seq2seq:
294
- return [(self.pipe(a)["text"] or "").strip() for a in arrays]
295
-
296
- out: list[str] = []
297
- for i in range(0, len(arrays), BATCH_CTC):
298
- batch = arrays[i:i + BATCH_CTC]
299
- inputs = self.processor(
300
- batch, sampling_rate=16000, return_tensors="pt", padding=True
301
- )
302
- inputs = {k: v.to(dev) for k, v in inputs.items()}
303
- with torch.inference_mode():
304
- logits = self.model(**inputs).logits
305
- ids = torch.argmax(logits, dim=-1).cpu()
306
- out.extend(t.strip() for t in self.processor.batch_decode(ids))
307
- return out
308
-
309
- def hardware(self) -> str:
310
- try:
311
- if self.torch.cuda.is_available():
312
- return f"HF Space ZeroGPU ({self.torch.cuda.get_device_name(0)})"
313
- except Exception: # noqa: BLE001
314
- pass
315
- return "HF Space CPU"
316
-
317
-
318
- class FonasrTranscriber:
319
- """Système `fonasr` (liste blanche TRUSTED_RUNNERS), révision figée.
320
-
321
- Décodage complet : faisceau 4-grammes sur syllabes puis rescorage LSTM
322
- (lm=True, nlm=True). lm=True et non le défaut : sans le paquet `kenlm`, le
323
- défaut retomberait en silence sur du glouton, que le classement afficherait
324
- pourtant comme un système avec LM.
325
- """
326
-
327
- seq2seq = False
328
- architecture = "wav2vec2-bert"
329
- decoder_type = "ctc"
330
-
331
- def __init__(self, model_id: str, revision: str):
332
- # La garde d'abord, avant tout import ou téléchargement.
333
- spec = TRUSTED_RUNNERS[model_id]
334
- sha = revision.split(":")[0]
335
- if sha != spec["revision"]:
336
- raise RuntimeError("Révision non autorisée pour ce modèle.")
337
-
338
- import sys
339
- import tempfile
340
-
341
- import torch
342
- from huggingface_hub import snapshot_download
343
-
344
- self.torch = torch
345
- self.model_id = model_id
346
- self.chunk_size = spec["chunk"]
347
-
348
- # local_dir : de vrais fichiers. Dans le cache du Hub, __init__.py est
349
- # un lien vers blobs/, et fonasr retrouve ses poids via
350
- # Path(__file__).resolve() — il chercherait vocab.json au mauvais endroit.
351
- dossier = os.path.join(tempfile.gettempdir(), f"fonasr_{sha[:8]}")
352
- snapshot_download(
353
- model_id, revision=sha, token=_DATA_TOKEN, local_dir=dossier,
354
- allow_patterns=["fonasr/*"],
355
- ignore_patterns=["fonasr/tests/*", "fonasr/__pycache__/*"],
356
- )
357
- os.environ["FONASR_DIR"] = os.path.join(dossier, "fonasr")
358
- sys.path.insert(0, dossier)
359
- import fonasr
360
-
361
- self.fonasr = fonasr
362
- # Chargé ici, dans le processus principal : le créneau GPU est un
363
- # processus dérivé, tout ce qu'il chargerait lui-même serait perdu
364
- # d'une tranche à l'autre. Mais ce thread n'a pas l'émulation CUDA de
365
- # ZeroGPU : fonasr choisit « cuda » dès que torch.cuda.is_available()
366
- # et y déplace le modèle, ce qui lève. On masque donc la disponibilité
367
- # le temps du chargement (modèle sur CPU) ; le passage sur GPU se fait
368
- # dans transcribe(), à l'intérieur du créneau.
369
- disponible = torch.cuda.is_available
370
- torch.cuda.is_available = lambda: False
371
- try:
372
- modele = fonasr._load()[0]
373
- finally:
374
- torch.cuda.is_available = disponible
375
- self.model_params = sum(p.numel() for p in modele.parameters())
376
- log(f"fonasr {fonasr.__version__} chargé ({self.model_params / 1e6:.0f} M)")
377
-
378
- def transcribe(self, arrays: list) -> list[str]:
379
- """Une tranche, depuis un créneau GPU."""
380
- modele, fe, units, neww, dev = self.fonasr._state
381
- if self.torch.cuda.is_available() and dev != "cuda":
382
- self.fonasr._state = (modele.to("cuda"), fe, units, neww, "cuda")
383
- return [self.fonasr.transcribe(a, lm=True, nlm=True) for a in arrays]
384
-
385
- def hardware(self) -> str:
386
- try:
387
- if self.torch.cuda.is_available():
388
- return f"HF Space ZeroGPU ({self.torch.cuda.get_device_name(0)})"
389
- except Exception: # noqa: BLE001
390
- pass
391
- return "HF Space CPU"
392
-
393
-
394
- # Le modèle reste une variable de module : ZeroGPU exécute la fonction
395
- # décorée dans un processus dérivé, qui hérite de la mémoire sans rien
396
- # sérialiser. Le passer en argument recopierait tout le modèle à chaque appel.
397
- _CURRENT: Transcriber | None = None
398
-
399
-
400
- @GPU(duration=GPU_DURATION)
401
- def gpu_transcribe(arrays: list) -> list[str]:
402
- return _CURRENT.transcribe(arrays)
403
-
404
-
405
- def _is_quota_error(exc: Exception) -> bool:
406
- """Le quota ZeroGPU est-il épuisé (par opposition à une vraie panne) ?"""
407
- texte = f"{type(exc).__name__} {exc}".lower()
408
- return any(m in texte for m in ("quota", "gpu task aborted",
409
- "no gpu is currently available",
410
- "exceeded your"))
411
-
412
-
413
- # --- une évaluation -----------------------------------------------------
414
-
415
- def check_model(model_id: str) -> str:
416
- """Révision figée du modèle. Lève si le dépôt est inutilisable."""
417
- from huggingface_hub import HfApi
418
-
419
- confiance = TRUSTED_RUNNERS.get(model_id)
420
- if confiance:
421
- # Dépôt privé par décision de ses propriétaires : on ne le sonde pas,
422
- # la révision exécutable est celle qui a été relue, pas la dernière.
423
- return f"{confiance['revision']}:{confiance['tag']}"
424
-
425
- # Sans token : un modèle soumis doit être public pour être vérifiable.
426
- api = HfApi(token=False)
427
- try:
428
- info = api.model_info(model_id, files_metadata=False)
429
- except Exception: # noqa: BLE001
430
- raise RuntimeError("Modèle introuvable ou privé sur le Hub.")
431
-
432
- # On accepte aussi les poids `.bin` : la moitié des modèles fongbe
433
- # publics datent d'avant safetensors, les exclure viderait le classement.
434
- # Le risque du pickle est couvert ailleurs — transformers charge les .bin
435
- # avec `weights_only=True` (donc sans exécution de code) depuis la 4.36,
436
- # et `trust_remote_code=False` bloque le code personnalisé du dépôt.
437
- names = [s.rfilename for s in (info.siblings or [])]
438
- if not any(n.endswith((".safetensors", ".bin", ".ckpt", ".pt"))
439
- for n in names):
440
- raise RuntimeError(
441
- "Aucun fichier de poids trouvé dans le dépôt "
442
- "(.safetensors ou .bin attendu)."
443
- )
444
- return info.sha
445
-
446
-
447
- def run_job(job: dict) -> None:
448
- request, bench = job["request"], job["benchmark"]
449
- rid, model_id = request["id"], request["model_id"]
450
- _state.update(model=model_id, message="préparation", done=0, total=0)
451
- log(f"▶ {model_id} sur {bench['id']}")
452
-
453
- revision = check_model(model_id)
454
-
455
- # Corpus d'abord : le token de lecture ne doit jamais coexister avec un
456
- # modèle inconnu déjà chargé en mémoire.
457
- ds = load_corpus(bench)
458
- total = len(ds)
459
-
460
- verdict = rpc("space_begin", {
461
- "p_token": SPACE_TOKEN, "p_id": rid,
462
- "p_revision": revision, "p_total": total,
463
- })
464
- if verdict == "duplicate":
465
- log(f"↷ {model_id} : déjà évalué à cette révision")
466
- _state.update(message="doublon ignoré", model=None)
467
- return
468
-
469
- global _CURRENT
470
- tr = _CURRENT = (FonasrTranscriber(model_id, revision)
471
- if model_id in TRUSTED_RUNNERS
472
- else Transcriber(model_id, revision))
473
- chunk = tr.chunk_size
474
-
475
- saved = job.get("progress") or {}
476
- counters = saved.get("counters") or fe.new_counters()
477
- counters = {k: int(counters.get(k, 0)) for k in fe.COUNTER_KEYS}
478
- next_chunk = int(saved.get("next_chunk") or 0)
479
- audio_s = float(saved.get("audio_seconds") or 0.0)
480
- compute_s = float(saved.get("compute_seconds") or 0.0)
481
- if next_chunk:
482
- log(f"↻ reprise à la tranche {next_chunk} ({next_chunk * chunk} énoncés)")
483
-
484
- # Chaque tranche tient dans un créneau GPU et vaut battement de cœur :
485
- # elle reste bien en deçà des 20 min au-delà desquelles la base
486
- # considère la tâche abandonnée et la redonne.
487
- n_chunks = (total + chunk - 1) // chunk
488
- skipped = 0
489
-
490
- ci = next_chunk
491
- while ci < n_chunks:
492
- rows = ds.select(range(ci * chunk, min((ci + 1) * chunk, total)))
493
- arrays, refs = [], []
494
- for row in rows:
495
- try:
496
- arrays.append(decode_audio(row["audio"]))
497
- refs.append(row["transcription"])
498
- except Exception: # noqa: BLE001 — énoncé illisible : pas la faute du modèle
499
- skipped += 1
500
-
501
- if arrays:
502
- t0 = time.time()
503
- try:
504
- hyps = gpu_transcribe(arrays)
505
- except Exception as exc: # noqa: BLE001
506
- if not _is_quota_error(exc):
507
- raise
508
- # Quota ZeroGPU épuisé : on patiente et on refait la même
509
- # tranche. Le point de reprise est déjà en base, rien n'est
510
- # perdu — c'est précisément ce que le découpage permet.
511
- log(f"⏸ quota GPU épuisé ({exc}) — reprise dans "
512
- f"{QUOTA_WAIT_SECONDS // 60} min à la tranche {ci}")
513
- _state.update(message="quota GPU épuisé — en attente")
514
- time.sleep(QUOTA_WAIT_SECONDS)
515
- continue
516
- compute_s += time.time() - t0
517
- audio_s += sum(len(a) for a in arrays) / 16000.0
518
- fe.accumulate(counters, refs, hyps)
519
-
520
- done = min((ci + 1) * chunk, total)
521
- _state.update(message="évaluation", done=done, total=total)
522
- rpc("space_checkpoint", {
523
- "p_token": SPACE_TOKEN, "p_id": rid, "p_next_chunk": ci + 1,
524
- "p_done": done, "p_counters": counters,
525
- "p_audio": round(audio_s, 2), "p_compute": round(compute_s, 2),
526
- })
527
- rtfx = audio_s / compute_s if compute_s else 0
528
- log(f" {done}/{total} — RTFx {rtfx:.2f}")
529
- ci += 1
530
-
531
- if skipped:
532
- log(f"⚠ {skipped} énoncés illisibles écartés")
533
- if not counters["n_scored"]:
534
- raise RuntimeError("Aucune transcription exploitable produite.")
535
-
536
- metrics = fe.finalize(counters)
537
- rtfx = round(audio_s / compute_s, 3) if compute_s else None
538
- rpc("space_finish", {
539
- "p_token": SPACE_TOKEN, "p_id": rid, "p_status": "done",
540
- "p_metrics": {k: metrics[k] for k in (
541
- "wer", "cer", "mer", "wil", "wer_seg", "cer_seg", "wer_ton", "twer"
542
- )},
543
- "p_meta": {
544
- "architecture": tr.architecture,
545
- "decoder_type": tr.decoder_type,
546
- "model_params": tr.model_params,
547
- "rtfx": rtfx,
548
- "rtf": round(1 / rtfx, 4) if rtfx else None,
549
- "eval_seconds": round(compute_s, 1),
550
- "hardware": tr.hardware(),
551
- "worker_version": WORKER_VERSION,
552
- },
553
- "p_error": None,
554
- })
555
- log(f"✔ {model_id} — WER_seg {metrics['wer_seg']:.1%} "
556
- f"T-WER {metrics['twer']} RTFx {rtfx}")
557
- _state.update(message="terminé", model=None, done=0, total=0)
558
-
559
-
560
- def loop() -> None:
561
- if not SPACE_TOKEN:
562
- _state["message"] = "SPACE_TOKEN absent — évaluateur à l'arrêt"
563
- log("SPACE_TOKEN absent : aucune évaluation ne sera lancée.")
564
- return
565
- if not _DATA_TOKEN:
566
- _state["message"] = "HF_DATA_TOKEN absent — évaluateur à l'arrêt"
567
- log("HF_DATA_TOKEN absent : le corpus privé est illisible.")
568
- return
569
-
570
- # Un token de lecture mal choisi ne se voit qu'au premier échec, après
571
- # qu'une demande a déjà été prise et perdue : autant le dire au démarrage.
572
- try:
573
- from huggingface_hub import HfApi, whoami
574
-
575
- who = whoami(token=_DATA_TOKEN)
576
- role = (who.get("auth") or {}).get("accessToken", {}).get("role")
577
- orgs = [o["name"] for o in who.get("orgs", [])]
578
- log(f"token de lecture : compte « {who['name']} », portée « {role} », "
579
- f"orgs {orgs or '(aucune)'}")
580
-
581
- # Appartenir à l'organisation ne suffit pas : un token fine-grained
582
- # ne voit que les dépôts explicitement cochés. On teste l'accès réel.
583
- api = HfApi(token=_DATA_TOKEN)
584
- for b in requests.get(
585
- f"{REST}/benchmarks", headers=HEADERS,
586
- params={"is_active": "eq.true", "select": "id,hf_dataset_id"},
587
- timeout=30,
588
- ).json():
589
- try:
590
- api.dataset_info(b["hf_dataset_id"])
591
- log(f" ✓ {b['hf_dataset_id']} lisible")
592
- except Exception as exc: # noqa: BLE001
593
- log(f" ✗ {b['hf_dataset_id']} INACCESSIBLE "
594
- f"({type(exc).__name__}) — corriger HF_DATA_TOKEN : il "
595
- f"faut un token « read », ou un token fine-grained avec "
596
- f"« Read access to contents » cochée sur ce dépôt.")
597
- except Exception as exc: # noqa: BLE001
598
- log(f"⚠ HF_DATA_TOKEN refusé par le Hub : {type(exc).__name__}")
599
-
600
- log("évaluateur démarré")
601
- while True:
602
- job = None
603
- try:
604
- job = rpc("space_claim", {"p_token": SPACE_TOKEN})
605
- except Exception as exc: # noqa: BLE001 — réseau : on réessaie
606
- log(f"⚠ space_claim : {exc}")
607
-
608
- if not job:
609
- _state.update(message="aucune tâche en attente", model=None)
610
- time.sleep(POLL_SECONDS)
611
- continue
612
-
613
- try:
614
- run_job(job)
615
- except Exception as exc: # noqa: BLE001 — tout échec doit être publié
616
- log(f"✘ échec : {exc}")
617
- traceback.print_exc()
618
- _state.update(message=f"échec : {exc}", model=None)
619
- try:
620
- rpc("space_finish", {
621
- "p_token": SPACE_TOKEN, "p_id": job["request"]["id"],
622
- "p_status": "failed", "p_metrics": None, "p_meta": None,
623
- # Message court et sans extrait du corpus.
624
- "p_error": f"{type(exc).__name__}: {exc}"[:300],
625
- })
626
- except Exception: # noqa: BLE001
627
- traceback.print_exc()
628
- finally:
629
- import gc
630
-
631
- gc.collect()
632
-
633
-
634
- _thread: threading.Thread | None = None
635
-
636
-
637
- def start() -> threading.Thread:
638
- """Démarre l'évaluateur. Sans effet s'il tourne déjà.
639
-
640
- Le lanceur du Space peut importer app.py plus d'une fois : sans cette
641
- garde, deux threads se disputeraient la même tâche.
642
- """
643
- global _thread
644
- if _thread is None or not _thread.is_alive():
645
- _thread = threading.Thread(target=loop, name="fonbench-eval",
646
- daemon=True)
647
- _thread.start()
648
- return _thread
 
 
 
 
 
 
1
+ """FonBench — évaluateur de fond du Space.
2
+
3
+ Un unique thread démon : il prend une demande dans la file Supabase,
4
+ charge le corpus de test PRIVÉ, transcrit par tranches et republie les
5
+ scores. Aucun GPU : tout se fait sur le CPU du Space.
6
+
7
+ Trois précautions valent d'être expliquées, parce qu'elles dictent la
8
+ structure du fichier.
9
+
10
+ 1. Le corpus est chargé AVANT le modèle, et le token qui y donne accès est
11
+ retiré de l'environnement dès l'import. Le Space charge des modèles
12
+ écrits par des inconnus : aucun ne doit tourner dans un processus où
13
+ traîne encore de quoi lire le corpus. S'y ajoute `trust_remote_code=False`
14
+ sans exception — c'est ce qui empêche le code personnalisé d'un dépôt de
15
+ s'exécuter.
16
+
17
+ 2. On reprend après redémarrage. Un Space gratuit redémarre souvent et une
18
+ évaluation dure des heures : sans reprise, les gros modèles ne
19
+ finiraient jamais. Ce qu'on sauvegarde à chaque tranche, ce sont des
20
+ COMPTEURS d'erreurs, jamais des transcriptions — sinon on recopierait le
21
+ corpus privé dans la base. La somme des compteurs redonne exactement les
22
+ mêmes scores (voir fonbench_eval).
23
+
24
+ 3. Le Space n'a pas la clé service de la base. Il écrit via quatre
25
+ procédures protégées par un jeton dédié : au pire, un jeton volé permet
26
+ de polluer la file publique, pas de toucher aux comptes ni aux scores.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import os
32
+ import threading
33
+ import time
34
+ import traceback
35
+
36
+ import requests
37
+
38
+ import fonbench_eval as fe
39
+
40
+ # ZeroGPU : le GPU n'est attaché que le temps d'une fonction décorée. En
41
+ # dehors d'un Space ZeroGPU (tests locaux), le décorateur devient neutre.
42
+ try:
43
+ import spaces
44
+
45
+ GPU = spaces.GPU
46
+ ON_ZEROGPU = True
47
+ except Exception: # noqa: BLE001
48
+ ON_ZEROGPU = False
49
+
50
+ def GPU(*args, **kwargs): # noqa: D103
51
+ def deco(fn):
52
+ return fn
53
+ return deco(args[0]) if args and callable(args[0]) else deco
54
+
55
+ # --- configuration ------------------------------------------------------
56
+
57
+ def env(name: str, default: str = "") -> str:
58
+ """Variable d'environnement nettoyée.
59
+
60
+ Un secret collé dans l'interface du Space embarque volontiers un retour
61
+ à la ligne, ce qui rend l'en-tête HTTP invalide et fait échouer tous les
62
+ appels — sans indice évident dans les logs. On tranche à la source.
63
+ """
64
+ return (os.environ.get(name) or default).strip()
65
+
66
+
67
+ SUPABASE_URL = env(
68
+ "SUPABASE_URL", "https://cqdimvcnmhrsdcoobkmd.supabase.co"
69
+ ).rstrip("/")
70
+ # Publique par conception : la Row Level Security fait le tri, pas le secret.
71
+ ANON_KEY = env(
72
+ "SUPABASE_ANON_KEY", "sb_publishable_MapYll-_Y0hNoLYOfaDR3w_tsTEHyiz"
73
+ )
74
+ SPACE_TOKEN = env("SPACE_TOKEN")
75
+
76
+ # Sorti de l'environnement immédiatement : un modèle inconnu chargé plus
77
+ # tard ne doit pas pouvoir le lire dans os.environ.
78
+ _DATA_TOKEN = (os.environ.pop("HF_DATA_TOKEN", None)
79
+ or os.environ.pop("HF_TOKEN", None) or "").strip() or None
80
+
81
+ REST = f"{SUPABASE_URL}/rest/v1"
82
+ HEADERS = {"apikey": ANON_KEY, "Content-Type": "application/json"}
83
+
84
+ WORKER_VERSION = "space-1.0"
85
+ POLL_SECONDS = 60
86
+
87
+ # Une tranche = un créneau GPU. ZeroGPU plafonne la durée d'un créneau, et
88
+ # plus on en demande, plus on attend : on vise ~10 s de calcul réel.
89
+ # Sur A10G, un CTC tourne autour de 100× temps réel (100 énoncés ≈ 700 s
90
+ # d'audio ≈ 7 s), un Whisper autour de 5× (20 énoncés ≈ 140 s ≈ 30 s).
91
+ GPU_DURATION = 120
92
+ CHUNK_CTC = 100
93
+ CHUNK_SEQ2SEQ = 20
94
+ BATCH_CTC = 8
95
+ # Quota journalier épuisé : on attend au lieu d'abandonner. Le point de
96
+ # reprise est déjà enregistré, rien n'est perdu.
97
+ QUOTA_WAIT_SECONDS = 1800
98
+
99
+ # Architectures autorégressives : pipeline + découpage à 30 s, sinon les
100
+ # énoncés longs font échouer Whisper.
101
+ SEQ2SEQ_TYPES = {
102
+ "whisper", "speech_to_text", "speech-encoder-decoder",
103
+ "speech_encoder_decoder", "seamless_m4t", "seamless_m4t_v2",
104
+ }
105
+
106
+ # EXCEPTION AU « AUCUN CODE DE DÉPÔT » — liste blanche explicite.
107
+ # Un modèle dont la tête CTC prédit des syllabes ne se décode pas avec les
108
+ # classes transformers standard : il faut son propre paquet. Ce paquet n'est
109
+ # exécuté QUE pour les dépôts listés ici, et QU'À LA RÉVISION figée ci-dessous
110
+ # (code relu avant l'ajout) : une modification ultérieure du dépôt n'est
111
+ # jamais exécutée. Aucun autre modèle ne passe par ce chemin.
112
+ TRUSTED_RUNNERS = {
113
+ "asrDIL/fonglab-asr-w2vbert": {
114
+ "revision": "0550598179ed0535ca2c6802a8bfcb09a940d580", # fonasr 0.8.0
115
+ "tag": "kenlm-a0.3-b1.0-nlm", # affiché : le système complet, LM + rescorage
116
+ "chunk": 50, # le décodage avec LM tourne sur CPU dans le même créneau
117
+ },
118
+ }
119
+
120
+ _state = {"message": "démarrage…", "model": None, "done": 0, "total": 0}
121
+
122
+
123
+ def log(msg: str) -> None:
124
+ print(f"[evaluator] {msg}", flush=True)
125
+
126
+
127
+ def status() -> dict:
128
+ return dict(_state)
129
+
130
+
131
+ # --- accès à la file ----------------------------------------------------
132
+
133
+ def rpc(name: str, payload: dict):
134
+ r = requests.post(
135
+ f"{REST}/rpc/{name}", headers=HEADERS, json=payload, timeout=60
136
+ )
137
+ if r.status_code >= 400:
138
+ raise RuntimeError(f"{name}: {r.status_code} {r.text[:200]}")
139
+ return r.json() if r.text.strip() else None
140
+
141
+
142
+ # --- audio --------------------------------------------------------------
143
+
144
+ def decode_audio(cell, target_sr: int = 16000):
145
+ """Décode un audio (WAV/FLAC/OGG/Opus/WebM/MP3…) en mono float32 16 kHz.
146
+
147
+ PyAV plutôt que soundfile : le corpus contient des conteneurs que
148
+ libsndfile ne sait pas ouvrir (WebM, Opus) et des fichiers dont
149
+ l'en-tête le fait échouer (« array is too big »).
150
+ """
151
+ import io
152
+
153
+ import av
154
+ import numpy as np
155
+
156
+ raw = cell["bytes"] if isinstance(cell, dict) else cell
157
+ if raw is None and isinstance(cell, dict) and cell.get("path"):
158
+ with open(cell["path"], "rb") as f:
159
+ raw = f.read()
160
+
161
+ with av.open(io.BytesIO(raw)) as container:
162
+ stream = container.streams.audio[0]
163
+ resampler = av.audio.resampler.AudioResampler(
164
+ format="flt", layout="mono", rate=target_sr
165
+ )
166
+ chunks: list = []
167
+
168
+ def _emit(frame):
169
+ res = resampler.resample(frame)
170
+ for rf in res if isinstance(res, list) else ([res] if res else []):
171
+ chunks.append(rf.to_ndarray().reshape(-1))
172
+
173
+ for frame in container.decode(stream):
174
+ _emit(frame)
175
+ _emit(None) # flush du resampler
176
+
177
+ if not chunks:
178
+ return np.zeros(1, dtype="float32")
179
+ return np.concatenate(chunks).astype("float32")
180
+
181
+
182
+ # --- corpus -------------------------------------------------------------
183
+
184
+ _dataset_cache: dict = {}
185
+
186
+
187
+ def load_corpus(bench: dict):
188
+ """Charge le corpus de test. Gardé en mémoire entre deux évaluations."""
189
+ from datasets import Audio, load_dataset
190
+
191
+ # La taille de l'échantillon fait partie de la clé : deux benchmarks sur le
192
+ # même dépôt (l'un complet, l'autre tiré à N clips) ne doivent pas se
193
+ # partager un corpus en cache, sans quoi l'évaluation complète tournerait
194
+ # sur l'échantillon et enregistrerait ce score comme celui du test entier.
195
+ key = (bench["hf_dataset_id"], bench.get("hf_revision"), bench["hf_split"],
196
+ bench.get("sample_size"), bench.get("sample_seed"))
197
+ if key in _dataset_cache:
198
+ return _dataset_cache[key]
199
+
200
+ ds = load_dataset(
201
+ bench["hf_dataset_id"],
202
+ split=bench["hf_split"],
203
+ revision=bench.get("hf_revision") or None,
204
+ token=_DATA_TOKEN,
205
+ )
206
+ if bench.get("sample_size"):
207
+ ds = ds.shuffle(seed=bench.get("sample_seed") or 42)
208
+ ds = ds.select(range(min(bench["sample_size"], len(ds))))
209
+ ds = ds.cast_column("audio", Audio(decode=False))
210
+ _dataset_cache.clear() # 16 Go de RAM : un seul corpus à la fois
211
+ _dataset_cache[key] = ds
212
+ return ds
213
+
214
+
215
+ # --- modèle -------------------------------------------------------------
216
+
217
+ class Transcriber:
218
+ """Charge un modèle du Hub et transcrit des tableaux 16 kHz."""
219
+
220
+ def __init__(self, model_id: str, revision: str):
221
+ import torch
222
+ from transformers import AutoConfig
223
+
224
+ torch.set_num_threads(max(1, (os.cpu_count() or 2)))
225
+ self.torch = torch
226
+ self.model_id = model_id
227
+
228
+ cfg = AutoConfig.from_pretrained(
229
+ model_id, revision=revision, trust_remote_code=False
230
+ )
231
+ self.architecture = cfg.model_type
232
+ self.seq2seq = cfg.model_type in SEQ2SEQ_TYPES or any(
233
+ "ConditionalGeneration" in a or "Seq2Seq" in a
234
+ for a in (getattr(cfg, "architectures", None) or [])
235
+ )
236
+ self.chunk_size = CHUNK_SEQ2SEQ if self.seq2seq else CHUNK_CTC
237
+ self.decoder_type = "encoder-decoder" if self.seq2seq else "ctc"
238
+
239
+ if self.seq2seq:
240
+ self._load_seq2seq(model_id, revision)
241
+ else:
242
+ self._load_ctc(model_id, revision)
243
+
244
+ self.model_params = sum(p.numel() for p in self.model.parameters())
245
+
246
+ def _load_seq2seq(self, model_id, revision):
247
+ from transformers import (
248
+ AutoModelForSpeechSeq2Seq,
249
+ AutoProcessor,
250
+ pipeline,
251
+ )
252
+
253
+ self.processor = AutoProcessor.from_pretrained(
254
+ model_id, revision=revision, trust_remote_code=False
255
+ )
256
+ self.model = AutoModelForSpeechSeq2Seq.from_pretrained(
257
+ model_id, revision=revision, trust_remote_code=False,
258
+ ).eval()
259
+ self.pipe = pipeline(
260
+ "automatic-speech-recognition",
261
+ model=self.model,
262
+ tokenizer=self.processor.tokenizer,
263
+ feature_extractor=self.processor.feature_extractor,
264
+ # Sans ce découpage, tout énoncé de plus de 30 s fait échouer
265
+ # Whisper sur une incompatibilité de dimensions.
266
+ chunk_length_s=30,
267
+ )
268
+
269
+ def _load_ctc(self, model_id, revision):
270
+ from transformers import AutoModelForCTC, AutoProcessor
271
+
272
+ self.processor = AutoProcessor.from_pretrained(
273
+ model_id, revision=revision, trust_remote_code=False
274
+ )
275
+ self.model = AutoModelForCTC.from_pretrained(
276
+ model_id, revision=revision, trust_remote_code=False,
277
+ ).eval()
278
+ self.pipe = None
279
+
280
+ # MMS multilingue : les poids fongbe vivent dans un adaptateur séparé,
281
+ # sans quoi le modèle transcrit dans une tout autre langue.
282
+ try:
283
+ if getattr(self.model.config, "adapter_attn_dim", None):
284
+ self.model.load_adapter("fon")
285
+ self.processor.tokenizer.set_target_lang("fon")
286
+ log("adaptateur MMS « fon » chargé")
287
+ except Exception as exc: # noqa: BLE001 — modèle déjà spécialisé
288
+ log(f"pas d'adaptateur fon ({type(exc).__name__}) — poids tels quels")
289
+
290
+ def transcribe(self, arrays: list) -> list[str]:
291
+ """Transcrit une tranche. À n'appeler que depuis un créneau GPU."""
292
+ torch = self.torch
293
+ dev = "cuda" if torch.cuda.is_available() else "cpu"
294
+ self.model.to(dev)
295
+ if self.pipe is not None:
296
+ self.pipe.device = torch.device(dev)
297
+
298
+ if self.seq2seq:
299
+ return [(self.pipe(a)["text"] or "").strip() for a in arrays]
300
+
301
+ out: list[str] = []
302
+ for i in range(0, len(arrays), BATCH_CTC):
303
+ batch = arrays[i:i + BATCH_CTC]
304
+ inputs = self.processor(
305
+ batch, sampling_rate=16000, return_tensors="pt", padding=True
306
+ )
307
+ inputs = {k: v.to(dev) for k, v in inputs.items()}
308
+ with torch.inference_mode():
309
+ logits = self.model(**inputs).logits
310
+ ids = torch.argmax(logits, dim=-1).cpu()
311
+ out.extend(t.strip() for t in self.processor.batch_decode(ids))
312
+ return out
313
+
314
+ def hardware(self) -> str:
315
+ try:
316
+ if self.torch.cuda.is_available():
317
+ return f"HF Space ZeroGPU ({self.torch.cuda.get_device_name(0)})"
318
+ except Exception: # noqa: BLE001
319
+ pass
320
+ return "HF Space CPU"
321
+
322
+
323
+ class FonasrTranscriber:
324
+ """Système `fonasr` (liste blanche TRUSTED_RUNNERS), révision figée.
325
+
326
+ Décodage complet : faisceau 4-grammes sur syllabes puis rescorage LSTM
327
+ (lm=True, nlm=True). lm=True et non le défaut : sans le paquet `kenlm`, le
328
+ défaut retomberait en silence sur du glouton, que le classement afficherait
329
+ pourtant comme un système avec LM.
330
+ """
331
+
332
+ seq2seq = False
333
+ architecture = "wav2vec2-bert"
334
+ decoder_type = "ctc"
335
+
336
+ def __init__(self, model_id: str, revision: str):
337
+ # La garde d'abord, avant tout import ou téléchargement.
338
+ spec = TRUSTED_RUNNERS[model_id]
339
+ sha = revision.split(":")[0]
340
+ if sha != spec["revision"]:
341
+ raise RuntimeError("Révision non autorisée pour ce modèle.")
342
+
343
+ import sys
344
+ import tempfile
345
+
346
+ import torch
347
+ from huggingface_hub import snapshot_download
348
+
349
+ self.torch = torch
350
+ self.model_id = model_id
351
+ self.chunk_size = spec["chunk"]
352
+
353
+ # local_dir : de vrais fichiers. Dans le cache du Hub, __init__.py est
354
+ # un lien vers blobs/, et fonasr retrouve ses poids via
355
+ # Path(__file__).resolve() — il chercherait vocab.json au mauvais endroit.
356
+ dossier = os.path.join(tempfile.gettempdir(), f"fonasr_{sha[:8]}")
357
+ snapshot_download(
358
+ model_id, revision=sha, token=_DATA_TOKEN, local_dir=dossier,
359
+ allow_patterns=["fonasr/*"],
360
+ ignore_patterns=["fonasr/tests/*", "fonasr/__pycache__/*"],
361
+ )
362
+ os.environ["FONASR_DIR"] = os.path.join(dossier, "fonasr")
363
+ sys.path.insert(0, dossier)
364
+ import fonasr
365
+
366
+ self.fonasr = fonasr
367
+ # Chargé ici, dans le processus principal : le créneau GPU est un
368
+ # processus dérivé, tout ce qu'il chargerait lui-même serait perdu
369
+ # d'une tranche à l'autre. Mais ce thread n'a pas l'émulation CUDA de
370
+ # ZeroGPU : fonasr choisit « cuda » dès que torch.cuda.is_available()
371
+ # et y déplace le modèle, ce qui lève. On masque donc la disponibilité
372
+ # le temps du chargement (modèle sur CPU) ; le passage sur GPU se fait
373
+ # dans transcribe(), à l'intérieur du créneau.
374
+ disponible = torch.cuda.is_available
375
+ torch.cuda.is_available = lambda: False
376
+ try:
377
+ modele = fonasr._load()[0]
378
+ finally:
379
+ torch.cuda.is_available = disponible
380
+ self.model_params = sum(p.numel() for p in modele.parameters())
381
+ log(f"fonasr {fonasr.__version__} chargé ({self.model_params / 1e6:.0f} M)")
382
+
383
+ def transcribe(self, arrays: list) -> list[str]:
384
+ """Une tranche, depuis un créneau GPU."""
385
+ modele, fe, units, neww, dev = self.fonasr._state
386
+ if self.torch.cuda.is_available() and dev != "cuda":
387
+ self.fonasr._state = (modele.to("cuda"), fe, units, neww, "cuda")
388
+ return [self.fonasr.transcribe(a, lm=True, nlm=True) for a in arrays]
389
+
390
+ def hardware(self) -> str:
391
+ try:
392
+ if self.torch.cuda.is_available():
393
+ return f"HF Space ZeroGPU ({self.torch.cuda.get_device_name(0)})"
394
+ except Exception: # noqa: BLE001
395
+ pass
396
+ return "HF Space CPU"
397
+
398
+
399
+ # Le modèle reste une variable de module : ZeroGPU exécute la fonction
400
+ # décorée dans un processus dérivé, qui hérite de la mémoire sans rien
401
+ # sérialiser. Le passer en argument recopierait tout le modèle à chaque appel.
402
+ _CURRENT: Transcriber | None = None
403
+
404
+
405
+ @GPU(duration=GPU_DURATION)
406
+ def gpu_transcribe(arrays: list) -> list[str]:
407
+ return _CURRENT.transcribe(arrays)
408
+
409
+
410
+ def _is_quota_error(exc: Exception) -> bool:
411
+ """Le quota ZeroGPU est-il épuisé (par opposition à une vraie panne) ?"""
412
+ texte = f"{type(exc).__name__} {exc}".lower()
413
+ return any(m in texte for m in ("quota", "gpu task aborted",
414
+ "no gpu is currently available",
415
+ "exceeded your"))
416
+
417
+
418
+ # --- une évaluation -----------------------------------------------------
419
+
420
+ def check_model(model_id: str) -> str:
421
+ """Révision figée du modèle. Lève si le dépôt est inutilisable."""
422
+ from huggingface_hub import HfApi
423
+
424
+ confiance = TRUSTED_RUNNERS.get(model_id)
425
+ if confiance:
426
+ # Dépôt privé par décision de ses propriétaires : on ne le sonde pas,
427
+ # la révision exécutable est celle qui a été relue, pas la dernière.
428
+ return f"{confiance['revision']}:{confiance['tag']}"
429
+
430
+ # Sans token : un modèle soumis doit être public pour être vérifiable.
431
+ api = HfApi(token=False)
432
+ try:
433
+ info = api.model_info(model_id, files_metadata=False)
434
+ except Exception: # noqa: BLE001
435
+ raise RuntimeError("Modèle introuvable ou privé sur le Hub.")
436
+
437
+ # On accepte aussi les poids `.bin` : la moitié des modèles fongbe
438
+ # publics datent d'avant safetensors, les exclure viderait le classement.
439
+ # Le risque du pickle est couvert ailleurs — transformers charge les .bin
440
+ # avec `weights_only=True` (donc sans exécution de code) depuis la 4.36,
441
+ # et `trust_remote_code=False` bloque le code personnalisé du dépôt.
442
+ names = [s.rfilename for s in (info.siblings or [])]
443
+ if not any(n.endswith((".safetensors", ".bin", ".ckpt", ".pt"))
444
+ for n in names):
445
+ raise RuntimeError(
446
+ "Aucun fichier de poids trouvé dans le dépôt "
447
+ "(.safetensors ou .bin attendu)."
448
+ )
449
+ return info.sha
450
+
451
+
452
+ def run_job(job: dict) -> None:
453
+ request, bench = job["request"], job["benchmark"]
454
+ rid, model_id = request["id"], request["model_id"]
455
+ _state.update(model=model_id, message="préparation", done=0, total=0)
456
+ log(f"▶ {model_id} sur {bench['id']}")
457
+
458
+ revision = check_model(model_id)
459
+
460
+ # Corpus d'abord : le token de lecture ne doit jamais coexister avec un
461
+ # modèle inconnu déjà chargé en mémoire.
462
+ ds = load_corpus(bench)
463
+ total = len(ds)
464
+
465
+ verdict = rpc("space_begin", {
466
+ "p_token": SPACE_TOKEN, "p_id": rid,
467
+ "p_revision": revision, "p_total": total,
468
+ })
469
+ if verdict == "duplicate":
470
+ log(f"↷ {model_id} : déjà évalué à cette révision")
471
+ _state.update(message="doublon ignoré", model=None)
472
+ return
473
+
474
+ global _CURRENT
475
+ tr = _CURRENT = (FonasrTranscriber(model_id, revision)
476
+ if model_id in TRUSTED_RUNNERS
477
+ else Transcriber(model_id, revision))
478
+ chunk = tr.chunk_size
479
+
480
+ saved = job.get("progress") or {}
481
+ counters = saved.get("counters") or fe.new_counters()
482
+ counters = {k: int(counters.get(k, 0)) for k in fe.COUNTER_KEYS}
483
+ next_chunk = int(saved.get("next_chunk") or 0)
484
+ audio_s = float(saved.get("audio_seconds") or 0.0)
485
+ compute_s = float(saved.get("compute_seconds") or 0.0)
486
+ if next_chunk:
487
+ log(f"↻ reprise à la tranche {next_chunk} ({next_chunk * chunk} énoncés)")
488
+
489
+ # Chaque tranche tient dans un créneau GPU et vaut battement de cœur :
490
+ # elle reste bien en deçà des 20 min au-delà desquelles la base
491
+ # considère la tâche abandonnée et la redonne.
492
+ n_chunks = (total + chunk - 1) // chunk
493
+ skipped = 0
494
+
495
+ ci = next_chunk
496
+ while ci < n_chunks:
497
+ rows = ds.select(range(ci * chunk, min((ci + 1) * chunk, total)))
498
+ arrays, refs = [], []
499
+ for row in rows:
500
+ try:
501
+ arrays.append(decode_audio(row["audio"]))
502
+ refs.append(row["transcription"])
503
+ except Exception: # noqa: BLE001 — énoncé illisible : pas la faute du modèle
504
+ skipped += 1
505
+
506
+ if arrays:
507
+ t0 = time.time()
508
+ try:
509
+ hyps = gpu_transcribe(arrays)
510
+ except Exception as exc: # noqa: BLE001
511
+ if not _is_quota_error(exc):
512
+ raise
513
+ # Quota ZeroGPU épuisé : on patiente et on refait la même
514
+ # tranche. Le point de reprise est déjà en base, rien n'est
515
+ # perdu — c'est précisément ce que le découpage permet.
516
+ log(f"⏸ quota GPU épuisé ({exc}) — reprise dans "
517
+ f"{QUOTA_WAIT_SECONDS // 60} min à la tranche {ci}")
518
+ _state.update(message="quota GPU épuisé — en attente")
519
+ time.sleep(QUOTA_WAIT_SECONDS)
520
+ continue
521
+ compute_s += time.time() - t0
522
+ audio_s += sum(len(a) for a in arrays) / 16000.0
523
+ fe.accumulate(counters, refs, hyps)
524
+
525
+ done = min((ci + 1) * chunk, total)
526
+ _state.update(message="évaluation", done=done, total=total)
527
+ rpc("space_checkpoint", {
528
+ "p_token": SPACE_TOKEN, "p_id": rid, "p_next_chunk": ci + 1,
529
+ "p_done": done, "p_counters": counters,
530
+ "p_audio": round(audio_s, 2), "p_compute": round(compute_s, 2),
531
+ })
532
+ rtfx = audio_s / compute_s if compute_s else 0
533
+ log(f" {done}/{total} — RTFx {rtfx:.2f}")
534
+ ci += 1
535
+
536
+ if skipped:
537
+ log(f"⚠ {skipped} énoncés illisibles écartés")
538
+ if not counters["n_scored"]:
539
+ raise RuntimeError("Aucune transcription exploitable produite.")
540
+
541
+ metrics = fe.finalize(counters)
542
+ rtfx = round(audio_s / compute_s, 3) if compute_s else None
543
+ rpc("space_finish", {
544
+ "p_token": SPACE_TOKEN, "p_id": rid, "p_status": "done",
545
+ "p_metrics": {k: metrics[k] for k in (
546
+ "wer", "cer", "mer", "wil", "wer_seg", "cer_seg", "wer_ton", "twer"
547
+ )},
548
+ "p_meta": {
549
+ "architecture": tr.architecture,
550
+ "decoder_type": tr.decoder_type,
551
+ "model_params": tr.model_params,
552
+ "rtfx": rtfx,
553
+ "rtf": round(1 / rtfx, 4) if rtfx else None,
554
+ "eval_seconds": round(compute_s, 1),
555
+ "hardware": tr.hardware(),
556
+ "worker_version": WORKER_VERSION,
557
+ },
558
+ "p_error": None,
559
+ })
560
+ log(f"✔ {model_id} — WER_seg {metrics['wer_seg']:.1%} "
561
+ f"T-WER {metrics['twer']} RTFx {rtfx}")
562
+ _state.update(message="terminé", model=None, done=0, total=0)
563
+
564
+
565
+ def loop() -> None:
566
+ if not SPACE_TOKEN:
567
+ _state["message"] = "SPACE_TOKEN absent — évaluateur à l'arrêt"
568
+ log("SPACE_TOKEN absent : aucune évaluation ne sera lancée.")
569
+ return
570
+ if not _DATA_TOKEN:
571
+ _state["message"] = "HF_DATA_TOKEN absent — évaluateur à l'arrêt"
572
+ log("HF_DATA_TOKEN absent : le corpus privé est illisible.")
573
+ return
574
+
575
+ # Un token de lecture mal choisi ne se voit qu'au premier échec, après
576
+ # qu'une demande a déjà été prise et perdue : autant le dire au démarrage.
577
+ try:
578
+ from huggingface_hub import HfApi, whoami
579
+
580
+ who = whoami(token=_DATA_TOKEN)
581
+ role = (who.get("auth") or {}).get("accessToken", {}).get("role")
582
+ orgs = [o["name"] for o in who.get("orgs", [])]
583
+ log(f"token de lecture : compte « {who['name']} », portée « {role} », "
584
+ f"orgs {orgs or '(aucune)'}")
585
+
586
+ # Appartenir à l'organisation ne suffit pas : un token fine-grained
587
+ # ne voit que les dépôts explicitement cochés. On teste l'accès réel.
588
+ api = HfApi(token=_DATA_TOKEN)
589
+ for b in requests.get(
590
+ f"{REST}/benchmarks", headers=HEADERS,
591
+ params={"is_active": "eq.true", "select": "id,hf_dataset_id"},
592
+ timeout=30,
593
+ ).json():
594
+ try:
595
+ api.dataset_info(b["hf_dataset_id"])
596
+ log(f" ✓ {b['hf_dataset_id']} lisible")
597
+ except Exception as exc: # noqa: BLE001
598
+ log(f" ✗ {b['hf_dataset_id']} INACCESSIBLE "
599
+ f"({type(exc).__name__}) — corriger HF_DATA_TOKEN : il "
600
+ f"faut un token « read », ou un token fine-grained avec "
601
+ f"« Read access to contents » cochée sur ce dépôt.")
602
+ except Exception as exc: # noqa: BLE001
603
+ log(f"⚠ HF_DATA_TOKEN refusé par le Hub : {type(exc).__name__}")
604
+
605
+ log("évaluateur démarré")
606
+ while True:
607
+ job = None
608
+ try:
609
+ job = rpc("space_claim", {"p_token": SPACE_TOKEN})
610
+ except Exception as exc: # noqa: BLE001 — réseau : on réessaie
611
+ log(f"⚠ space_claim : {exc}")
612
+
613
+ if not job:
614
+ _state.update(message="aucune tâche en attente", model=None)
615
+ time.sleep(POLL_SECONDS)
616
+ continue
617
+
618
+ try:
619
+ run_job(job)
620
+ except Exception as exc: # noqa: BLE001 — tout échec doit être publié
621
+ log(f"✘ échec : {exc}")
622
+ traceback.print_exc()
623
+ _state.update(message=f"échec : {exc}", model=None)
624
+ try:
625
+ rpc("space_finish", {
626
+ "p_token": SPACE_TOKEN, "p_id": job["request"]["id"],
627
+ "p_status": "failed", "p_metrics": None, "p_meta": None,
628
+ # Message court et sans extrait du corpus.
629
+ "p_error": f"{type(exc).__name__}: {exc}"[:300],
630
+ })
631
+ except Exception: # noqa: BLE001
632
+ traceback.print_exc()
633
+ finally:
634
+ import gc
635
+
636
+ gc.collect()
637
+
638
+
639
+ _thread: threading.Thread | None = None
640
+
641
+
642
+ def start() -> threading.Thread:
643
+ """Démarre l'évaluateur. Sans effet s'il tourne déjà.
644
+
645
+ Le lanceur du Space peut importer app.py plus d'une fois : sans cette
646
+ garde, deux threads se disputeraient la même tâche.
647
+ """
648
+ global _thread
649
+ if _thread is None or not _thread.is_alive():
650
+ _thread = threading.Thread(target=loop, name="fonbench-eval",
651
+ daemon=True)
652
+ _thread.start()
653
+ return _thread