seamon67 commited on
Commit
5a5febf
·
1 Parent(s): cd4548a

Upload Model

Browse files
.gitattributes CHANGED
@@ -21,6 +21,11 @@
21
  *.pkl filter=lfs diff=lfs merge=lfs -text
22
  *.pt filter=lfs diff=lfs merge=lfs -text
23
  *.pth filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
24
  *.rar filter=lfs diff=lfs merge=lfs -text
25
  *.safetensors filter=lfs diff=lfs merge=lfs -text
26
  saved_model/**/* filter=lfs diff=lfs merge=lfs -text
@@ -28,8 +33,17 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
28
  *.tar filter=lfs diff=lfs merge=lfs -text
29
  *.tflite filter=lfs diff=lfs merge=lfs -text
30
  *.tgz filter=lfs diff=lfs merge=lfs -text
 
31
  *.wasm filter=lfs diff=lfs merge=lfs -text
32
  *.xz filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
21
  *.pkl filter=lfs diff=lfs merge=lfs -text
22
  *.pt filter=lfs diff=lfs merge=lfs -text
23
  *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.png filter=lfs diff=lfs merge=lfs -text
25
+ *.jpg filter=lfs diff=lfs merge=lfs -text
26
+ *.gif filter=lfs diff=lfs merge=lfs -text
27
+ *.mp4 filter=lfs diff=lfs merge=lfs -text
28
+ *.svg filter=lfs diff=lfs merge=lfs -text
29
  *.rar filter=lfs diff=lfs merge=lfs -text
30
  *.safetensors filter=lfs diff=lfs merge=lfs -text
31
  saved_model/**/* filter=lfs diff=lfs merge=lfs -text
 
33
  *.tar filter=lfs diff=lfs merge=lfs -text
34
  *.tflite filter=lfs diff=lfs merge=lfs -text
35
  *.tgz filter=lfs diff=lfs merge=lfs -text
36
+ tokenizer.json filter=lfs diff=lfs merge=lfs -text
37
  *.wasm filter=lfs diff=lfs merge=lfs -text
38
  *.xz filter=lfs diff=lfs merge=lfs -text
39
  *.zip filter=lfs diff=lfs merge=lfs -text
40
  *.zst filter=lfs diff=lfs merge=lfs -text
41
  *tfevents* filter=lfs diff=lfs merge=lfs -text
