Spaces:
Sleeping
Sleeping
sync from GitHub repo (space/)
Browse files- MODEL_CARD.md +127 -0
- app.py +97 -38
- config_fr-en.json +10 -0
- tokenizer_fr-en.json +0 -0
MODEL_CARD.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: apache-2.0
|
| 3 |
+
language:
|
| 4 |
+
- fr
|
| 5 |
+
- en
|
| 6 |
+
library_name: onnx
|
| 7 |
+
pipeline_tag: text-classification
|
| 8 |
+
base_model: sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
|
| 9 |
+
tags:
|
| 10 |
+
- yes-no
|
| 11 |
+
- intent-classification
|
| 12 |
+
- distillation
|
| 13 |
+
- onnx
|
| 14 |
+
- edge
|
| 15 |
+
- quantization
|
| 16 |
+
- sentence-transformers
|
| 17 |
+
metrics:
|
| 18 |
+
- accuracy
|
| 19 |
+
model-index:
|
| 20 |
+
- name: ForSureLLM
|
| 21 |
+
results:
|
| 22 |
+
- task:
|
| 23 |
+
type: text-classification
|
| 24 |
+
name: Yes/No/Unknown classification
|
| 25 |
+
metrics:
|
| 26 |
+
- type: accuracy
|
| 27 |
+
value: 0.952
|
| 28 |
+
name: Adversarial accuracy (124 cases)
|
| 29 |
+
- type: accuracy
|
| 30 |
+
value: 0.917
|
| 31 |
+
name: Test accuracy (1178 cases)
|
| 32 |
+
---
|
| 33 |
+
|
| 34 |
+
# ForSureLLM
|
| 35 |
+
|
| 36 |
+
Ultra-rapid `yes` / `no` / `unknown` classifier for short user replies, distilled from Claude Sonnet 4.6 into a multilingual MiniLM-L12. **2 ms on CPU**, **24-113 MB**, no API call needed.
|
| 37 |
+
|
| 38 |
+
- 🎯 **Try it live**: [HuggingFace Space demo](https://huggingface.co/spaces/jcfossati/ForSureLLM)
|
| 39 |
+
- 📦 **Source code**: [github.com/jcfossati/ForSureLLM](https://github.com/jcfossati/ForSureLLM)
|
| 40 |
+
|
| 41 |
+
## What it does
|
| 42 |
+
|
| 43 |
+
Given a short French or English reply (1-30 words typically), returns whether the user is **agreeing**, **refusing**, or **hesitating** about a pending action. Designed as a consent-intent oracle for chatbots, IVR systems, CLI confirmations, and automation flows.
|
| 44 |
+
|
| 45 |
+
```python
|
| 46 |
+
from forsurellm import classify
|
| 47 |
+
|
| 48 |
+
classify("carrément") # ("yes", 0.97)
|
| 49 |
+
classify("laisse tomber") # ("no", 0.98)
|
| 50 |
+
classify("je sais pas trop") # ("unknown", 0.96)
|
| 51 |
+
classify("oui mais non") # ("unknown", 0.92)
|
| 52 |
+
classify("yeah right") # ("no", 0.87) # sarcasm detected
|
| 53 |
+
classify("+1") # ("yes", 1.00) # symbolic preprocessor
|
| 54 |
+
classify("👍") # ("yes", 1.00)
|
| 55 |
+
```
|
| 56 |
+
|
| 57 |
+
## Numbers
|
| 58 |
+
|
| 59 |
+
| Metric | Value |
|
| 60 |
+
|---|---|
|
| 61 |
+
| Adversarial accuracy (124 trap phrases, 22 categories) | **95.2 %** |
|
| 62 |
+
| Surface-variant robustness (1227 variants) | 95.8 % |
|
| 63 |
+
| Test set accuracy (1178 phrases) | 91.7 % |
|
| 64 |
+
| Calibration ECE | 0.012 |
|
| 65 |
+
| **CPU latency p50** | **1.8 ms** |
|
| 66 |
+
| ONNX int8 size | 113 MB (multilingual) · **24 MB (FR+EN pruned variant)** |
|
| 67 |
+
|
| 68 |
+
**Head-to-head on the 124-case adversarial bench**:
|
| 69 |
+
|
| 70 |
+
| Classifier | Accuracy | p50 latency | API cost |
|
| 71 |
+
|---|---|---|---|
|
| 72 |
+
| **ForSureLLM** | **95.2 %** | **1.8 ms** | 0 |
|
| 73 |
+
| Haiku 4.5 zero-shot | 75.0 % | 602 ms | $$ |
|
| 74 |
+
| Cosine MiniLM-L12 (no fine-tune) | 67.7 % | 8 ms | 0 |
|
| 75 |
+
|
| 76 |
+
ForSureLLM beats Haiku 4.5 zero-shot by **+20.2 pts** while running **~330× faster**.
|
| 77 |
+
|
| 78 |
+
## Strengths
|
| 79 |
+
|
| 80 |
+
Categories where ForSureLLM crushes a generalist LLM (Haiku 4.5):
|
| 81 |
+
|
| 82 |
+
- `modern_slang` (Gen-Z): `no cap`, `bet`, `say less`, `deadass` — 100 % vs 43 %
|
| 83 |
+
- `negated_verb`: `I wouldn't say no`, `ce n'est pas un non` — 83 % vs 17 %
|
| 84 |
+
- `sarcasm`: `oui bien sûr...`, `yeah right` — 100 % vs 40 %
|
| 85 |
+
- `symbolic`: `+1`, `100%`, `👍`, `10/10` — 100 % vs 40 % (deterministic preprocessor)
|
| 86 |
+
- `slang_abbrev`: `np`, `tkt`, `kk`, `nope` — 100 % vs 50 %
|
| 87 |
+
|
| 88 |
+
## Files in this repo
|
| 89 |
+
|
| 90 |
+
- `forsurellm-int8.onnx` — full multilingual model, 113 MB (50+ languages supported via shared subwords, FR+EN tuned)
|
| 91 |
+
- (Optional) `forsurellm-int8_fr-en.onnx` — vocab-pruned FR+EN variant, 24 MB. Same predictions as the full model on FR+EN inputs, 5× lighter on disk and in RAM (+85 MB process memory vs +418 MB), latency unchanged. Tokens outside FR+EN become `<unk>`.
|
| 92 |
+
|
| 93 |
+
## How to use (without the package)
|
| 94 |
+
|
| 95 |
+
```python
|
| 96 |
+
import onnxruntime as ort
|
| 97 |
+
from huggingface_hub import hf_hub_download
|
| 98 |
+
from tokenizers import Tokenizer
|
| 99 |
+
import numpy as np
|
| 100 |
+
|
| 101 |
+
onnx_path = hf_hub_download("jcfossati/ForSureLLM", "forsurellm-int8.onnx")
|
| 102 |
+
session = ort.InferenceSession(onnx_path, providers=["CPUExecutionProvider"])
|
| 103 |
+
# tokenizer.json must be downloaded from the GitHub repo (space/tokenizer.json)
|
| 104 |
+
# or installed via the forsurellm package once published.
|
| 105 |
+
```
|
| 106 |
+
|
| 107 |
+
For the full preprocessing pipeline (case normalisation, symbolic shortcuts, sarcasm-aware threshold), use the `forsurellm` Python package — see the [GitHub repo](https://github.com/jcfossati/ForSureLLM) for installation.
|
| 108 |
+
|
| 109 |
+
## Training procedure
|
| 110 |
+
|
| 111 |
+
- **Backbone**: `sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2` (12 layers, 384 hidden)
|
| 112 |
+
- **Teacher**: Claude Sonnet 4.6 (generation) + Claude Haiku 4.5 (labeling, with Sonnet fallback when confidence < 0.6)
|
| 113 |
+
- **Loss**: KL-divergence on soft labels (3 classes)
|
| 114 |
+
- **Dataset**: ~5,800 hand-curated + LLM-generated EN+FR phrases, balanced across 22 adversarial categories
|
| 115 |
+
- **Training**: 8 epochs, batch 32, lr 2e-5, warmup 10%, weight decay 0.01 (~2 min on RTX Blackwell)
|
| 116 |
+
- **Calibration**: temperature scaling (T = 0.680, fitted by LBFGS on val set NLL)
|
| 117 |
+
- **Export**: ONNX dynamic quantization (avx512-vnni, int8)
|
| 118 |
+
|
| 119 |
+
## Limitations
|
| 120 |
+
|
| 121 |
+
- **EN + FR only**. The full model (113 MB) keeps the multilingual vocab and may produce reasonable cross-lingual outputs on related Latin-script languages (Spanish/Italian/German), but is not trained for them. The pruned variant (24 MB) drops non-FR/EN tokens entirely.
|
| 122 |
+
- **Short replies**. Optimized for 1-30 word answers. Long passages will be truncated at 64 tokens.
|
| 123 |
+
- **Sarcasm detection has cultural priors**. `yeah right` defaults to "no" because it's overwhelmingly sarcastic in modern English usage — a sincere user without punctuation might get the wrong call. Use `threshold=0.85` for action-confirmation contexts to fall back to `unknown` on borderline cases.
|
| 124 |
+
|
| 125 |
+
## License
|
| 126 |
+
|
| 127 |
+
Apache 2.0 — same as the base MiniLM model.
|
app.py
CHANGED
|
@@ -1,8 +1,10 @@
|
|
| 1 |
"""Gradio demo for ForSureLLM hosted on HuggingFace Spaces.
|
| 2 |
|
| 3 |
-
Loads
|
| 4 |
-
|
| 5 |
-
|
|
|
|
|
|
|
| 6 |
"""
|
| 7 |
from __future__ import annotations
|
| 8 |
|
|
@@ -11,6 +13,7 @@ import json
|
|
| 11 |
import re
|
| 12 |
import time
|
| 13 |
import unicodedata
|
|
|
|
| 14 |
from pathlib import Path
|
| 15 |
|
| 16 |
import gradio as gr
|
|
@@ -20,22 +23,62 @@ from huggingface_hub import hf_hub_download
|
|
| 20 |
from tokenizers import Tokenizer
|
| 21 |
|
| 22 |
MODEL_REPO = "jcfossati/ForSureLLM"
|
| 23 |
-
ONNX_FILE = "forsurellm-int8.onnx"
|
| 24 |
|
| 25 |
-
# ---
|
| 26 |
ROOT = Path(__file__).parent
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
# --- Preprocessing (mirror forsurellm/classifier.py) ------------------------
|
| 41 |
_HAS_LETTER_RE = re.compile(r"[^\W\d_]", re.UNICODE)
|
|
@@ -96,40 +139,42 @@ def _softmax(x: np.ndarray) -> np.ndarray:
|
|
| 96 |
return e / e.sum(axis=-1, keepdims=True)
|
| 97 |
|
| 98 |
|
| 99 |
-
def classify_full(phrase: str, threshold: float = 0.0) -> dict:
|
| 100 |
-
"""Returns {label, confidence, probs
|
|
|
|
| 101 |
sym = _classify_symbolic(phrase or "")
|
| 102 |
if sym is not None:
|
| 103 |
label, conf = sym
|
| 104 |
-
probs = {c: 0.0 for c in
|
| 105 |
probs[label] = conf
|
| 106 |
-
return {"label": label, "confidence": conf, "probs": probs,
|
|
|
|
| 107 |
if not _HAS_LETTER_RE.search(phrase or ""):
|
| 108 |
return {"label": "unknown", "confidence": 1.0,
|
| 109 |
"probs": {"yes": 0.0, "no": 0.0, "unknown": 1.0},
|
| 110 |
-
"tokens": [], "source": "no-letter shortcut"}
|
| 111 |
|
| 112 |
-
enc =
|
| 113 |
feeds = {"input_ids": np.array([enc.ids], dtype=np.int64),
|
| 114 |
"attention_mask": np.array([enc.attention_mask], dtype=np.int64)}
|
| 115 |
-
if "token_type_ids" in
|
| 116 |
feeds["token_type_ids"] = np.array([enc.type_ids], dtype=np.int64)
|
| 117 |
-
feeds = {k:
|
| 118 |
-
logits =
|
| 119 |
-
probs_arr = _softmax(logits /
|
| 120 |
-
probs = {c: float(p) for c, p in zip(
|
| 121 |
idx = int(probs_arr.argmax())
|
| 122 |
-
label =
|
| 123 |
conf = probs[label]
|
| 124 |
if conf < threshold and label != "unknown":
|
| 125 |
label = "unknown"
|
| 126 |
conf = probs["unknown"]
|
| 127 |
return {"label": label, "confidence": conf, "probs": probs,
|
| 128 |
"tokens": [t for t in enc.tokens if t not in {"<s>", "</s>", "[CLS]", "[SEP]", "[PAD]"}],
|
| 129 |
-
"source": "model"}
|
| 130 |
|
| 131 |
|
| 132 |
-
# --- Rendering --------------------------------------------------------------
|
| 133 |
EMPTY_HTML = '<div class="fsl-result fsl-result-empty">Tape une phrase pour la classifier.</div>'
|
| 134 |
|
| 135 |
RESULT_TEMPLATE = """
|
|
@@ -143,7 +188,7 @@ RESULT_TEMPLATE = """
|
|
| 143 |
<div class="fsl-tokens-label">{token_label}</div>
|
| 144 |
<div class="fsl-tokens-list">{tokens_html}</div>
|
| 145 |
</div>
|
| 146 |
-
<div class="fsl-meta">{source} · {elapsed_ms:.1f} ms</div>
|
| 147 |
</div>
|
| 148 |
"""
|
| 149 |
|
|
@@ -156,11 +201,11 @@ BAR_TEMPLATE = """
|
|
| 156 |
"""
|
| 157 |
|
| 158 |
|
| 159 |
-
def render_result(phrase: str, threshold: float) -> str:
|
| 160 |
if not phrase or not phrase.strip():
|
| 161 |
return EMPTY_HTML
|
| 162 |
t0 = time.perf_counter()
|
| 163 |
-
res = classify_full(phrase, threshold)
|
| 164 |
elapsed_ms = (time.perf_counter() - t0) * 1000
|
| 165 |
bars = "".join(
|
| 166 |
BAR_TEMPLATE.format(name=cls, pct=res["probs"][cls] * 100, value=res["probs"][cls])
|
|
@@ -181,6 +226,7 @@ def render_result(phrase: str, threshold: float) -> str:
|
|
| 181 |
tokens_html=tokens_html,
|
| 182 |
token_label=token_label,
|
| 183 |
source=res["source"],
|
|
|
|
| 184 |
elapsed_ms=elapsed_ms,
|
| 185 |
)
|
| 186 |
|
|
@@ -231,6 +277,9 @@ CSS = """
|
|
| 231 |
.fsl-threshold .wrap { background: transparent !important; }
|
| 232 |
.fsl-threshold input[type="range"] { accent-color: var(--fsl-accent) !important; }
|
| 233 |
|
|
|
|
|
|
|
|
|
|
| 234 |
.fsl-result {
|
| 235 |
background: var(--fsl-surface); border: 1px solid var(--fsl-border); border-radius: 12px;
|
| 236 |
padding: 20px; min-height: 120px; color: var(--fsl-text);
|
|
@@ -300,8 +349,17 @@ with gr.Blocks(title="ForSureLLM", css=CSS, theme=gr.themes.Base()) as demo:
|
|
| 300 |
autofocus=True,
|
| 301 |
show_label=False,
|
| 302 |
)
|
| 303 |
-
|
| 304 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 305 |
|
| 306 |
gr.HTML('<div id="fsl-presets-label">exemples</div>')
|
| 307 |
with gr.Row(elem_classes="fsl-presets-row"):
|
|
@@ -309,8 +367,9 @@ with gr.Blocks(title="ForSureLLM", css=CSS, theme=gr.themes.Base()) as demo:
|
|
| 309 |
|
| 310 |
out = gr.HTML(value=EMPTY_HTML)
|
| 311 |
|
| 312 |
-
inp.change(render_result, inputs=[inp, thr], outputs=[out], show_progress="hidden")
|
| 313 |
-
thr.change(render_result, inputs=[inp, thr], outputs=[out], show_progress="hidden")
|
|
|
|
| 314 |
for btn, p in zip(preset_btns, PRESETS):
|
| 315 |
btn.click(lambda v=p: v, outputs=[inp])
|
| 316 |
|
|
|
|
| 1 |
"""Gradio demo for ForSureLLM hosted on HuggingFace Spaces.
|
| 2 |
|
| 3 |
+
Loads BOTH variants (full multilingual + pruned FR+EN) at startup and exposes
|
| 4 |
+
a dropdown so visitors can compare predictions / sizes / speed in real time.
|
| 5 |
+
|
| 6 |
+
ONNX files are downloaded from the jcfossati/ForSureLLM Model repo at boot.
|
| 7 |
+
Tokenizer + config files are bundled in this Space (small).
|
| 8 |
"""
|
| 9 |
from __future__ import annotations
|
| 10 |
|
|
|
|
| 13 |
import re
|
| 14 |
import time
|
| 15 |
import unicodedata
|
| 16 |
+
from dataclasses import dataclass
|
| 17 |
from pathlib import Path
|
| 18 |
|
| 19 |
import gradio as gr
|
|
|
|
| 23 |
from tokenizers import Tokenizer
|
| 24 |
|
| 25 |
MODEL_REPO = "jcfossati/ForSureLLM"
|
|
|
|
| 26 |
|
| 27 |
+
# --- Variant registry ------------------------------------------------------
|
| 28 |
ROOT = Path(__file__).parent
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@dataclass
|
| 32 |
+
class Variant:
|
| 33 |
+
label: str # display name in the dropdown
|
| 34 |
+
onnx_remote: str # filename in the Model repo to download
|
| 35 |
+
tokenizer_local: str # bundled tokenizer file in space/
|
| 36 |
+
config_local: str # bundled config file in space/
|
| 37 |
+
tokenizer: Tokenizer = None
|
| 38 |
+
session: ort.InferenceSession = None
|
| 39 |
+
classes: list = None
|
| 40 |
+
input_names: set = None
|
| 41 |
+
temperature: float = 1.0
|
| 42 |
+
size_mb: float = 0.0
|
| 43 |
+
vocab_size: int = 0
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
VARIANTS: dict[str, Variant] = {
|
| 47 |
+
"Full multilingual (113 MB)": Variant(
|
| 48 |
+
label="Full multilingual (113 MB)",
|
| 49 |
+
onnx_remote="forsurellm-int8.onnx",
|
| 50 |
+
tokenizer_local="tokenizer.json",
|
| 51 |
+
config_local="config.json",
|
| 52 |
+
),
|
| 53 |
+
"Pruned FR+EN (24 MB)": Variant(
|
| 54 |
+
label="Pruned FR+EN (24 MB)",
|
| 55 |
+
onnx_remote="forsurellm-int8_fr-en.onnx",
|
| 56 |
+
tokenizer_local="tokenizer_fr-en.json",
|
| 57 |
+
config_local="config_fr-en.json",
|
| 58 |
+
),
|
| 59 |
+
}
|
| 60 |
+
DEFAULT_VARIANT = "Pruned FR+EN (24 MB)"
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _load_variant(v: Variant) -> None:
|
| 64 |
+
print(f"[boot] loading {v.label}: downloading {v.onnx_remote}...")
|
| 65 |
+
onnx_path = hf_hub_download(MODEL_REPO, v.onnx_remote)
|
| 66 |
+
v.size_mb = round(Path(onnx_path).stat().st_size / (1024 * 1024), 1)
|
| 67 |
+
v.tokenizer = Tokenizer.from_file(str(ROOT / v.tokenizer_local))
|
| 68 |
+
with (ROOT / v.config_local).open(encoding="utf-8") as f:
|
| 69 |
+
cfg = json.load(f)
|
| 70 |
+
v.tokenizer.enable_truncation(max_length=cfg["max_length"])
|
| 71 |
+
v.classes = cfg["classes"]
|
| 72 |
+
v.temperature = float(cfg.get("temperature", 1.0))
|
| 73 |
+
v.session = ort.InferenceSession(onnx_path, providers=["CPUExecutionProvider"])
|
| 74 |
+
v.input_names = {i.name for i in v.session.get_inputs()}
|
| 75 |
+
v.vocab_size = v.tokenizer.get_vocab_size()
|
| 76 |
+
print(f"[boot] -> {v.label} ready ({v.size_mb} MB ONNX, {v.vocab_size} tokens)")
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
for v in VARIANTS.values():
|
| 80 |
+
_load_variant(v)
|
| 81 |
+
|
| 82 |
|
| 83 |
# --- Preprocessing (mirror forsurellm/classifier.py) ------------------------
|
| 84 |
_HAS_LETTER_RE = re.compile(r"[^\W\d_]", re.UNICODE)
|
|
|
|
| 139 |
return e / e.sum(axis=-1, keepdims=True)
|
| 140 |
|
| 141 |
|
| 142 |
+
def classify_full(phrase: str, variant_label: str, threshold: float = 0.0) -> dict:
|
| 143 |
+
"""Returns {label, confidence, probs, tokens, source, variant}."""
|
| 144 |
+
v = VARIANTS[variant_label]
|
| 145 |
sym = _classify_symbolic(phrase or "")
|
| 146 |
if sym is not None:
|
| 147 |
label, conf = sym
|
| 148 |
+
probs = {c: 0.0 for c in v.classes}
|
| 149 |
probs[label] = conf
|
| 150 |
+
return {"label": label, "confidence": conf, "probs": probs,
|
| 151 |
+
"tokens": [phrase.strip()], "source": "symbolic", "variant": variant_label}
|
| 152 |
if not _HAS_LETTER_RE.search(phrase or ""):
|
| 153 |
return {"label": "unknown", "confidence": 1.0,
|
| 154 |
"probs": {"yes": 0.0, "no": 0.0, "unknown": 1.0},
|
| 155 |
+
"tokens": [], "source": "no-letter shortcut", "variant": variant_label}
|
| 156 |
|
| 157 |
+
enc = v.tokenizer.encode(_normalize(phrase))
|
| 158 |
feeds = {"input_ids": np.array([enc.ids], dtype=np.int64),
|
| 159 |
"attention_mask": np.array([enc.attention_mask], dtype=np.int64)}
|
| 160 |
+
if "token_type_ids" in v.input_names:
|
| 161 |
feeds["token_type_ids"] = np.array([enc.type_ids], dtype=np.int64)
|
| 162 |
+
feeds = {k: x for k, x in feeds.items() if k in v.input_names}
|
| 163 |
+
logits = v.session.run(None, feeds)[0][0]
|
| 164 |
+
probs_arr = _softmax(logits / v.temperature)
|
| 165 |
+
probs = {c: float(p) for c, p in zip(v.classes, probs_arr)}
|
| 166 |
idx = int(probs_arr.argmax())
|
| 167 |
+
label = v.classes[idx]
|
| 168 |
conf = probs[label]
|
| 169 |
if conf < threshold and label != "unknown":
|
| 170 |
label = "unknown"
|
| 171 |
conf = probs["unknown"]
|
| 172 |
return {"label": label, "confidence": conf, "probs": probs,
|
| 173 |
"tokens": [t for t in enc.tokens if t not in {"<s>", "</s>", "[CLS]", "[SEP]", "[PAD]"}],
|
| 174 |
+
"source": "model", "variant": variant_label}
|
| 175 |
|
| 176 |
|
| 177 |
+
# --- Rendering --------------------------------------------------------------
|
| 178 |
EMPTY_HTML = '<div class="fsl-result fsl-result-empty">Tape une phrase pour la classifier.</div>'
|
| 179 |
|
| 180 |
RESULT_TEMPLATE = """
|
|
|
|
| 188 |
<div class="fsl-tokens-label">{token_label}</div>
|
| 189 |
<div class="fsl-tokens-list">{tokens_html}</div>
|
| 190 |
</div>
|
| 191 |
+
<div class="fsl-meta">{source} · {variant} · {elapsed_ms:.1f} ms</div>
|
| 192 |
</div>
|
| 193 |
"""
|
| 194 |
|
|
|
|
| 201 |
"""
|
| 202 |
|
| 203 |
|
| 204 |
+
def render_result(phrase: str, variant_label: str, threshold: float) -> str:
|
| 205 |
if not phrase or not phrase.strip():
|
| 206 |
return EMPTY_HTML
|
| 207 |
t0 = time.perf_counter()
|
| 208 |
+
res = classify_full(phrase, variant_label, threshold)
|
| 209 |
elapsed_ms = (time.perf_counter() - t0) * 1000
|
| 210 |
bars = "".join(
|
| 211 |
BAR_TEMPLATE.format(name=cls, pct=res["probs"][cls] * 100, value=res["probs"][cls])
|
|
|
|
| 226 |
tokens_html=tokens_html,
|
| 227 |
token_label=token_label,
|
| 228 |
source=res["source"],
|
| 229 |
+
variant=res["variant"].split(" (")[0], # short label
|
| 230 |
elapsed_ms=elapsed_ms,
|
| 231 |
)
|
| 232 |
|
|
|
|
| 277 |
.fsl-threshold .wrap { background: transparent !important; }
|
| 278 |
.fsl-threshold input[type="range"] { accent-color: var(--fsl-accent) !important; }
|
| 279 |
|
| 280 |
+
.fsl-variant select, .fsl-variant .wrap-inner { background: var(--fsl-surface-2) !important; color: var(--fsl-text) !important; border-color: var(--fsl-border) !important; }
|
| 281 |
+
.fsl-variant label span { color: var(--fsl-muted) !important; font-size: 13px !important; }
|
| 282 |
+
|
| 283 |
.fsl-result {
|
| 284 |
background: var(--fsl-surface); border: 1px solid var(--fsl-border); border-radius: 12px;
|
| 285 |
padding: 20px; min-height: 120px; color: var(--fsl-text);
|
|
|
|
| 349 |
autofocus=True,
|
| 350 |
show_label=False,
|
| 351 |
)
|
| 352 |
+
with gr.Row():
|
| 353 |
+
variant_dd = gr.Dropdown(
|
| 354 |
+
choices=list(VARIANTS.keys()),
|
| 355 |
+
value=DEFAULT_VARIANT,
|
| 356 |
+
label="model variant",
|
| 357 |
+
elem_classes="fsl-variant",
|
| 358 |
+
scale=2,
|
| 359 |
+
)
|
| 360 |
+
thr = gr.Slider(0, 1, value=0, step=0.01,
|
| 361 |
+
label="threshold (force unknown si conf < seuil)",
|
| 362 |
+
elem_classes="fsl-threshold", scale=3)
|
| 363 |
|
| 364 |
gr.HTML('<div id="fsl-presets-label">exemples</div>')
|
| 365 |
with gr.Row(elem_classes="fsl-presets-row"):
|
|
|
|
| 367 |
|
| 368 |
out = gr.HTML(value=EMPTY_HTML)
|
| 369 |
|
| 370 |
+
inp.change(render_result, inputs=[inp, variant_dd, thr], outputs=[out], show_progress="hidden")
|
| 371 |
+
thr.change(render_result, inputs=[inp, variant_dd, thr], outputs=[out], show_progress="hidden")
|
| 372 |
+
variant_dd.change(render_result, inputs=[inp, variant_dd, thr], outputs=[out], show_progress="hidden")
|
| 373 |
for btn, p in zip(preset_btns, PRESETS):
|
| 374 |
btn.click(lambda v=p: v, outputs=[inp])
|
| 375 |
|
config_fr-en.json
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"classes": [
|
| 3 |
+
"yes",
|
| 4 |
+
"no",
|
| 5 |
+
"unknown"
|
| 6 |
+
],
|
| 7 |
+
"max_length": 64,
|
| 8 |
+
"temperature": 0.6799102425575256,
|
| 9 |
+
"tokenizer_file": "tokenizer_fr-en.json"
|
| 10 |
+
}
|
tokenizer_fr-en.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|