Skip to main content

Tibetan-WER

Character (CER), syllable (SER) and segmented word (SWER) error rates for Tibetan text, with three word segmentation methods, per-sentence scores, paired significance tests, error-structure diagnostics and a command-line interface.

This package is the reference implementation of Segmented Word Error Rate (SWER), introduced in:

J. Moore, S. Li and P. Lauren, "Evaluating Tibetan ASR With Segmented Word Error Rate: Beyond Character-Level Metrics," in IEEE Access, vol. 14, pp. 101790-101805, 2026, doi: 10.1109/ACCESS.2026.3709206.

SWER computes WER for Tibetan by first applying automatic word segmentation to both hypothesis and reference text, since Tibetan orthography marks syllable (tsek) boundaries but not word boundaries. Three segmentation methods are provided, each corresponding to a variant reported in the paper.

Install

pip install tibetan-wer                  # cer, ser, botok wer, statistics, CLI
pip install "tibetan-wer[fast]"          # + the fast alignment backend
pip install "tibetan-wer[bert]"          # + BERT segmentation
pip install "tibetan-wer[gemini]"        # + Gemini segmentation

Metrics

Function Metric Unit Extra dependency
cer CER Unicode codepoint (none)
ser SER syllable, bounded by tsek, whitespace or shad (below) (none)
wer / botok_wer BoTok-SWER botok morphological tokenizer (none)
bert_wer BERT-SWER KoichiYasuoka/tibetan-bert-base-upos tibetan-wer[bert]
gemini_wer Gem-SWER Gemini 2.5 Flash Lite tibetan-wer[gemini]
score_segments any units you supply (none)

All take (predictions, references), either as single strings or as equal-length lists, and return a dict:

Key
micro_wer / macro_wer corpus rate, and mean of per-sentence rates (micro_cer/macro_cer for cer, micro_ser/macro_ser for ser)
substitutions, insertions, deletions corpus edit-operation totals
num_sentences, num_scored, num_skipped sentence counts
per_utterance numpy array of per-sentence rates, aligned with the input, nan where the sentence was not scored
per_sentence per-sentence substitutions, insertions, deletions, edits, reference_length, score
num_segmentation_fallbacks strings the segmenter could not handle (SWER variants only)

Note the argument order: (predictions, references). Reversing it leaves substitutions unchanged but swaps insertions and deletions.

A sentence is not scored when its reference is empty, or when the Gemini repetition filter drops it; those appear as nan and are excluded from both rates.

Usage

CER and SER

from tibetan_wer import cer, ser

predictions = ['གཞོན་ནུར་གྱུར་པ་ལ་ཕྱག་འཚལ་ལོ༔']
references  = ['འཇམ་དཔལ་གཞོན་ནུར་གྱུར་པ་ལ་ཕྱག་འཚལ་ལོ༔']

print(f'Micro-CER: {cer(predictions, references)["micro_cer"]:.3f}')
print(f'Micro-SER: {ser(predictions, references)["micro_ser"]:.3f}')

WER

from tibetan_wer import wer, bert_wer, gemini_wer

result = wer(predictions, references)             # botok
result = bert_wer(predictions, references)        # auto-detects CUDA
result = gemini_wer(predictions, references)      # GEMINI_API_KEY, or api_key=

print(f'Micro-WER: {result["micro_wer"]:.3f}')
print(f'Substitutions: {result["substitutions"]}')

Normalization

Nothing is normalized by default: text is scored as stored, no Unicode normalization, no case folding, no punctuation stripping. Rates depend on those conventions, which is a known source of incomparable CER figures, so state which you used.

from tibetan_wer import cer, normalize

cer(predictions, references, normalize=True)      # NFC; ༌ -> ་; ༈ -> །; strip
cer(predictions, references, normalize={'fold_sbrul_shad': False})

normalize('ཀ༌ཁ')      # 'ཀ་ཁ'

Folding changes CER only. SER treats tsek and tsek bstar alike, and drops punctuation, so neither folding changes it.

Syllables

ser, syllable_segment and every level='syllable' diagnostic share one rule. A syllable is a maximal run of characters that are not delimiters, and the delimiters are:

