فهرست مقاله
برای بررسی یک سامانهٔ گفتار به متن، دیدن یک درصد کافی نیست؛ باید بدانیم کدام واژه جایگزین، حذف یا اضافه شده است. این درس، بخش امتیازدهی را با سه جفت متن فارسیِ ساختگی بازسازی میکند. کد کامل پایتون، مسیر همترازی و شمارش خطاها را برمیگرداند تا نتیجه با دست کنترل شود. هیچ صوت، مدل تشخیص گفتار، تماس یا محصول واقعی در این آزمایش وجود ندارد.
۱. پیش از محاسبه، قرارداد واژه را مشخص کنیم

متن مرجع، دنبالهای است که برای مقایسه درست فرض کردهایم؛ متن فرضی، دنبالهٔ مقابل آن است. در دادهٔ واقعی، کیفیت و دستور نگارش مرجع باید جداگانه بررسی شود. اینجا هر دو متن را خودمان ساختهایم تا علت اختلاف روشن باشد. مقالهٔ ارزیابی تبدیل گفتار فارسی به متن مراحل گستردهتر طراحی آزمون را توضیح میدهد؛ موضوع فعلی، پیادهسازی قابل بازرسیِ یک محاسبهٔ کوچک است.
ورودی اصلی تابع align_words، فهرست یا تاپل واژههاست و رشتهٔ خام را نمیپذیرد. هر واژه باید رشتهای ناتهی و بدون فاصلهٔ سفید باشد. تابع کمکی tokenize از str.split پایتون بدون جداکننده استفاده میکند؛ فاصلههای سفید پیاپی یک مرز محسوب میشوند. این تابع، نشانهگذاری را حذف نمیکند، نویسهها را یکسان نمیکند و نیمفاصله را به فاصله تبدیل نمیکند.
بنابراین «کم» و «کم،» در این قرارداد دو واژهٔ متفاوتاند. تصمیم دربارهٔ نشانهگذاری، اعداد و صورت نوشتن ترکیبها باید پیش از مقایسه ثبت شود و یکسان بر دو طرف اعمال شود. بحث نرمالسازی فارسی و برخورد صورتهای نوشتاری برای فهم این انتخابها مفید است؛ ولی سیاست جستوجو را بدون بررسی به ارزیابی گفتار منتقل نمیکنیم. تغییر همین سیاست میتواند عدد نهایی را تغییر دهد.
۲. همترازی کمهزینه و شمارش سه نوع خطا

تطابق درست را M، جایگزینی را S، حذف واژهٔ مرجع را D و درج واژهٔ اضافی را I مینامیم. طبق تعریف WER در مقالهٔ OpenASR21 از NIST، بخش ۲٫۴، مجموع سه نوع خطا بر تعداد واژههای مرجع تقسیم میشود. رابطهٔ WER = (S + D + I) / N نسبت بدون واحد میدهد؛ برای نمایش درصد، آن را در صد ضرب میکنیم.
برای یافتن مسیر، جدول برنامهریزی پویا میسازیم. خانهٔ i و j کمترین هزینهٔ تبدیل پیشوند مرجع با طول i به پیشوند فرضی با طول j را نگه میدارد. سطر و ستون نخست با هزینهٔ درج یا حذف مقداردهی میشوند. هر خانه، کمترینِ سه انتخاب را میگیرد: حرکت قطری برای تطابق یا جایگزینی، حرکت از بالا برای حذف و حرکت از چپ برای درج. در قرارداد ما تطابق صفر و هر خطا یک هزینه دارد.
ممکن است چند مسیر همهزینه باشند. هنگام بازگشت، ابتدا قطر، سپس حذف و بعد درج را ترجیح میدهیم. این انتخاب، مسیر را تکرارپذیر میکند و ممکن است تفکیک S، D و I را عوض کند. مستندات SCLITE در مخزن رسمی NIST همترازی وزندار با هزینههای پیشفرض دیگری را شرح میدهد. این کد آموزشی، بازنویسی سازگار با SCLITE نیست و در برابر آن آزمایش نشده است.
۳. کد کامل پایتون و هفده بررسی اجراشده

