| |
| |
| try: |
| import spaces |
|
|
| _HAS_SPACES = True |
| except ImportError: |
| _HAS_SPACES = False |
|
|
| import html |
|
|
| import gradio as gr |
| import torch |
| import torch.nn.functional as F |
| from transformers import AutoModelForMaskedLM, AutoTokenizer |
|
|
| MODEL_ID = "doctolib-lab/doctomodernbert-fr-base" |
|
|
| |
| |
| |
| |
| |
| |
| if torch.cuda.is_available(): |
| DEVICE = "cuda" |
| elif torch.backends.mps.is_available(): |
| DEVICE = "mps" |
| else: |
| DEVICE = "cpu" |
| print(f"[doctobert-fill-mask] device={DEVICE} has_spaces={_HAS_SPACES}", flush=True) |
|
|
| tokenizer = AutoTokenizer.from_pretrained(MODEL_ID) |
| model = AutoModelForMaskedLM.from_pretrained(MODEL_ID).to(DEVICE).eval() |
| MASK = tokenizer.mask_token |
|
|
|
|
| def gpu(fn): |
| """@spaces.GPU is documented as effect-free outside real ZeroGPU hardware, |
| so it's always safe to apply once `spaces` is importable; no-op locally, |
| where the package isn't installed at all.""" |
| return spaces.GPU(duration=15)(fn) if _HAS_SPACES else fn |
|
|
|
|
| def _card(inner: str) -> str: |
| return f'<div class="fm">{inner}</div>' |
|
|
|
|
| def _err(msg: str) -> str: |
| return _card(f'<div class="fm-err">{html.escape(msg)}</div>') |
|
|
|
|
| def _render(text: str, toks: list[str], scores: list[float]) -> str: |
| left, right = text.split(MASK, 1) |
| top1 = toks[0] if toks else "" |
| sentence = ( |
| f'<span class="fm-txt">{html.escape(left)}</span>' |
| + f'<span class="fm-slot">{html.escape(top1) or "·"}</span>' |
| + f'<span class="fm-txt">{html.escape(right)}</span>' |
| ) |
| maxp = max(scores) if scores else 1.0 |
| rows = [] |
| for r, (t, s) in enumerate(zip(toks, scores), 1): |
| w = (s / maxp * 100) if maxp else 0 |
| rows.append( |
| f'<div class="fm-row"><span class="fm-rank">{r}</span>' |
| f'<span class="fm-tok">{html.escape(t) or "∅"}</span>' |
| f'<div class="fm-bar"><i style="--w:{w:.1f}%"></i></div>' |
| f'<span class="fm-pct">{s * 100:.1f}%</span></div>' |
| ) |
| return _card( |
| f'<div class="fm-sentence">{sentence}</div>' |
| '<div class="fm-cap">Top predictions</div>' + "".join(rows) |
| ) |
|
|
|
|
| @gpu |
| def predict(text: str, top_k: int = 8) -> str: |
| text = (text or "").strip() |
| if not text: |
| return _err("Type a sentence first.") |
| if MASK not in text: |
| return _err(f"Your sentence must contain a {MASK} token.") |
| if text.count(MASK) != 1: |
| return _err(f"Use exactly one {MASK} token.") |
|
|
| enc = tokenizer(text, return_tensors="pt").to(DEVICE) |
| ids = enc["input_ids"][0] |
| pos = (ids == tokenizer.mask_token_id).nonzero(as_tuple=True)[0] |
| if pos.numel() == 0: |
| return _err("Could not locate the mask after tokenization.") |
| mp = int(pos[0]) |
|
|
| with torch.no_grad(): |
| logits = model(**enc).logits |
| probs = F.softmax(logits[0, mp].float(), dim=-1) |
| k = min(int(top_k), probs.shape[-1]) |
| tp, ti = torch.topk(probs, k) |
| toks = [ |
| tokenizer.decode([i], clean_up_tokenization_spaces=False).strip() |
| for i in ti.tolist() |
| ] |
| scores = [float(x) for x in tp.tolist()] |
| return _render(text, toks, scores) |
|
|
|
|
| |
| CSS = """ |
| #col { max-width: 900px; margin: 0 auto; } |
| .fm { |
| --fg:#e6edf6; --dim:#7d8aa0; --line:#1e2636; --accent:#7aa2ff; --accent2:#b78bff; |
| background: linear-gradient(180deg,#0e1420,#0b0f17); border:1px solid var(--line); |
| border-radius:14px; padding:18px 20px; color:var(--fg); |
| font-family: ui-monospace,"JetBrains Mono",Menlo,Consolas,monospace; |
| } |
| .fm-sentence { font-size:18px; line-height:1.9; margin-bottom:14px; } |
| /* Gradio's own text color otherwise wins on the bare text nodes; force ours. |
| Blue accent matches the DoctoBERT Diffusion demo's prompt text. */ |
| .fm-txt { color: var(--accent) !important; } |
| .fm-slot { background:linear-gradient(90deg,var(--accent),var(--accent2)); color:#0b0f17; |
| font-weight:600; padding:1px 9px; border-radius:6px; } |
| .fm-cap { color:var(--dim) !important; font-size:12px; text-transform:uppercase; |
| letter-spacing:.08em; margin:6px 0 10px; } |
| .fm-row { display:flex; align-items:center; gap:12px; margin:7px 0; font-size:14px; } |
| .fm-rank { color:var(--dim) !important; width:1.5em; text-align:right; } |
| /* Token + probability are the actual content, not secondary metadata -- keep |
| them fully bright (and !important, like .fm-txt) regardless of bar size. */ |
| .fm-tok { min-width:130px; color:var(--fg) !important; overflow:hidden; |
| text-overflow:ellipsis; white-space:nowrap; } |
| .fm-bar { flex:1; height:10px; background:#182034; border-radius:6px; overflow:hidden; } |
| .fm-bar>i { display:block; height:100%; width:var(--w); |
| background:linear-gradient(90deg,var(--accent),var(--accent2)); border-radius:6px; |
| animation: fmgrow .6s cubic-bezier(.2,.8,.2,1); } |
| @keyframes fmgrow { from { width:0 } } |
| .fm-pct { color:var(--fg) !important; width:4.5em; text-align:right; font-weight:600; } |
| .fm-err { color:#ff6b6b !important; } |
| """ |
|
|
| EXAMPLES = [ |
| f"Le patient présente une douleur {MASK} aiguë.", |
| f"Le médecin a prescrit un {MASK} pour traiter l'infection.", |
| f"Une douleur thoracique irradiant vers le bras gauche évoque un {MASK} du myocarde.", |
| f"Les analyses sanguines ont révélé un taux élevé de {MASK}.", |
| ] |
|
|
| with gr.Blocks(title="DoctoBERT Fill-Mask") as demo: |
| with gr.Column(elem_id="col"): |
| gr.Markdown( |
| "# 🩺 DoctoBERT Fill-Mask\n" |
| "Fill-mask demo of " |
| f"[doctolib-lab/doctomodernbert-fr-base](https://huggingface.co/{MODEL_ID}), " |
| "a French medical ModernBERT encoder.\n\n" |
| f"Write a sentence with a `{MASK}` token and the model predicts the most likely " |
| "words, ranked by probability." |
| ) |
| with gr.Row(): |
| inp = gr.Textbox( |
| label=f"Sentence (with {MASK})", |
| value=EXAMPLES[0], |
| placeholder=f"Le patient souffre d'une {MASK} aiguë.", |
| scale=4, |
| ) |
| btn = gr.Button("Predict", variant="primary", scale=1) |
| with gr.Accordion("Options", open=False): |
| topk = gr.Slider(1, 20, value=8, step=1, label="Top-k predictions") |
| out = gr.HTML() |
| gr.Examples(EXAMPLES, inputs=inp) |
| btn.click(predict, [inp, topk], out) |
| inp.submit(predict, [inp, topk], out) |
|
|
| if __name__ == "__main__": |
| demo.launch(theme=gr.themes.Soft(), css=CSS, show_error=True) |
|
|