42
+
43
+ # LF-pinned: manifest integrity hashes must match git-served bytes
44
+ *.py text eol=lf
45
+ *.json text eol=lf
46
+ *.md text eol=lf
47
+ *.jinja text eol=lf
48
+ LICENSE text eol=lf
49
+ README text eol=lf
LICENSE ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 valkayuh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ -------------------------------------------------------------------------------
24
+ Third-party material
25
+
26
+ The encoders are fine-tuned from jhu-clsp/mmBERT-base (MIT) and
27
+ FacebookAI/xlm-roberta-large (MIT).
28
+
29
+ Training data included Wikipedia (CC-BY-SA-4.0), Tatoeba sentences
30
+ (CC-BY-2.0 FR, (c) Tatoeba contributors, https://tatoeba.org), Europarl,
31
+ HPLT 2.0 (CC0-1.0), VoxPopuli transcripts (CC0-1.0) and the Aya dataset
32
+ (Apache-2.0), plus Whisper transcripts of synthetic speech produced with
33
+ Kokoro-82M (Apache-2.0) and facebook/mms-tts (CC-BY-NC-4.0). None of that
34
+ data is redistributed here. See README.md, "Licence and data provenance".
README.md ADDED
@@ -0,0 +1,770 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: mit
3
+ language:
4
+ - ar
5
+ - az
6
+ - bg
7
+ - bn
8
+ - bs
9
+ - ca
10
+ - cs
11
+ - da
12
+ - de
13
+ - el
14
+ - en
15
+ - eo
16
+ - es
17
+ - et
18
+ - fa
19
+ - fi
20
+ - fr
21
+ - he
22
+ - hi
23
+ - hr
24
+ - hu
25
+ - hy
26
+ - id
27
+ - it
28
+ - ja
29
+ - ka
30
+ - kk
31
+ - ko
32
+ - ku
33
+ - lt
34
+ - lv
35
+ - mk
36
+ - mn
37
+ - mr
38
+ - ms
39
+ - my
40
+ - nb
41
+ - nl
42
+ - pl
43
+ - pt
44
+ - ro
45
+ - ru
46
+ - sk
47
+ - sl
48
+ - sq
49
+ - sr
50
+ - sv
51
+ - sw
52
+ - ta
53
+ - th
54
+ - tr
55
+ - uk
56
+ - ur
57
+ - vi
58
+ - zh
59
+ - af
60
+ - be
61
+ - cy
62
+ - eu
63
+ - ga
64
+ - gl
65
+ - is
66
+ - kn
67
+ - ml
68
+ - ne
69
+ - nn
70
+ - si
71
+ - te
72
+ - tl
73
+ - uz
74
+ - gu
75
+ - pa
76
+ - km
77
+ - la
78
+ - lb
79
+ - mt
80
+ - so
81
+ - am
82
+ - ky
83
+ pipeline_tag: token-classification
84
+ tags:
85
+ - punctuation
86
+ - punctuation-restoration
87
+ - truecasing
88
+ - capitalization
89
+ - asr
90
+ - speech-recognition-postprocessing
91
+ - multilingual
92
+ - ensemble
93
+ - modernbert
94
+ - xlm-roberta
95
+ base_model:
96
+ - jhu-clsp/mmBERT-base
97
+ - valkayuh/dewpoint
98
+ base_model_relation: quantized
99
+ datasets:
100
+ - wikimedia/wikipedia
101
+ - Helsinki-NLP/europarl
102
+ - HPLT/HPLT2.0_cleaned
103
+ - facebook/voxpopuli
104
+ - CohereLabs/aya_dataset
105
+ metrics:
106
+ - f1
107
+ model-index:
108
+ - name: Dewpoint
109
+ results:
110
+ - task:
111
+ type: token-classification
112
+ name: Punctuation restoration
113
+ dataset:
114
+ name: IWSLT 2017 TED, official tst2010-2015 (9 languages)
115
+ type: IWSLT/iwslt2017
116
+ metrics:
117
+ - type: f1
118
+ value: 0.7559
119
+ name: Macro-F1 (COMMA, PERIOD, QUESTION), mean over languages
120
+ - type: f1
121
+ value: 0.6557
122
+ name: Comma F1
123
+ - type: f1
124
+ value: 0.7923
125
+ name: Period F1
126
+ - type: f1
127
+ value: 0.8197
128
+ name: Question F1
129
+ - task:
130
+ type: token-classification
131
+ name: Punctuation restoration
132
+ dataset:
133
+ name: WIT3 TED, same 90 talks (57 language tracks)
134
+ type: wit3
135
+ metrics:
136
+ - type: f1
137
+ value: 0.7303
138
+ name: Macro-F1 (COMMA, PERIOD, QUESTION), mean over languages
139
+ - type: f1
140
+ value: 0.6482
141
+ name: Comma F1
142
+ - type: f1
143
+ value: 0.7794
144
+ name: Period F1
145
+ - type: f1
146
+ value: 0.7632
147
+ name: Question F1
148
+ - task:
149
+ type: token-classification
150
+ name: Punctuation restoration
151
+ dataset:
152
+ name: IWSLT TED through TTS and Whisper ASR (33 languages)
153
+ type: iwslt-ted-asr
154
+ metrics:
155
+ - type: f1
156
+ value: 0.6332
157
+ name: Macro-F1 (COMMA, PERIOD, QUESTION), mean over languages
158
+ - type: f1
159
+ value: 0.5656
160
+ name: Comma F1
161
+ - type: f1
162
+ value: 0.7102
163
+ name: Period F1
164
+ - type: f1
165
+ value: 0.6238
166
+ name: Question F1
167
+ ---
168
+
169
+ # About
170
+
171
+ This model was quantized to BF16 from valkayuh/dewpoint. Refer to the [original model card](https://huggingface.co/valkayuh/dewpoint) for more details on the model.
172
+
173
+ # Original Model Card
174
+
175
+ ![Dewpoint: punctuation and capitals for the spoken word](assets/banner.jpg)
176
+
177
+ # Dewpoint
178
+
179
+ **Punctuation and truecasing for speech-recognition output, in 79 languages.** Speech
180
+ recognisers hand back a stream of lowercase words with no punctuation, and Dewpoint
181
+ restores it: it adds the commas, full stops and question marks, and the capitals.
182
+ Open weights, MIT licence, free for everyone.
183
+
184
+ ```text
185
+ so i said meet at three thirty tuesday what do you think the iphone was announced by steve jobs in san francisco
186
+ → So I said meet at three thirty Tuesday. What do you think? The iPhone was announced by Steve Jobs in San Francisco.
187
+
188
+ ich weiß nicht ob wir das schaffen aber wir sollten es versuchen was meinst du
189
+ → Ich weiß nicht, ob wir das schaffen, aber wir sollten es versuchen. Was meinst du?
190
+
191
+ πού είναι ο σταθμός ξέρεις να μου πεις
192
+ → Πού είναι ο σταθμός; Ξέρεις να μου πεις;
193
+ ```
194
+
195
+ These are unedited outputs of this checkpoint. Dewpoint is a **tagger, not a
196
+ generator**. For each word it decides which mark follows (none, `,` `.` `?` `!`) and how
197
+ the word is cased (lower, Capitalised, UPPER). It never adds, drops, reorders or rewrites
198
+ a word, so it cannot invent content.
199
+
200
+ ## Results
201
+
202
+ On the IWSLT TED benchmark, Dewpoint scores higher than every other multilingual
203
+ punctuation model we tested, under all three test conditions. On the two text test
204
+ sets it is behind no other model in any language: **0 of 9** on the official
205
+ set and **0 of 57** on the extended one. Under ASR noise it trails in
206
+ **7 of 33**, 5 of them to raw Whisper.
207
+
208
+ ![Macro-F1 on three benchmarks](assets/benchmarks.jpg)
209
+
210
+ | system | params | official, 9 lang | WIT3, 57 lang | ASR, 33 lang |
211
+ |---|---|---|---|---|
212
+ | **Dewpoint** | 869M | **0.7559** | **0.7303** | **0.6332** |
213
+ | Dewpoint, mmBERT-base half alone | 307M | 0.7392 | 0.7098 | 0.6197 |
214
+ | Dewpoint, XLM-R-large half alone | 561M | 0.7443 | 0.7241 | 0.6208 |
215
+ | [oliverguhr/fullstop-punctuation-multilang-large](https://huggingface.co/oliverguhr/fullstop-punctuation-multilang-large) | 559M | 0.6438 | 0.5984 | 0.5301 |
216
+ | [kredor/punctuate-all](https://huggingface.co/kredor/punctuate-all) | 278M | 0.6344 | 0.5950 | 0.5333 |
217
+ | [oliverguhr/fullstop-punctuation-multilingual-sonar-base](https://huggingface.co/oliverguhr/fullstop-punctuation-multilingual-sonar-base) | 278M | 0.6381 | 0.5653 | 0.5205 |
218
+ | [1-800-BAD-CODE/xlm-roberta_punctuation_fullstop_truecase](https://huggingface.co/1-800-BAD-CODE/xlm-roberta_punctuation_fullstop_truecase) | 278M | 0.6204 | 0.5710 | 0.5346 |
219
+ | raw [openai/whisper-large-v3-turbo](https://huggingface.co/openai/whisper-large-v3-turbo) punctuation | 809M | — | — | 0.4436 |
220
+
221
+ Macro-F1 over COMMA, PERIOD and QUESTION, averaged over languages. Every system runs
222
+ through the same harness and gets the same dev-set threshold calibration.
223
+
224
+ **Head to head on each model's own languages.** The averages above include languages the
225
+ other models were never trained on, which pulls their means down. Scored only on the
226
+ languages each model lists on its own model card, the margins are smaller:
227
+
228
+ | other model | languages it supports | official | WIT3 | ASR |
229
+ |---|---|---|---|---|
230
+ | [oliverguhr/fullstop-punctuation-multilang-large](https://huggingface.co/oliverguhr/fullstop-punctuation-multilang-large) | 4 | 0.755 vs **0.791** (4) | 0.758 vs **0.793** (5) | 0.741 vs **0.772** (4) |
231
+ | [oliverguhr/fullstop-punctuation-multilingual-sonar-base](https://huggingface.co/oliverguhr/fullstop-punctuation-multilingual-sonar-base) | 5 | 0.734 vs **0.786** (5) | 0.752 vs **0.797** (6) | 0.702 vs **0.753** (5) |
232
+ | [kredor/punctuate-all](https://huggingface.co/kredor/punctuate-all) | 12 | 0.726 vs **0.786** (5) | 0.733 vs **0.797** (14) | 0.695 vs **0.755** (9) |
233
+ | [1-800-BAD-CODE/xlm-roberta_punctuation_fullstop_truecase](https://huggingface.co/1-800-BAD-CODE/xlm-roberta_punctuation_fullstop_truecase) | 47 | 0.620 vs **0.756** (9) | 0.598 vs **0.744** (37) | 0.540 vs **0.636** (27) |
234
+
235
+ Dewpoint is ahead in every one of these languages except Bengali on Whisper ASR output against xlm-r truecase.
236
+
237
+ Each cell is that model's mean macro-F1 vs Dewpoint's on the same languages, with the
238
+ number of languages in brackets. This is the fairer comparison.
239
+
240
+ ---
241
+
242
+ ## Quick start
243
+
244
+ ```bash
245
+ pip install torch transformers safetensors huggingface_hub numpy # GPU or CPU
246
+ # or, with no torch at all:
247
+ pip install onnxruntime tokenizers huggingface_hub numpy
248
+ ```
249
+
250
+ ```python
251
+ import os, sys
252
+ from huggingface_hub import hf_hub_download
253
+ sys.path.insert(0, os.path.dirname(hf_hub_download("valkayuh/dewpoint", "dewpoint.py")))
254
+ from dewpoint import Punctuator
255
+
256
+ p = Punctuator.from_pretrained("valkayuh/dewpoint") # torch if installed, else ONNX
257
+ p.restore("what time is it in tokyo right now", lang="en")
258
+ # 'What time is it in Tokyo right now?'
259
+ ```
260
+
261
+ `from_pretrained` downloads only what the chosen backend needs. The torch backend
262
+ fetches the safetensors (3.5 GB, or 1.2 GB with `members=["mmbert-base"]`), the ONNX
263
+ backend fetches the ONNX graphs, and neither fetches the other.
264
+
265
+ **Always pass the language.** It selects the calibrated decision thresholds, turns
266
+ casing off for scripts that have none, and picks the right marks to write: `؟` for
267
+ Arabic, `;` for the Greek question mark, `。` and `,` for Chinese, `।` for Hindi.
268
+
269
+ **Single-model mode.** Pass `members=["mmbert-base"]` to download and run only the
270
+ 307M-parameter mmBERT-base half. It uses its own calibration, runs in about half the
271
+ GPU time, and still beats every other model we tested on every benchmark:
272
+
273
+ ```python
274
+ p = Punctuator.from_pretrained("valkayuh/dewpoint", members=["mmbert-base"])
275
+ ```
276
+
277
+ **Streaming.** For live captions, each word is released only once it has right-hand
278
+ context and a stable label, so marks don't flicker:
279
+
280
+ ```python
281
+ s = p.stream(lang="en", lag=6)
282
+ for chunk in asr_chunks: # partial transcripts as they arrive
283
+ print(s.push(chunk), end="", flush=True)
284
+ print(s.finish())
285
+ ```
286
+
287
+ **Labels and probabilities.** `p.predict(words, lang)` returns per-word labels plus
288
+ the averaged posteriors (`punct_probs`, `case_probs`), in case you want confidence
289
+ scores or your own decision rule.
290
+
291
+ **Command line.**
292
+
293
+ ```bash
294
+ echo "i called the office this morning but nobody answered did you get my message" | python dewpoint.py --lang en
295
+ # I called the office this morning, but nobody answered. Did you get my message?
296
+ ```
297
+
298
+ ## Formats and integration
299
+
300
+ | you want | use |
301
+ |---|---|
302
+ | Python with a GPU | `Punctuator(..., backend="torch")`: bf16 on CUDA, fp32 on CPU |
303
+ | Python without torch, e.g. a slim CPU server | `Punctuator(..., backend="onnx")`: needs only `onnxruntime`, `tokenizers`, `numpy` |
304
+ | a local HTTP API | `python serve.py` gives `POST /restore {"text", "lang"}` (standard library only) |
305
+ | a hosted API | deploy this repo as a [Hugging Face Inference Endpoint](https://huggingface.co/inference-endpoints); `handler.py` serves `{"inputs", "parameters": {"lang"}}` |
306
+ | the shell | `python dewpoint.py --lang de "…"` or pipe text on stdin |
307
+ | C#, Java, JavaScript, C++, mobile | the ONNX graphs in `onnx/`, with the pre- and post-processing below |
308
+
309
+ **ONNX graphs.** There is one per member, in `onnx/mmbert-base/` and
310
+ `onnx/xlm-roberta-large/` (the latter keeps its weights in `model.onnx.data` beside
311
+ the graph). Both are fp32, opset 17, with dynamic batch and sequence axes:
312
+
313
+ - inputs: `input_ids`, `attention_mask`, int64 `[batch, seq]`, from that member's
314
+ `tokenizer.json` with the words passed pre-tokenised
315
+ - outputs: `punct_logits` `[batch, seq, 5]` and `case_logits` `[batch, seq, 3]`
316
+
317
+ Around the graph, `dewpoint.py` does four things, and a port needs the same four:
318
+ 1. split the input into overlapping windows of at most 508 subwords, keeping each
319
+ window's centre (`plan_windows`)
320
+ 2. read each word's label from its first subword
321
+ 3. average the two members' softmax posteriors
322
+ 4. add the calibrated per-class bias from `ensemble_config.json` for the language, then
323
+ take the argmax
324
+
325
+ **Verified.** Run over the whole official test set on CPU, the ONNX backend scores **0.7558** macro-F1 against the published 0.7559; the largest difference in any scored language is 0.0011, the gap between fp32 ONNX and the bf16 GPU run the published numbers come from. Its tokenisation matches transformers exactly on 323 documents in 57 languages.
326
+
327
+ ### Speed
328
+
329
+ Measured on a 584-word TED passage, after warm-up, with the code in this repo:
330
+
331
+ | hardware | Dewpoint (869M) | mmBERT half (307M) |
332
+ |---|---|---|
333
+ | RTX 5080, torch bf16 | 45 ms (13k words/s) | 23 ms (25k words/s) |
334
+ | Ryzen 7 5800X3D, torch fp32 | 2.18 s | 632 ms (1k words/s) |
335
+ | Ryzen 7 5800X3D, ONNX Runtime fp32 | 2.13 s | 606 ms (1k words/s) |
336
+
337
+ Input of any length is handled with overlapping 512-token windows. Only the centre of
338
+ each window commits its predictions, because words near a window edge lack context and
339
+ score measurably worse.
340
+
341
+ ---
342
+
343
+ ## Model
344
+
345
+ Dewpoint is an **ensemble of two dual-head token classifiers**. Both members read the
346
+ same word list and produce one posterior per word, and those posteriors are averaged.
347
+
348
+ | member | encoder | architecture | parameters | of which embeddings |
349
+ |---|---|---|---|---|
350
+ | `mmbert-base/` | [jhu-clsp/mmBERT-base](https://huggingface.co/jhu-clsp/mmBERT-base) | ModernBERT | 307.5M | 196.6M |
351
+ | `xlm-roberta-large/` | [FacebookAI/xlm-roberta-large](https://huggingface.co/FacebookAI/xlm-roberta-large) | XLM-RoBERTa | 561.0M | 256.5M |
352
+
353
+ **Each member** is an encoder, then a residual shared trunk (Linear, GELU, LayerNorm),
354
+ then two linear heads:
355
+
356
+ - **punctuation**, 5 classes: `O` `COMMA` `PERIOD` `QUESTION` `EXCLAM`
357
+ - **case**, 3 classes: `LOWER` (*meeting*), `CAP` for a capitalised first letter
358
+ (*Tuesday*, *Tokyo*, a sentence's first word), and `UPPER` for a word written
359
+ entirely in capitals (*NASA*, *BBC*, *DNA*). Mixed-case words such as *iPhone* are
360
+ handled by a word list, described below.
361
+
362
+ There is one decision per word, not per subword: the label sits on each word's first
363
+ subword. Punctuation and case are learned together because they are correlated: a
364
+ sentence end means a capital comes next.
365
+
366
+ **Combining the members.** Both members label the same words, so their outputs line up
367
+ without any tokenizer reconciliation. The average is taken in **probability space**, not
368
+ log space. With a geometric mean, one confident but wrong member could veto a class;
369
+ the arithmetic mean degrades gracefully instead.
370
+
371
+ **Calibration.** Class weighting stops the model predicting "no punctuation"
372
+ everywhere, but it buys recall at the cost of precision, so a plain argmax is not the
373
+ F1-optimal rule. Instead, a per-class additive bias on the log-posterior is fitted on
374
+ IWSLT dev2010, which is held out of every test table. Whether to add per-language
375
+ biases on top, and how far to trust them, is chosen by cross-validation *inside*
376
+ dev2010: its talks are split in half five times, and each strategy is fitted on one
377
+ half and scored on the other. An unshrunk bias per language, fitted on eight talks,
378
+ overfits: it was never the winning strategy, and for Dewpoint it scored worst. Dewpoint
379
+ uses per-language biases pulled three quarters of the way back to the pooled one. The ensemble is
380
+ calibrated as a system in its own right, and every other model in the tables went
381
+ through the identical procedure, each choosing its own strategy.
382
+
383
+ **Post-processing**, in `restore()` only (`predict()` returns the raw decision):
384
+ - a capital after every sentence-final mark
385
+ - a gazetteer of 4,896 mixed-case forms such as *iPhone*, *McDonald* and *GHz*
386
+ - a closing mark if the text would otherwise end on none
387
+
388
+ ---
389
+
390
+ ## Evaluation
391
+
392
+ - **Three test conditions:**
393
+ - the IWSLT 2017 official tst2010–2015 sets, with 9 scorable languages
394
+ - the WIT3 releases of the *same 90 talks* in 57 language tracks, so only the
395
+ language changes
396
+ - an ASR condition: TED text spoken by TTS, transcribed by Whisper
397
+ large-v3-turbo, and aligned back to the reference, in 33 languages
398
+ - **Metric.** Macro-F1 over COMMA, PERIOD and QUESTION, averaged over languages.
399
+ EXCLAM is folded into PERIOD, because no other model emits it. Accuracy is never
400
+ reported: about 87% of words carry no mark, so a model that predicts nothing is
401
+ "87% accurate".
402
+ - **Exclusions.** Japanese and Thai are excluded, and named here. Their TED references
403
+ carry almost no punctuation (0.38% and 0.12% of tokens).
404
+ - **Reference audit.** Two errors in the reference's label maps were fixed for every
405
+ system alike. Armenian writes its full stop as an ASCII colon; fixing that doubled
406
+ *every* system's Armenian score. And the Burmese comma U+104A had been mapped to a
407
+ full stop.
408
+ - **No TED data was used for training.** TED is the test set.
409
+
410
+ ### Every language on the extended set
411
+
412
+ ![Per-language macro-F1 on 57 WIT3 language tracks](assets/per_language_wit3.jpg)
413
+
414
+ ### Under ASR noise
415
+
416
+ ![Per-language gap to the best rival under ASR](assets/asr_condition_gap.jpg)
417
+
418
+ 5 of the 7 are to raw Whisper (English, Spanish, French, Italian, Portuguese). Whisper punctuates from the audio itself, so it can use pauses and intonation that a text-only tagger never sees. The rest: Bengali (−0.044 to fullstop-sonar), Albanian (−0.025 to xlm-r truecase). Languages marked * in the chart have under 3,000 scored words, so their gaps are noisy.
419
+
420
+ ### Per class, and truecasing
421
+
422
+ ![Per-class F1](assets/per_class.jpg)
423
+
424
+ Mean per-class F1 against the strongest other model:
425
+
426
+ | test set | system | COMMA | PERIOD | QUESTION |
427
+ |---|---|---|---|---|
428
+ | IWSLT 2017 official (9 languages) | Dewpoint | **0.6557** | **0.7923** | **0.8197** |
429
+ | IWSLT 2017 official (9 languages) | fullstop-large | 0.5334 | 0.7206 | 0.6772 |
430
+ | WIT3, same 90 talks (57 language tracks) | Dewpoint | **0.6482** | **0.7794** | **0.7632** |
431
+ | WIT3, same 90 talks (57 language tracks) | fullstop-large | 0.5067 | 0.6988 | 0.5896 |
432
+ | Whisper ASR output (33 languages) | Dewpoint | **0.5656** | **0.7102** | **0.6238** |
433
+ | Whisper ASR output (33 languages) | fullstop-large | 0.4489 | 0.6556 | 0.4858 |
434
+
435
+ Truecasing F1 against the only other model that restores case, on cased scripts:
436
+
437
+ | test set | cased languages | Dewpoint CAP | UPPER | xlm-r truecase CAP | UPPER | languages behind |
438
+ |---|---|---|---|---|---|---|
439
+ | IWSLT 2017 official (9 languages) | 6 | **0.8772** | **0.8748** | 0.7708 | 0.7888 | 0 |
440
+ | WIT3, same 90 talks (57 language tracks) | 43 | **0.8533** | **0.8599** | 0.7173 | 0.8005 | 0 |
441
+ | Whisper ASR output (33 languages) | 25 | **0.8007** | **0.7584** | 0.7136 | 0.7083 | 0 |
442
+
443
+ **Reading the truecasing table.** `CAP` scores words whose first letter should be a
444
+ capital: names, places, and the first word of every sentence. `UPPER` scores words
445
+ written entirely in capitals, which in practice means acronyms and initialisms such as
446
+ *NASA*, *BBC* or *DNA*. These are much rarer, so they get their own score and are not
447
+ swamped by ordinary capitals. A single capital letter such as *I* counts as `CAP`.
448
+
449
+ The truecasing comparison covers cased scripts only. On caseless scripts (Arabic,
450
+ Chinese, Korean, Hindi and others) the case head is masked by design, so Latin loanwords
451
+ inside them are never capitalised. `xlm-roberta_punctuation_fullstop_truecase` does
452
+ capitalise them, so it wins on those words by construction. The native EXCLAM class
453
+ scores 0.11 F1 on the official set and 0.07 on WIT3. No other
454
+ model emits it at all.
455
+
456
+ ### Size
457
+
458
+ ![Parameters against macro-F1](assets/size_vs_score.jpg)
459
+
460
+ ### Where the gain comes from
461
+
462
+ Every other model in these tables is an XLM-RoBERTa model, so a win could come from the
463
+ architecture or from the training recipe. To separate the two, we trained
464
+ **XLM-R-large, the encoder `fullstop-punctuation-multilang-large` uses, on this corpus
465
+ with this recipe**.
466
+
467
+ ![Method against architecture](assets/method_vs_architecture.jpg)
468
+
469
+ The recipe is worth **+0.08 to +0.13** macro-F1. Swapping the encoder is worth −0.004
470
+ to +0.016. The corpus, the orthography fixes and the decision rule matter roughly an
471
+ order of magnitude more than the encoder. Neither encoder dominated the other, and
472
+ averaging exploits exactly that: members that do not make the same errors.
473
+
474
+ ### Per-language tables
475
+
476
+ <details>
477
+ <summary>IWSLT 2017 official, 9 languages</summary>
478
+
479
+ | language | Dewpoint | mmBERT half alone | best other model | which | gap |
480
+ |---|---|---|---|---|---|
481
+ | Arabic (`ar`) | 0.6538 | 0.6229 | 0.5586 | fullstop-large | +0.095 |
482
+ | Chinese (`zh`) | 0.6551 | 0.6546 | 0.5502 | xlm-r truecase | +0.105 |
483
+ | Dutch (`nl`) | 0.7686 | 0.7587 | 0.7304 | fullstop-sonar | +0.038 |
484
+ | English (`en`) | 0.8033 | 0.7935 | 0.7430 | fullstop-large | +0.060 |
485
+ | French (`fr`) | 0.7956 | 0.7841 | 0.7581 | fullstop-large | +0.038 |
486
+ | German (`de`) | 0.8496 | 0.8402 | 0.8338 | fullstop-large | +0.016 |
487
+ | Italian (`it`) | 0.7145 | 0.6886 | 0.6838 | fullstop-large | +0.031 |
488
+ | Korean (`ko`) | 0.8010 | 0.7825 | 0.6553 | xlm-r truecase | +0.146 |
489
+ | Romanian (`ro`) | 0.7618 | 0.7274 | 0.6860 | fullstop-large | +0.076 |
490
+
491
+ </details>
492
+
493
+ <details>
494
+ <summary>WIT3 extended set, 57 language tracks</summary>
495
+
496
+ | language | Dewpoint | mmBERT half alone | best other model | which | gap |
497
+ |---|---|---|---|---|---|
498
+ | Albanian (`sq`) | 0.7152 | 0.6828 | 0.6036 | fullstop-large | +0.112 |
499
+ | Arabic (`ar`) | 0.6538 | 0.6225 | 0.5585 | fullstop-large | +0.095 |
500
+ | Armenian (`hy`) | 0.7902 | 0.7709 | 0.4924 | kredor | +0.298 |
501
+ | Azerbaijani (`az`) | 0.7286 | 0.7105 | 0.5832 | fullstop-sonar | +0.145 |
502
+ | Bengali (`bn`) | 0.6940 | 0.6497 | 0.5584 | fullstop-large | +0.136 |
503
+ | Bosnian (`bs`) | 0.7753 | 0.7540 | 0.6324 | fullstop-large | +0.143 |
504
+ | Bulgarian (`bg`) | 0.8089 | 0.7906 | 0.7491 | kredor | +0.060 |
505
+ | Burmese (`my`) | 0.4427 | 0.3569 | 0.3553 | xlm-r truecase | +0.087 |
506
+ | Catalan (`ca`) | 0.7189 | 0.6880 | 0.6339 | fullstop-large | +0.085 |
507
+ | Chinese (`zh`) | 0.6543 | 0.6349 | 0.4509 | xlm-r truecase | +0.203 |
508
+ | Chinese (Simplified) (`zh-cn`) | 0.6486 | 0.6483 | 0.5439 | xlm-r truecase | +0.105 |
509
+ | Chinese (Traditional) (`zh-tw`) | 0.5941 | 0.5960 | 0.5136 | xlm-r truecase | +0.081 |
510
+ | Croatian (`hr`) | 0.7712 | 0.7540 | 0.6942 | fullstop-large | +0.077 |
511
+ | Czech (`cs`) | 0.8120 | 0.7930 | 0.7461 | kredor | +0.066 |
512
+ | Danish (`da`) | 0.7661 | 0.7498 | 0.6975 | fullstop-large | +0.069 |
513
+ | Dutch (`nl`) | 0.8171 | 0.8060 | 0.7819 | fullstop-sonar | +0.035 |
514
+ | English (`en`) | 0.8036 | 0.7937 | 0.7431 | fullstop-large | +0.061 |
515
+ | Esperanto (`eo`) | 0.7663 | 0.7297 | 0.6385 | fullstop-sonar | +0.128 |
516
+ | Estonian (`et`) | 0.8146 | 0.7884 | 0.7178 | fullstop-large | +0.097 |
517
+ | Finnish (`fi`) | 0.8207 | 0.7996 | 0.7283 | kredor | +0.092 |
518
+ | French (`fr`) | 0.7961 | 0.7842 | 0.7588 | fullstop-large | +0.037 |
519
+ | French (Canada) (`fr-ca`) | 0.7778 | 0.7604 | 0.7445 | fullstop-sonar | +0.033 |
520
+ | Georgian (`ka`) | 0.7142 | 0.6583 | 0.6179 | fullstop-large | +0.096 |
521
+ | German (`de`) | 0.8472 | 0.8392 | 0.8302 | fullstop-large | +0.017 |
522
+ | Greek (`el`) | 0.7331 | 0.7071 | 0.6951 | fullstop-large | +0.038 |
523
+ | Hebrew (`he`) | 0.7398 | 0.7030 | 0.6506 | fullstop-large | +0.089 |
524
+ | Hindi (`hi`) | 0.5855 | 0.5600 | 0.4925 | fullstop-large | +0.093 |
525
+ | Hungarian (`hu`) | 0.7903 | 0.7637 | 0.6771 | fullstop-large | +0.113 |
526
+ | Indonesian (`id`) | 0.7350 | 0.7174 | 0.6401 | fullstop-large | +0.095 |
527
+ | Italian (`it`) | 0.7412 | 0.7211 | 0.7148 | fullstop-large | +0.026 |
528
+ | Kazakh (`kk`) | 0.7657 | 0.7556 | 0.6508 | xlm-r truecase | +0.115 |
529
+ | Korean (`ko`) | 0.8008 | 0.7825 | 0.6564 | xlm-r truecase | +0.144 |
530
+ | Kurdish (`ku`) | 0.1114 | 0.2843 | 0.0298 | xlm-r truecase | +0.082 |
531
+ | Latvian (`lv`) | 0.8174 | 0.7928 | 0.7117 | kredor | +0.106 |
532
+ | Lithuanian (`lt`) | 0.7935 | 0.7725 | 0.6945 | kredor | +0.099 |
533
+ | Macedonian (`mk`) | 0.7479 | 0.7190 | 0.6378 | fullstop-large | +0.110 |
534
+ | Malay (`ms`) | 0.7748 | 0.7426 | 0.6387 | fullstop-large | +0.136 |
535
+ | Marathi (`mr`) | 0.6624 | 0.6120 | 0.5224 | fullstop-large | +0.140 |
536
+ | Mongolian (`mn`) | 0.7962 | 0.7447 | 0.6349 | xlm-r truecase | +0.161 |
537
+ | Norwegian (`nb`) | 0.7704 | 0.7531 | 0.7399 | fullstop-large | +0.031 |
538
+ | Persian (`fa`) | 0.6895 | 0.6700 | 0.5903 | fullstop-large | +0.099 |
539
+ | Polish (`pl`) | 0.8181 | 0.7999 | 0.7696 | kredor | +0.049 |
540
+ | Portuguese (`pt`) | 0.7725 | 0.7555 | 0.7095 | kredor | +0.063 |
541
+ | Portuguese (Brazil) (`pt-br`) | 0.7550 | 0.7354 | 0.6701 | kredor | +0.085 |
542
+ | Romanian (`ro`) | 0.7623 | 0.7320 | 0.6975 | fullstop-large | +0.065 |
543
+ | Russian (`ru`) | 0.8354 | 0.8218 | 0.7312 | kredor | +0.104 |
544
+ | Serbian (`sr`) | 0.7783 | 0.7588 | 0.6984 | fullstop-large | +0.080 |
545
+ | Slovak (`sk`) | 0.8050 | 0.7787 | 0.7440 | kredor | +0.061 |
546
+ | Slovenian (`sl`) | 0.8386 | 0.8211 | 0.8100 | kredor | +0.029 |
547
+ | Spanish (`es`) | 0.7582 | 0.7463 | 0.7070 | fullstop-large | +0.051 |
548
+ | Swahili (`sw`) | 0.6403 | 0.5944 | 0.5180 | fullstop-sonar | +0.122 |
549
+ | Swedish (`sv`) | 0.7822 | 0.7611 | 0.7432 | fullstop-large | +0.039 |
550
+ | Tamil (`ta`) | 0.5091 | 0.4862 | 0.4771 | xlm-r truecase | +0.032 |
551
+ | Turkish (`tr`) | 0.7290 | 0.7082 | 0.5883 | xlm-r truecase | +0.141 |
552
+ | Ukrainian (`uk`) | 0.8149 | 0.7951 | 0.7241 | kredor | +0.091 |
553
+ | Urdu (`ur`) | 0.5309 | 0.5186 | 0.5042 | fullstop-large | +0.027 |
554
+ | Vietnamese (`vi`) | 0.7105 | 0.6830 | 0.5800 | fullstop-sonar | +0.131 |
555
+
556
+ </details>
557
+
558
+ <details>
559
+ <summary>ASR condition, 33 languages</summary>
560
+
561
+ | language | Dewpoint | mmBERT half alone | best other model | which | gap |
562
+ |---|---|---|---|---|---|
563
+ | Albanian (`sq`) | 0.3580 | 0.4005 | 0.3830 | xlm-r truecase | −0.025 |
564
+ | Arabic (`ar`) | 0.5309 | 0.5020 | 0.4840 | fullstop-large | +0.047 |
565
+ | Bengali (`bn`) | 0.0730 | 0.1404 | 0.1170 | fullstop-sonar | −0.044 |
566
+ | Bulgarian (`bg`) | 0.7247 | 0.6928 | 0.6776 | kredor | +0.047 |
567
+ | Catalan (`ca`) | 0.7112 | 0.6698 | 0.6025 | fullstop-sonar | +0.109 |
568
+ | Chinese (`zh`) | 0.7528 | 0.7620 | 0.7312 | xlm-r truecase | +0.022 |
569
+ | Dutch (`nl`) | 0.6765 | 0.6661 | 0.6115 | kredor | +0.065 |
570
+ | English (`en`) | 0.7837 | 0.7869 | 0.9382 | raw Whisper | −0.155 |
571
+ | Finnish (`fi`) | 0.7233 | 0.7053 | 0.6593 | kredor | +0.064 |
572
+ | French (`fr`) | 0.7702 | 0.7705 | 0.8240 | raw Whisper | −0.054 |
573
+ | German (`de`) | 0.8329 | 0.8204 | 0.8140 | fullstop-large | +0.019 |
574
+ | Greek (`el`) | 0.6437 | 0.5979 | 0.5943 | kredor | +0.049 |
575
+ | Hebrew (`he`) | 0.6332 | 0.5870 | 0.5265 | fullstop-sonar | +0.107 |
576
+ | Hindi (`hi`) | 0.6238 | 0.5805 | 0.5509 | fullstop-sonar | +0.073 |
577
+ | Hungarian (`hu`) | 0.7210 | 0.7041 | 0.6368 | fullstop-large | +0.084 |
578
+ | Indonesian (`id`) | 0.6723 | 0.6582 | 0.6249 | fullstop-large | +0.047 |
579
+ | Italian (`it`) | 0.7025 | 0.6750 | 0.7745 | raw Whisper | −0.072 |
580
+ | Kazakh (`kk`) | 0.4953 | 0.4933 | 0.3990 | raw Whisper | +0.096 |
581
+ | Latvian (`lv`) | 0.7218 | 0.7039 | 0.6108 | kredor | +0.111 |
582
+ | Malay (`ms`) | 0.6743 | 0.6516 | 0.5907 | fullstop-large | +0.084 |
583
+ | Marathi (`mr`) | 0.2584 | 0.2685 | 0.2000 | xlm-r truecase | +0.058 |
584
+ | Persian (`fa`) | 0.5209 | 0.4990 | 0.4512 | xlm-r truecase | +0.070 |
585
+ | Polish (`pl`) | 0.7608 | 0.7462 | 0.7409 | kredor | +0.020 |
586
+ | Portuguese (`pt`) | 0.7616 | 0.7535 | 0.8522 | raw Whisper | −0.091 |
587
+ | Romanian (`ro`) | 0.6462 | 0.6148 | 0.5553 | xlm-r truecase | +0.091 |
588
+ | Russian (`ru`) | 0.7627 | 0.7647 | 0.6927 | kredor | +0.070 |
589
+ | Spanish (`es`) | 0.7866 | 0.7844 | 0.8386 | raw Whisper | −0.052 |
590
+ | Swahili (`sw`) | 0.4165 | 0.3931 | 0.3596 | kredor | +0.057 |
591
+ | Swedish (`sv`) | 0.6646 | 0.6514 | 0.6435 | fullstop-large | +0.021 |
592
+ | Tamil (`ta`) | 0.4052 | 0.3825 | 0.2485 | fullstop-sonar | +0.157 |
593
+ | Turkish (`tr`) | 0.6670 | 0.6613 | 0.5448 | xlm-r truecase | +0.122 |
594
+ | Ukrainian (`uk`) | 0.7310 | 0.6955 | 0.6545 | xlm-r truecase | +0.076 |
595
+ | Vietnamese (`vi`) | 0.6895 | 0.6684 | 0.5873 | xlm-r truecase | +0.102 |
596
+
597
+ </details>
598
+
599
+ All per-language, per-class scores for every system are in
600
+ [`results/scores.json`](results/scores.json).
601
+
602
+ ---
603
+
604
+ ## Training
605
+
606
+ ### Text: 79 languages, no TED
607
+
608
+ | source | licence | role |
609
+ |---|---|---|
610
+ | [Wikipedia](https://huggingface.co/datasets/wikimedia/wikipedia) | CC-BY-SA-4.0 | volume, and most of the proper nouns truecasing needs |
611
+ | [Europarl](https://huggingface.co/datasets/Helsinki-NLP/europarl) | no known restrictions | spoken-style parliamentary prose |
612
+ | [Tatoeba](https://tatoeba.org/en/downloads) | CC-BY-2.0 FR | questions: 10–25% of its sentences are questions |
613
+ | [HPLT 2.0 cleaned](https://huggingface.co/datasets/HPLT/HPLT2.0_cleaned) | CC0-1.0 | fills six languages Wikipedia leaves thin |
614
+ | [VoxPopuli transcripts](https://github.com/facebookresearch/voxpopuli) | CC0-1.0 | verbatim spoken transcripts, 16 languages |
615
+ | [Aya dataset](https://huggingface.co/datasets/CohereLabs/aya_dataset) | Apache-2.0 | question-dense human prose for low-resource languages |
616
+
617
+ The corpus is temperature-sampled so that the largest languages don't swamp the rest.
618
+ Labels come free: strip the punctuation and case from clean prose, and what was
619
+ stripped is exactly what the model must predict. **Synthetic ASR noise** is applied to
620
+ about two thirds of training examples: filler words, dropped short words, character
621
+ substitutions, merged and split words, numbers written the way they're spoken, and
622
+ truncated utterances.
623
+
624
+ ### Speech: round-trip ASR data
625
+
626
+ A slice of the training text was spoken aloud by TTS, transcribed by Whisper
627
+ large-v3-turbo, and aligned back to the original. That gives inputs with genuine
628
+ recognition errors but ground-truth labels. The corpus has 0.96M unique words across 23 languages, voiced by three permissively licensed engines. Oversampled to match the per-language dose of the original recipe, it makes up 6.8% of the final fine-tuning stage.
629
+
630
+ | engine | licence | languages (utterances kept) |
631
+ |---|---|---|
632
+ | [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M) | Apache-2.0 | en (5,998), es (5,077), fr (3,996), hi (3,879), it (3,996), pt (3,996), zh (2,992) |
633
+ | [Piper voices](https://huggingface.co/rhasspy/piper-voices) | CC0 or CC-BY-4.0, per voice | bg (2,180), de (2,198), el (2,124), fa (1,057), fi (1,196), hu (1,184), kk (244), lv (1,067), nl (2,168), pl (2,189), ro (1,194), ru (1,177), sq (662), sv (2,183), uk (1,150) |
634
+ | [Chatterbox Multilingual](https://huggingface.co/ResembleAI/chatterbox) | MIT | ar (606) |
635
+
636
+ Every Piper voice's licence was read from its model card by code, and any voice whose
637
+ card said non-commercial, share-alike, "see URL" or nothing at all was refused.
638
+ The voice that produced a language's ASR *test* audio is never used for its training
639
+ audio. French is the exception, because Kokoro has only one French voice. No audio was
640
+ kept or is distributed.
641
+
642
+ ### Procedure
643
+
644
+ Each member was trained in stages. Every stage starts from the weights the previous
645
+ stage ended with, and each later stage makes smaller, more targeted changes:
646
+
647
+ 1. **Learn the task.** The encoder starts from its public pretrained weights, knowing
648
+ language but nothing about punctuation, and learns to predict marks and capitals on
649
+ a large clean-text corpus.
650
+ 2. **Rebalance toward speech.** Training continues on a resample of the corpus that
651
+ gives more weight to spoken-style sources (parliamentary transcripts, Tatoeba, Aya)
652
+ and mixes short passages in with long ones, closer to what ASR produces.
653
+ 3. **Harden against recognition errors.** A short final stage on a 55M-word mix that
654
+ includes the round-trip ASR data above, at a low learning rate, so the model learns
655
+ to punctuate text containing Whisper's mistakes without forgetting the rest.
656
+
657
+ | member | stage | starts from | data | steps | peak learning rate |
658
+ |---|---|---|---|---|---|
659
+ | mmBERT-base | learn the task | `jhu-clsp/mmBERT-base` | 1.02B-word corpus, long passages | 34,000 | 4e-5 |
660
+ | mmBERT-base | rebalance | previous stage | 735M-word resample | 13,000 | 1.8e-5 |
661
+ | mmBERT-base | rebalance, continued | previous stage | 735M-word resample | 3,500 | 8e-6 |
662
+ | mmBERT-base | harden | previous stage | 55M-word mix with round-trip data | 4,000 | 9e-6 |
663
+ | XLM-R-large | learn + rebalance | `FacebookAI/xlm-roberta-large` | 735M-word resample | 17,000 | 8e-6 |
664
+ | XLM-R-large | harden | previous stage | 55M-word mix with round-trip data | 4,000 | 3e-6 |
665
+
666
+ How to read the table:
667
+ - **Steps.** A step is one update of the weights, computed from a batch of 64 text
668
+ windows of up to 512 subword tokens each, roughly 30,000 tokens. The first mmBERT
669
+ stage, for example, saw about a billion tokens.
670
+ - **Peak learning rate.** This is the largest step size in that stage's schedule. It
671
+ falls from stage to stage because later stages refine rather than relearn.
672
+ - **XLM-R-large has fewer stages** because it went straight to the rebalanced
673
+ corpus. It was first trained as a controlled comparison against mmBERT-base, and
674
+ later became the second member.
675
+
676
+ Settings shared by all stages:
677
+ - AdamW, with a learning rate that warms up and then decays along a cosine curve
678
+ - a higher learning rate for the two heads than for the encoder
679
+ - class-weighted cross-entropy on both heads, so the rare marks are not drowned out by
680
+ the many words that take none
681
+ - label smoothing 0.02
682
+ - for XLM-R-large only, a frozen embedding matrix
683
+
684
+ Everything was trained on a single RTX 5080 with 16 GB.
685
+
686
+ ### What did not work
687
+
688
+ | attempt | result |
689
+ |---|---|
690
+ | supervised contrastive loss (reported +1.9 to +3.2 F1 on this task) | +0.001 / +0.001 / −0.002; the rarest class got worse |
691
+ | joint punctuation–case decoding | cut head contradictions from 9.0% to 0.27% but lowered F1 at every setting |
692
+ | mDeBERTa-v3-base as a third encoder | lost on all three sets |
693
+ | distilling the ensemble into one mmBERT-base | in-domain validation +0.015, every held-out set flat |
694
+ | choosing case *given* the decided punctuation | lowered case F1 at every coupling strength |
695
+
696
+ The distillation result is why Dewpoint is an ensemble. Its robustness comes from the
697
+ two members disagreeing on each input. A single model trained to mimic their average
698
+ has no disagreement left to exploit. Two of these experiments were pre-registered:
699
+ their success criteria were written down before the run.
700
+
701
+ ---
702
+
703
+ ## Limitations
704
+
705
+ - **Two forward passes.** The ensemble has 2.8× the parameters of its mmBERT half, for +0.013 to +0.020 macro-F1. If latency matters more than that margin, use `members=["mmbert-base"]`.
706
+ - **Text only.** Raw Whisper still wins in English, Spanish, French, Italian, Portuguese under ASR conditions, most likely because it hears pauses and intonation. Adding a tagger after ASR only helps if your ASR doesn't already punctuate.
707
+ - **English is not state of the art against English-only systems.** On English tst2011, Dewpoint scores 0.800 macro-F1 (COMMA 0.715, PERIOD 0.823, QUESTION 0.860) without having seen any TED text. The strongest published English-only systems report about 0.85 overall F1 on IWSLT2011; they were trained in-domain on TED and scored on a differently preprocessed version of the set.
708
+ - **Weak languages.** Kurdish (0.11), Burmese (0.44), Tamil (0.51), Urdu (0.53) are still low in absolute terms, even where Dewpoint leads the other models. In Kurdish (0.11 against 0.28) the ensemble is below its own mmBERT half, so averaging in probability space does not help everywhere.
709
+ - **Japanese and Thai are unmeasured.** Their TED references are nearly unpunctuated, so no trustworthy number exists. The model is trained on both.
710
+ - **Very short inputs.** With only a few words there is little context, and the case head can guess a sentence boundary that the punctuation head didn't place (`hello how are you` → `Hello, How are You?`). On full passages it is rare: 0.4% of words on the official test set. Pass whole utterances, not fragments.
711
+ - **Caseless-script loanwords** are never capitalised (see truecasing above).
712
+ - **EXCLAM is weak**: 0.11 F1 on the official set and 0.07 on WIT3, and exclamation marks are rare there too.
713
+ - **Only five marks.** Colons, semicolons, dashes, quotes and parentheses are not restored, and French typographic spacing before `?` and `!` is not added.
714
+ - **Domain.** The benchmark is TED, which is prepared monologue. Conversational, overlapping, multi-speaker speech is harder, and isn't measured here.
715
+
716
+ ---
717
+
718
+ ## Licence and provenance
719
+
720
+ **Weights and code: MIT.** Both base encoders are MIT-licensed. Every TTS voice used for
721
+ training audio is MIT, Apache-2.0, CC0 or CC-BY-4.0.
722
+
723
+ A few things are worth knowing before commercial use:
724
+
725
+ - **Wikipedia is CC-BY-SA-4.0.** Whether share-alike terms reach model weights trained
726
+ on such text is legally unsettled. Many encoders trained on Wikipedia, BERT among
727
+ them, are released under permissive licences, and Dewpoint follows that practice.
728
+ - **Tatoeba is CC-BY-2.0 FR.** Sentences © Tatoeba contributors, https://tatoeba.org.
729
+ - **Some Piper voices are CC-BY-4.0**, which asks for attribution to the voice
730
+ datasets. They are listed in [`results/rt2_voices.json`](results/rt2_voices.json).
731
+ - **Evaluation data.** IWSLT/WIT3 TED transcripts are CC-BY-NC-ND-4.0. They were used
732
+ for measurement only and are not included here.
733
+
734
+ This is a description, not legal advice.
735
+
736
+ ---
737
+
738
+ ## Files
739
+
740
+ | path | contents |
741
+ |---|---|
742
+ | `dewpoint.py` | self-contained inference: `Punctuator` (torch or ONNX backend), streaming, command line |
743
+ | `onnx/` | both members as fp32 ONNX graphs |
744
+ | `serve.py` | a standard-library HTTP server |
745
+ | `handler.py` | the Hugging Face Inference Endpoints handler |
746
+ | `requirements.txt`, `requirements-onnx.txt` | dependencies for each backend |
747
+ | `ensemble_config.json` | member list, weights, the ensemble's calibrated biases |
748
+ | `mmbert-base/` | weights (safetensors), encoder config, tokenizer, its own calibration |
749
+ | `xlm-roberta-large/` | the same, for the XLM-R-large member |
750
+ | `gazetteer.json` | mixed-case surface forms, per language |
751
+ | `results/` | per-language scores for every system, and round-trip voice provenance |
752
+ | `assets/` | the images on this page |
753
+
754
+ The inference file reproduces the published official-set score exactly, per language,
755
+ against the evaluation harness it was measured with.
756
+
757
+ ## Citation
758
+
759
+ ```bibtex
760
+ @misc{dewpoint2026,
761
+ title = {Dewpoint: multilingual punctuation restoration and truecasing for speech},
762
+ author = {valkayuh},
763
+ year = {2026},
764
+ url = {https://huggingface.co/valkayuh/dewpoint}
765
+ }
766
+ ```
767
+
768
+ Please also cite the encoders Dewpoint builds on:
769
+ [mmBERT](https://huggingface.co/jhu-clsp/mmBERT-base) and
770
+ [XLM-RoBERTa](https://arxiv.org/abs/1911.02116).
assets/asr_condition_gap.jpg ADDED

Git LFS Details

  • SHA256: b8793f3bc60fa0cf6f2651bab61a4e7ea2341885334ce8c613ddaf4dd5e1f7ef
  • Pointer size: 131 Bytes
  • Size of remote file: 419 kB
assets/banner.jpg ADDED

Git LFS Details

  • SHA256: ea14fdaf59f3b6ed67c425b7de8025476b49357299d3dcecdc561e7739cb5cf8
  • Pointer size: 131 Bytes
  • Size of remote file: 247 kB
assets/benchmarks.jpg ADDED

Git LFS Details

  • SHA256: ad09a24547378d9f3922dcff39c49bdf0dc74d21cdde50ae3476912d5301a674
  • Pointer size: 131 Bytes
  • Size of remote file: 332 kB
assets/method_vs_architecture.jpg ADDED

Git LFS Details

  • SHA256: cd439f41b1c6d86cc4246a89c3c76550547af598296aedb07eed3ffe4dc943db
  • Pointer size: 131 Bytes
  • Size of remote file: 223 kB
assets/per_class.jpg ADDED

Git LFS Details

  • SHA256: f5968243acc57a0fc5dc44f548a4efc82e1f93db45240f6abfe337e9b7266b92
  • Pointer size: 131 Bytes
  • Size of remote file: 278 kB
assets/per_language_wit3.jpg ADDED

Git LFS Details

  • SHA256: 72fddd9a01594768cb1bc297bd442286ec1aa1d071da3aa63b5a2cfda9ab1d44
  • Pointer size: 131 Bytes
  • Size of remote file: 521 kB
assets/size_vs_score.jpg ADDED

Git LFS Details

  • SHA256: fcade2f51542cbccb80953cb9bef2234d322733f58c02b8c1f655b3c03052fd6
  • Pointer size: 131 Bytes
  • Size of remote file: 250 kB
dewpoint.py ADDED
@@ -0,0 +1,596 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Dewpoint: multilingual punctuation restoration and truecasing.
2
+
3
+ Self-contained inference for the released checkpoints, in one file, with two
4
+ interchangeable backends:
5
+
6
+ from dewpoint import Punctuator
7
+ p = Punctuator.from_pretrained("valkayuh/dewpoint")
8
+ p.restore("so i said meet at three thirty tuesday what do you think", lang="en")
9
+ # -> 'So I said meet at three thirty Tuesday. What do you think?'
10
+
11
+ backend="torch" needs torch + transformers + safetensors, and uses a GPU if present.
12
+ backend="onnx" needs only onnxruntime + tokenizers + numpy (no torch at all),
13
+ and downloads the ONNX graphs instead of the safetensors.
14
+ backend="auto" (the default) uses torch when it is installed, otherwise ONNX.
15
+
16
+ The model is an ensemble of two dual-head token taggers (mmBERT-base and
17
+ XLM-RoBERTa-large). Each member reads the same word list and returns one
18
+ posterior per word; the posteriors are averaged in probability space, a
19
+ per-class decision bias fitted on IWSLT dev2010 is applied, and the argmax is
20
+ taken. Pass ``members=["mmbert-base"]`` for the single 307M-parameter model,
21
+ which uses its own calibration.
22
+
23
+ It is a tagger, not a generator: it never adds, drops, reorders or rewrites a
24
+ word. It only decides, per word, which mark follows it (none / , / . / ? / !)
25
+ and how it is cased (lower / Capitalised / UPPER).
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import os
31
+ import re
32
+ import unicodedata
33
+ from typing import Iterable, Sequence
34
+
35
+ import numpy as np
36
+
37
+ try: # torch is optional: the ONNX backend needs none
38
+ import torch
39
+ import torch.nn as nn
40
+ except ImportError: # pragma: no cover
41
+ torch = None
42
+ nn = None
43
+
44
+ __all__ = ["Punctuator", "StreamingPunctuator"]
45
+
46
+ PUNCT_LABELS = ["O", "COMMA", "PERIOD", "QUESTION", "EXCLAM"]
47
+ CASE_LABELS = ["LOWER", "CAP", "UPPER"]
48
+ _SENT_END = {"PERIOD", "QUESTION", "EXCLAM"}
49
+
50
+ # ------------------------------------------------------------ language rules
51
+ # Scripts with no case distinction: the case head is masked to LOWER.
52
+ CASELESS_LANGS = {
53
+ "zh", "zh-cn", "zh-tw", "ja", "ko", "ar", "he", "fa", "ur", "th", "hi", "bn",
54
+ "ta", "te", "ml", "kn", "mr", "gu", "pa", "ne", "si", "my", "km", "lo", "am",
55
+ "ti", "dv", "bo", "dz", "ps", "sd", "ug", "yi", "yue", "wuu", "as", "or",
56
+ "arq", "arz", "ary", "acm", "apc", "ckb", "ka", "he-il", "sa", "ks", "bho",
57
+ }
58
+ # Written without spaces between words: one grapheme cluster is one token.
59
+ NO_SPACE_LANGS = {"zh", "zh-cn", "zh-tw", "ja", "th", "my", "km", "lo", "bo",
60
+ "dz", "yue", "wuu"}
61
+
62
+ _SURFACE = {
63
+ "ascii": {"O": "", "COMMA": ",", "PERIOD": ".", "QUESTION": "?", "EXCLAM": "!"},
64
+ "cjk": {"O": "", "COMMA": ",", "PERIOD": "。", "QUESTION": "?", "EXCLAM": "!"},
65
+ "ja": {"O": "", "COMMA": "、", "PERIOD": "。", "QUESTION": "?", "EXCLAM": "!"},
66
+ "arab": {"O": "", "COMMA": "،", "PERIOD": ".", "QUESTION": "؟", "EXCLAM": "!"},
67
+ "deva": {"O": "", "COMMA": ",", "PERIOD": "।", "QUESTION": "?", "EXCLAM": "!"},
68
+ # Greek writes its question mark with the ASCII semicolon.
69
+ "el": {"O": "", "COMMA": ",", "PERIOD": ".", "QUESTION": ";", "EXCLAM": "!"},
70
+ "hy": {"O": "", "COMMA": ",", "PERIOD": "։", "QUESTION": "՞", "EXCLAM": "՜"},
71
+ }
72
+ _SURFACE_BY_LANG = {}
73
+ for _l in ("zh", "zh-cn", "zh-tw", "yue", "wuu"):
74
+ _SURFACE_BY_LANG[_l] = _SURFACE["cjk"]
75
+ for _l in ("ar", "fa", "ur", "ps", "arq", "arz", "ary", "ckb", "sd", "ug"):
76
+ _SURFACE_BY_LANG[_l] = _SURFACE["arab"]
77
+ for _l in ("hi", "bn", "mr", "ne", "pa", "as", "or", "sa", "bho"):
78
+ _SURFACE_BY_LANG[_l] = _SURFACE["deva"]
79
+ _SURFACE_BY_LANG.update(el=_SURFACE["el"], hy=_SURFACE["hy"], ja=_SURFACE["ja"])
80
+
81
+ # Characters stripped from the edges of an input token before tagging, so text
82
+ # that already carries some punctuation is handled the same as bare ASR output.
83
+ _MARKS = set(
84
+ ".。.۔।॥။։។…⋯።,,،٫၊、᠂᠈፣??؟;՞⸮፧!!՜::;؛·-–—―−፡"
85
+ "\"'‘’“”„‚«»()[]{}〈〉《》「」『』()‹›‟″′¡¿⸘‛*_`•⁃・"'´‐‑"
86
+ )
87
+ _LATIN_RUN = re.compile(r"[A-Za-z0-9À-ɏ]+(?:[.'’-][A-Za-z0-9]+)*")
88
+
89
+
90
+ def base_lang(lang: str) -> str:
91
+ return lang.split("-")[0].lower()
92
+
93
+
94
+ def is_caseless(lang: str) -> bool:
95
+ return lang.lower() in CASELESS_LANGS or base_lang(lang) in CASELESS_LANGS
96
+
97
+
98
+ def is_no_space(lang: str) -> bool:
99
+ return lang.lower() in NO_SPACE_LANGS or base_lang(lang) in NO_SPACE_LANGS
100
+
101
+
102
+ def surface_for(lang: str) -> dict:
103
+ return _SURFACE_BY_LANG.get(base_lang(lang), _SURFACE["ascii"])
104
+
105
+
106
+ def apply_case(word: str, case: str) -> str:
107
+ if case == "UPPER":
108
+ return word.upper()
109
+ if case == "CAP":
110
+ return word[:1].upper() + word[1:]
111
+ return word
112
+
113
+
114
+ def split_words(text: str, lang: str) -> list[str]:
115
+ """Raw text -> the lowercased, mark-free word list the model was trained on."""
116
+ text = unicodedata.normalize("NFC", text)
117
+ if is_no_space(lang):
118
+ return _split_charwise(text)
119
+ out = []
120
+ for raw in text.split():
121
+ chars = [i for i, ch in enumerate(raw) if ch not in _MARKS]
122
+ if not chars:
123
+ continue
124
+ core = raw[chars[0]:chars[-1] + 1]
125
+ if any(ch.isalnum() for ch in core):
126
+ out.append(core.lower())
127
+ return out
128
+
129
+
130
+ def _split_charwise(text: str) -> list[str]:
131
+ """CJK / Thai / Khmer: grapheme clusters, with Latin runs kept whole."""
132
+ words, i, n = [], 0, len(text)
133
+ while i < n:
134
+ ch = text[i]
135
+ if ch.isspace() or ch in _MARKS:
136
+ i += 1
137
+ continue
138
+ m = _LATIN_RUN.match(text, i)
139
+ if m and m.group():
140
+ words.append(m.group().lower())
141
+ i = m.end()
142
+ continue
143
+ if ch.isalnum() or unicodedata.category(ch) in ("Mn", "Mc", "Me"):
144
+ j = i + 1
145
+ while j < n and unicodedata.category(text[j]) in ("Mn", "Mc", "Me"):
146
+ j += 1
147
+ words.append(text[i:j].lower())
148
+ i = j
149
+ continue
150
+ i += 1
151
+ return words
152
+
153
+
154
+ # ------------------------------------------------------------------- model
155
+ if torch is not None:
156
+ class PunctCaseModel(nn.Module):
157
+ """Encoder -> residual shared trunk -> punctuation head and case head."""
158
+
159
+ def __init__(self, config, n_punct=5, n_case=3):
160
+ super().__init__()
161
+ from transformers import AutoModel
162
+ self.encoder = AutoModel.from_config(config)
163
+ h = config.hidden_size
164
+ self.drop = nn.Dropout(0.1)
165
+ self.trunk = nn.Sequential(nn.Linear(h, h), nn.GELU(),
166
+ nn.LayerNorm(h, eps=1e-5))
167
+ self.head_punct = nn.Linear(h, n_punct)
168
+ self.head_case = nn.Linear(h, n_case)
169
+
170
+ def forward(self, input_ids, attention_mask=None):
171
+ x = self.encoder(input_ids=input_ids,
172
+ attention_mask=attention_mask).last_hidden_state
173
+ x = self.drop(x)
174
+ x = x + self.trunk(x)
175
+ return self.head_punct(x), self.head_case(x)
176
+
177
+ __all__.append("PunctCaseModel")
178
+
179
+
180
+ def plan_windows(sub_counts, max_sub=508, overlap_sub=256):
181
+ """Overlapping windows under a subword budget; only each window's centre
182
+ commits. Returns (win_start, win_end, commit_start, commit_end) in words."""
183
+ n = len(sub_counts)
184
+ if n == 0:
185
+ return []
186
+ overlap_sub = min(overlap_sub, max_sub // 2)
187
+ prefix = [0] * (n + 1)
188
+ for i, c in enumerate(sub_counts):
189
+ prefix[i + 1] = prefix[i] + max(1, c)
190
+
191
+ def end_for(start):
192
+ lo, hi = start + 1, n
193
+ budget = prefix[start] + max_sub
194
+ while lo < hi:
195
+ mid = (lo + hi + 1) // 2
196
+ if prefix[mid] <= budget:
197
+ lo = mid
198
+ else:
199
+ hi = mid - 1
200
+ return max(lo, start + 1)
201
+
202
+ wins, start = [], 0
203
+ while True:
204
+ end = end_for(start)
205
+ wins.append([start, end])
206
+ if end >= n:
207
+ break
208
+ target = prefix[end] - overlap_sub
209
+ nxt = end
210
+ while nxt > start + 1 and prefix[nxt] > target:
211
+ nxt -= 1
212
+ start = max(nxt, start + 1)
213
+ out = []
214
+ for i, (s, e) in enumerate(wins):
215
+ cs = s if i == 0 else (wins[i - 1][1] + s) // 2
216
+ ce = e if i == len(wins) - 1 else (e + wins[i + 1][0] + 1) // 2
217
+ cs = max(cs, s)
218
+ out.append((s, e, cs, min(max(ce, cs), e)))
219
+ return out
220
+
221
+
222
+ class _MemberBase:
223
+ """Windowing, first-subword gathering and caseless masking, shared by both
224
+ backends so that they cannot drift apart. Subclasses supply `_counts`
225
+ (subwords per word), `_encode` (a padded batch plus word ids) and
226
+ `_forward` (softmaxed posteriors as numpy)."""
227
+
228
+ def _load_meta(self, path):
229
+ with open(os.path.join(path, "vpunct_config.json"), encoding="utf-8") as f:
230
+ self.meta = json.load(f)
231
+ self.max_len = int(self.meta.get("max_len", 512))
232
+
233
+ def posteriors(self, words, lang):
234
+ n = len(words)
235
+ pp = np.zeros((n, len(PUNCT_LABELS)), dtype=np.float32)
236
+ pp[:, 0] = 1.0
237
+ cp = np.zeros((n, len(CASE_LABELS)), dtype=np.float32)
238
+ cp[:, 0] = 1.0
239
+ if n == 0:
240
+ return pp, cp
241
+ wins = plan_windows(self._counts(list(words)), max_sub=self.max_len - 4,
242
+ overlap_sub=256)
243
+ for i in range(0, len(wins), self.batch_size):
244
+ chunk = wins[i:i + self.batch_size]
245
+ ids, mask, wids = self._encode([list(words[s:e]) for s, e, _, _ in chunk])
246
+ p, c = self._forward(ids, mask)
247
+ for b, (s, e, cs, ce) in enumerate(chunk):
248
+ loc_p = np.zeros((e - s, len(PUNCT_LABELS)), dtype=np.float32)
249
+ loc_p[:, 0] = 1.0
250
+ loc_c = np.zeros((e - s, len(CASE_LABELS)), dtype=np.float32)
251
+ loc_c[:, 0] = 1.0
252
+ seen = set()
253
+ for pos, w in enumerate(wids[b]):
254
+ if w is None or w in seen or pos >= p.shape[1]:
255
+ continue
256
+ seen.add(w) # label lives on a word's first subword
257
+ loc_p[w] = p[b, pos]
258
+ loc_c[w] = c[b, pos]
259
+ pp[cs:ce] = loc_p[cs - s:ce - s]
260
+ cp[cs:ce] = loc_c[cs - s:ce - s]
261
+ if is_caseless(lang):
262
+ cp[:] = 0.0
263
+ cp[:, 0] = 1.0
264
+ return pp, cp
265
+
266
+
267
+ def _softmax(x):
268
+ x = x - x.max(-1, keepdims=True)
269
+ e = np.exp(x)
270
+ return e / e.sum(-1, keepdims=True)
271
+
272
+
273
+ class _TorchMember(_MemberBase):
274
+ """One tagger run by torch, from its safetensors."""
275
+
276
+ def __init__(self, path, device, dtype, batch_size=64):
277
+ from safetensors.torch import load_file
278
+ from transformers import AutoConfig, AutoTokenizer
279
+ self._load_meta(path)
280
+ cfg = AutoConfig.from_pretrained(path)
281
+ model = PunctCaseModel(cfg, self.meta["n_punct"], self.meta["n_case"])
282
+ model.load_state_dict(load_file(os.path.join(path, "model.safetensors")))
283
+ self.model = model.to(device=device, dtype=dtype).eval()
284
+ try:
285
+ self.model.encoder.config._attn_implementation = "sdpa"
286
+ except Exception:
287
+ pass
288
+ self.tok = AutoTokenizer.from_pretrained(path)
289
+ self.device = device
290
+ self.batch_size = batch_size
291
+
292
+ def _counts(self, words):
293
+ return [len(e) for e in self.tok(words, add_special_tokens=False)["input_ids"]]
294
+
295
+ def _encode(self, batch):
296
+ enc = self.tok(batch, is_split_into_words=True, truncation=True,
297
+ max_length=self.max_len, padding=True, return_tensors="pt")
298
+ return enc["input_ids"], enc["attention_mask"], \
299
+ [enc.word_ids(b) for b in range(len(batch))]
300
+
301
+ def _forward(self, ids, mask):
302
+ with torch.no_grad():
303
+ lp, lc = self.model(ids.to(self.device), mask.to(self.device))
304
+ return (lp.float().softmax(-1).cpu().numpy(),
305
+ lc.float().softmax(-1).cpu().numpy())
306
+
307
+
308
+ class _OnnxMember(_MemberBase):
309
+ """One tagger run by onnxruntime, tokenised by the `tokenizers` library.
310
+ No torch and no transformers anywhere on this path."""
311
+
312
+ def __init__(self, path, onnx_dir, providers=None, batch_size=8, threads=None):
313
+ import onnxruntime as ort
314
+ from tokenizers import Tokenizer
315
+ self._load_meta(path)
316
+ self.tok = Tokenizer.from_file(os.path.join(path, "tokenizer.json"))
317
+ self.tok.no_padding()
318
+ # truncate the way transformers does: before the special tokens are
319
+ # added, so an over-long window still ends in its closing token
320
+ self.tok.enable_truncation(max_length=self.max_len)
321
+ with open(os.path.join(path, "tokenizer_config.json"), encoding="utf-8") as f:
322
+ tcfg = json.load(f)
323
+ self.pad_id = self.tok.token_to_id(tcfg.get("pad_token", "<pad>")) or 0
324
+ so = ort.SessionOptions()
325
+ if threads:
326
+ so.intra_op_num_threads = threads
327
+ if providers is None:
328
+ avail = ort.get_available_providers()
329
+ providers = [p for p in ("CUDAExecutionProvider", "CoreMLExecutionProvider",
330
+ "DmlExecutionProvider") if p in avail]
331
+ providers.append("CPUExecutionProvider")
332
+ self.sess = ort.InferenceSession(os.path.join(onnx_dir, "model.onnx"), so,
333
+ providers=providers)
334
+ self.batch_size = batch_size
335
+
336
+ def _counts(self, words):
337
+ return [len(e.ids) for e in
338
+ self.tok.encode_batch(words, add_special_tokens=False)]
339
+
340
+ def _encode(self, batch):
341
+ encs = self.tok.encode_batch(batch, is_pretokenized=True)
342
+ L = min(self.max_len, max(len(e.ids) for e in encs))
343
+ ids = np.full((len(encs), L), self.pad_id, dtype=np.int64)
344
+ mask = np.zeros((len(encs), L), dtype=np.int64)
345
+ wids = []
346
+ for b, e in enumerate(encs):
347
+ n = min(L, len(e.ids))
348
+ ids[b, :n] = e.ids[:n]
349
+ mask[b, :n] = 1
350
+ wids.append(list(e.word_ids[:n]))
351
+ return ids, mask, wids
352
+
353
+ def _forward(self, ids, mask):
354
+ lp, lc = self.sess.run(None, {"input_ids": ids, "attention_mask": mask})
355
+ return _softmax(lp.astype(np.float32)), _softmax(lc.astype(np.float32))
356
+
357
+
358
+ # -------------------------------------------------------------- public api
359
+ class Punctuator:
360
+ """Restore punctuation and case in unpunctuated, lowercased text."""
361
+
362
+ MEMBERS = ("mmbert-base", "xlm-roberta-large")
363
+
364
+ def __init__(self, path, members=None, backend="auto", device=None, dtype=None,
365
+ use_gazetteer=True, batch_size=None, providers=None, threads=None):
366
+ members = list(members or self.MEMBERS)
367
+ self.backend = self._pick_backend(path, members, backend)
368
+ if self.backend == "torch":
369
+ if device is None:
370
+ device = "cuda" if torch.cuda.is_available() else "cpu"
371
+ if dtype is None:
372
+ dtype = torch.bfloat16 if str(device).startswith("cuda") else torch.float32
373
+ self.members = [_TorchMember(os.path.join(path, m), device, dtype,
374
+ batch_size or 64) for m in members]
375
+ else:
376
+ self.members = [_OnnxMember(os.path.join(path, m),
377
+ os.path.join(path, "onnx", m), providers,
378
+ batch_size or 8, threads) for m in members]
379
+ if len(members) == len(self.MEMBERS):
380
+ # The ensemble is calibrated as its own system: each member's bias
381
+ # describes its own posterior, not the average of two.
382
+ with open(os.path.join(path, "ensemble_config.json"), encoding="utf-8") as f:
383
+ ens = json.load(f)
384
+ self.weights = ens["weights"]
385
+ self.bias = ens["punct_bias"]
386
+ self.bias_per_lang = ens["punct_bias_per_lang"]
387
+ else:
388
+ self.weights = [1.0 / len(members)] * len(members)
389
+ meta = self.members[0].meta if len(members) == 1 else {}
390
+ self.bias = meta.get("punct_bias")
391
+ self.bias_per_lang = meta.get("punct_bias_per_lang") or {}
392
+ gz = os.path.join(path, "gazetteer.json")
393
+ self.gazetteer = {}
394
+ if use_gazetteer and os.path.exists(gz):
395
+ with open(gz, encoding="utf-8") as f:
396
+ self.gazetteer = json.load(f)
397
+
398
+ @staticmethod
399
+ def _pick_backend(path, members, backend):
400
+ if backend == "auto":
401
+ has_st = all(os.path.exists(os.path.join(path, m, "model.safetensors"))
402
+ for m in members)
403
+ backend = "torch" if (torch is not None and has_st) else "onnx"
404
+ if backend == "torch" and torch is None:
405
+ raise ImportError("backend='torch' needs torch; install it, or use "
406
+ "backend='onnx' (onnxruntime + tokenizers only)")
407
+ if backend not in ("torch", "onnx"):
408
+ raise ValueError("backend must be 'auto', 'torch' or 'onnx'")
409
+ return backend
410
+
411
+ @classmethod
412
+ def from_pretrained(cls, repo_or_path="valkayuh/dewpoint", **kw):
413
+ """A local directory, or a Hugging Face repo id to download. Only the
414
+ files the chosen backend needs are fetched: the ONNX backend never
415
+ downloads the safetensors, and the torch backend never downloads ONNX."""
416
+ if os.path.isdir(repo_or_path):
417
+ return cls(repo_or_path, **kw)
418
+ from huggingface_hub import snapshot_download
419
+ members = kw.get("members") or cls.MEMBERS
420
+ backend = kw.get("backend", "auto")
421
+ if backend == "auto":
422
+ backend = "torch" if torch is not None else "onnx"
423
+ kw["backend"] = backend
424
+ allow = ["dewpoint.py", "ensemble_config.json", "gazetteer.json"]
425
+ for m in members:
426
+ allow += [f"{m}/vpunct_config.json", f"{m}/tokenizer.json",
427
+ f"{m}/tokenizer_config.json"]
428
+ allow += ([f"{m}/config.json", f"{m}/model.safetensors"]
429
+ if backend == "torch" else [f"onnx/{m}/*"])
430
+ return cls(snapshot_download(repo_or_path, allow_patterns=allow), **kw)
431
+
432
+ # ------------------------------------------------------------ core
433
+ def _bias_for(self, lang):
434
+ b = None
435
+ if self.bias_per_lang:
436
+ b = self.bias_per_lang.get(lang) or self.bias_per_lang.get(base_lang(lang))
437
+ return b if b is not None else self.bias
438
+
439
+ def predict(self, words: Sequence[str], lang: str = "en") -> dict:
440
+ """Per-word labels and averaged posteriors for an already-split word list.
441
+
442
+ ``words`` should be lowercased and free of punctuation, as ASR output is.
443
+ """
444
+ words = list(words)
445
+ acc_p = acc_c = None
446
+ for w, m in zip(self.weights, self.members):
447
+ p, c = m.posteriors(words, lang)
448
+ acc_p = w * p if acc_p is None else acc_p + w * p
449
+ acc_c = w * c if acc_c is None else acc_c + w * c
450
+ lp = np.log(np.clip(acc_p, 1e-9, None))
451
+ b = self._bias_for(lang)
452
+ if b is not None:
453
+ lp = lp + np.asarray(b, dtype=np.float32)[None, :]
454
+ punct = [PUNCT_LABELS[i] for i in lp.argmax(1)] if len(words) else []
455
+ case = [CASE_LABELS[i] for i in acc_c.argmax(1)] if len(words) else []
456
+ return {"words": words, "punct": punct, "case": case,
457
+ "punct_probs": acc_p, "case_probs": acc_c, "punct_scores": lp}
458
+
459
+ @staticmethod
460
+ def _close(punct, scores):
461
+ """A complete text ends on a sentence mark: if the last word got none,
462
+ take the likeliest of . ? ! for it. Applied to finished text only --
463
+ predict() is left exactly as benchmarked."""
464
+ if punct and punct[-1] not in _SENT_END:
465
+ ends = [PUNCT_LABELS.index(x) for x in ("PERIOD", "QUESTION", "EXCLAM")]
466
+ punct[-1] = PUNCT_LABELS[max(ends, key=lambda i: scores[-1][i])]
467
+ return punct
468
+
469
+ def _postprocess(self, words, punct, case, lang, is_start=True, prev_punct=None):
470
+ """Gazetteer surface forms (iPhone, McDonald) and a capital after every
471
+ sentence-final mark."""
472
+ case = list(case)
473
+ caseless = is_caseless(lang)
474
+ gz = self.gazetteer.get(base_lang(lang), {})
475
+ out = []
476
+ for i, w in enumerate(words):
477
+ s = gz.get(w)
478
+ if s and not caseless:
479
+ out.append(s)
480
+ case[i] = "AS_IS"
481
+ else:
482
+ out.append(w)
483
+ if not caseless and words:
484
+ lead = prev_punct in _SENT_END if prev_punct is not None else False
485
+ if case[0] == "LOWER" and (is_start or lead):
486
+ case[0] = "CAP"
487
+ for i in range(1, len(words)):
488
+ if punct[i - 1] in _SENT_END and case[i] == "LOWER":
489
+ case[i] = "CAP"
490
+ return out, list(punct), case
491
+
492
+ def _render(self, words, punct, case, lang):
493
+ surf = surface_for(lang)
494
+ sep = "" if is_no_space(lang) else " "
495
+ return sep.join((w if c == "AS_IS" else apply_case(w, c)) + surf.get(p, "")
496
+ for w, p, c in zip(words, punct, case))
497
+
498
+ def restore(self, text: str, lang: str = "en", close: bool = True) -> str:
499
+ """Unpunctuated text in, punctuated and truecased text out.
500
+
501
+ ``close`` ends the text on a sentence mark; turn it off when ``text``
502
+ is a fragment that continues elsewhere.
503
+ """
504
+ words = split_words(text, lang)
505
+ if not words:
506
+ return text
507
+ r = self.predict(words, lang)
508
+ punct = self._close(r["punct"], r["punct_scores"]) if close else r["punct"]
509
+ w, p, c = self._postprocess(words, punct, r["case"], lang)
510
+ return self._render(w, p, c, lang)
511
+
512
+ def restore_batch(self, texts: Iterable[str], lang: str = "en") -> list[str]:
513
+ return [self.restore(t, lang) for t in texts]
514
+
515
+ def stream(self, lang="en", lag=6, context=180, stable_n=2):
516
+ return StreamingPunctuator(self, lang, lag, context, stable_n)
517
+
518
+
519
+ class StreamingPunctuator:
520
+ """Commit-with-lag for live transcripts.
521
+
522
+ Later words change earlier decisions -- "what do you think" only becomes a
523
+ question at its last word -- so a word is released only once it has
524
+ ``lag`` words of right context and the same label for ``stable_n``
525
+ consecutive updates. Call ``push(words)`` as ASR emits, ``finish()`` at
526
+ the end; each returns the newly committed text.
527
+ """
528
+
529
+ def __init__(self, punctuator, lang="en", lag=6, context=180, stable_n=2):
530
+ self.p, self.lang = punctuator, lang
531
+ self.lag, self.context, self.stable_n = lag, context, stable_n
532
+ self.words, self.emitted = [], 0
533
+ self._last, self._streak = {}, {}
534
+
535
+ def push(self, new_words) -> str:
536
+ if isinstance(new_words, str):
537
+ new_words = split_words(new_words, self.lang)
538
+ self.words.extend(w.lower() for w in new_words)
539
+ return self._commit(final=False)
540
+
541
+ def finish(self) -> str:
542
+ return self._commit(final=True)
543
+
544
+ def _commit(self, final):
545
+ if not self.words:
546
+ return ""
547
+ off = max(0, len(self.words) - self.context)
548
+ r = self.p.predict(self.words[off:], self.lang)
549
+ punct, case = r["punct"], r["case"]
550
+ if final:
551
+ punct = self.p._close(punct, r["punct_scores"])
552
+ limit = len(self.words) if final else len(self.words) - self.lag
553
+ done, i = [], self.emitted
554
+ while i < limit:
555
+ j = i - off
556
+ if j < 0:
557
+ i += 1
558
+ continue
559
+ if not final:
560
+ lab = (punct[j], case[j])
561
+ if self._last.get(i) == lab:
562
+ self._streak[i] = self._streak.get(i, 1) + 1
563
+ else:
564
+ self._last[i], self._streak[i] = lab, 1
565
+ if self._streak[i] < self.stable_n:
566
+ break
567
+ done.append(i)
568
+ i += 1
569
+ if not done:
570
+ return ""
571
+ s, e = done[0], done[-1] + 1
572
+ prev = punct[s - off - 1] if s - off - 1 >= 0 else None
573
+ w, p, c = self.p._postprocess(self.words[s:e], punct[s - off:e - off],
574
+ case[s - off:e - off], self.lang,
575
+ is_start=(s == 0), prev_punct=prev)
576
+ self.emitted = e
577
+ out = self.p._render(w, p, c, self.lang)
578
+ return out if s == 0 or is_no_space(self.lang) else " " + out
579
+
580
+
581
+ if __name__ == "__main__":
582
+ import argparse
583
+ import sys
584
+ ap = argparse.ArgumentParser(description="Restore punctuation and case.")
585
+ ap.add_argument("text", nargs="*", help="text to restore (default: stdin)")
586
+ ap.add_argument("--lang", default="en", help="ISO 639-1 code, e.g. en, de, zh")
587
+ ap.add_argument("--model", default=os.path.dirname(os.path.abspath(__file__)))
588
+ ap.add_argument("--single", action="store_true",
589
+ help="use only the mmBERT-base member (307M, faster)")
590
+ ap.add_argument("--backend", default="auto", choices=["auto", "torch", "onnx"])
591
+ a = ap.parse_args()
592
+ p = Punctuator.from_pretrained(a.model, backend=a.backend,
593
+ members=["mmbert-base"] if a.single else None)
594
+ src = [" ".join(a.text)] if a.text else sys.stdin.read().splitlines()
595
+ for line in src:
596
+ print(p.restore(line, a.lang))
ensemble_config.json ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "members": [
3
+ "mmbert-base",
4
+ "xlm-roberta-large"
5
+ ],
6
+ "weights": [
7
+ 0.5,
8
+ 0.5
9
+ ],
10
+ "combine": "arithmetic mean of per-word softmax posteriors",
11
+ "punct_labels": [
12
+ "O",
13
+ "COMMA",
14
+ "PERIOD",
15
+ "QUESTION",
16
+ "EXCLAM"
17
+ ],
18
+ "case_labels": [
19
+ "LOWER",
20
+ "CAP",
21
+ "UPPER"
22
+ ],
23
+ "punct_bias": [
24
+ 0.0,
25
+ -0.25,
26
+ 0.375,
27
+ -1.0,
28
+ -3.0
29
+ ],
30
+ "punct_bias_per_lang": {
31
+ "ar": [
32
+ 0.0,
33
+ -0.1875,
34
+ 0.46875,
35
+ -0.9375,
36
+ -3.0
37
+ ],
38
+ "de": [
39
+ 0.0,
40
+ -0.15625,
41
+ 0.5625,
42
+ -0.75,
43
+ -2.625
44
+ ],
45
+ "en": [
46
+ 0.0,
47
+ -0.25,
48
+ 0.3125,
49
+ -0.9375,
50
+ -2.84375
51
+ ],
52
+ "fr": [
53
+ 0.0,
54
+ -0.28125,
55
+ 0.40625,
56
+ -0.96875,
57
+ -2.9375
58
+ ],
59
+ "it": [
60
+ 0.0,
61
+ -0.3125,
62
+ 0.75,
63
+ -0.75,
64
+ -3.0
65
+ ],
66
+ "ko": [
67
+ 0.0,
68
+ -0.34375,
69
+ 0.25,
70
+ -0.90625,
71
+ -3.0
72
+ ],
73
+ "nl": [
74
+ 0.0,
75
+ -0.1875,
76
+ 0.5,
77
+ -1.0,
78
+ -2.9375
79
+ ],
80
+ "ro": [
81
+ 0.0,
82
+ -0.40625,
83
+ 0.25,
84
+ -1.125,
85
+ -2.96875
86
+ ],
87
+ "zh": [
88
+ 0.0,
89
+ -0.28125,
90
+ 0.34375,
91
+ -1.21875,
92
+ -3.0
93
+ ]
94
+ },
95
+ "calibration": {
96
+ "fitted_on": "IWSLT dev2010 (held out of every test table)",
97
+ "dev_macro_f1_argmax": 0.7191,
98
+ "dev_macro_f1_calibrated": 0.7472
99
+ },
100
+ "window": {
101
+ "max_len": 512,
102
+ "overlap_subwords": 256
103
+ },
104
+ "params": {
105
+ "mmbert-base": {
106
+ "params": 307537928,
107
+ "embedding_params": 196608768,
108
+ "dtype": "torch.float32"
109
+ },
110
+ "xlm-roberta-large": {
111
+ "params": 560950280,
112
+ "embedding_params": 256531456,
113
+ "dtype": "torch.float32"
114
+ }
115
+ }
116
+ }
gazetteer.json ADDED
The diff for this file is too large to render. See raw diff
 
handler.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hugging Face Inference Endpoints handler.
2
+
3
+ Deploy this repository as an Inference Endpoint and it serves Dewpoint with no
4
+ further code. Request body:
5
+
6
+ {"inputs": "so i said meet at three thirty tuesday what do you think",
7
+ "parameters": {"lang": "en"}}
8
+
9
+ `inputs` may also be a list of strings. Optional parameters:
10
+ lang ISO 639-1 code (default "en")
11
+ single true to run only the mmBERT-base member
12
+ labels true to also return per-word punctuation and case labels
13
+
14
+ Response: [{"text": "..."}] per input (plus "words", "punct", "case" if
15
+ labels=true).
16
+ """
17
+ import os
18
+ import sys
19
+
20
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
21
+ from dewpoint import Punctuator, split_words # noqa: E402
22
+
23
+
24
+ class EndpointHandler:
25
+ def __init__(self, path=""):
26
+ path = path or os.path.dirname(os.path.abspath(__file__))
27
+ self.full = Punctuator(path)
28
+ self._single = None
29
+ self.path = path
30
+
31
+ def _model(self, single):
32
+ if not single:
33
+ return self.full
34
+ if self._single is None:
35
+ self._single = Punctuator(self.path, members=["mmbert-base"])
36
+ return self._single
37
+
38
+ def __call__(self, data):
39
+ inputs = data.get("inputs", data)
40
+ params = data.get("parameters") or {}
41
+ lang = params.get("lang", "en")
42
+ p = self._model(bool(params.get("single", False)))
43
+ texts = [inputs] if isinstance(inputs, str) else list(inputs)
44
+ out = []
45
+ for t in texts:
46
+ item = {"text": p.restore(t, lang)}
47
+ if params.get("labels"):
48
+ r = p.predict(split_words(t, lang), lang)
49
+ item.update(words=r["words"], punct=r["punct"], case=r["case"])
50
+ out.append(item)
51
+ return out
mmbert-base/config.json ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "architectures": [
3
+ "ModernBertForMaskedLM"
4
+ ],
5
+ "attention_bias": false,
6
+ "attention_dropout": 0.0,
7
+ "bos_token_id": 2,
8
+ "classifier_activation": "gelu",
9
+ "classifier_bias": false,
10
+ "classifier_dropout": 0.0,
11
+ "classifier_pooling": "mean",
12
+ "cls_token_id": 1,
13
+ "decoder_bias": true,
14
+ "deterministic_flash_attn": false,
15
+ "dtype": "bfloat16",
16
+ "embedding_dropout": 0.0,
17
+ "eos_token_id": 1,
18
+ "global_attn_every_n_layers": 3,
19
+ "gradient_checkpointing": false,
20
+ "hidden_activation": "gelu",
21
+ "hidden_size": 768,
22
+ "initializer_cutoff_factor": 2.0,
23
+ "initializer_range": 0.02,
24
+ "intermediate_size": 1152,
25
+ "layer_norm_eps": 1e-05,
26
+ "layer_types": [
27
+ "full_attention",
28
+ "sliding_attention",
29
+ "sliding_attention",
30
+ "full_attention",
31
+ "sliding_attention",
32
+ "sliding_attention",
33
+ "full_attention",
34
+ "sliding_attention",
35
+ "sliding_attention",
36
+ "full_attention",
37
+ "sliding_attention",
38
+ "sliding_attention",
39
+ "full_attention",
40
+ "sliding_attention",
41
+ "sliding_attention",
42
+ "full_attention",
43
+ "sliding_attention",
44
+ "sliding_attention",
45
+ "full_attention",
46
+ "sliding_attention",
47
+ "sliding_attention",
48
+ "full_attention"
49
+ ],
50
+ "local_attention": 128,
51
+ "mask_token_id": 4,
52
+ "max_position_embeddings": 8192,
53
+ "mlp_bias": false,
54
+ "mlp_dropout": 0.0,
55
+ "model_type": "modernbert",
56
+ "norm_bias": false,
57
+ "norm_eps": 1e-05,
58
+ "num_attention_heads": 12,
59
+ "num_hidden_layers": 22,
60
+ "pad_token_id": 0,
61
+ "position_embedding_type": "sans_pos",
62
+ "rope_parameters": {
63
+ "full_attention": {
64
+ "rope_theta": 160000,
65
+ "rope_type": "default"
66
+ },
67
+ "sliding_attention": {
68
+ "rope_theta": 160000,
69
+ "rope_type": "default"
70
+ }
71
+ },
72
+ "sep_token_id": 1,
73
+ "sparse_pred_ignore_index": -100,
74
+ "sparse_prediction": false,
75
+ "tie_word_embeddings": true,
76
+ "transformers_version": "5.17.0",
77
+ "vocab_size": 256000
78
+ }
mmbert-base/model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d2448124ab577870de8388cd6819587a52c311d5fb6b7ef816e25e3ac97b858d
3
+ size 615090800
mmbert-base/tokenizer.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:609d8f4c067cd3950f88594c5a802616cea245823836ef5848ee4fc40aab5b6f
3
+ size 34363188
mmbert-base/tokenizer_config.json ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "backend": "tokenizers",
3
+ "bos_token": "<bos>",
4
+ "clean_up_tokenization_spaces": false,
5
+ "cls_token": "<bos>",
6
+ "eos_token": "<eos>",
7
+ "extra_special_tokens": [
8
+ "<start_of_turn>",
9
+ "<end_of_turn>"
10
+ ],
11
+ "is_local": false,
12
+ "local_files_only": false,
13
+ "mask_token": "<mask>",
14
+ "model_input_names": [
15
+ "input_ids",
16
+ "attention_mask"
17
+ ],
18
+ "model_max_length": 8192,
19
+ "pad_token": "<pad>",
20
+ "padding_side": "right",
21
+ "sep_token": "<eos>",
22
+ "spaces_between_special_tokens": false,
23
+ "tokenizer_class": "TokenizersBackend",
24
+ "unk_token": "<unk>"
25
+ }
mmbert-base/vpunct_config.json ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "base_id": "jhu-clsp/mmBERT-base",
3
+ "n_punct": 5,
4
+ "n_case": 3,
5
+ "max_len": 512,
6
+ "punct_labels": [
7
+ "O",
8
+ "COMMA",
9
+ "PERIOD",
10
+ "QUESTION",
11
+ "EXCLAM"
12
+ ],
13
+ "case_labels": [
14
+ "LOWER",
15
+ "CAP",
16
+ "UPPER"
17
+ ],
18
+ "punct_bias": [
19
+ 0.0,
20
+ -0.625,
21
+ -0.125,
22
+ -1.375,
23
+ -2.875
24
+ ],
25
+ "punct_bias_per_lang": {},
26
+ "calibration_strategy": "pooled"
27
+ }
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ # torch backend (GPU or CPU): pip install -r requirements.txt
2
+ torch>=2.4
3
+ transformers>=5.0
4
+ safetensors>=0.4
5
+ huggingface_hub>=0.24
6
+ numpy>=1.26
7
+ # Measured with torch 2.11.0, transformers 5.17.0, tokenizers 0.23.2, numpy 2.5.3
results/rt2_voices.json ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "kokoro": {
3
+ "repo": "hexgrad/Kokoro-82M",
4
+ "licence": "Apache-2.0"
5
+ },
6
+ "chatterbox": {
7
+ "repo": "ResembleAI/chatterbox",
8
+ "licence": "MIT"
9
+ },
10
+ "piper": {
11
+ "admitted": {
12
+ "bg/bg_BG/dimitar/medium/bg_BG-dimitar-medium": [
13
+ "* License: CC0"
14
+ ],
15
+ "de/de_DE/kerstin/low/de_DE-kerstin-low": [
16
+ "* License: CC0"
17
+ ],
18
+ "de/de_DE/mls/medium/de_DE-mls-medium": [
19
+ "* License: CC-BY 4.0"
20
+ ],
21
+ "de/de_DE/thorsten/high/de_DE-thorsten-high": [
22
+ "* License: CC0"
23
+ ],
24
+ "de/de_DE/thorsten/low/de_DE-thorsten-low": [
25
+ "* License: CC0"
26
+ ],
27
+ "de/de_DE/thorsten/medium/de_DE-thorsten-medium": [
28
+ "* License: CC0"
29
+ ],
30
+ "de/de_DE/thorsten_emotional/medium/de_DE-thorsten_emotional-medium": [
31
+ "* License: CC0"
32
+ ],
33
+ "el/el_GR/rapunzelina/low/el_GR-rapunzelina-low": [
34
+ "* License: CC0"
35
+ ],
36
+ "el/el_GR/rapunzelina/medium/el_GR-rapunzelina-medium": [
37
+ "* License: CC0"
38
+ ],
39
+ "hu/hu_HU/anna/medium/hu_HU-anna-medium": [
40
+ "* License: CC0"
41
+ ],
42
+ "hu/hu_HU/berta/medium/hu_HU-berta-medium": [
43
+ "* License: CC0"
44
+ ],
45
+ "hu/hu_HU/imre/medium/hu_HU-imre-medium": [
46
+ "* License: CC0"
47
+ ],
48
+ "ro/ro_RO/mihai/medium/ro_RO-mihai-medium": [
49
+ "* License: CC0"
50
+ ],
51
+ "fa/fa_IR/amir/medium/fa_IR-amir-medium": [
52
+ "* License: CC0"
53
+ ],
54
+ "fa/fa_IR/ganji/medium/fa_IR-ganji-medium": [
55
+ "* License: CC0"
56
+ ],
57
+ "fa/fa_IR/ganji_adabi/medium/fa_IR-ganji_adabi-medium": [
58
+ "* License: CC0"
59
+ ],
60
+ "fa/fa_IR/reza_ibrahim/medium/fa_IR-reza_ibrahim-medium": [
61
+ "* License: CC0"
62
+ ],
63
+ "fi/fi_FI/harri/low/fi_FI-harri-low": [
64
+ "* License: CC0"
65
+ ],
66
+ "fi/fi_FI/harri/medium/fi_FI-harri-medium": [
67
+ "* License: CC0"
68
+ ],
69
+ "uk/uk_UA/mykyta/high/uk_UA-mykyta-high": [
70
+ "* License: Apache 2.0"
71
+ ],
72
+ "uk/uk_UA/oleksa/high/uk_UA-oleksa-high": [
73
+ "* License: Apache 2.0"
74
+ ],
75
+ "uk/uk_UA/tetiana/high/uk_UA-tetiana-high": [
76
+ "* License: Apache 2.0"
77
+ ],
78
+ "uk/uk_UA/ukrainian_tts/medium/uk_UA-ukrainian_tts-medium": [
79
+ "* License: CC0"
80
+ ],
81
+ "lv/lv_LV/aivars/medium/lv_LV-aivars-medium": [
82
+ "* License: CC0 1.0 Universal"
83
+ ],
84
+ "nl/nl_BE/nathalie/medium/nl_BE-nathalie-medium": [
85
+ "* License: CC0"
86
+ ],
87
+ "nl/nl_BE/rdh/medium/nl_BE-rdh-medium": [
88
+ "* License: CC0 1.0 Universal"
89
+ ],
90
+ "nl/nl_NL/alex/medium/nl_NL-alex-medium": [
91
+ "* License: CC0"
92
+ ],
93
+ "nl/nl_NL/mls/medium/nl_NL-mls-medium": [
94
+ "* License: CC-BY 4.0"
95
+ ],
96
+ "nl/nl_NL/mls_5809/low/nl_NL-mls_5809-low": [
97
+ "* License: CC BY 4.0"
98
+ ],
99
+ "nl/nl_NL/mls_7432/low/nl_NL-mls_7432-low": [
100
+ "* License: CC BY 4.0"
101
+ ],
102
+ "nl/nl_NL/pim/medium/nl_NL-pim-medium": [
103
+ "* License: CC0"
104
+ ],
105
+ "nl/nl_NL/ronnie/medium/nl_NL-ronnie-medium": [
106
+ "* License: CC0"
107
+ ],
108
+ "pl/pl_PL/bass/high/pl_PL-bass-high": [
109
+ "## License",
110
+ "[Apache 2.0](https://www.apache.org/licenses/LICENSE-2.0)"
111
+ ],
112
+ "pl/pl_PL/darkman/medium/pl_PL-darkman-medium": [
113
+ "* License: CC0"
114
+ ],
115
+ "pl/pl_PL/gosia/medium/pl_PL-gosia-medium": [
116
+ "* License: CC0"
117
+ ],
118
+ "pl/pl_PL/mc_speech/medium/pl_PL-mc_speech-medium": [
119
+ "* License: CC0"
120
+ ],
121
+ "pl/pl_PL/mls_6892/low/pl_PL-mls_6892-low": [
122
+ "* License: CC BY 4.0"
123
+ ],
124
+ "ru/ru_RU/denis/medium/ru_RU-denis-medium": [
125
+ "* License: CC0"
126
+ ],
127
+ "ru/ru_RU/dmitri/medium/ru_RU-dmitri-medium": [
128
+ "* License: CC0"
129
+ ],
130
+ "sq/sq_AL/edon/medium/sq_AL-edon-medium": [
131
+ "* License: CC0"
132
+ ],
133
+ "sv/sv_SE/alma/medium/sv_SE-alma-medium": [
134
+ "## License",
135
+ "The model weights are released under CC BY 4.0, following the license of the NST training data."
136
+ ],
137
+ "sv/sv_SE/nst/medium/sv_SE-nst-medium": [
138
+ "* License: CC0"
139
+ ]
140
+ },
141
+ "refused": {
142
+ "de/de_DE/karlsson/low/de_DE-karlsson-low": [
143
+ "* License: See URL"
144
+ ],
145
+ "de/de_DE/pavoque/low/de_DE-pavoque-low": [
146
+ "* License: http://creativecommons.org/licenses/by-nc-sa/4.0/"
147
+ ],
148
+ "de/de_DE/ramona/low/de_DE-ramona-low": [
149
+ "* License: See URL"
150
+ ],
151
+ "el/el_GR/joy/medium/el_GR-joy-medium": [
152
+ "* License: CC BY-NC 4.0"
153
+ ],
154
+ "fa/fa_IR/gyro/medium/fa_IR-gyro-medium": [
155
+ "* License: See URL"
156
+ ],
157
+ "ru/ru_RU/irina/medium/ru_RU-irina-medium": [
158
+ "* License: Unknown"
159
+ ],
160
+ "ru/ru_RU/ruslan/medium/ru_RU-ruslan-medium": [
161
+ "* License: https://creativecommons.org/licenses/by-nc-sa/4.0/"
162
+ ],
163
+ "sv/sv_SE/lisa/medium/sv_SE-lisa-medium": []
164
+ }
165
+ }
166
+ }
results/scores.json ADDED
The diff for this file is too large to render. See raw diff
 
serve.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """A small local HTTP server for Dewpoint, using only the standard library.
2
+
3
+ python serve.py # ensemble, port 8000
4
+ python serve.py --single --port 9000 # the 307M member only
5
+ python serve.py --backend onnx # no torch needed
6
+
7
+ curl -s localhost:8000/restore -H "content-type: application/json" \\
8
+ -d '{"text": "what time is it in tokyo right now", "lang": "en"}'
9
+ -> {"text": "What time is it in Tokyo right now?"}
10
+
11
+ POST /restore {"text": str | [str], "lang": "en", "labels": false}
12
+ GET /health {"ok": true, "backend": ..., "members": [...]}
13
+
14
+ It serves one request at a time, which is right for a single model on one
15
+ machine; put it behind a process manager for anything larger.
16
+ """
17
+ import argparse
18
+ import json
19
+ import os
20
+ import sys
21
+ from http.server import BaseHTTPRequestHandler, HTTPServer
22
+
23
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
24
+ from dewpoint import Punctuator, split_words # noqa: E402
25
+
26
+
27
+ def make_handler(p):
28
+ class H(BaseHTTPRequestHandler):
29
+ def _send(self, code, obj):
30
+ body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
31
+ self.send_response(code)
32
+ self.send_header("content-type", "application/json; charset=utf-8")
33
+ self.send_header("content-length", str(len(body)))
34
+ self.end_headers()
35
+ self.wfile.write(body)
36
+
37
+ def do_GET(self):
38
+ if self.path.rstrip("/") == "/health":
39
+ self._send(200, {"ok": True, "backend": p.backend,
40
+ "members": len(p.members)})
41
+ else:
42
+ self._send(404, {"error": "try POST /restore or GET /health"})
43
+
44
+ def do_POST(self):
45
+ if self.path.rstrip("/") != "/restore":
46
+ return self._send(404, {"error": "POST /restore"})
47
+ try:
48
+ n = int(self.headers.get("content-length", 0))
49
+ req = json.loads(self.rfile.read(n) or b"{}")
50
+ text, lang = req.get("text", ""), req.get("lang", "en")
51
+ texts = [text] if isinstance(text, str) else list(text)
52
+ out = []
53
+ for t in texts:
54
+ item = {"text": p.restore(t, lang)}
55
+ if req.get("labels"):
56
+ r = p.predict(split_words(t, lang), lang)
57
+ item.update(words=r["words"], punct=r["punct"], case=r["case"])
58
+ out.append(item)
59
+ self._send(200, out[0] if isinstance(text, str) else out)
60
+ except Exception as e: # report, don't crash the server
61
+ self._send(400, {"error": f"{type(e).__name__}: {e}"})
62
+
63
+ def log_message(self, fmt, *args):
64
+ pass
65
+
66
+ return H
67
+
68
+
69
+ def main():
70
+ ap = argparse.ArgumentParser(description="Serve Dewpoint over HTTP.")
71
+ ap.add_argument("--host", default="127.0.0.1")
72
+ ap.add_argument("--port", type=int, default=8000)
73
+ ap.add_argument("--model", default=os.path.dirname(os.path.abspath(__file__)))
74
+ ap.add_argument("--single", action="store_true")
75
+ ap.add_argument("--backend", default="auto", choices=["auto", "torch", "onnx"])
76
+ a = ap.parse_args()
77
+ p = Punctuator.from_pretrained(a.model, backend=a.backend,
78
+ members=["mmbert-base"] if a.single else None)
79
+ print(f"Dewpoint ({p.backend}, {len(p.members)} member(s)) on http://{a.host}:{a.port}")
80
+ HTTPServer((a.host, a.port), make_handler(p)).serve_forever()
81
+
82
+
83
+ if __name__ == "__main__":
84
+ main()
xlm-roberta-large/config.json ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "add_cross_attention": false,
3
+ "architectures": [
4
+ "XLMRobertaForMaskedLM"
5
+ ],
6
+ "attention_probs_dropout_prob": 0.1,
7
+ "bos_token_id": 0,
8
+ "classifier_dropout": null,
9
+ "dtype": "bfloat16",
10
+ "eos_token_id": 2,
11
+ "hidden_act": "gelu",
12
+ "hidden_dropout_prob": 0.1,
13
+ "hidden_size": 1024,
14
+ "initializer_range": 0.02,
15
+ "intermediate_size": 4096,
16
+ "is_decoder": false,
17
+ "layer_norm_eps": 1e-05,
18
+ "max_position_embeddings": 514,
19
+ "model_type": "xlm-roberta",
20
+ "num_attention_heads": 16,
21
+ "num_hidden_layers": 24,
22
+ "output_past": true,
23
+ "pad_token_id": 1,
24
+ "position_embedding_type": "absolute",
25
+ "tie_word_embeddings": true,
26
+ "transformers_version": "5.17.0",
27
+ "type_vocab_size": 1,
28
+ "use_cache": true,
29
+ "vocab_size": 250002
30
+ }
xlm-roberta-large/model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:11e801af31f95a8bef83789559b6baf8f4aa868cdbb09f5db2f826c408d41680
3
+ size 1121949608
xlm-roberta-large/tokenizer.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:acbd420e2269cdc1ef45332d3d5c418be4aef6b8cb5a0b7ccae0893485307153
3
+ size 17098086
xlm-roberta-large/tokenizer_config.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "add_prefix_space": true,
3
+ "backend": "tokenizers",
4
+ "bos_token": "<s>",
5
+ "cls_token": "<s>",
6
+ "eos_token": "</s>",
7
+ "is_local": false,
8
+ "local_files_only": false,
9
+ "mask_token": "<mask>",
10
+ "model_max_length": 512,
11
+ "pad_token": "<pad>",
12
+ "sep_token": "</s>",
13
+ "tokenizer_class": "XLMRobertaTokenizer",
14
+ "unk_token": "<unk>"
15
+ }
xlm-roberta-large/vpunct_config.json ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "base_id": "FacebookAI/xlm-roberta-large",
3
+ "n_punct": 5,
4
+ "n_case": 3,
5
+ "max_len": 512,
6
+ "punct_labels": [
7
+ "O",
8
+ "COMMA",
9
+ "PERIOD",
10
+ "QUESTION",
11
+ "EXCLAM"
12
+ ],
13
+ "case_labels": [
14
+ "LOWER",
15
+ "CAP",
16
+ "UPPER"
17
+ ],
18
+ "punct_bias": [
19
+ 0.0,
20
+ -0.25,
21
+ 0.5,
22
+ -1.0,
23
+ -3.0
24
+ ],
25
+ "punct_bias_per_lang": {
26
+ "ar": [
27
+ 0.0,
28
+ -0.09375,
29
+ 0.5625,
30
+ -1.03125,
31
+ -3.0
32
+ ],
33
+ "de": [
34
+ 0.0,
35
+ -0.46875,
36
+ 0.5,
37
+ -1.03125,
38
+ -2.96875
39
+ ],
40
+ "en": [
41
+ 0.0,
42
+ -0.1875,
43
+ 0.46875,
44
+ -0.75,
45
+ -3.0
46
+ ],
47
+ "fr": [
48
+ 0.0,
49
+ -0.28125,
50
+ 0.53125,
51
+ -1.03125,
52
+ -2.6875
53
+ ],
54
+ "it": [
55
+ 0.0,
56
+ -0.28125,
57
+ 0.59375,
58
+ -0.90625,
59
+ -2.9375
60
+ ],
61
+ "ko": [
62
+ 0.0,
63
+ -0.28125,
64
+ 0.6875,
65
+ -0.875,
66
+ -3.0
67
+ ],
68
+ "nl": [
69
+ 0.0,
70
+ -0.28125,
71
+ 0.6875,
72
+ -0.875,
73
+ -2.875
74
+ ],
75
+ "ro": [
76
+ 0.0,
77
+ -0.40625,
78
+ 0.6875,
79
+ -0.875,
80
+ -2.9375
81
+ ],
82
+ "zh": [
83
+ 0.0,
84
+ -0.28125,
85
+ 0.5,
86
+ -0.90625,
87
+ -2.4375
88
+ ]
89
+ },
90
+ "calibration_strategy": "shrink0.25"
91
+ }