Codepoints
whitespace any Unicode whitespace
U+0F04–U+0F0A yig mgo head marks ༄ ༅ ༆ ༇, sbrul shad ༈, ༉ ༊
U+0F0B, U+0F0C tsek ་ and tsek bstar ༌
U+0F0D–U+0F12 the shads ། ༎ ༏ ༐ ༑ ༒
U+0F13, U+0F14 ༓, gter tsheg ༔
U+0F34 bsdus rtags ༴
U+0F3A–U+0F3D brackets ༺ ༻ ༼ ༽
U+0FD0–U+0FD4, U+0FD9–U+0FDA further head marks, nyis tsheg ࿒, mchan rtags

A syllable that ends a clause carries a shad and no tsek, and one before a space carries neither, so all of these are boundaries. Tsek bstar is a tsek that forbids a line break there; it still separates syllables.

Delimiters are dropped, not scored: SER counts syllables only, and punctuation errors show up in CER. Runs of delimiters, and delimiters at either end, produce no empty units. Combining signs, the visarga ཿ among them, belong to their syllable, and so do ༀ, ༁–༃, ༸ and digits.

syllable_segment("༄༅། །བཀྲ་ཤིས། ")   # ['བཀྲ', 'ཤིས']
syllable_segment("པས། ལུགས")          # ['པས', 'ལུགས']
syllable_segment("ཛཿ  ཀི")            # ['ཛཿ', 'ཀི']
syllable_segment("ཀ༌ཁ་ག")             # ['ཀ', 'ཁ', 'ག']

The delimiter class is exported as SYLLABLE_DELIMITERS. Before 2.0.0, SER split on the tsek alone; see the changelog for what that changed and how to reproduce older figures.

Confidence intervals

bootstrap_ci resamples sentences and recomputes the rate. statistic='macro' (default) takes the mean of the per-sentence rates; statistic='micro' recomputes total edits over total reference units, matching the reported micro_*.

from tibetan_wer import ser, bootstrap_ci, significance_matrix

result = ser(predictions, references)
mean, lo, hi = bootstrap_ci(result)
print(f'SER {mean:.3f} [{lo:.3f}, {hi:.3f}]')

cis = {name: bootstrap_ci(ser(p, references)) for name, p in systems.items()}
significance_matrix(cis)['baseline']['finetuned']   # True if the intervals miss each other

Comparing two systems

Non-overlapping intervals is a conservative test — it ignores that both systems saw the same sentences. compare pairs them, which removes the sentence-to-sentence variance that dominates either interval alone:

from tibetan_wer import ser, compare

a, b = ser(preds_a, references), ser(preds_b, references)
c = compare(a, b)

print(f"{c['delta']:+.4f}  95% CI [{c['ci'][0]:+.4f}, {c['ci'][1]:+.4f}]")
print(c['p_bootstrap'], c['p_permutation'], c['significant'])

delta is a minus b, so negative means a is better. method='bootstrap' resamples sentences; method='permutation' swaps the two systems' scores within randomly chosen sentences, the exact null of "these systems are interchangeable"; the default runs both. statistic='micro' compares micro rates instead.

The two tests disagree in exactly the direction you would expect. Over 144 system pairs from the OCR study, 5 had overlapping intervals but a paired difference that excludes zero — for instance TrOCR-base standard (SER 0.377 [0.369, 0.387]) against its 8-bit quantization (0.395 [0.387, 0.406]) on woodblock print: the intervals overlap, while the paired difference is −0.0178 [−0.0205, −0.0151].

Per-sentence scores

per_utterance is aligned with the input, so it can be correlated against another metric, sorted to find the worst lines, or fed to any further analysis without realigning.

import numpy as np
from tibetan_wer import cer, ser

c = cer(predictions, references)["per_utterance"]
s = ser(predictions, references)["per_utterance"]

ok = ~np.isnan(c) & ~np.isnan(s)
print(np.corrcoef(c[ok], s[ok])[0, 1])          # do the two metrics agree line by line?
print(np.argsort(-np.nan_to_num(c))[:10])       # the ten worst lines

Error structure

A rate says how much a system is wrong; error_profile says how, from the same alignment.

from tibetan_wer import error_profile, syllable_segment