کد را در فایل wer_alignment.py ذخیره و با دستور python wer_alignment.py اجرا کنید. فقط کتابخانهٔ استاندارد نیاز است و اجرای ثبتشده با پایتون ۳٫۱۴٫۵ انجام شده است. خروجی هر جفت، تعداد واژهها، شمارندهٔ عملیات و خودِ ردیفهای همترازی را دارد؛ مقدار None یعنی در آن طرف، واژهای مقابل این عملیات وجود ندارد.
"""Original unit-cost word alignment for synthetic text pairs; no ASR or audio."""
import json
import math
import platform
PAIRS = (
('u1', ('جریان', 'موتور', 'زیاد', 'است'), ('جریان', 'زیاد', 'شد')),
('u2', ('سلام',), ('سلام', 'دوست', 'خوب')),
('u3', ('ولتاژ', 'کم'), ('ولتاژ', 'کم')),
)
def tokenize(text):
if type(text) is not str:
raise TypeError('Unicode text required')
return tuple(text.split())
def valid_tokens(tokens):
if type(tokens) not in (tuple, list):
raise ValueError('Pass a list or tuple of tokens, not raw text')
tokens = tuple(tokens)
if any(type(token) is not str or not token or any(ch.isspace() for ch in token)
for token in tokens):
raise ValueError('Each token must be a nonempty string without whitespace')
return tokens
def align_words(reference, hypothesis):
reference, hypothesis = valid_tokens(reference), valid_tokens(hypothesis)
n, m = len(reference), len(hypothesis)
dp = [[0] * (m + 1) for _ in range(n + 1)]
for i in range(n + 1):
dp[i][0] = i
for j in range(m + 1):
dp[0][j] = j
for i in range(1, n + 1):
for j in range(1, m + 1):
substitution = int(reference[i-1] != hypothesis[j-1])
dp[i][j] = min(dp[i-1][j-1] + substitution, dp[i-1][j] + 1, dp[i][j-1] + 1)
alignment = []
i, j = n, m
while i or j:
# Backtracking tie policy: diagonal(match/substitution), then deletion, then insertion.
if i and j and dp[i][j] == dp[i-1][j-1] + int(reference[i-1] != hypothesis[j-1]):
op = 'M' if reference[i-1] == hypothesis[j-1] else 'S'
alignment.append({'op': op, 'reference': reference[i-1], 'hypothesis': hypothesis[j-1]})
i, j = i - 1, j - 1
elif i and dp[i][j] == dp[i-1][j] + 1:
alignment.append({'op': 'D', 'reference': reference[i-1], 'hypothesis': None})
i -= 1
else:
alignment.append({'op': 'I', 'reference': None, 'hypothesis': hypothesis[j-1]})
j -= 1
alignment.reverse()
counts = {op: sum(row['op'] == op for row in alignment) for op in ('M', 'S', 'D', 'I')}
return {'reference_words': n, 'hypothesis_words': m, 'counts': counts,
'errors': dp[n][m], 'wer': None if n == 0 else dp[n][m] / n,
'alignment': alignment}
def score_corpus(pairs):
pairs = tuple(pairs)
if not pairs:
raise ValueError('A nonempty corpus required')
results = [{'utterance_id': uid, **align_words(ref, hyp)} for uid, ref, hyp in pairs]
if any(row['reference_words'] == 0 for row in results):
raise ValueError('This corpus contract rejects empty-reference utterances')
words = sum(row['reference_words'] for row in results)
errors = sum(row['errors'] for row in results)
return {'utterances': results, 'total_reference_words': words, 'total_errors': errors,
'micro_wer': errors / words,
'macro_utterance_wer': math.fsum(row['wer'] for row in results) / len(results)}
def rejects(call, exception=ValueError):
try:
call()
except exception:
return True
return False
def run_example():
corpus = score_corpus(PAIRS)
u1, u2, u3 = corpus['utterances']
tie = align_words(('الف', 'ب'), ('ب', 'الف'))
empty_reference = align_words((), ('اضافی',))
both_empty = align_words((), ())
reports = corpus['utterances'] + [tie, empty_reference, both_empty]
expected_pairs = [(ref, hyp) for _, ref, hyp in PAIRS] + [
(('الف', 'ب'), ('ب', 'الف')), ((), ('اضافی',)), ((), ())]
checks = {
'first_pair_has_independent_reference_counts': u1['counts'] == {'M': 2, 'S': 1, 'D': 1, 'I': 0} and u1['wer'] == 0.5,
'identical_pair_has_zero_errors': u3['counts'] == {'M': 2, 'S': 0, 'D': 0, 'I': 0} and u3['wer'] == 0,
'one_insertion_counted': align_words(('مدار', 'روشن'), ('مدار', 'اکنون', 'روشن'))['counts'] == {'M': 2, 'S': 0, 'D': 0, 'I': 1},
'one_deletion_counted': align_words(('مدار', 'اکنون', 'روشن'), ('مدار', 'روشن'))['counts'] == {'M': 2, 'S': 0, 'D': 1, 'I': 0},
'one_substitution_counted': align_words(('مدار', 'روشن'), ('مدار', 'خاموش'))['counts'] == {'M': 1, 'S': 1, 'D': 0, 'I': 0},
'empty_hypothesis_deletes_all_reference_words': align_words(('مدار', 'روشن'), ())['counts']['D'] == 2 and align_words(('مدار', 'روشن'), ())['wer'] == 1,
'empty_reference_retains_insertions_without_ratio': empty_reference['counts']['I'] == 1 and empty_reference['wer'] is None,
'both_empty_has_zero_distance_but_undefined_ratio': both_empty['errors'] == 0 and both_empty['wer'] is None,
'insertions_can_make_wer_larger_than_one': u2['counts'] == {'M': 1, 'S': 0, 'D': 0, 'I': 2} and u2['wer'] == 2,
'diagonal_tie_policy_selects_two_substitutions': tie['counts'] == {'M': 0, 'S': 2, 'D': 0, 'I': 0} and [r['op'] for r in tie['alignment']] == ['S', 'S'],
'chosen_alignments_reconstruct_both_token_sequences': all(
tuple(r['reference'] for r in row['alignment'] if r['reference'] is not None) == ref
and tuple(r['hypothesis'] for r in row['alignment'] if r['hypothesis'] is not None) == hyp
for row, (ref, hyp) in zip(reports, expected_pairs, strict=True)),
'operation_counts_equal_distance_and_word_counts': all(
row['counts']['S'] + row['counts']['D'] + row['counts']['I'] == row['errors']
and row['counts']['M'] + row['counts']['S'] + row['counts']['D'] == row['reference_words']
and row['counts']['M'] + row['counts']['S'] + row['counts']['I'] == row['hypothesis_words'] for row in reports),
'corpus_micro_is_four_over_seven': corpus['total_errors'] == 4 and corpus['total_reference_words'] == 7 and math.isclose(corpus['micro_wer'], 4/7),
'corpus_macro_is_five_over_six_and_differs': math.isclose(corpus['macro_utterance_wer'], 5/6) and not math.isclose(corpus['macro_utterance_wer'], corpus['micro_wer']),
'whitespace_split_does_not_normalize_zwnj_or_punctuation': tokenize(' می\u200cرود\tاکنون؟ ') == ('می\u200cرود', 'اکنون؟') and align_words(tokenize('کم'), tokenize('کم،'))['counts']['S'] == 1,
'raw_text_and_empty_token_arguments_rejected': rejects(lambda: align_words('مدار', ('مدار',))) and rejects(lambda: align_words(('',), ('مدار',))),
'empty_or_empty_reference_corpus_rejected': rejects(lambda: score_corpus(())) and rejects(lambda: score_corpus((('empty', (), ('اضافی',)),))),
}
assert all(checks.values()), checks
return {'python': platform.python_version(), 'costs': {'match': 0, 'substitution': 1, 'deletion': 1, 'insertion': 1},
'tie_policy': 'backtrack diagonal(M/S), deletion, insertion',
'tokenization': 'str.split whitespace only; no normalization or punctuation removal',
'corpus': corpus, 'tie_example': tie, 'empty_reference_example': empty_reference,
'audio_or_asr_model_used': False, 'sctk_compatibility_tested': False, 'checks': checks}
if __name__ == '__main__':
print(json.dumps(run_example(), ensure_ascii=False, indent=2))
بررسیها، نمونههای مستقلِ درج، حذف و جایگزینی، متن یکسان، ورودی تهی، حالت همهزینه و تجمیع مجموعه را پوشش میدهند. همچنین از ردیفهای همترازی، هر دو دنبالهٔ اولیه بازسازی میشوند؛ تنها شمارش طول کافی نیست. دو رابطهٔ کنترلی داریم: شمار واژههای مرجع برابر M+S+D و شمار واژههای متن فرضی برابر M+S+I است. مجموع S+D+I نیز باید با فاصلهٔ نهایی جدول برابر باشد.
جدول کامل، برای طولهای N و K به حافظه و زمان متناسب با حاصلضرب آنها نیاز دارد. مثال کوتاه است و ادعای سرعت، پردازش زنده یا مناسب بودن برای متن نامحدود نداریم. مرجع تهی، حتی وقتی متن مقابل هم تهی باشد، مخرج معتبر ندارد؛ تابع همترازی شمارش را نگه میدارد و WER را None میدهد. تابع مجموعه، هر جفت با مرجع تهی را صریح رد میکند تا حذف بیصدای نمونه رخ ندهد.
۴. نتیجهٔ دستی، WER بزرگتر از یک و وزندهی مجموعه

