bofenghuang commited on
Commit
62085e8
·
unverified ·
1 Parent(s): 88f8035

feat: DoctoBERT Fill-Mask demo

Browse files

Switch to doctolib-lab/doctomodernbert-fr-base (ModernBERT, [MASK]
token), translate the UI to English, and replace the raw JSON output
with a styled prediction card matching the DoctoBERT Diffusion demo's
dark palette and accent gradient. Auto-run predictions on load and on
example click instead of leaving the card blank.

Runs on ZeroGPU when deployed; resolves the device from
torch.cuda.is_available() rather than guessing from whether the
spaces package merely imported, which adapts correctly across
cpu-basic, ZeroGPU, and local MPS/CPU with no crashes.

Files changed (2) hide show
  1. README.md +17 -8
  2. app.py +156 -120
README.md CHANGED
@@ -1,19 +1,28 @@
1
  ---
2
- title: DoctoBERT-fr-base
3
- emoji: 🏥
4
- colorFrom: yellow
5
  colorTo: purple
6
  sdk: gradio
7
  sdk_version: 6.19.0
8
  app_file: app.py
9
- short_description: Fill-mask demo for French medical RoBERTa
10
  python_version: "3.12"
11
  startup_duration_timeout: 30m
12
  ---
13
 
14
- # DoctoBERT-fr-base
15
 
16
- French medical RoBERTa (111M parameters) fill-mask demo. Enter a French medical sentence
17
- containing a `<mask>` token and the model predicts the most likely completions.
 
18
 