p = error_profile(predictions, references, level='char')
p['operations']['deletion_share']      # 0.594 -- this system drops units
p['boundary_edits']['share']           # 0.093 -- edits landing on the tsek
p['space_edits']['share']              # 0.463 -- edits producing a space
p['confusions'][0]                     # {'reference': '་', 'hypothesis': ' ', 'count': 420}
p['concentration']                     # clean-line share, p90, worst-decile share, gini

vocabulary = {s for r in references for s in syllable_segment(r)}
q = error_profile(predictions, references, level='syllable', vocabulary=vocabulary)
q['malformed']['share']                # 0.449 -- share of produced syllables not attested

Malformed syllables are mechanically detectable and so correctable without a language model; well-formed but wrong ones are not. Two systems at the same SER can sit at 45% and 10%, which is a difference in how repairable their output is.

Edit operations

from tibetan_wer import edit_operations

for op in edit_operations(prediction, reference, level='syllable'):
    print(op.op, op.ref, '->', op.hyp, 'at', op.ref_index)
    # S ནུར -> ནུས at 3

level is 'char', 'syllable', 'botok', 'bert' or 'gemini'. Lower-level entry points — align, edit_counts, distance — take unit sequences directly.

Segmenting once, scoring many

Segmentation, not alignment, dominates the cost of an evaluation with the BERT or Gemini segmenter. Segment once, then score every system against the same segmentation:

from tibetan_wer import segment_all, score_segments

cache = {}                                       # {text: tokens}, reusable, picklable
ref_segments = segment_all(references, method='bert', cache=cache)

for name, preds in systems.items():
    hyp_segments = segment_all(preds, method='bert', cache=cache)
    print(name, score_segments(hyp_segments, ref_segments)['micro_wer'])

method is 'syllable', 'botok', 'bert' or 'gemini'; each distinct string is segmented once. For the API segmenter, workers=8 runs calls concurrently. The individual segmenters — syllable_segment, word_segment, bert_segment, gemini_segment — are also public.

Speed

The alignment ships in two backends. python is the reference implementation whose backtrace produced the published operation counts. fast uses rapidfuzz and is roughly 700x quicker, returning the same distance on every input — so rates are identical — but where several alignments tie in cost it may split a total differently between substitutions, insertions and deletions.

So rates and operation counts are treated differently:

cer(predictions, references)                     # exact counts, reference alignment
cer(predictions, references, detail='rates')     # identical rates, ~700x faster, no S/I/D
cer(predictions, references, backend='fast')     # fast counts, may redistribute S/I/D

detail='rates' is the one to reach for when scoring a grid of systems, or bootstrapping: on 400 lines of woodblock OCR, CER takes 7.6 s at detail='full' and 0.010 s at detail='rates', with identical micro and macro rates.

When a segmenter fails

botok can raise on pathological input, and the BERT model reads at most 512 tokens. A failure falls back to a syllable split for that string, which silently turns that line's word rate into a syllable rate — so it is counted rather than swallowed:

result = wer(predictions, references)
result['num_segmentation_fallbacks']             # 0, or a caveat on the number

from tibetan_wer import fallback_counts
fallback_counts()                                # {'botok': 3} -- by segmenter

wer(predictions, references, on_error='raise')   # or refuse to fall back at all
bert_wer(predictions, references, max_syllables=100)   # window long lines instead

Degenerate output

A collapsed model emits long runs of a few characters. Such strings make the API segmenter time out rather than merely score badly, so gemini_wer drops any sentence whose prediction or reference has a character repetition ratio (length ÷ distinct characters) above max_repetition_ratio, default 10. Dropped sentences are nan in per_utterance and counted in num_skipped.

result = gemini_wer(predictions, references, max_repetition_ratio=10.0, workers=8)
print(result['num_skipped'], 'of', result['num_sentences'], 'sentences not scored')

A rate computed over the remainder is not comparable with one computed over every sentence, and the dropped sentences are ones the system did badly on, so report num_skipped alongside the rate. Pass max_repetition_ratio=None to disable the filter, and max_output_tokens= to cap each response.

Command line