در جفت نخست، مرجع «جریان موتور زیاد است» و متن فرضی «جریان زیاد شد» است. جریان و زیاد تطابق دارند؛ موتور حذف و است با شد جایگزین شده است. پس دو خطا روی چهار واژهٔ مرجع داریم و نسبت برابر نیم میشود. در جفت دوم، مرجع فقط سلام است و متن مقابل سلام دوست خوب؛ دو درج، WER را به دو یا دویست درصد میرسانند. آن را به صد درصد محدود نمیکنیم.
{
"python": "3.14.5",
"costs": {
"match": 0,
"substitution": 1,
"deletion": 1,
"insertion": 1
},
"tie_policy": "backtrack diagonal(M/S), deletion, insertion",
"tokenization": "str.split whitespace only; no normalization or punctuation removal",
"corpus": {
"utterances": [
{
"utterance_id": "u1",
"reference_words": 4,
"hypothesis_words": 3,
"counts": {
"M": 2,
"S": 1,
"D": 1,
"I": 0
},
"errors": 2,
"wer": 0.5,
"alignment": [
{
"op": "M",
"reference": "جریان",
"hypothesis": "جریان"
},
{
"op": "D",
"reference": "موتور",
"hypothesis": null
},
{
"op": "M",
"reference": "زیاد",
"hypothesis": "زیاد"
},
{
"op": "S",
"reference": "است",
"hypothesis": "شد"
}
]
},
{
"utterance_id": "u2",
"reference_words": 1,
"hypothesis_words": 3,
"counts": {
"M": 1,
"S": 0,
"D": 0,
"I": 2
},
"errors": 2,
"wer": 2.0,
"alignment": [
{
"op": "M",
"reference": "سلام",
"hypothesis": "سلام"
},
{
"op": "I",
"reference": null,
"hypothesis": "دوست"
},
{
"op": "I",
"reference": null,
"hypothesis": "خوب"
}
]
},
{
"utterance_id": "u3",
"reference_words": 2,
"hypothesis_words": 2,
"counts": {
"M": 2,
"S": 0,
"D": 0,
"I": 0
},
"errors": 0,
"wer": 0.0,
"alignment": [
{
"op": "M",
"reference": "ولتاژ",
"hypothesis": "ولتاژ"
},
{
"op": "M",
"reference": "کم",
"hypothesis": "کم"
}
]
}
],
"total_reference_words": 7,
"total_errors": 4,
"micro_wer": 0.5714285714285714,
"macro_utterance_wer": 0.8333333333333334
},
"tie_example": {
"reference_words": 2,
"hypothesis_words": 2,
"counts": {
"M": 0,
"S": 2,
"D": 0,
"I": 0
},
"errors": 2,
"wer": 1.0,
"alignment": [
{
"op": "S",
"reference": "الف",
"hypothesis": "ب"
},
{
"op": "S",
"reference": "ب",
"hypothesis": "الف"
}
]
},
"empty_reference_example": {
"reference_words": 0,
"hypothesis_words": 1,
"counts": {
"M": 0,
"S": 0,
"D": 0,
"I": 1
},
"errors": 1,
"wer": null,
"alignment": [
{
"op": "I",
"reference": null,
"hypothesis": "اضافی"
}
]
},
"audio_or_asr_model_used": false,
"sctk_compatibility_tested": false,
"checks": {
"first_pair_has_independent_reference_counts": true,
"identical_pair_has_zero_errors": true,
"one_insertion_counted": true,
"one_deletion_counted": true,
"one_substitution_counted": true,
"empty_hypothesis_deletes_all_reference_words": true,
"empty_reference_retains_insertions_without_ratio": true,
"both_empty_has_zero_distance_but_undefined_ratio": true,
"insertions_can_make_wer_larger_than_one": true,
"diagonal_tie_policy_selects_two_substitutions": true,
"chosen_alignments_reconstruct_both_token_sequences": true,
"operation_counts_equal_distance_and_word_counts": true,
"corpus_micro_is_four_over_seven": true,
"corpus_macro_is_five_over_six_and_differs": true,
"whitespace_split_does_not_normalize_zwnj_or_punctuation": true,
"raw_text_and_empty_token_arguments_rejected": true,
"empty_or_empty_reference_corpus_rejected": true
}
}
جفت سوم «ولتاژ کم» را بدون تغییر مقایسه میکند و خطا ندارد. در کل مجموعه، چهار خطا روی هفت واژهٔ مرجع داریم؛ WER تجمیعی یا micro برابر چهار هفتم، حدود ۵۷٫۱۴ درصد است. میانگین سادهٔ سه نسبتِ نیم، دو و صفر، پنج ششم یا حدود ۸۳٫۳۳ درصد میشود. در این میانگین جملهای یا macro، جملهٔ یکواژهای همان سهم جملهٔ چهارواژهای را دارد؛ پس عنوان و روش تجمیع را همراه عدد گزارش میکنیم.
هیچیک از این اعداد، امتیاز یک مدل یا دادهٔ واقعی نیستند. تعداد خطای واژه به تنهایی هزینهٔ اشتباه در یک وظیفهٔ مهندسی را مشخص نمیکند. برای نمونه، جابهجایی مقدار اندازهگیری یا نفی یک فرمان ممکن است به بررسی جداگانه نیاز داشته باشد؛ از این سه جمله نمیتوان اهمیت یا فراوانی چنین خطاهایی را برآورد کرد. تبدیل یک نسبت به عبارت «دقت سامانه» نیز بدون تعریف سنجه، گمراهکننده است.
۵. چهار تمرین پاسخدار برای کنترل تفسیر

