# ZeroGPU: `spaces` must be imported before torch. Present on any HF Spaces
# Gradio SDK image regardless of hardware tier; absent locally.
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"
# torch.cuda.is_available() is the single source of truth for whether a CUDA
# device is actually usable here -- true on real GPU hardware, true on a
# properly-attached ZeroGPU Space (its emulation mode makes this report
# correctly outside @spaces.GPU functions too), false on cpu-basic. No need to
# infer it from whether `spaces` merely imported, which says nothing about
# the Space's actual hardware tier.
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 # ModernBERT -> "[MASK]"
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'
{inner}
'
def _err(msg: str) -> str:
return _card(f'{html.escape(msg)}
')
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'{html.escape(left)}'
+ f'{html.escape(top1) or "·"}'
+ f'{html.escape(right)}'
)
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'{r}'
f'
{html.escape(t) or "∅"}'
f'
'
f'
{s * 100:.1f}% '
)
return _card(
f'{sentence}
'
'Top predictions
' + "".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)
# Palette matches the DoctoBERT Diffusion demo (dark card, accent gradient bars).
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)