tibetan-wer predictions.txt references.txt --metric ser --ci
tibetan-wer a.txt references.txt --compare b.txt --metric wer
tibetan-wer predictions.txt references.txt --profile --level syllable --vocabulary -
tibetan-wer predictions.txt references.txt --metric cer --detail rates --json out.json

Files hold one sentence per line, UTF-8, prediction i against reference i; - reads predictions from stdin. --vocabulary - builds the attested-syllable set from the references themselves.

SER over 300 sentences
  micro  0.1011
  macro  0.1099
  95% CI [0.0974, 0.1232]  (macro rate, bootstrap)
  S/I/D  793 / 44 / 34

comparison (macro rate, paired over 300 sentences)
  delta -0.0719  95% CI [-0.0923, -0.0534]
  p (bootstrap) 0.0000   p (permutation) 0.0001
  significant at the 5% level

Hugging Face evaluate

integrations/huggingface/ holds an evaluate metric module:

import evaluate
metric = evaluate.load("integrations/huggingface/tibetan_wer.py")
metric.compute(predictions=preds, references=refs, metric="ser")

Usage for Model Evaluation

from tibetan_wer import cer as tib_cer, wer as tib_wer, ser as tib_ser

def compute_metrics(pred):
    pred_ids = pred.predictions
    label_ids = pred.label_ids

    label_ids[label_ids == -100] = tokenizer.pad_token_id

    pred_str  = tokenizer.batch_decode(pred_ids,   skip_special_tokens=True)
    label_str = tokenizer.batch_decode(label_ids,  skip_special_tokens=True)

    # detail='rates' keeps evaluation off the critical path during training
    cer_result = tib_cer(pred_str, label_str, detail="rates")
    wer_result = tib_wer(pred_str, label_str, detail="rates")
    ser_result = tib_ser(pred_str, label_str, detail="rates")

    return {
        "tib_micro_cer": cer_result["micro_cer"],
        "tib_macro_cer": cer_result["macro_cer"],
        "tib_micro_wer": wer_result["micro_wer"],
        "tib_macro_wer": wer_result["macro_wer"],
        "tib_micro_ser": ser_result["micro_ser"],
        "tib_macro_ser": ser_result["macro_ser"],
    }
trainer = Seq2SeqTrainer(
    args=training_args,
    model=model,
    train_dataset=dataset["train"],
    eval_dataset=dataset["test"],
    data_collator=data_collator,
    compute_metrics=compute_metrics,
    tokenizer=processor.feature_extractor,
)

trainer.train()

Reference

Everything importable from tibetan_wer:

Metrics
cer(predictions, references, normalize=False, backend='auto', detail='full') character error rate
ser(...) syllable error rate
wer(..., cache=None, on_error='fallback') / botok_wer BoTok-SWER
bert_wer(..., device=None, max_syllables=None, nlp=None) BERT-SWER; nlp= reuses a loaded pipeline
gemini_wer(..., api_key=None, max_repetition_ratio=10.0, max_output_tokens=None, workers=1, client=None) Gem-SWER; client= reuses a built client
score_segments(prediction_segments, reference_segments, unit='wer') score units you segmented yourself
Statistics
bootstrap_ci(scores, n_iterations=10000, percentiles=(2.5, 97.5), seed=42, statistic='macro') (point, lo, hi)
compare(result_a, result_b, statistic='macro', method='both') paired bootstrap and permutation test
cis_overlap(ci_a, ci_b) do two (lo, hi) intervals meet
significance_matrix({name: (mean, lo, hi)}) pairwise non-overlap
Diagnostics
error_profile(predictions, references, level='char', vocabulary=None, top_confusions=10) operations, boundary and space edits, confusions, malformed share, concentration
concentration(per_utterance) clean-line share, p90, worst-decile share, gini
gini(values) Gini coefficient of a non-negative array
edit_operations(prediction, reference, level='char') the edits between one pair
Alignment
align(ref_units, hyp_units, backend='auto') list of EditOperation
edit_counts(ref_units, hyp_units) (S, I, D)
distance(ref_units, hyp_units) Levenshtein distance
EditOperation (op, ref, hyp, ref_index, hyp_index), op in S/I/D
available_backends() ['fast', 'python'] or ['python']
Segmentation
syllable_segment(text) split into syllables, dropping tsek, whitespace and shad
word_segment(sentence, tokenizer=None, on_error='fallback') / botok_segment botok
bert_segment(text, device=None, max_syllables=None, on_error='fallback') UPOS classifier
gemini_segment(text, api_key=None, model=..., max_output_tokens=None, on_error='raise') Gemini
segment_all(texts, method='botok', cache=None, workers=1, **kwargs) batch, de-duplicated
fallback_counts() / reset_fallback_counts() strings that fell back to a syllable split
repetition_ratio(text) / is_degenerate(text, threshold=10.0) degenerate-output detection
Normalization
normalize(text, form='NFC', fold_tsek_bstar=True, fold_sbrul_shad=True, strip=True) one string
normalize_all(texts, **kwargs) many