تمرین اول: مرجع دو واژه دارد و متن مقابل تهی است؛ نتیجه چیست؟ پاسخ: هر دو واژه حذف میشوند، پس D برابر دو و WER برابر یک است. اگر مرجع تهی و متن مقابل یک واژه باشد، یک درج داریم، ولی نسبت تعریف نشده و مقدار None برمیگردد. این دو حالت را به دلیل تفاوت مخرج، یکسان گزارش نمیکنیم.
تمرین دوم: چرا برای مرجع «الف ب» و متن «ب الف»، دو جایگزینی گزارش میشود؟ پاسخ: هزینهٔ دو جایگزینی، دو است و مسیر حذف الف، تطابق ب و درج الف هم هزینهٔ دو دارد. سیاست بازگشت ما قطر را مقدم میداند؛ بنابراین S برابر دو میشود. کمترین هزینه ثابت میماند، اما تفکیک عملیات به سیاست رفع تساوی وابسته است.
تمرین سوم: WER جفت دوم چرا دویست درصد است؟ پاسخ: دو واژهٔ اضافی بر یک واژهٔ مرجع تقسیم شدهاند؛ صورت میتواند از مخرج بزرگتر باشد. تمرین چهارم: برای سنجش همین مجموعه، آیا ۵۷٫۱۴ و ۸۳٫۳۳ درصد متناقضاند؟ پاسخ: خیر؛ اولی جمع خطاها تقسیم بر جمع واژههاست و دومی میانگین سادهٔ نسبت جملهها. این درس آموزشیِ تألیفی، با منابع خواندهشده در ۲۴ مهر ۱۴۰۵، خبر پژوهشی تازه یا گزارش کیفیت محصول نیست.
منابع و مطالعهٔ بیشتر
- NIST / INTERSPEECH 2022؛ OpenASR21 و تعریف WER، بخش ۲٫۴
- NIST SCTK؛ مستندات SCLITE و همترازی با برنامهریزی پویا
- Python 3.14؛ رفتار str.split در جداسازی فاصلههای سفید
بررسی منابع: ۲۴ مهر ۱۴۰۵. تحلیلها و پیشنهادهای اجرایی، جمعبندی نویسندهاند.