19
- Model: [doctolib-lab/doctobert-fr-base](https://huggingface.co/doctolib-lab/doctobert-fr-base)
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: DoctoBERT Fill-Mask
3
+ emoji: 🩺
4
+ colorFrom: indigo
5
  colorTo: purple
6
  sdk: gradio
7
  sdk_version: 6.19.0
8
  app_file: app.py
9
+ short_description: Fill-mask demo for a French medical ModernBERT
10
  python_version: "3.12"
11
  startup_duration_timeout: 30m
12
  ---
13
 
14
+ # 🩺 DoctoBERT Fill-Mask
15
 
16
+ Fill-mask demo of [doctolib-lab/doctomodernbert-fr-base](https://huggingface.co/doctolib-lab/doctomodernbert-fr-base),
17
+ a French medical **ModernBERT** encoder. Write a sentence containing a `[MASK]` token and the
18
+ model predicts the most likely words, ranked by probability.
19
 
20
+ Runs on **ZeroGPU** when deployed (via `@spaces.GPU`); falls back to MPS/CPU when run locally.
21
+
22
+ ## Run locally
23
+
24
+ ```bash
25
+ uv venv --python 3.12 .venv
26
+ uv pip install torch transformers gradio
27
+ python app.py
28
+ ```
app.py CHANGED
@@ -1,136 +1,172 @@
1
- import spaces # MUST come before torch / any CUDA-touching import
2
- import torch
3
- import gradio as gr
4
- from transformers import AutoTokenizer, AutoModelForMaskedLM
5
- import torch.nn.functional as F
6
-
7
- MODEL_ID = "doctolib-lab/doctobert-fr-base"
8
-
9
- tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
10
- model = AutoModelForMaskedLM.from_pretrained(MODEL_ID).to("cuda")
11
- model.eval()
12
-
13
- MASK_TOKEN_DISPLAY = "<mask>"
14
- MASK_TOKEN_ID = tokenizer.mask_token_id
15
 
 
 
 
16
 
17
- @spaces.GPU(duration=15)
18
- def predict_mask(text: str, top_k: int = 5):
19
- """Predict the most likely tokens to fill the <mask> in a French medical sentence.
20
 
21
- Args:
22
- text: A French medical sentence containing one <mask> token.
23
- top_k: Number of top predictions to return.
24
- """
25
- if MASK_TOKEN_DISPLAY not in text:
26
- error_msg = f"Le texte doit contenir le jeton {MASK_TOKEN_DISPLAY}."
27
- return {"error": error_msg}, error_msg
28
-
29
- # Split text around the <mask> display token and tokenize each part,
30
- # then insert the actual mask token id between them.
31
- parts = text.split(MASK_TOKEN_DISPLAY)
32
- if len(parts) != 2:
33
- error_msg = f"Le texte doit contenir exactement un jeton {MASK_TOKEN_DISPLAY}."
34
- return {"error": error_msg}, error_msg
35
-
36
- left_ids = tokenizer(parts[0], add_special_tokens=False)["input_ids"]
37
- right_ids = tokenizer(parts[1], add_special_tokens=False)["input_ids"]
38
-
39
- # Build: [CLS] left... [MASK] right... [SEP]
40
- cls_id = tokenizer.cls_token_id
41
- sep_id = tokenizer.sep_token_id
42
- input_ids = [cls_id] + left_ids + [MASK_TOKEN_ID] + right_ids + [sep_id]
43
- input_ids = torch.tensor([input_ids], device="cuda")
44
- attention_mask = torch.ones_like(input_ids)
45
 
46
- mask_pos = len(left_ids) + 1 # +1 for [CLS]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
 
48
  with torch.no_grad():
49
- logits = model(input_ids=input_ids, attention_mask=attention_mask).logits
50
-
51
- mask_logits = logits[0, mask_pos, :]
52
- probs = F.softmax(mask_logits, dim=-1)
53
- topk_probs, topk_indices = torch.topk(probs, min(int(top_k), probs.shape[-1]))
54
-
55
- results = []
56
- for i in range(topk_probs.shape[0]):
57
- token_id = topk_indices[i].item()
58
- token = tokenizer.convert_ids_to_tokens(token_id)
59
- if isinstance(token, str):
60
- token = token.replace("▁", "").strip()
61
- prob = topk_probs[i].item()
62
- # Build the full sequence with this token substituted
63
- filled_ids = input_ids[0].clone()
64
- filled_ids[mask_pos] = token_id
65
- sequence = tokenizer.decode(filled_ids, skip_special_tokens=True).strip()
66
- results.append({
67
- "token": token,
68
- "score": round(prob, 4),
69
- "sequence": sequence,
70
- })
71
-
72
- best = results[0]["sequence"] if results else ""
73
-
74
- return {"predictions": results}, best
75
-
76
-
77
  CSS = """
78
- #col-container { max-width: 900px; margin: 0 auto; }
79
- .dark .gradio-container { color: var(--body-text-color); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  """
81
 
82
- with gr.Blocks(theme=gr.themes.Citrus(), css=CSS) as demo:
83
- with gr.Column(elem_id="col-container"):
 
 
 
 
 
 
 
84
  gr.Markdown(
85
- """
86
- # 🏥 DoctoBERT-fr-base
87
- **DoctoBERT-fr-base** est un encodeur médical français (RoBERTa, 111M de paramètres)
88
- pré-entraîné sur des données biomédicales et cliniques françaises.
89
- Démo de **fill-mask** : donnez une phrase médicale contenant `<mask>`
90
- et le modèle prédit les jetons les plus probables.
91
-
92
- 🔗 [Modèle sur le Hub](https://huggingface.co/doctolib-lab/doctobert-fr-base)
93
- """
94
  )
95
-
96
  with gr.Row():
97
- text_input = gr.Textbox(
98
- label="Phrase médicale (avec <mask>)",
99
- placeholder=f"Le patient souffre d'une {MASK_TOKEN_DISPLAY} aiguë.",
 
100
  scale=4,
101
  )
102
- run_btn = gr.Button("Prédire", variant="primary", scale=1)
103
-
104
- with gr.Accordion("Paramètres avancés", open=False):
105
- top_k_input = gr.Slider(
106
- minimum=1, maximum=20, value=5, step=1,
107
- label="Nombre de prédictions (top-k)",
108
- )
109
-
110
- best_pred_output = gr.Textbox(label="Meilleure prédiction", interactive=False)
111
- predictions_output = gr.JSON(label="Top-k prédictions")
112
-
113
- run_btn.click(
114
- fn=predict_mask,
115
- inputs=[text_input, top_k_input],
116
- outputs=[predictions_output, best_pred_output],
117
- api_name="predict",
118
- )
119
-
120
- gr.Examples(
121
- examples=[
122
- [f"Le patient souffre d'une {MASK_TOKEN_DISPLAY} aiguë.", 5],
123
- [f"Le médecin prescrit un traitement pour l'{MASK_TOKEN_DISPLAY}.", 5],
124
- [f"La dose recommandée est de {MASK_TOKEN_DISPLAY} mg par jour.", 5],
125
- [f"Le patient présente des symptômes de {MASK_TOKEN_DISPLAY} chronique.", 5],
126
- [f"L'examen clinique révèle une {MASK_TOKEN_DISPLAY} au niveau du thorax.", 5],
127
- ],
128
- inputs=[text_input, top_k_input],
129
- outputs=[predictions_output, best_pred_output],
130
- fn=predict_mask,
131
- cache_examples=True,
132
- cache_mode="lazy",
133
- )
134
 
135
  if __name__ == "__main__":
136
- demo.launch(mcp_server=True)
 
1
+ # ZeroGPU: `spaces` must be imported before torch. Present on any HF Spaces
2
+ # Gradio SDK image regardless of hardware tier; absent locally.
3
+ try:
4
+ import spaces
 
 
 
 
 
 
 
 
 
 
5
 
6
+ _HAS_SPACES = True
7
+ except ImportError:
8
+ _HAS_SPACES = False
9
 
10
+ import html
 
 
11
 
12
+ import gradio as gr
13
+ import torch
14
+ import torch.nn.functional as F
15
+ from transformers import AutoModelForMaskedLM, AutoTokenizer
16
+
17
+ MODEL_ID = "doctolib-lab/doctomodernbert-fr-base"
18
+
19
+ # torch.cuda.is_available() is the single source of truth for whether a CUDA
20
+ # device is actually usable here -- true on real GPU hardware, true on a
21
+ # properly-attached ZeroGPU Space (its emulation mode makes this report
22
+ # correctly outside @spaces.GPU functions too), false on cpu-basic. No need to
23
+ # infer it from whether `spaces` merely imported, which says nothing about
24
+ # the Space's actual hardware tier.
25
+ if torch.cuda.is_available():
26
+ DEVICE = "cuda"
27
+ elif torch.backends.mps.is_available():
28
+ DEVICE = "mps"
29
+ else:
30
+ DEVICE = "cpu"
31
+ print(f"[doctobert-fill-mask] device={DEVICE} has_spaces={_HAS_SPACES}", flush=True)
 
 
 
 
32
 
33
+ tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
34
+ model = AutoModelForMaskedLM.from_pretrained(MODEL_ID).to(DEVICE).eval()
35
+ MASK = tokenizer.mask_token # ModernBERT -> "[MASK]"
36
+
37
+
38
+ def gpu(fn):
39
+ """@spaces.GPU is documented as effect-free outside real ZeroGPU hardware,
40
+ so it's always safe to apply once `spaces` is importable; no-op locally,
41
+ where the package isn't installed at all."""
42
+ return spaces.GPU(duration=15)(fn) if _HAS_SPACES else fn
43
+
44
+
45
+ def _card(inner: str) -> str:
46
+ return f'<div class="fm">{inner}</div>'
47
+
48
+
49
+ def _err(msg: str) -> str:
50
+ return _card(f'<div class="fm-err">{html.escape(msg)}</div>')
51
+
52
+
53
+ def _render(text: str, toks: list[str], scores: list[float]) -> str:
54
+ left, right = text.split(MASK, 1)
55
+ top1 = toks[0] if toks else ""
56
+ sentence = (
57
+ f'<span class="fm-txt">{html.escape(left)}</span>'
58
+ + f'<span class="fm-slot">{html.escape(top1) or "·"}</span>'
59
+ + f'<span class="fm-txt">{html.escape(right)}</span>'
60
+ )
61
+ maxp = max(scores) if scores else 1.0
62
+ rows = []
63
+ for r, (t, s) in enumerate(zip(toks, scores), 1):
64
+ w = (s / maxp * 100) if maxp else 0
65
+ rows.append(
66
+ f'<div class="fm-row"><span class="fm-rank">{r}</span>'
67
+ f'<span class="fm-tok">{html.escape(t) or "∅"}</span>'
68
+ f'<div class="fm-bar"><i style="--w:{w:.1f}%"></i></div>'
69
+ f'<span class="fm-pct">{s * 100:.1f}%</span></div>'
70
+ )
71
+ return _card(
72
+ f'<div class="fm-sentence">{sentence}</div>'
73
+ '<div class="fm-cap">Top predictions</div>' + "".join(rows)
74
+ )
75
+
76
+
77
+ @gpu
78
+ def predict(text: str, top_k: int = 8) -> str:
79
+ text = (text or "").strip()
80
+ if not text:
81
+ return _err("Type a sentence first.")
82
+ if MASK not in text:
83
+ return _err(f"Your sentence must contain a {MASK} token.")
84
+ if text.count(MASK) != 1:
85
+ return _err(f"Use exactly one {MASK} token.")
86
+
87
+ enc = tokenizer(text, return_tensors="pt").to(DEVICE)
88
+ ids = enc["input_ids"][0]
89
+ pos = (ids == tokenizer.mask_token_id).nonzero(as_tuple=True)[0]
90
+ if pos.numel() == 0:
91
+ return _err("Could not locate the mask after tokenization.")
92
+ mp = int(pos[0])
93
 
94
  with torch.no_grad():
95
+ logits = model(**enc).logits
96
+ probs = F.softmax(logits[0, mp].float(), dim=-1)
97
+ k = min(int(top_k), probs.shape[-1])
98
+ tp, ti = torch.topk(probs, k)
99
+ toks = [
100
+ tokenizer.decode([i], clean_up_tokenization_spaces=False).strip()
101
+ for i in ti.tolist()
102
+ ]
103
+ scores = [float(x) for x in tp.tolist()]
104
+ return _render(text, toks, scores)
105
+
106
+
107
+ # Palette matches the DoctoBERT Diffusion demo (dark card, accent gradient bars).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
  CSS = """
109
+ #col { max-width: 900px; margin: 0 auto; }
110
+ .fm {
111
+ --fg:#e6edf6; --dim:#7d8aa0; --line:#1e2636; --accent:#7aa2ff; --accent2:#b78bff;
112
+ background: linear-gradient(180deg,#0e1420,#0b0f17); border:1px solid var(--line);
113
+ border-radius:14px; padding:18px 20px; color:var(--fg);
114
+ font-family: ui-monospace,"JetBrains Mono",Menlo,Consolas,monospace;
115
+ }
116
+ .fm-sentence { font-size:18px; line-height:1.9; margin-bottom:14px; }
117
+ /* Gradio's own text color otherwise wins on the bare text nodes; force ours.
118
+ Blue accent matches the DoctoBERT Diffusion demo's prompt text. */
119
+ .fm-txt { color: var(--accent) !important; }
120
+ .fm-slot { background:linear-gradient(90deg,var(--accent),var(--accent2)); color:#0b0f17;
121
+ font-weight:600; padding:1px 9px; border-radius:6px; }
122
+ .fm-cap { color:var(--dim) !important; font-size:12px; text-transform:uppercase;
123
+ letter-spacing:.08em; margin:6px 0 10px; }
124
+ .fm-row { display:flex; align-items:center; gap:12px; margin:7px 0; font-size:14px; }
125
+ .fm-rank { color:var(--dim) !important; width:1.5em; text-align:right; }
126
+ /* Token + probability are the actual content, not secondary metadata -- keep
127
+ them fully bright (and !important, like .fm-txt) regardless of bar size. */
128
+ .fm-tok { min-width:130px; color:var(--fg) !important; overflow:hidden;
129
+ text-overflow:ellipsis; white-space:nowrap; }
130
+ .fm-bar { flex:1; height:10px; background:#182034; border-radius:6px; overflow:hidden; }
131
+ .fm-bar>i { display:block; height:100%; width:var(--w);
132
+ background:linear-gradient(90deg,var(--accent),var(--accent2)); border-radius:6px;
133
+ animation: fmgrow .6s cubic-bezier(.2,.8,.2,1); }
134
+ @keyframes fmgrow { from { width:0 } }
135
+ .fm-pct { color:var(--fg) !important; width:4.5em; text-align:right; font-weight:600; }
136
+ .fm-err { color:#ff6b6b !important; }
137
  """
138
 
139
+ EXAMPLES = [
140
+ f"Le patient présente une douleur {MASK} aiguë.",
141
+ f"Le médecin a prescrit un {MASK} pour traiter l'infection.",
142
+ f"Une douleur thoracique irradiant vers le bras gauche évoque un {MASK} du myocarde.",
143
+ f"Les analyses sanguines ont révélé un taux élevé de {MASK}.",
144
+ ]
145
+
146
+ with gr.Blocks(title="DoctoBERT Fill-Mask") as demo:
147
+ with gr.Column(elem_id="col"):
148
  gr.Markdown(
149
+ "# 🩺 DoctoBERT Fill-Mask\n"
150
+ "Fill-mask demo of "
151
+ f"[doctolib-lab/doctomodernbert-fr-base](https://huggingface.co/{MODEL_ID}), "
152
+ "a French medical ModernBERT encoder.\n\n"
153
+ f"Write a sentence with a `{MASK}` token and the model predicts the most likely "
154
+ "words, ranked by probability."
 
 
 
155
  )
 
156
  with gr.Row():
157
+ inp = gr.Textbox(
158
+ label=f"Sentence (with {MASK})",
159
+ value=EXAMPLES[0],
160
+ placeholder=f"Le patient souffre d'une {MASK} aiguë.",
161
  scale=4,
162
  )
163
+ btn = gr.Button("Predict", variant="primary", scale=1)
164
+ with gr.Accordion("Options", open=False):
165
+ topk = gr.Slider(1, 20, value=8, step=1, label="Top-k predictions")
166
+ out = gr.HTML()
167
+ gr.Examples(EXAMPLES, inputs=inp)
168
+ btn.click(predict, [inp, topk], out)
169
+ inp.submit(predict, [inp, topk], out)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
 
171
  if __name__ == "__main__":
172
+ demo.launch(theme=gr.themes.Soft(), css=CSS, show_error=True)