Command-line options

Option
--metric {cer,ser,wer,bert,gemini} default ser
--compare FILE paired comparison against a second system
--ci, --statistic {macro,micro}, --iterations N, --seed N bootstrap interval
--normalize NFC, fold tsek bstar and sbrul shad, strip
--detail {full,rates}, --backend {auto,python,fast} alignment cost and exactness
--profile, --level {char,syllable,botok,bert,gemini}, --vocabulary FILE error structure; --vocabulary - builds one from the references
--workers N concurrent API calls for --metric gemini
--json [FILE] JSON instead of text, stdout when no file is given

Tests

pip install "tibetan-wer[dev]"
pytest test/                       # or run any file directly: python test/test_cli.py

Three files, 107 tests:

File
test/test_metrics.py metrics, alignment, backends, normalization, statistics, diagnostics, and a cross-check of score_segments against jiwer on space-delimited text
test/test_segmenter_plumbing.py the BERT and Gemini paths — caching, windowing, the repetition filter, fallback accounting, concurrency — with stand-ins passed through nlp= and client=
test/test_cli.py the command line: text and JSON reports, every --metric branch, the vocabulary options, stdin, exit statuses

The two segmenters that need a model download or a paid endpoint are never called for real, and CI runs the suite twice, with and without the optional fast backend.

Changes

See CHANGELOG.md. 2.0.0 changes SER on any text containing whitespace, shad or other punctuation; BoTok-, BERT- and Gem-SWER and CER are unchanged.

Citation

If you use this package, please cite:

@ARTICLE{moore2026tibetanasr,
  author={Moore, Jacob and Li, Sheng and Lauren, Paula},
  journal={IEEE Access},
  title={Evaluating Tibetan {ASR} With Segmented Word Error Rate: Beyond Character-Level Metrics},
  year={2026},
  volume={14},
  pages={101790-101805},
  doi={10.1109/ACCESS.2026.3709206}
}

Metadata

Release files for tibetan-wer 2.0.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for tibetan-wer 2.0.0
File Size Uploaded
tibetan_wer-2.0.0.tar.gz 51.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for tibetan-wer 2.0.0
File Interpreter ABI Platform
tibetan_wer-2.0.0-py3-none-any.whl Python 3 none any Details

Total release size: 89.9 kB

Release files / tibetan_wer-2.0.0.tar.gz

Download URL tibetan_wer-2.0.0.tar.gz
Size 51.3 kB
Tags Source
SHA-256 checksum
How to use checksums
9e8813da1e363faf7c6657da6f2de800e3d1710a676b0a7dd3ad25835ef53030
BLAKE2b-256 checksum
How to use checksums
66736f5bcbb6872ee71072ddb71510fa636a8b5d6999031d10d6ba52d4410eea
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.5

Release files / tibetan_wer-2.0.0-py3-none-any.whl

Download URL tibetan_wer-2.0.0-py3-none-any.whl
Size 38.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
df0ec98d8f550ac07a55a96644894d2f97881fb0efad0319ce3045e6cbb21f61
BLAKE2b-256 checksum
How to use checksums
68a7d71d393cb780ad51733f2ca22a308293d7d8accacc064a42c31784bf7b5a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.5

Release history Release notifications | RSS feed

This release

2.0.0 This release

2 release files

1.1.1

2 release files

1.1.0

2 release files

1.0.1

2 release files

1.0.0

2 release files

0.0.2

2 release files

0.0.1

2 release files

0.0.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page